16e24f67f9
Suite du balayage commencé dans la doc : 20 occurrences de P1a/P1b dans les commentaires, les titres de tests et le README. Les phrases ont été récrites, pas substituées : « the breach P1a opened » devient « the breach that cap-surface opened », et « labelled P1b's » ne survivait pas à un nom plus long. Un lecteur qui n'a jamais entendu ni l'un ni l'autre doit comprendre la phrase. L'avertissement de déploiement du README garde sa force et gagne un nom : « "anonymous" or "private" until cap-enforcement lands per-document encryption. » Reste une occurrence dans e2e/polyfill-entry.ts, qui part avec le lot e2e.
538 lines
24 KiB
TypeScript
538 lines
24 KiB
TypeScript
/**
|
||
* The durable cap/inbox registers — this library's stand-in for the compartments the
|
||
* verifier maintains on a repo's own branches.
|
||
*
|
||
* Upstream these are not RDF at all: they are streams of service commits on branches
|
||
* whose CRDT is `BranchCrdt::None` (`engine/repo/src/types.rs:1420`). Each register here
|
||
* names its native counterpart:
|
||
*
|
||
* - **Store branch** — `AddRepo { read_cap }` (`engine/repo/src/types.rs:1890-1899`):
|
||
* the cap of a document you CREATED, filed beside the store that holds it. Replaying
|
||
* it is what reloads a store's documents with their keys (`AddRepo::verify` ->
|
||
* `Verifier::load_repo_from_read_cap`, `engine/verifier/src/verifier.rs:2237`).
|
||
* - **User branch, links** — `AddLink { read_cap }` (`types.rs:1939-1948`), *"so that a
|
||
* user can share with all its device a new Link they received"*, external repos only.
|
||
* - **User branch, inbox caps** — `AddInboxCap { repo_id, overlay, priv_key }`
|
||
* (`types.rs:1969-1981`): which inboxes you may READ. Keyed by `repo_id`, hence valid
|
||
* for ANY repo — `update_inbox_cap_v0` applies it with no `is_store` check
|
||
* (`engine/verifier/src/verifier.rs:1920`).
|
||
* - **Header branch** — a document's deposit ADDRESS, readable by any holder of it.
|
||
* The one register with NO native counterpart: upstream an address is TRANSMITTED
|
||
* (a message, a profile QR code), never published, and `inboxes: PubKey -> RepoId` is
|
||
* a per-session local table (`verifier.rs:105`). Publishing is our divergence, taken
|
||
* because an emulation has no message channel — see
|
||
* `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
||
*
|
||
* Why separate from the shim next door: these emulate the VERIFIER's bookkeeping and
|
||
* survive conceptually — at migration the native side keeps them, only our RDF
|
||
* representation goes. `shared-wallet/account-registry.ts` has no counterpart at all and
|
||
* evaporates. One file until 2026-08-03, two fates.
|
||
*
|
||
* The imports back into `shared-wallet/` are deliberate, visible cross-fate edges: a
|
||
* register needs the shim to know WHOSE it is, and where its store document lives. Every
|
||
* use sits inside a function body, so the module cycle is inert at evaluation time.
|
||
*/
|
||
|
||
import { sparqlQuery } from "../surface/docs";
|
||
import { registerUpdate } from "./register-write";
|
||
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||
import { escapeLiteral } from "../surface/sparql";
|
||
import { hasReadCap, isNuri, toNuri } from "../model/nuri";
|
||
import { mustNotAttempt } from "./reach";
|
||
import { fetchReadCap } from "./public-store";
|
||
import { ensureRepoOpen } from "./open-repo";
|
||
import { accessLogPrefix } from "../shared-wallet/access-log";
|
||
import {
|
||
P,
|
||
USER_BRANCH_SUBJECT,
|
||
STORE_BRANCH_SUBJECT,
|
||
HEADER_BRANCH_SUBJECT,
|
||
accountKey,
|
||
session,
|
||
readBindings,
|
||
bindingValue,
|
||
resolveAccount,
|
||
storeOf,
|
||
readUserStore,
|
||
userInbox,
|
||
createDoc,
|
||
ensureAccount,
|
||
recordInbox,
|
||
type VirtualUserRecord,
|
||
} from "../shared-wallet/account-registry";
|
||
import type { InboxScope, Nuri, NuriLike, ReadCap, Scope } from "../model/types";
|
||
|
||
/**
|
||
* Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the
|
||
* inbox read guard consults (`inbox.ts`). Anonymous holds no inbox, so it is false
|
||
* for everyone until an identity is set.
|
||
*/
|
||
export async function isOwnInbox(nuri: Nuri): Promise<boolean> {
|
||
const holder = getCurrentUser();
|
||
if (holder === null) return false;
|
||
// Either of the user's two inboxes counts as its own.
|
||
for (const scope of ["public", "protected"] as const) {
|
||
if ((await userInbox(holder, scope)) === nuri) return true;
|
||
}
|
||
// …and the inbox of any document this user opened one on (the emulated
|
||
// `AddInboxCap` records on its User branch).
|
||
return (await readInboxCapPairs()).some((p) => p.inbox === nuri);
|
||
}
|
||
|
||
// --- the cap side of a user's store ----------------------------------
|
||
|
||
/**
|
||
* File the caps of documents the CURRENT holder owns into what they hold — the
|
||
* emulated `AddRepo { read_cap }`.
|
||
*
|
||
* Upstream, creating a document commits an `AddRepo { read_cap }` into a typed
|
||
* branch of the store, and that branch — listing the store's documents, each with
|
||
* its read key — carries the owner's caps. Here the per-(account × scope) index
|
||
* document plays the store-container role, so it carries the caps too: a
|
||
* document appended to it on creation, or read back from it on a later session,
|
||
* puts its cap in the owner's hands with nothing for the consumer to do. That is
|
||
* what makes the invariant hold both ways — you never derive a cap from a bare
|
||
* reference, and yet a document's own creator is never locked out of it.
|
||
*
|
||
* Scoped to the current holder ON PURPOSE: another account's documents are listed
|
||
* by the cross-account fan-out (`listEntityDocs`), and those caps are emphatically
|
||
* not ours to hold. `id` is compared through the shim key, so it matches however
|
||
* the consumer spells the identity.
|
||
*/
|
||
export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void {
|
||
const holder = getCurrentUser();
|
||
if (holder === null || accountKey(holder) !== accountKey(id)) return;
|
||
const caps = getCaps();
|
||
// `learn(cap)`, not `open(doc, scope)` — the cap must be the SAME value that was
|
||
// written to the Store branch, not a second one minted from the NURI. They agree
|
||
// today only because the stand-in value is a constant; with a real key (cap-enforcement) a
|
||
// second mint would produce a DIFFERENT key and the document would be unreadable
|
||
// by the very session that created it. Mint once, store it, hold that one.
|
||
caps.learn(cap);
|
||
// Which store the document sits in is a registry fact, applied separately — and a
|
||
// MARK only, for the same reason the cap above is learned rather than re-minted.
|
||
if (scope === "public") caps.markInPublicStore(doc);
|
||
}
|
||
|
||
/**
|
||
* File the caps of the documents a virtual user owns BY BEING one: its three
|
||
* stores, and its inbox. They are as much its documents as any entity it creates,
|
||
* and without them it cannot even list its own content — the boundary would lock a
|
||
* user out of itself.
|
||
*
|
||
* Scoped to the current holder, like {@link holdOwnCap}: another user's stores are
|
||
* emphatically not ours to hold.
|
||
*/
|
||
export function fileOwnStructure(id: string, record: VirtualUserRecord): void {
|
||
const holder = getCurrentUser();
|
||
if (holder === null || accountKey(holder) !== accountKey(id)) return;
|
||
const caps = getCaps();
|
||
if (record.docPublic) caps.open(record.docPublic, "public");
|
||
if (record.docProtected) caps.open(record.docProtected, "protected");
|
||
if (record.docPrivate) caps.open(record.docPrivate, "private");
|
||
}
|
||
|
||
/** Same, for the user's own inbox — it is its document, and it must be able to
|
||
* read it. Depositing into someone else's needs no cap (see `register-write.depositInto`). */
|
||
export function fileOwnInbox(id: string, inbox: Nuri): void {
|
||
const holder = getCurrentUser();
|
||
if (holder === null || accountKey(holder) !== accountKey(id)) return;
|
||
getCaps().open(inbox, "private");
|
||
}
|
||
|
||
// --- per-entity documents + per-scope index -------------------------------
|
||
|
||
/**
|
||
* Publish WHERE to deposit for `doc`, on its Header branch — the compartment any
|
||
* holder of the document can read.
|
||
*
|
||
* Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's
|
||
* `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option<PrivKey>`),
|
||
* so two addresses on one document is a state the model has no meaning for — and a
|
||
* depositor picking the stale one writes where nobody reads.
|
||
*/
|
||
export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
|
||
const s = await session();
|
||
try {
|
||
// Two separate updates, not one compound statement: `DELETE WHERE { … }` is the
|
||
// form verified against the real broker (see
|
||
// `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update
|
||
// is not exercised anywhere in this lib.
|
||
await registerUpdate(
|
||
s.sessionId,
|
||
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
|
||
doc,
|
||
"publishInboxAddress:clear",
|
||
);
|
||
await registerUpdate(
|
||
s.sessionId,
|
||
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`,
|
||
doc,
|
||
"publishInboxAddress",
|
||
);
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " publishInboxAddress failed:", error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The ReadCaps recorded on a store's Store branch — its documents, each with its
|
||
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
|
||
* what it owns without recomputing anything.
|
||
*/
|
||
export async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> {
|
||
const s = await session();
|
||
const out: ReadCap[] = [];
|
||
try {
|
||
const res = await sparqlQuery(
|
||
s.sessionId,
|
||
`SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`,
|
||
undefined,
|
||
storeDoc,
|
||
"readStoreCaps",
|
||
);
|
||
for (const row of readBindings(res)) {
|
||
const v = bindingValue(row, "c");
|
||
if (v && hasReadCap(v)) out.push(v);
|
||
}
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " readStoreCaps failed:", error);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never
|
||
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
|
||
* function an app calls before `inbox.post`.
|
||
*
|
||
* Readable by whoever can read the document, because it lives on its Header branch —
|
||
* an address is public by nature (upstream a depositor needs only the inbox PUBLIC
|
||
* key). Conversely someone who cannot read the document learns nothing, which is
|
||
* faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to
|
||
* reach you.
|
||
*
|
||
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
|
||
* only its owner opens one, and only on its own document.
|
||
*/
|
||
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
|
||
// RULE 2 — do not even attempt. Not holding the document, we have no address to
|
||
// find: upstream the inbox pubkey travels WITH what you can read, so "where do I
|
||
// deposit for a document I cannot read" is not a refused question, it is a question
|
||
// with no referent. Answering `undefined` here keeps the caller's shape (an address
|
||
// or none) instead of turning the boundary into an exception it must catch.
|
||
// …but ask the (emulated) network first: a document in a public store serves its cap
|
||
// to whoever asks (public-store.ts), and "where do I deposit for this public
|
||
// document" is exactly the question a third party arrives with, holding nothing but
|
||
// the reference.
|
||
await fetchReadCap(doc);
|
||
if (mustNotAttempt(doc)) return undefined;
|
||
const s = await session();
|
||
try {
|
||
const res = await sparqlQuery(
|
||
s.sessionId,
|
||
`SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
|
||
undefined,
|
||
doc,
|
||
"documentInboxAddress",
|
||
);
|
||
for (const row of readBindings(res)) {
|
||
const a = bindingValue(row, "a");
|
||
if (a && isNuri(a)) return a;
|
||
}
|
||
} catch (error) {
|
||
// Unreadable document (no cap) or not synced → no address to give. Refusing to
|
||
// read is the boundary doing its job, not an error to propagate here.
|
||
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Does the connected user own `doc`? Answered from its **Store branches** — the
|
||
* register of the documents it created — across the three scopes, which is the only
|
||
* place that records authorship. Holding a cap is NOT ownership: a cap can be
|
||
* received, and a recipient must not be able to open an inbox on what it merely reads.
|
||
*/
|
||
export async function ownsDocument(doc: Nuri): Promise<boolean> {
|
||
const holder = getCurrentUser();
|
||
if (holder === null) return false;
|
||
const record = await resolveAccount(holder);
|
||
if (record === null) return false;
|
||
for (const scope of ["public", "protected", "private"] as const) {
|
||
const store = storeOf(record, scope);
|
||
if (!store) continue;
|
||
if ((await readUserStore(store)).includes(doc)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** The `(document, inbox)` pairs recorded on this user's User branch. */
|
||
/**
|
||
* Encode the `(document, inbox)` pair of an emulated `AddInboxCap` record.
|
||
*
|
||
* Upstream this is a TYPED structure — `AddInboxCapV0 { repo_id, overlay, priv_key }`
|
||
* (`engine/repo/src/types.rs:1973`) — carried by a service commit, not a string. Ours is
|
||
* one RDF literal because our User branch is a subject in a document, so the pairing has
|
||
* to live inside a value. That is the emulation's shape, and it is what migration
|
||
* replaces: the fields become fields again.
|
||
*
|
||
* The separator is a space, which is safe for a reason worth stating rather than
|
||
* assuming: a NURI is `did:ng:` followed by base64url and `:`-separated segments
|
||
* (`NuriV0`, `engine/net/src/app_protocol.rs`), an alphabet that contains no space. The
|
||
* assertion below turns that from an implicit property into a checked one — a silently
|
||
* mis-split pair would file an inbox under a truncated document and lose deposits with
|
||
* no error, which is exactly the failure class this whole path already paid for once.
|
||
*/
|
||
function encodeInboxCap(doc: Nuri, inbox: Nuri): string {
|
||
if (doc.includes(" ") || inbox.includes(" ")) {
|
||
throw new Error(
|
||
"[ng-eventually] branch-registers: a NURI containing a space cannot be paired in " +
|
||
`an inbox-cap record — the separator would be ambiguous: ${JSON.stringify([doc, inbox])}`,
|
||
);
|
||
}
|
||
return `${doc} ${inbox}`;
|
||
}
|
||
|
||
export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
|
||
const holder = getCurrentUser();
|
||
if (holder === null) return [];
|
||
const record = await resolveAccount(holder);
|
||
const store = record?.docPrivate;
|
||
if (!store) return [];
|
||
const s = await session();
|
||
const out: Array<{ doc: Nuri; inbox: Nuri }> = [];
|
||
try {
|
||
const res = await sparqlQuery(
|
||
s.sessionId,
|
||
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> ?c }`,
|
||
undefined,
|
||
store,
|
||
"readInboxCaps",
|
||
);
|
||
for (const row of readBindings(res)) {
|
||
// See `encodeInboxCap` for why a space is a safe separator here, and why this
|
||
// pairing exists at all.
|
||
const [doc, inbox] = bindingValue(row, "c").split(" ");
|
||
if (doc && inbox && isNuri(doc) && isNuri(inbox)) out.push({ doc, inbox });
|
||
}
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " readInboxCaps failed:", error);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** The inbox recorded for one document, if this user opened one. */
|
||
export async function readInboxCapsFor(doc: Nuri): Promise<Nuri | undefined> {
|
||
return (await readInboxCapPairs()).find((p) => p.doc === doc)?.inbox;
|
||
}
|
||
|
||
/**
|
||
* Every inbox this user may READ: its own, plus one per document it opened an
|
||
* inbox on. What `connect.connectedUser` drains, and what `isOwnInbox` answers from.
|
||
*/
|
||
export async function myInboxes(): Promise<Nuri[]> {
|
||
const holder = getCurrentUser();
|
||
if (holder === null) return [];
|
||
const out: Nuri[] = [];
|
||
// BOTH of the user's inboxes — public and protected — since upstream a site carries
|
||
// one on each of those two store repos (`engine/verifier/src/site.rs:127-152`).
|
||
if ((await resolveAccount(holder)) !== null) {
|
||
for (const scope of ["public", "protected"] as const) out.push(await userInbox(holder, scope));
|
||
}
|
||
for (const { inbox } of await readInboxCapPairs()) out.push(inbox);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* File a cap received for someone ELSE's document — the emulated
|
||
* `AddLink { read_cap }` on the User branch of the current user's private store.
|
||
*
|
||
* This is what makes a received cap DURABLE. Before it, a shared document survived
|
||
* only by re-reading the inbox every session, which uses a queue as a database:
|
||
* upstream an inbox is consumed, and processing a message *applies* it. Applying a
|
||
* Link means writing it here.
|
||
*
|
||
* Idempotent — re-applying the same Link is a no-op, so re-processing an inbox
|
||
* (a second tab, a reconnect) costs nothing.
|
||
*/
|
||
export async function addLink(cap: ReadCap): Promise<void> {
|
||
const holder = getCurrentUser();
|
||
if (holder === null) return;
|
||
const record = await ensureAccount(holder);
|
||
const store = record.docPrivate;
|
||
if (!store) return;
|
||
if ((await readLinks()).includes(cap)) return;
|
||
const s = await session();
|
||
try {
|
||
await registerUpdate(
|
||
s.sessionId,
|
||
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`,
|
||
store,
|
||
"addLink",
|
||
);
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " addLink failed:", error);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
/**
|
||
* The caps this user has received and applied — the User branch read back. Called
|
||
* at connection to restore what was shared with them, without touching any inbox.
|
||
*/
|
||
export async function readLinks(): Promise<ReadCap[]> {
|
||
const holder = getCurrentUser();
|
||
if (holder === null) return [];
|
||
const record = await ensureAccount(holder);
|
||
const store = record.docPrivate;
|
||
if (!store) return [];
|
||
const s = await session();
|
||
const out: ReadCap[] = [];
|
||
await ensureRepoOpen(store);
|
||
try {
|
||
const res = await sparqlQuery(
|
||
s.sessionId,
|
||
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`,
|
||
undefined,
|
||
store,
|
||
"readLinks",
|
||
);
|
||
for (const row of readBindings(res)) {
|
||
const v = bindingValue(row, "c");
|
||
if (v && hasReadCap(v)) out.push(v);
|
||
}
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " readLinks failed:", error);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
|
||
/**
|
||
* The inbox of a document this user owns — resolved, and created on first ask.
|
||
*
|
||
* Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`):
|
||
* an inbox is a keypair on the repo, whose PRIVATE half its owner holds. That half is
|
||
* recorded with `AddInboxCap { repo_id, overlay, priv_key }` — *"into the user branch,
|
||
* so that a user can share with all its device"* (`engine/repo/src/types.rs:1973`), the
|
||
* same branch that carries `AddLink`. So "which inboxes may I read" is answered by the
|
||
* User branch, and that is what this emulates.
|
||
*
|
||
* **The engine SUPPORTS this; nothing exercises it automatically.** Those are two
|
||
* different statements, and conflating them is what made an earlier version of this
|
||
* comment call the feature an "anticipation". It is not. `inbox: Option<PrivKey>` is a
|
||
* field of EVERY `Repo` (`engine/repo/src/repo.rs:126`), not of a store structure;
|
||
* `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`); and
|
||
* `update_inbox_cap_v0` applies it with `self.repos.get_mut(repo_id)` and **no
|
||
* `is_store` check of any kind** (`engine/verifier/src/verifier.rs:1920`). Generic by
|
||
* construction, and at any time (see the User-branch note above).
|
||
*
|
||
* What is true is narrower: no code path CREATES one for a document — `new_store_default`
|
||
* attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None`,
|
||
* and the only two `AddInboxCap` commits in the engine are for the
|
||
* public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So the
|
||
* capability exists and is simply unexposed above level 1: this function is aligned on
|
||
* the engine's model, it does not bet past it.
|
||
*
|
||
* *(The `inbox: None` claim is true; its citation was wrong until 2026-08-10. It pointed
|
||
* at `repo.rs:574`, inside `Repo::new_with_member` (`engine/repo/src/repo.rs:543`) —
|
||
* a constructor reached only from `Repo::new_with_perms`, itself gated
|
||
* `#[cfg(any(test, feature = "testing"))]` (`repo.rs:186-192`), and from `#[cfg(test)]`
|
||
* blocks (`branch.rs:387,490`; `commit.rs:1659,1849,1919`). The PRODUCTION path is
|
||
* `doc_create` → `Verifier::new_repo_default` (`engine/verifier/src/verifier.rs:3004`,
|
||
* called at `request_processor.rs:689`) → `Store::create_repo_default`
|
||
* (`engine/repo/src/store.rs:264`) → `create_repo_with_keys` (`store.rs:284`), which
|
||
* builds the `Repo` with `inbox: None` at `store.rs:691`.)*
|
||
*
|
||
* Lazy on purpose, for the same reason: creating an inbox document for every entity up
|
||
* front would double every `createEntityDoc` for inboxes most documents never receive
|
||
* anything in. Upstream the keypair is cheap; here an inbox is a document, so it is
|
||
* minted when first asked for.
|
||
*
|
||
* *(Not covered: ROTATING an inbox key — the engine's "update" case with a new
|
||
* `priv_key`. This function is idempotent and returns the existing inbox instead. A
|
||
* known limit, not an oversight.)*
|
||
*
|
||
* Only for a document this user OWNS — see {@link ownsDocument}. Opening an inbox on
|
||
* someone else's document would be usurpation, not a courtesy: the opener keeps the
|
||
* reading half, so it would silently divert to itself the deposits meant for the
|
||
* owner. To deposit into someone else's document, resolve
|
||
* {@link documentInboxAddress} and `inbox.post` into it.
|
||
*/
|
||
export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
|
||
// Permissive in, precise out — see `model/nuri.ts`. Published through
|
||
// `surface/placement.ts`, so it is a door an application types against.
|
||
const doc = toNuri(docLike, "openDocumentInbox");
|
||
const holder = getCurrentUser();
|
||
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
|
||
const known = (await readInboxCapsFor(doc)) ?? null;
|
||
if (known) return known;
|
||
|
||
// OWNERSHIP is the criterion, and holding a cap is NOT ownership — a cap can be
|
||
// received. Opening an inbox is what PUBLISHES this document's address, so a
|
||
// non-owner doing it would route the owner's deposits to itself, silently, on a
|
||
// document it merely reads.
|
||
//
|
||
// **This guard compensates OUR design, not an upstream constraint** — an earlier
|
||
// comment here claimed "upstream only the owner can commit `AddInboxCap`", which is
|
||
// false: that commit lands on the committer's OWN User branch, so anyone may write
|
||
// one naming anyone's repo. What protects upstream is that an inbox address is never
|
||
// PUBLISHED — it is TRANSMITTED (in a `ContactDetails` message, or a profile QR
|
||
// code), and `inboxes: PubKey → RepoId` is a table of the VERIFIER
|
||
// (`engine/verifier/src/verifier.rs:105`) — one per user. A forged pair reaches
|
||
// nobody because it only ever lands in the forger's OWN table; nobody else was told.
|
||
//
|
||
// The motive matters, and it was wrong here until 2026-08-10: this comment said the
|
||
// table is "rebuilt empty each session", which is not what the source does. It is
|
||
// initialized empty at construction (`:520`, `:2820`) and then REPOPULATED at every
|
||
// load — `Verifier::load` (`:534-566`) → `add_repo_without_saving` (`:2871`) →
|
||
// `add_repo_` (`:2887`), which re-inserts `repo.inbox.to_pub() → repo.id` for each
|
||
// repo it reloads — and the inbox private key itself is persisted per repo
|
||
// (`INBOX_CAP`, `engine/verifier/src/user_storage/repo.rs:61,171,207,362`). So the
|
||
// knowledge is durable; what it is not is SHARED. Per-verifier, not ephemeral.
|
||
//
|
||
// We publish instead of transmitting — the only way a third party can find the
|
||
// address at all here — which creates a vector upstream does not have: whoever can
|
||
// write the document can redirect its deposits. Hence this guard. It is a real
|
||
// divergence, deliberately taken; see `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
||
if (!(await ownsDocument(doc))) {
|
||
throw new Error(
|
||
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
|
||
"you own. To reach its owner, name the DOCUMENT: `inbox.postToDocument(doc, …)`, " +
|
||
`which resolves the address itself: ${JSON.stringify(doc)}`,
|
||
);
|
||
}
|
||
|
||
const inbox = await createDoc();
|
||
const s = await session();
|
||
const record = await ensureAccount(holder);
|
||
const store = record.docPrivate;
|
||
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
|
||
// …and the shim records that it IS an inbox, so a depositor can find that out without
|
||
// holding anything of it. See `recordInbox`: upstream a deposit cannot address a plain
|
||
// document at all, and this is what stands in for that impossibility.
|
||
await recordInbox(inbox);
|
||
if (store) {
|
||
try {
|
||
await registerUpdate(
|
||
s.sessionId,
|
||
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(encodeInboxCap(doc, inbox))}" }`,
|
||
store,
|
||
"openDocumentInbox",
|
||
);
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
|
||
}
|
||
}
|
||
// …and the PUBLIC half, in the document itself, so a depositor can find it at all.
|
||
// Without this the inbox is reachable only by its owner — the opposite of what an
|
||
// inbox is for, and the bug this path shipped with.
|
||
await publishInboxAddress(doc, inbox);
|
||
return inbox;
|
||
}
|
||
|