diff --git a/docs/api-contract.md b/docs/api-contract.md index b3fef7b..fb6590f 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -614,12 +614,12 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat ```text 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, shareCap, watch -storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph +inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch +storeRegistry: createEntityDoc, linkTo, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph ``` ### `@ng-eventually/client/polyfill` — `src/polyfill.ts` ```text -direct: EventuallyConfig, RegistrySession, StoreRegistryDeps, VirtualUserRecord, capFor, configure, configureStoreRegistry, connectedUser, getConfig, getStoreRegistryDeps, resetCaps, resetConfig, resetStoreRegistry, setCurrentUser, shareCap +direct: EventuallyConfig, RegistrySession, StoreRegistryDeps, VirtualUserRecord, configure, configureStoreRegistry, connectedUser, getConfig, getStoreRegistryDeps, hasCap, resetCaps, resetConfig, resetStoreRegistry, setCurrentUser, share ``` diff --git a/examples/notebook/app.ts b/examples/notebook/app.ts index 29164bf..006eaf9 100644 --- a/examples/notebook/app.ts +++ b/examples/notebook/app.ts @@ -37,7 +37,7 @@ import { type Nuri, type Scope, } from "@ng-eventually/client"; -import { capFor, configure, configureStoreRegistry, setCurrentUser } from "@ng-eventually/client/polyfill"; +import { configure, configureStoreRegistry, setCurrentUser } from "@ng-eventually/client/polyfill"; import { ng as realNg, init as realInit } from "@ng-org/web"; // --- the domain, such as it is --------------------------------------------- @@ -135,16 +135,15 @@ async function readSharedNote(link: string): Promise { } /** - * Hand a reader the key to one of my notes. + * Hand a reader access to one of my notes. * - * Names the PERSON. Where their inbox is, and whether they have one yet, is the - * library's business — an application will never handle an inbox address once this is - * native, so it does not handle one now. + * Names the NOTE and the PERSON — the two things this app has. Neither the key nor the + * recipient's inbox appears: an application will handle neither once this is native + * (upstream the verifier fills `ContactDetails.read_cap` itself), so it handles neither + * now. Refuses if the note is not mine to share. */ async function shareNote(doc: Nuri, withUser: string): Promise { - const cap = capFor(doc); - if (!cap) throw new Error("this note is not mine to share"); - await inbox.shareCap(cap, withUser); + await inbox.share(doc, withUser); } /** Open a note for messages — only its owner can, and only they will read them. */ diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index 77121de..5c0abf1 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -19,9 +19,7 @@ import { configure, configureStoreRegistry, setCurrentUser, - capFor, resetCaps, - shareCap, connectedUser, } from "@ng-eventually/client/polyfill"; import { @@ -873,7 +871,7 @@ const identity = new IdentityStore( setCurrentUser(ownerId); const doc = await storeRegistry.createEntityDoc(ownerId, "public"); const ownerInbox = await storeRegistry.openDocumentInbox(doc); - const link = capFor(doc)!; // the repo link the owner circulates + const link = storeRegistry.linkTo(doc); // the repo link the owner circulates setCurrentUser(depositorId); getCaps().learn(link); @@ -917,13 +915,12 @@ const identity = new IdentityStore( const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); injectedSetItems = [{ "@graph": doc, "@id": "1", v: "shared-item" }]; getCaps().open(doc, "protected"); - const cap = capFor(doc)!; setCurrentUser(friendId); const before = [...(libUseShape(null, null) as Iterable)].length; setCurrentUser("owner-O"); - await shareCap(cap, friendId); + await inbox.share(doc, friendId); setCurrentUser(friendId); const absorbed = await inbox.read(friendInbox); // processing it applies the cap diff --git a/packages/client/src/polyfill.ts b/packages/client/src/polyfill.ts index 1d379be..84d9a7d 100644 --- a/packages/client/src/polyfill.ts +++ b/packages/client/src/polyfill.ts @@ -22,7 +22,7 @@ export { getStoreRegistryDeps, resetStoreRegistry, setCurrentUser, - capFor, + hasCap, resetCaps, } from "./shared-wallet/bootstrap"; @@ -31,7 +31,7 @@ export { // lives in `inbox.ts` because sharing IS an inbox deposit (upstream: a sealed // message carrying the cap), but it is surfaced here so the cap vocabulary stays // on the polyfill side of the boundary rather than in the SDK-identical entry. -export { shareCap } from "./surface/inbox"; +export { share } from "./surface/inbox"; export { connectedUser } from "./emulated-verifier/connect"; // --- what is deliberately NOT published -------------------------------------- diff --git a/packages/client/src/shared-wallet/bootstrap.ts b/packages/client/src/shared-wallet/bootstrap.ts index e63b02e..8b4dde4 100644 --- a/packages/client/src/shared-wallet/bootstrap.ts +++ b/packages/client/src/shared-wallet/bootstrap.ts @@ -15,8 +15,9 @@ * The entry now only re-exports; the internals import this module instead. */ -import type { NgLike, UseShapeLike, Nuri, PrincipalId, ReadCap } from "../model/types"; +import type { NgLike, UseShapeLike, Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types"; import type { SharedWalletConfig } from "./access-gate"; +import { toNuri } from "../model/nuri"; import type { RegistrySession } from "./account-registry"; import { CapRegistry } from "../emulated-verifier/caps"; import { setAccessLog } from "./access-log"; @@ -207,15 +208,20 @@ export function getCaps(): CapRegistry { } /** - * Do I hold the cap of `nuri`? — the held-caps lookup, the ONLY way a cap is - * obtained besides being given one. Returns `undefined` when what I hold has none; - * that is the whole answer the model can give (there is no "may P read D?"). + * Do I hold this document's key? * - * Shorthand for `getCaps().capFor(nuri)`, exposed because it is the surface the - * consumer actually uses. + * The only question the model admits. There is no "may principal P read D" anywhere + * upstream and there cannot be: reading IS key possession, so a cap-introspection API + * would have to invent an ACL the engine does not have (`docs/api-contract.md` § 10). + * + * Returns a BOOLEAN, not the cap. It used to hand the value back, and the only consumer + * that used it did so to pass it to `shareCap` — which now takes the document instead. + * Nothing an application does requires holding a key: upstream it never sees one, the + * verifier fills `ContactDetails.read_cap` itself. So the surface answers the question + * and keeps the key. */ -export function capFor(nuri: Nuri): ReadCap | undefined { - return caps.capFor(nuri); +export function hasCap(nuri: NuriLike): boolean { + return caps.capFor(toNuri(nuri, "hasCap")) !== undefined; } /** diff --git a/packages/client/src/surface/inbox.ts b/packages/client/src/surface/inbox.ts index c358ea1..d908f6c 100644 --- a/packages/client/src/surface/inbox.ts +++ b/packages/client/src/surface/inbox.ts @@ -258,7 +258,7 @@ function capOfPayload(payload: unknown): ReadCap | null { } /** - * Share ONE document's read cap with ONE recipient, addressed by their inbox. + * Share ONE document with ONE recipient. * * The unit of sharing is the DOCUMENT: never hand over a store's cap, which would * give away everything the store contains, present and future. The recipient needs @@ -282,18 +282,21 @@ function capOfPayload(payload: unknown): ReadCap | null { * The shape is right; the implementation is absent at both ends, so we emulate it * meanwhile. */ -export async function shareCap(cap: ReadCap, toUser: string): Promise { - if (!hasReadCap(cap)) { +export async function share(doc: NuriLike, toUser: string): Promise { + const target = toNuri(doc, "inbox.share"); + // Names the DOCUMENT and the PERSON — the two things an application has. Neither the + // key nor the address appears, because a caller will handle neither once this is + // native: upstream the verifier fills `ContactDetails.read_cap` itself, and an inbox + // is resolved from a profile. This took `(cap, toInbox)` at first, then `(cap, toUser)`; + // both made the caller hold something it will not hold later. + const cap = getCaps().capFor(target); + if (!cap) { throw new Error( - "[ng-eventually] inbox.shareCap: expected a ReadCap (a NURI carrying `:r:`), " + - `got a bare reference — naming is not reading: ${JSON.stringify(cap)}`, + "[ng-eventually] inbox.share: this document is not yours to share — you hold no cap " + + `for it. A cap is looked up in what you hold, or it was delivered to you: ${JSON.stringify(target)}`, ); } - // Takes the RECIPIENT, not their inbox address. Sharing is an act toward someone; - // which inbox carries it is the library's business, and an address is exactly what a - // caller will not have to handle once this is native. It used to take `toInbox`, which - // forced every consumer to resolve an address first — a step it would then have to - // unlearn. Protected, because directed sharing is not a public announcement. + // Protected, because directed sharing is not a public announcement. await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } }); } diff --git a/packages/client/src/surface/placement.ts b/packages/client/src/surface/placement.ts index 28b7772..3ba5230 100644 --- a/packages/client/src/surface/placement.ts +++ b/packages/client/src/surface/placement.ts @@ -39,4 +39,37 @@ export { /** Open an inbox on a document you OWN, so others can deposit into it. */ /** 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; +} diff --git a/packages/client/test/cross-user-access.test.ts b/packages/client/test/cross-user-access.test.ts index 7022cbe..34f1f1f 100644 --- a/packages/client/test/cross-user-access.test.ts +++ b/packages/client/test/cross-user-access.test.ts @@ -24,9 +24,10 @@ 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,capFor,resetCaps,setCurrentUser,shareCap,connectedUser} from "../src/polyfill"; +import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill"; import { post, postToDocument, read as readInbox } from "../src/surface/inbox"; import { readUnion } from "../src/surface/read-model"; import { sparqlUpdate } from "../src/surface/docs"; @@ -219,9 +220,8 @@ async function aliceSetsUpHerDocuments() { // grants nothing. This is the whole point of the scenario. await write(pubDoc, REFERS_TO, protDoc); - const pubLink = capFor(pubDoc)!; // the shareable repo link of the public doc - const protCap = capFor(protDoc)!; // the cap Alice may hand to whoever she chooses - return { protDoc, pubDoc, pubLink, protCap }; + const pubLink = linkTo(pubDoc); // the shareable repo link of the public doc + return { protDoc, pubDoc, pubLink }; } /** Follow the reference found in the public document — what a reader actually does. */ @@ -245,19 +245,19 @@ test("Bob: reads the public document, sees the reference, and cannot read throug expect(ref).toBe(protDoc); // he can NAME Alice's protected document // …and that is all it gets him: no cap, no read. Publication is NOT recursive. - expect(capFor(ref)).toBeUndefined(); + expect(hasCap(ref)).toBe(false); expect(await readValues([ref], SECRET)).toEqual([]); }); test("Charlie: same public document, same reference — and he reads through it", async () => { inject(); - const { protDoc, pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments(); + const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); const CHARLIE_INBOX = await userInbox("charlie", "protected"); // Alice decides Charlie may read that ONE document, and delivers its cap to his // inbox. She names no principal to the registry; she addresses an inbox. setCurrentUser("alice"); - await shareCap(protCap, "charlie"); + await share(protDoc, "charlie"); setCurrentUser("charlie"); getCaps().learn(pubLink); @@ -265,17 +265,17 @@ test("Charlie: same public document, same reference — and he reads through it" const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO)); expect(ref).toBe(protDoc); - expect(capFor(ref)).toBe(protCap); + expect(hasCap(ref)).toBe(true); expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]); }); test("the ONLY difference between Bob and Charlie is each of them holds", async () => { inject(); - const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments(); + const { protDoc, pubLink } = await aliceSetsUpHerDocuments(); const CHARLIE_INBOX = await userInbox("charlie", "protected"); setCurrentUser("alice"); - await shareCap(protCap, "charlie"); + await share(protDoc, "charlie"); setCurrentUser("bob"); getCaps().learn(pubLink); @@ -294,7 +294,7 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async // that was empty becomes full — with nothing re-declared and nobody re-authorized. test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => { inject(); - const { pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments(); + const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); const BOB_INBOX = await userInbox("bob", "protected"); setCurrentUser("bob"); @@ -316,7 +316,7 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl // Alice delivers the cap. Bob's client processes his inbox — the only thing that // happens; no "receive" call exists. setCurrentUser("alice"); - await shareCap(protCap, "bob"); + await share(protDoc, "bob"); setCurrentUser("bob"); await readInbox(BOB_INBOX); @@ -326,7 +326,7 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl await new Promise((r) => setTimeout(r, 0)); // …and the read that was empty now yields the content. - expect(capFor(ref)).toBe(protCap); + expect(hasCap(ref)).toBe(true); expect(latest).toEqual(["the-protected-content"]); expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]); unsub(); @@ -334,7 +334,7 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl test("a bare reference to the PUBLIC document is not enough either — the link is", async () => { inject(); - const { pubDoc, pubLink } = await aliceSetsUpHerDocuments(); + const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); setCurrentUser("bob"); // Bob knows the public document's NURI but was never given its link. @@ -350,11 +350,11 @@ test("a bare reference to the PUBLIC document is not enough either — the link // Re-reading a queue to recover state is using it as a database. test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => { const ng = inject(); - const { protDoc, protCap } = await aliceSetsUpHerDocuments(); + const { protDoc } = await aliceSetsUpHerDocuments(); const bobInbox = await userInbox("bob", "protected"); setCurrentUser("alice"); - await shareCap(protCap, "bob"); + await share(protDoc, "bob"); // Bob connects: the library restores + drains, with nothing asked of the app. setCurrentUser("bob"); @@ -374,7 +374,7 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async // Connecting restores it — from the User branch, since the inbox has nothing left. await connectedUser(); - expect(capFor(protDoc)).toBe(protCap); + expect(hasCap(protDoc)).toBe(true); expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]); }); @@ -397,7 +397,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 = capFor(doc)!; // the repo link alice circulates — links DO travel + const link = linkTo(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 +423,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 = capFor(doc)!; + const link = linkTo(doc); // Bob holds the document — that is a READ right, and it is not ownership. setCurrentUser("bob"); @@ -437,7 +437,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 = capFor(doc)!; + const link = linkTo(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 +457,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 = capFor(doc)!; + const link = linkTo(doc); setCurrentUser("bob"); getCaps().learn(link); expect(await documentInboxAddress(doc)).toBe(dedicated); @@ -492,14 +492,14 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async // Two deposits, one at each level, both made by someone else. setCurrentUser("carol"); const carolDoc = await createEntityDoc("carol", "protected"); - await shareCap(capFor(carolDoc)!, "alice"); // a Link, to alice herself + await share(carolDoc, "alice"); // a Link, to alice herself await post(docInbox, { payload: { onTheDocument: true }, ts: 2 }); // Alice connects: one call, both queues. setCurrentUser("alice"); await connectedUser(); - expect(capFor(carolDoc)).toBeDefined(); // the Link was applied + expect(hasCap(carolDoc)).toBe(true); // the Link was applied expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here) const left = await readInbox(docInbox); expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays diff --git a/packages/client/test/isolation-active.test.ts b/packages/client/test/isolation-active.test.ts index 98fd64c..6186c2c 100644 --- a/packages/client/test/isolation-active.test.ts +++ b/packages/client/test/isolation-active.test.ts @@ -18,9 +18,10 @@ 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,capFor,resetCaps,setCurrentUser,shareCap} from "../src/polyfill"; +import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share} from "../src/polyfill"; import { read as readInbox } from "../src/surface/inbox"; import { filterReadable } from "../src/emulated-verifier/read-filter"; @@ -221,7 +222,7 @@ test("(a) sharing one document's cap to ONE inbox reveals it there, and only the // bob's OWN inbox — the only cross-wallet act there is. const bobInbox = await userInbox("bob", "protected"); setCurrentUser("alice"); - await shareCap(capFor(shared)!, "bob"); + await share(shared, "bob"); // bob processes his inbox — no dedicated "receive" operation exists. setCurrentUser("bob"); @@ -239,12 +240,12 @@ test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () = setCurrentUser("alice"); const doc = await createEntityDoc("alice", "protected"); const bobInbox = await userInbox("bob", "protected"); - await shareCap(capFor(doc)!, "bob"); + await share(doc, "bob"); setCurrentUser("bob"); const deposits = await readInbox(bobInbox); expect(deposits).toEqual([]); // infrastructure, not consumer data - expect(capFor(doc)).toBeDefined(); // …but it landed in bob's held caps + expect(hasCap(doc)).toBe(true); // …but it landed in bob's held caps }); // (b) A bare reference grants nothing; the repo link of a published document does. @@ -254,7 +255,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().isPublished(pub)).toBe(true); - const link = capFor(pub)!; + const link = linkTo(pub); // bob HAS the document's bare NURI (it is right there in `items`) and reads nothing. setCurrentUser("bob"); @@ -270,14 +271,13 @@ test("(c) switching identity switches heldByHolder — a returning identity keep inject(); setCurrentUser("alice"); const doc = await createEntityDoc("alice", "protected"); - const cap = capFor(doc); - expect(cap).toBeDefined(); + expect(hasCap(doc)).toBe(true); setCurrentUser("bob"); - expect(capFor(doc)).toBeUndefined(); + expect(hasCap(doc)).toBe(false); setCurrentUser("alice"); - expect(capFor(doc)).toBe(cap!); // durable across the switch — nothing re-declared + expect(hasCap(doc)).toBe(true); // durable across the switch — nothing re-declared }); // A virtual user IS a shim account, and the shim keys accounts through the @@ -289,18 +289,17 @@ test("one held caps per virtual WALLET, not per spelling of its id", async () => setCurrentUser("@Alice"); const doc = await createEntityDoc("@Alice", "protected"); - const cap = capFor(doc); - expect(cap).toBeDefined(); + expect(hasCap(doc)).toBe(true); // Same account, spelled differently — same shim account, so the same held caps. setCurrentUser("alice"); - expect(capFor(doc)).toBe(cap!); + expect(hasCap(doc)).toBe(true); setCurrentUser(" ALICE "); - expect(capFor(doc)).toBe(cap!); + expect(hasCap(doc)).toBe(true); // A genuinely different account still holds nothing. setCurrentUser("bob"); - expect(capFor(doc)).toBeUndefined(); + expect(hasCap(doc)).toBe(false); }); // THE BREACH P1a OPENED. Caps travel as inbox deposits, so an unguarded inbox read @@ -314,14 +313,14 @@ test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", asy const bobInbox = await userInbox("bob", "protected"); // Alice deposits into bob's inbox — allowed, and it grants her nothing back. - await shareCap(capFor(secret)!, "bob"); + await share(secret, "bob"); await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i); - expect(capFor(secret)).toBeDefined(); // still hers, obviously + expect(hasCap(secret)).toBe(true); // still hers, obviously // Mallory knows the NURI of bob's inbox and tries to pocket what is in it. setCurrentUser("mallory"); await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i); - expect(capFor(secret)).toBeUndefined(); // nothing was absorbed + expect(hasCap(secret)).toBe(false); // nothing was absorbed // Anonymous owns no inbox at all. setCurrentUser(null); @@ -330,7 +329,7 @@ test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", asy // Bob reads his own, and only then does the cap land. setCurrentUser("bob"); await readInbox(bobInbox); - expect(capFor(secret)).toBeDefined(); + expect(hasCap(secret)).toBe(true); }); test("a fresh session rebuilds the held caps from the scope index (the emulated AddRepo)", async () => { @@ -373,7 +372,7 @@ test("a document's cap is READ from the Store branch, never recomputed", async ( expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]); // Recomputing would have produced `:r:OK`; this is what was stored. - expect(capFor(doc)).toBe(`${doc}:r:FROM-THE-STORE` as ReadCap); + expect(getCaps().capFor(doc)).toBe(`${doc}:r:FROM-THE-STORE` as ReadCap); }); // The listing and the keys are separate upstream (Main vs Store branch), and the @@ -398,5 +397,5 @@ test("creation mints the cap ONCE — the stored value is the one held", async ( const doc = await createEntityDoc("alice", "protected"); const stored = ng._quads.find((q) => q.p === "urn:ng-eventually:shim:readCap")!; - expect(capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck + expect(getCaps().capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck }); diff --git a/packages/client/test/read-model.test.ts b/packages/client/test/read-model.test.ts index f25f761..b69a202 100644 --- a/packages/client/test/read-model.test.ts +++ b/packages/client/test/read-model.test.ts @@ -145,7 +145,7 @@ test("readUnion tolerates holes in the list, and refuses a malformed reference", // assembling a list from optional values should not have to compact it — while a // non-reference is a caller mistake worth a loud error. Validating before filtering // turned the first into the second. - inject(); + inject({}); await expect(readUnion(["", null as never, undefined as never])).resolves.toEqual([]); await expect(readUnion(["not-a-nuri"])).rejects.toThrow(/not a NextGraph reference/i); });