Beta

Workspaces

A workspace is a shared encrypted folder: a named group that owns a tree of files, with a roster of members who can read, write, or administer depending on their role. Technically it's a keyring chain (manager-only supersedes) plus per-path directory chains that members cascade-supersede on their own PDS — no cross-PDS write authority needed.

When to use a workspace (vs. a one-to-one grant) #

If you want to share a single file with one other person, grants are the lighter tool: one record per sharer, recipient decrypts directly from the sharer's PDS, no group state. Grants scale poorly though. Adding a new person to a folder of 200 files means 200 new grant records. Removing someone means deleting every grant they received.

A workspace inverts that. The content key for each file is wrapped under a single group key, and the group key is wrapped to each member individually. Adding a member is one operation regardless of how many files are in the workspace. Removing a member rotates the group key once; future uploads are inaccessible to them automatically.

Use workspaces for anything ongoing and multi-party: a family photo album, a research team's working files, a writing group's drafts.

Create a workspace #

create.ts
const { keyringUri, key } = await opake.createWorkspace(
  "Family Photos",
  "Shared vacation + milestone photos",
);

// key is the group key for this workspace. You don't usually hold onto
// it; subsequent operations re-resolve via keyringUri and the caller's
// Identity unwraps the member entry each time.
const fm = await opake.workspaceByUri(keyringUri);

createWorkspace generates a fresh group key inside WASM, wraps it to the caller's public key with the manager role (owners are always managers of their own workspace), writes the at.opake.keyring record to the caller's PDS, and writes the workspace root directory entry. The returned key is the group key bytes; you can hold onto it for immediate use, but you don't need to store it anywhere. Opake re-resolves via keyringUri on every subsequent operation.

List and watch workspaces #

list.ts
// One-shot list.
const workspaces = await opake.listWorkspaces();
for (const ws of workspaces) {
  console.log(ws.name, ws.role, ws.keyringUri);
}

// Subscription. Fires once immediately with the current snapshot,
// then again on every keyring:upsert / keyring:delete SSE event.
const watcher = opake.watchWorkspaces((snapshot) => {
  renderWorkspaceList(snapshot.entries, snapshot.loaded);
});

// Stop listening when you're done.
watcher.close();

listWorkspaces fetches from the indexer and decrypts workspace names inline. It also bootstraps the in-memory WorkspaceKeeper, so watchWorkspaces subscribers receive incremental updates from SSE events after the first call without the list round-tripping the indexer again.

@opake/react wraps this pattern as useWorkspaces with the subscription lifecycle handled. See React bindings when those pages land.

Add a member #

add-member.ts
// Resolve the invitee's DID once for UI affordances, then hand it off.
// Core handles the rest — resolves the hybrid public-key bundle inside
// WASM, wraps the group key to it, writes the updated keyring record.
const recipient = await opake.resolveIdentity(handle);

await opake.addWorkspaceMember(keyringUri, recipient.did, "editor");

// The workspace keyring record on the owner's PDS now has an extra
// member entry containing the group key wrapped to the invitee's
// hybrid bundle. They'll see the workspace in their next listWorkspaces.

The invitee needs an Opake public key published on their PDS. If resolveHandleAndPublicKey fails with RecipientNotReady, it means the target has a valid atproto identity but hasn't logged into Opake yet and therefore has no at.opake.publicKey/self record. You can queue a pending invite and wait for them to sign up; see Sharing for the pending-share mechanic, which applies the same way to workspace invites.

Three roles exist:

  • manager: can add, remove, and re-role members, supersede the keyring chain, and edit files like an editor. The keyring chain is manager-only — non-manager keyring supersedes are forked out at the indexer.
  • editor: can read everything, upload new documents, and supersede directory chains. Editor supersedes are checked against the editor-additivity invariant — the new entry set must be a superset of the prior canonical's. Removing other members' entries requires manager authority.
  • viewer: can read but not write. Viewer-authored supersedes are forked out at the indexer.

Roles are enforced at the indexer's authority layer over chain supersedes, not at the crypto layer. A viewer has the group key (that's what membership means) and can decrypt. Role enforcement stops them from advancing the canonical chain — their writes land on their PDS, but the indexer marks them forked-out and they never reach the canonical head. If your threat model requires a viewer who can't see content at all, they shouldn't be a workspace member.

Remove a member #

remove-member.ts
// Manager-authority required. Rotates the group key in place, re-wraps
// for remaining members, writes a keyring supersede on the caller's PDS.
const { rotation } = await opake.removeWorkspaceMember(keyringUri, memberDid);

// Existing documents stay readable by remaining members because
// keyHistory retains the prior rotation's member entries. Documents
// uploaded AFTER this point are wrapped under the new key, which the
// removed member doesn't have.
console.log("Rotated to", rotation);

Removing a member is the expensive operation by design. When a manager initiates it:

  1. A fresh group key is generated inside WASM.
  2. Every remaining member's entry in the keyring is re-wrapped to the new key.
  3. The previous rotation's wrapped keys are archived into keyHistory on the new keyring record so the remaining members can still decrypt older documents (which were wrapped under the old key).
  4. The rotation counter increments.
  5. The new keyring supersedes the prior chain head on the manager's PDS.
  6. Future uploads use the new key.

The removed member keeps whatever they already decrypted or cached locally. Rotation prevents new access, not retroactive access. If the threat model requires forward secrecy for existing documents, a manager has to re-upload them after rotation. Opake doesn't do this automatically.

Leave a workspace #

leave.ts
// Opt out of a workspace you're a member of. Any role can leave —
// the supersede is valid iff the only change is dropping yourself.
// Guards: the last member can't leave (that would orphan the
// workspace), and the only manager must promote someone first.
await opake.leaveWorkspace(keyringUri);

Any member can leave, whatever their role. The keyring chain is otherwise manager-only, but the indexer's authority layer admits a non-manager supersede when the sole membership change is the author dropping themselves — every remaining member's role carried over unchanged. Smuggle anything else into that supersede and it's rejected.

Two cases are refused client-side: leaving as the last member (workspace destruction isn't a supported operation) and leaving as the only manager (promote someone first). Leaving does not rotate the group key — the leaver would have to mint the new key themselves, so rotating buys no forward secrecy. Removal by a manager is the path that rotates.

How federated writes work #

Every member writes to their own PDS. There is no proposal flow. When an editor uploads a new document, supersedes a directory, or renames a file, the cascade lands on their PDS — no cross-PDS write authority required.

The directory tree under a workspace is split into per-path chains. One chain per path (/, /q1/, /q1/reports/, etc.); each chain has a sequence of at.opake.directory records linked by supersedes. The newest record in each chain is the canonical head. A write to a path:

  1. Produces a fresh directory record on the writer's PDS with supersedes: pointing at the prior canonical head (resolved via /api/workspace/chain-head).
  2. Cascades upward: each ancestor up to root is also superseded on the writer's PDS, with its child URI updated to point at the freshly-written supersede.
  3. The indexer chain-follows the supersedes and advances each affected path's head.

Roles are enforced at the indexer's authority layer:

  • Editor supersedes must satisfy the editor-additivity invariant. Additivity is supersede-aware: an editor may add entries, and may advance an existing entry by swapping in a target that supersedes the one it replaces (a wiki-style edit). What an editor can't do is drop an entry outright — a removal with nothing superseding it is a disguised delete, and is rejected.
  • Manager supersedes are unconstrained on directory chains, and are the only keyring writes accepted apart from a member's own self-removal.
  • Viewer supersedes are rejected — the record never reaches the index.

Concurrent supersedes against the same head race. The loser's record lands on their PDS and is still indexed, but the chain head doesn't advance to it, and the indexer emits a chain:forked event on the workspace topic naming the winner. Clients refetch, replay, and retry with bounded exponential backoff.

Reading MutationResult #

mutation-result.ts
// Every write returns MutationResult:
//   { uri: string | null }
//
// uri is the primary record's AT-URI when the mutation produces one
// (uploads, directory creates). Cascade-only mutations (deletes,
// member-list edits, supersedes that don't surface a "new primary")
// return uri: null.

const result = await fm.updateMetadata(documentUri, { filename: "v2.pdf" });
// The write is already on the caller's PDS. Optimistic UI is fine —
// the SSE echo will arrive and refresh the tree.
updateLocalTree(documentUri, "v2.pdf");

uri is populated for writes that produce a single primary record the caller would address (uploads, directory creates). Cascade-only mutations (deletes, member-list edits, supersedes of existing records) return uri: null — the cascade still wrote multiple records, the caller just doesn't have a single "new primary" to point at.

For optimistic UI, render the change immediately. The SSE echo for the matching directory:upsert arrives within ~1s on a healthy connection. If a concurrent writer races you, the chain:forked event triggers a refetch + replay; useTreeMutation in @opake/react wires this loop with a 4-retry cap.

A note on the indexer #

Workspaces are discovered via the Opake indexer, not the PDS firehose directly. listWorkspaces calls /api/keyrings, which takes no parameters — the caller's DID comes from the signed request — and returns the current keyring head of every workspace where that DID appears in the member list (including workspaces the caller owns, since owners are always listed as a member).

If you're running against a self-hosted indexer instead of the default, set the URL via the OPAKE_INDEXER_URL env var at build time, or call opake.setIndexerUrl() at runtime. See the account config mechanic in Authentication for how a user's preferred indexer syncs across their devices.