Sharing
A grant is an at.opake.grant record on the sharer's PDS that wraps a document's content key
to one specific recipient's public key. One record, one recipient, one document. This is the
minimal sharing primitive — for folders of files shared with multiple people, use
workspaces instead.
Sharing a document #
// Resolve the recipient first. This returns the recipient's hybrid public-key
// bundle: x25519PublicKey (classical) + mlKemPublicKey (post-quantum), plus
// the algo strings advertised on their at.opake.publicKey/self record.
const recipient = await opake.resolveIdentity("bob.bsky.social");
// Direct share. Writes an at.opake.grant record on YOUR PDS that wraps
// the document's content key to BOTH halves of the recipient's hybrid
// bundle. The grant lives under your repo; the recipient discovers it
// via the indexer.
const fm = await opake.cabinet();
await fm.share(
documentUri,
recipient.did,
recipient.x25519PublicKey,
recipient.mlKemPublicKey,
"read",
"For your review — draft v2",
);resolveIdentity takes a handle or DID, walks the atproto identity chain (handle resolution,
DID document, PDS discovery), and fetches the target's at.opake.publicKey/self record. The
result is everything fm.share needs: the recipient's DID and their X25519 public key.
The grant itself lives on your PDS, not the recipient's. The recipient discovers it through
the indexer's /api/inbox endpoint, which scans the firehose for grants that name them as the
recipient. No write permission to the recipient's repository is needed.
When the recipient hasn't set up Opake yet #
If resolveIdentity succeeds at the atproto layer (the handle exists, the DID resolves, the
PDS is reachable) but there's no at.opake.publicKey/self record, you get RecipientNotReady.
Handle it by queueing:
import { OpakeError } from "@opake/sdk";
try {
const recipient = await opake.resolveIdentity(handleOrDid);
await fm.share(
documentUri,
recipient.did,
recipient.x25519PublicKey,
recipient.mlKemPublicKey,
"read",
);
} catch (err) {
if (err instanceof OpakeError && err.kind === "RecipientNotReady") {
// The target has a valid atproto identity but hasn't published an
// Opake public key yet (hasn't used Opake). Queue a pending share;
// the daemon will retry until they sign up or it expires (7 days).
await fm.createPendingShare(documentUri, handleOrDid, "read", null);
return;
}
throw err;
}createPendingShare writes an at.opake.pendingShare record on your PDS. The daemon
periodically resolves the target handle and tries again. When the recipient eventually logs
into Opake and publishes their public key, the next retry succeeds: a real grant is written
and the pending share is deleted. Pending shares expire after seven days.
Listing outgoing grants #
const grants = await fm.listShares();
for (const g of grants) {
// g: { uri, document, recipient, createdAt }
console.log(`Shared ${g.document} with ${g.recipient}`);
}
// Grants don't carry the document filename directly; metadata stays
// encrypted on the wire. If you need names, do a separate
// getDocumentMetadata lookup per document URI.Grants are per-document, per-recipient. A "folder" of shared files is a collection of grants
that happen to target documents in the same directory, not a distinct record type. Grouping
them in your UI is the consumer's job. listShares gives you the flat list.
Revoking #
await fm.revokeShare(grantUri);
// The grant record is deleted from your PDS. The indexer removes it
// from its index on the next firehose event; the recipient's
// watchInbox fires with the updated (shorter) list.
//
// Caveat: revocation is forward-only. If the recipient already
// decrypted and saved the key, you can't take that back. If the
// document needs to be unreadable to the ex-recipient going forward,
// delete-and-reupload instead of revoke.Revocation is forward-only. The record is gone from your PDS after revoke, and the indexer removes it from the recipient's inbox shortly after that, but you can't claw back the content key the recipient already unwrapped. Opake's crypto model is identical to git-crypt here: once a recipient has the content key, they have it, and they can decrypt any copy of the ciphertext they kept. For actual forward secrecy on a single document, delete and re-upload with a fresh content key, then re-grant to the people who should still have access.
Receiving shares #
const grants = await opake.listInbox();
for (const g of grants) {
// g: { uri, ownerDid, documentUri, createdAt }
// ownerDid is the DID of whoever shared with you.
console.log(`Shared with you: ${g.documentUri} from ${g.authorDid}`);
}listInbox returns all grants visible to you via the indexer. Pair it with a subscription so
your UI reflects new shares in real time:
const watcher = opake.watchInbox((snapshot) => {
// snapshot.loaded is false while the keeper is bootstrapping. Once
// the initial listInbox completes, it flips to true and fires again
// with the current entries.
renderInbox(snapshot.entries, snapshot.loaded);
});
// Stop listening when you're done.
watcher.close();@opake/react wraps watchInbox as the useInbox hook. See React
bindings when those pages land.
Downloading shared content #
// Peek at the metadata (filename, MIME type, size, etc.) without
// downloading the blob. Useful for rendering a "shared with me" list.
const { filename, metadata } = await opake.resolveGrantMetadata(grantUri);
// Download and decrypt in full.
const { filename: f, data } = await opake.downloadFromGrant(grantUri);
saveAsFile(f, new Blob([data]));downloadFromGrant walks the full chain: fetches the grant, fetches the document record from
the sharer's PDS, fetches the blob, unwraps the content key with your private X25519 key, and
decrypts. All unauthenticated reads — atproto record and blob endpoints are public by design.
resolveGrantMetadata is the cheaper variant. It fetches the grant and document record but
skips the blob; you get the filename, MIME type, size, and tags decrypted, enough to render a
"shared with me" list without paying download costs for files the user hasn't opened yet.
Pending shares: the queue #
// List queued shares that haven't landed as grants yet.
const pending = await opake.listPendingShares();
for (const p of pending) {
// p: { uri, document, recipient, createdAt }
console.log(`Pending: ${p.document} → ${p.recipient}`);
}
// Manually kick the retry loop. The daemon runs this on a schedule too.
const outcome = await opake.retryPendingShares();
// { checked, completed, expired, still_pending, failed }
// Cancel a specific pending share.
await opake.cancelPendingShare(pendingShareUri);The daemon runs retryPendingShares on a schedule (default every 15 minutes), so in most apps
you don't call it manually. Exposing it on the SDK is useful for a "retry now" button in a
settings panel, or for tests that don't want to wait. The outcome shape lets you report
progress: how many pending shares were checked, how many got promoted to real grants, how many
expired, how many are still waiting, and how many errored out for other reasons.
cancelPendingShare is the "I changed my mind" path. Deletes the record, no retry fires
again, nothing lands even if the recipient signs up tomorrow.