diff --git a/docs/api-contract.md b/docs/api-contract.md index fb6590f..7c099a4 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -615,7 +615,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat direct: BaseType, DeepSignalSet, DocChange, DocChangeType, InboxScope, NG, NgLike, Nuri, NuriLike, PrincipalId, ReadCap, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape docs: depositInto, docCreate, sparqlQuery, sparqlUpdate inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch -storeRegistry: createEntityDoc, linkTo, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph +storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph ``` ### `@ng-eventually/client/polyfill` — `src/polyfill.ts` diff --git a/docs/readcap-and-nuri-model.md b/docs/readcap-and-nuri-model.md index 7c13d8d..49e8d91 100644 --- a/docs/readcap-and-nuri-model.md +++ b/docs/readcap-and-nuri-model.md @@ -16,12 +16,47 @@ Purpose: to give the ground truth of NextGraph's access-rights model, in order t > > - **A comment describing the CURRENT state is not the intent.** §3's DIRECTION block exists because `RepoLinkV0`'s comment was read as the target model. It is not. > - **A word you recognise probably does not mean what you think.** `branch` is not git's. `wallet` is only a keyring — what we call a virtual user is a **user** (a *site*). Check the type before using the word. +> - **The access rule is "whoever has the reference AND the key reads" — never "whoever has the reference reads".** An agent forgets this with remarkable consistency, because client-server reflexes supply a missing half: somewhere a server checks. Here nobody checks (§0 below). Getting it wrong does not fail — it silently grants. Every time you hand out a value, ask which of the two you just handed. > - **NextGraph's naming ALWAYS wins over any rule of ours — including the one that follows.** Where upstream has a word for something, that is the word, full stop. `publisher` is a case in point: it appears 126 times in the engine (`as_publisher`, `publisher_advert`) for a **pub/sub role on a topic**, and it must be used, unchanged, whenever that is what is meant. > - **What is banned is OUR loose "publish a document"**, which has no upstream referent and covers three different acts: *placing a document in a public store*, *making it findable*, and (in this emulation only) *handing out a key*. Every design discussion that used it drifted between them. NextGraph knows only the first — a document IS IN a public store, and brokers serve it accordingly. Say which act you mean. If you catch yourself writing "publish a document", you have not yet decided which one. This never licenses renaming something upstream calls `publisher`. > - **"I looked and it is not there" is not a finding.** §4quinquies once stated that no register existed for received caps, after checking one code path. `AddLink` had been sitting next to `AddRepo` in the same file the whole time. Absence needs at least as much evidence as presence — and an implementation *cache* (like local user storage) is never the model: it is what the model fills. --- +## 0. The security model, and why an agent keeps losing it + +**This is the one thing to hold on to.** Every design mistake this file records — a cap minted where a reference sufficed, a link that granted what it was meant to merely name, an "is it allowed?" call that cannot exist — comes from letting go of it. + +### The rule + +> A document is readable by whoever holds **the reference AND the key**. Not by whoever holds the reference. + +There is no third party in that sentence. No server, no ACL, no check at access time: the engine verifies a permission on WRITE only (`verify_permission` → `PermissionDenied` in `Commit::verify`), never on read. Content is encrypted; the key IS the right. That is what local-first means here — there is nobody to ask, so possession is the whole mechanism. + +### Why this is easy to lose + +Client-server reflexes supply the missing half without asking. In that world a link is an identifier, harmless on its own, and access is decided elsewhere — so handing out a URL feels safe, and "may Bob read this?" is a question with an answer. Both instincts are wrong here, and neither announces itself: code written on them **does not fail**, it silently grants. There is no error to notice, no test that goes red on its own. + +### What the rule buys — composable confidentiality + +Because a reference grants nothing, **a reference is not recursive**: a widely-circulated document may point at a restricted one. The reference says *it exists*; the key says *who reads it*. + +That is what lets confidentiality be composed inside one document: + +- a public summary pointing at restricted chapters, +- a public event pointing at its participant list, +- a profile pointing at data reserved for a circle. + +The author circulates one document and still decides, per referenced document, who gets the key. **Remove non-recursiveness and this whole construction collapses**: everything a circulated document mentions becomes readable by all its readers, and no level of confidentiality can exist inside a document one shares. + +`test/cross-user-access.test.ts` is this property's test, and it is the suite's centrepiece: Alice's document in a public store REFERENCES her protected one; Bob follows the reference, names the target, reads nothing of it. It writes the reference **bare** — which is exactly what an application must do. + +### The failure mode to watch for + +Any call that returns a key where a reference was asked for. It looks like a convenience ("give me the link to my document"), it type-checks, the tests stay green — and it converts "whoever has the reference and the key" into "whoever has the reference", for that document and every document it mentions. `linkTo` (added and removed 2026-08-06) was exactly this. + +--- + ## 1. A ReadCap = possession of a key, NOT a per-identity ACL A **ReadCap is fundamentally a cryptographic key that one holds**, not an ACL entry tied to a wallet. "Whoever holds the key can read." diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index 5c0abf1..7338a53 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -837,7 +837,7 @@ const identity = new IdentityStore( ]; setCurrentUser("owner-O"); getCaps().open("did:ng:o:protdoc", "protected"); - const link = getCaps().publishRepoLink("did:ng:o:pubdoc"); + const link = getCaps().recordInPublicStore("did:ng:o:pubdoc"); const ownerView = [...(libUseShape(null, null) as Iterable)].map((i) => i.v); // A stranger holds nothing — including the PUBLISHED document, until the repo // link reaches them (§5: whoever has the URL reads the content). @@ -871,7 +871,7 @@ const identity = new IdentityStore( setCurrentUser(ownerId); const doc = await storeRegistry.createEntityDoc(ownerId, "public"); const ownerInbox = await storeRegistry.openDocumentInbox(doc); - const link = storeRegistry.linkTo(doc); // the repo link the owner circulates + const link = getCaps().capFor(doc)!; // out-of-band: the harness plays 'the owner sent it' setCurrentUser(depositorId); getCaps().learn(link); diff --git a/packages/client/src/surface/placement.ts b/packages/client/src/surface/placement.ts index 3ba5230..f99b9aa 100644 --- a/packages/client/src/surface/placement.ts +++ b/packages/client/src/surface/placement.ts @@ -40,36 +40,16 @@ export { /** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */ } from "../shared-wallet/account-registry"; -import { getCaps } from "../shared-wallet/bootstrap"; -import { toNuri } from "../model/nuri"; -import type { NuriLike, ReadCap } from "../model/types"; export { openDocumentInbox } from "../emulated-verifier/branch-registers"; -/** - * The shareable link of a document — what you circulate so someone can open it. - * - * Distinct from {@link share}, and both are needed: a link is what TRAVELS (a message, - * a QR code, a page), whereas sharing hands the access to one named person through - * their inbox. Upstream the same split exists — a `RepoLinkV0 { read_cap }` is the - * thing you pass around, and `ContactDetails.read_cap` is the directed delivery. - * - * This is the one place an application legitimately holds a key, because a public - * document's link IS meant to be handled: you cannot circulate what you may not touch. - * A protected document's key never comes out this way — it goes through `share`. - * - * Typed `ReadCap`, since that is what it is — a reference with the key inside. A - * `ReadCap` is assignable wherever a `Nuri` is expected (a cap IS a NURI carrying the - * key, upstream's one `NuriV0`), so it hands straight to any call that takes a - * reference. Throws if you hold nothing: a link you cannot open is not a link. - */ -export function linkTo(doc: NuriLike): ReadCap { - const target = toNuri(doc, "linkTo"); - const cap = getCaps().capFor(target); - if (!cap) { - throw new Error( - "[ng-eventually] linkTo: you hold no key for this document, so there is no link " + - `to hand out: ${JSON.stringify(target)}`, - ); - } - return cap; -} +// No `linkTo` here, and its absence is deliberate (it existed 2026-08-06, one day). +// +// It returned a document's KEY where a caller would ask for its reference, which turns +// the access rule from "whoever has the reference AND the key reads" into "whoever has +// the reference reads" — see `docs/readcap-and-nuri-model.md` § 0. That is not a leak of +// hygiene, it is the rule changing: a document one circulates would grant everything it +// MENTIONS, and confidentiality could no longer be composed inside a shared document. +// +// An application names a document with the reference it already has (every call here +// returns bare ones), and grants access with `inbox.share(doc, toUser)`. What travels +// with a key in it is a deliberate act, not the result of asking for a link. diff --git a/packages/client/test/cross-user-access.test.ts b/packages/client/test/cross-user-access.test.ts index 34f1f1f..484105c 100644 --- a/packages/client/test/cross-user-access.test.ts +++ b/packages/client/test/cross-user-access.test.ts @@ -24,7 +24,6 @@ import { resetRegistryCache, userInbox, } from "../src/shared-wallet/account-registry"; -import { linkTo } from "../src/surface/placement"; import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers"; import type { RegistrySession } from "../src/shared-wallet/account-registry"; import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill"; @@ -220,7 +219,7 @@ async function aliceSetsUpHerDocuments() { // grants nothing. This is the whole point of the scenario. await write(pubDoc, REFERS_TO, protDoc); - const pubLink = linkTo(pubDoc); // the shareable repo link of the public doc + const pubLink = getCaps().capFor(pubDoc)!; // out-of-band: the test plays 'Alice sent Bob the link' // the shareable repo link of the public doc return { protDoc, pubDoc, pubLink }; } @@ -397,7 +396,7 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn const doc = await createEntityDoc("alice", "public"); const aliceInbox = await openDocumentInbox(doc); expect(aliceInbox).not.toBe(await userInbox("alice", "protected")); - const link = linkTo(doc); // the repo link alice circulates — links DO travel + const link = getCaps().capFor(doc)!; // the repo link alice circulates — links DO travel // Bob RESOLVES the address himself, from the document. The only thing he is handed // is the link, which is the one thing the model says circulates. The address is not @@ -423,7 +422,7 @@ test("opening an inbox on someone else's document is refused, not silently forke const doc = await createEntityDoc("alice", "public"); const aliceInbox = await openDocumentInbox(doc); - const link = linkTo(doc); + const link = getCaps().capFor(doc)!; // Bob holds the document — that is a READ right, and it is not ownership. setCurrentUser("bob"); @@ -437,7 +436,7 @@ test("a fresh document has NO inbox — one belongs to one document, and only it inject(); setCurrentUser("alice"); const doc = await createEntityDoc("alice", "public"); - const link = linkTo(doc); + const link = getCaps().capFor(doc)!; // Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo // (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents @@ -457,7 +456,7 @@ test("opening an inbox publishes ONE address, and re-opening does not accumulate const dedicated = await openDocumentInbox(doc); expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent - const link = linkTo(doc); + const link = getCaps().capFor(doc)!; setCurrentUser("bob"); getCaps().learn(link); expect(await documentInboxAddress(doc)).toBe(dedicated); diff --git a/packages/client/test/isolation-active.test.ts b/packages/client/test/isolation-active.test.ts index 81c66c0..65573a1 100644 --- a/packages/client/test/isolation-active.test.ts +++ b/packages/client/test/isolation-active.test.ts @@ -18,7 +18,6 @@ import { getCaps } from "../src/shared-wallet/bootstrap"; import { test, expect, mock, afterAll } from "bun:test"; import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry"; -import { linkTo } from "../src/surface/placement"; import type { RegistrySession } from "../src/shared-wallet/account-registry"; import type { ReadCap } from "../src/model/types"; import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share} from "../src/polyfill"; @@ -255,7 +254,7 @@ test("(b) a bare reference reads nothing; the repo link of a published document const pub = await createEntityDoc("alice", "public"); const items = [item(pub, "u1")]; expect(getCaps().isInPublicStore(pub)).toBe(true); - const link = linkTo(pub); + const link = getCaps().capFor(pub)!; // bob HAS the document's bare NURI (it is right there in `items`) and reads nothing. setCurrentUser("bob");