Beta

Files & directories

Once an identity is loaded, every file operation goes through a FileManager. The API is path-and-URI-based, mutations are atomic, and tree state is subscribable so your UI can reflect changes from other devices without polling. This page covers the surface as it applies to your personal cabinet; the same API reaches into shared workspaces, with a few semantics that carry over into Workspaces.

Getting a FileManager #

Two constructors on Opake, same return type:

get-file-manager.ts
// Personal files. One FileManager per Opake instance.
const cabinet = await opake.cabinet();

// Shared workspace. Resolved by URI; the WASM side unwraps the group
// key internally using the caller's Identity. Every member writes to
// their own PDS — federation cascades supersede the chain head on
// commit, with concurrent-write fork detection at the indexer.
const workspace = await opake.workspaceByUri(workspaceUri);

Each Opake instance can hand out multiple FileManagers, one per context. They don't share state, and freeing one doesn't affect the others; create as many as your UI needs and discard when the user navigates away.

The tree model #

A cabinet or workspace is a tree of directories (organisational containers, rendered in the UI as folders) and documents (actual files with encrypted content and metadata). Directories hold an ordered list of entries, each pointing to either another directory or a document by AT-URI.

Nothing in the tree is ever stored in plaintext on the PDS. Directory names and document metadata (filenames, MIME types, sizes, tags, descriptions) are encrypted with the same content key that protects the corresponding blob. The PDS sees opaque byte ranges and typed record shells.

Reading the tree #

Three functions to pick from, trading off freshness for speed:

reading-trees.ts
// Fast load. Uses whatever's in the local cache plus a delta sync;
// returns the directory structure only, no document metadata.
const tree = await fm.loadTree();

// Same tree, plus decrypted metadata for one directory's documents.
// Use this when rendering a directory view — the extra round-trips
// only happen for that one directory.
const withNames = await fm.loadTreeWithMetadata(currentDirectoryUri);

// Force a fresh PDS fetch before returning. Use sparingly; this bypasses
// the cache. Typically called after a write that invalidates state the
// indexer hasn't caught up to yet.
const fresh = await fm.syncAndLoadTree(currentDirectoryUri);

For rendering a file browser, loadTreeWithMetadata(currentDirectoryUri) is the right choice — you get the whole structure fast, and the directory the user is looking at has its document names decrypted. Other directories will render as "N items" placeholders until navigated to.

syncAndLoadTree forces a fresh fetch. Use it right after a mutation you want to confirm, or as a manual refresh action. Don't use it on every render; the SSE consumer keeps the cache current on its own (see Live updates).

Upload and download #

upload.ts
const data = await readAsBytes(userFile); // from your UI

const result = await fm.upload(data, "budget.pdf", "application/pdf", {
  description: "Q4 planning doc",
  tags: ["finance", "q4"],
  directoryUri: currentDirectoryUri, // where to place it in the tree
});

// Federated upload: the doc record + directory cascade land in a single
// applyWrites on the caller's PDS. The indexer chain-follows the new
// supersedes; other members see the entry on the next SSE echo.
notify("Uploaded.", result.uri);

upload generates a random AES-256-GCM content key inside WASM, encrypts the bytes, wraps the key to the caller's X25519 public key, and writes the document record plus blob to the PDS in a single applyWrites call. If you pass directoryUri, the same applyWrites also adds the new document as an entry in that directory, so the document never appears in the tree without its parent reference.

In a workspace, the same applyWrites also writes a directory cascade — new directory records superseding each ancestor up to the workspace root, with the writer's PDS as the authority. The indexer chain-follows the supersedes and emits directory:upsert events on the affected paths; other members see the new entry on the next SSE echo. Concurrent writers racing the same chain head are detected at the indexer; the loser receives chain:forked. See Workspaces for the federation model.

download.ts
const { filename, data } = await fm.download(documentUri);

// data is a Uint8Array — decrypted plaintext ready for use.
const blob = new Blob([data], { type: "application/octet-stream" });
saveAsFile(filename, blob);

download fetches the blob, unwraps the content key with the caller's private key, decrypts, and returns the plaintext as a Uint8Array plus the decrypted filename. Works identically whether the document lives in your own cabinet or in a workspace you're a member of.

Structural changes #

structure.ts
// Create a directory.
await fm.createDirectory("Photos", parentDirectoryUri);

// Move an entry (document or sub-directory) between directories.
await fm.move(entryUri, sourceDirectoryUri, targetDirectoryUri);

// Delete a document. parentDirectoryUri is required: the delete is
// atomic with removing this entry from the parent.
await fm.delete(documentUri, parentDirectoryUri);

// Recursively delete a directory and everything inside it.
await fm.deleteRecursive(directoryUri);

All four are atomic in the cabinet: createDirectory writes the new directory and updates the parent's entries in one applyWrites; move removes from source and adds to target in one call; delete removes the document record and its entry together; deleteRecursive walks the subtree and tears everything down in one batched operation.

In a workspace the same four operations cascade-supersede every affected path's chain on the caller's PDS — same atomic guarantees within a single applyWrites, plus indexer-side chain-following so the canonical heads advance for every member. See Workspaces for the federation model.

Editing an existing document #

update-metadata-content.ts
// Change metadata without re-uploading the blob.
await fm.updateMetadata(documentUri, {
  filename: "budget-final.pdf",
  description: "Approved version",
  tags: ["finance", "q4", "approved"],
});

// Replace document contents in place. Existing grants stay valid —
// updateContent re-encrypts with the same content key, so recipients
// don't need re-wrapping.
await fm.updateContent(documentUri, newBytes);

updateMetadata patches the encrypted metadata record; the blob is untouched, so shares and grants stay valid without re-wrapping. updateContent re-encrypts the blob with the same content key the document was originally uploaded with. Recipients who already have that key wrapped to them can decrypt the new version immediately. If you want forward secrecy across the edit, delete and re-upload instead.

Live updates #

The tree state isn't something you poll. FileManager.watchDirectory subscribes you to changes for a specific directory URI: uploads, deletes, renames, moves, and directory rearrangements fire the handler with the fresh snapshot. When the directory itself is deleted, the handler fires with null, and the watcher closes automatically.

watch-directory.ts
const watcher = fm.watchDirectory(directoryUri, (snapshot) => {
  if (snapshot === null) {
    // The directory was deleted (by the owner, or recursively from a
    // parent). Stop reading state that references it.
    handleDirectoryGone();
    return;
  }
  renderDirectory(snapshot);
});

// Stop listening when you're done — on UI teardown, logout, or before
// switching to a different directory.
watcher.close();

The watcher sits on top of the SSE consumer (see Live updates). When the consumer isn't started, the watcher still fires the initial snapshot from cache but won't update on remote changes.

@opake/react wraps watchDirectory as the useDirectory hook, with the same subscription semantics and the overlay layer for optimistic mutations folded in. See React bindings when those pages land.