diff --git a/.project/concepts/app-contract/_debt.md b/.project/concepts/app-contract/_debt.md new file mode 100644 index 0000000..9e3ef19 --- /dev/null +++ b/.project/concepts/app-contract/_debt.md @@ -0,0 +1,13 @@ +# Doc-debt — app-contract + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Raw markers (consolidate into blocks, then delete) +- TOUCHED packages/polyfill/src/surface/docs.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/src/surface/read-model.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/src/surface/inbox.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/src/surface/subscribe.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/src/surface/ng-proxy.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/src/index.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED docs/api-contract.md @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/.project/concepts/app-contract/contract_polyfill-surface.md b/.project/concepts/app-contract/contract_polyfill-surface.md index b2ffaad..efdc269 100644 --- a/.project/concepts/app-contract/contract_polyfill-surface.md +++ b/.project/concepts/app-contract/contract_polyfill-surface.md @@ -61,9 +61,14 @@ export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: Doc // ── low-level document / SPARQL primitives ─────────────────────────────── export const docs: { - docCreate(sessionId: string, crdt: string, cls: string, dest: string, store?: unknown): Promise; - sparqlQuery(sessionId: string, query: string, base?: string, anchor?: NuriLike, label?: string): Promise; - sparqlUpdate(sessionId: string, query: string, anchor?: NuriLike, label?: string): Promise; + // `sessionId` is `string | number` — upstream's own declared type (`Session.session_id`). + // It is RELAYED, never converted: the wasm side deserializes a `u64`, and stringifying it + // fails for real (`Deserialization error of session_id JsValue("1")`). + docCreate(sessionId: string | number, crdt: string, cls: string, dest: string, store?: unknown): Promise; + sparqlQuery(sessionId: string | number, query: string, base?: string, anchor?: NuriLike, label?: string): Promise; + // Returns the commits the update produced, as upstream does (it typed this `void` until + // 2026-08-14 while already relaying the value). A caller that ignores it is unaffected. + sparqlUpdate(sessionId: string | number, query: string, anchor?: NuriLike, label?: string): Promise; }; // ── inbox: giving to read, and depositing ──────────────────────────────── @@ -76,12 +81,15 @@ export const inbox: { readSynced(targetInbox: NuriLike): Promise; processInbox(targetInbox: NuriLike): Promise; watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void; - materialize: typeof read; + // `materialize` (a second published name for `read`) was REMOVED on 2026-08-14 — + // an alias with no call site, and no counterpart upstream. Use `read`. }; export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number } // ── the wrapped SDK objects ────────────────────────────────────────────── -export const ng: Record; // call this instead of the `ng` passed to `configure` +export const ng: NG; // call this instead of the `ng` passed to `configure` + // `NG` is upstream's own type (`@ng-org/web`), 88 typed + // members; it was `Record` until 2026-08-14 export function init(...args: any[]): any; // likewise — not the `init` passed to `configure` export function initNg(...args: any[]): any; ``` diff --git a/.project/concepts/sign-in/_debt.md b/.project/concepts/sign-in/_debt.md index 834742c..f16361b 100644 --- a/.project/concepts/sign-in/_debt.md +++ b/.project/concepts/sign-in/_debt.md @@ -5,3 +5,5 @@ ## Raw markers (consolidate into blocks, then delete) - TOUCHED packages/polyfill/src/shared-wallet/account-registry.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/src/shared-wallet/physical.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/src/shared-wallet/session.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/docs/api-contract.md b/docs/api-contract.md index c921f2e..5c88cb1 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -135,19 +135,19 @@ Why it lives here and not in the consumer application: the first consumer had ~3 ### Today — `@ng-eventually/polyfill` ```ts -// index.ts:55 -export const ng: Record; -// type re-export, index.ts:50 +// index.ts:152 +export const ng: NG; +// type re-export, index.ts:86 export type { NG } from "@ng-org/web"; ``` -`ng` is a `Proxy` (`ng-proxy.ts:10`) forwarding every property to the injected real `ng`, overriding exactly two things: `login` / `session_start` (currently a passthrough with a TODO for shared-wallet credentials) and `sparql_update` (the emulated write-cap guard, rejecting a write when a write policy governs the anchored document and the current user lacks the cap). +`ng` is a `Proxy` (`ng-proxy.ts:26`) forwarding every property to the injected real `ng`, overriding exactly one thing: `sparql_update` (the emulated write-cap guard, rejecting a write when a write policy governs the anchored document and the current user lacks the cap). `session_start` is intercepted but is a pure passthrough today, with a TODO for shared-wallet credentials. (`login` was named here until 2026-08-14 but has not existed since 2026-08-03: `@ng-org/web` exposes no such method, and the proxy was FABRICATING it.) ### Target -**PASSTHROUGH (level 2, VERIFIED).** `export declare const ng: NG` with `NG = typeof NGModule`, 77 exported members (`index.d.ts:136-231`). The surface is identical by construction — the proxy adds no member and removes none. +**PASSTHROUGH (level 2, VERIFIED).** `export declare const ng: NG` with `NG = typeof NGModule`, **88** exported members (`index.d.ts:136-231`; the namespace body is `:140-231`). The surface is identical by construction — the proxy adds no member and removes none — **and identical at the type level too since 2026-08-14**, when the published `ng` stopped being declared `Record` and took `NG`, upstream's own type. That escape hatch silently dropped all 88 signatures: an application got no completion, and a misspelt member typechecked. -The two overrides: +The overrides: - `session_start(wallet_name: string, user_id: any): Promise` (`index.d.ts:276`) — target signature unchanged; only the emulated credential injection disappears. - `sparql_update(session_id: any, sparql: string, nuri: any): Promise` (`index.d.ts:297`) — target signature unchanged. The native enforcement the guard stands in for is the engine's permission model (`verify_perm`, `engine/repo/src/commit.rs:897`), which today is **called only from tests** (its enclosing `Commit::verify` has no runtime caller — see `docs/nextgraph-current-state.md` § *Author-signature verification*). That absence says nothing about the target: write permissions are the engine's declared model, so the guard's *behaviour* (a refused write) is target-shaped even though its *mechanism* (a JS-side check) is emulation. Known limit, documented in `README.md`: the guard fires only on this proxy, and the lib's own writers call the injected `ng` directly, so it is best-effort until cap-enforcement. @@ -276,24 +276,24 @@ The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query ### Today — `@ng-eventually/polyfill` (namespace `docs`) ```ts -// docs.ts:46 +// docs.ts:53 export async function docCreate( - sessionId: string, + sessionId: string | number, crdt: string, cls: string, dest: string, store?: unknown, ): Promise; -// docs.ts:85 +// docs.ts:101 export async function sparqlUpdate( - sessionId: string, + sessionId: string | number, query: string, anchor?: Nuri, label = "sparqlUpdate", -): Promise; -// docs.ts:130 +): Promise; +// docs.ts:146 export async function sparqlQuery( - sessionId: string, + sessionId: string | number, query: string, base?: string, anchor?: Nuri, @@ -309,7 +309,12 @@ export async function sparqlQuery( ### Target -**PASSTHROUGH (level 2, VERIFIED)** — these mirror the real methods 1:1 minus the trailing `label` (a lib-internal access-log tag, never forwarded): +**PASSTHROUGH (level 2, VERIFIED)** — these forward to the real methods, and the ONLY argument they do not pass on is the trailing `label` (a lib-internal access-log tag). Until 2026-08-14 this entry claimed a 1:1 mirror, which was false in two ways, both now fixed rather than documented as deltas: + +- **The session id is `string | number`, upstream's own declared type for it** (`Session.session_id`, `sdk/js/web/src/index.ts:16` and the installed `index.d.ts:266`) — it used to be narrowed to `string` here, which made the value the SDK hands an application impossible to pass back into this library. It is RELAYED, never converted: the wasm side deserializes a `u64` (`sdk/js/lib-wasm/src/lib.rs:352-358` `sparql_query`, `:452-457` `sparql_update`, `:1575` `doc_create`), and stringifying it fails that deserialization for real — observed as `Deserialization error of session_id JsValue("1")`. +- **`sparqlUpdate` returns what the real method returns** — the commits the update produced (`lib.rs:481-483` serialises `AppResponseV0::Commits`; the installed `index.d.ts:297` types it `Promise`). It was declared `Promise` while already relaying the value at runtime, so the answer was thrown away for every caller. Typed `unknown` rather than `any`, exactly as `sparqlQuery` already renders the same upstream `Promise`. A caller that ignores it is unaffected. + +The real signatures forwarded to: ```ts // index.d.ts:60 — the installed web SDK's doc_create @@ -389,7 +394,9 @@ export async function postToDocument(doc: NuriLike, opts: PostOptions): Promise< export async function share(doc: NuriLike, toUser: string): Promise; export async function read(targetInbox: NuriLike): Promise; export async function readForDocument(doc: NuriLike): Promise; -export const materialize = read; +// `materialize` — a second published name for `read` — was REMOVED on 2026-08-14. It was +// an alias and nothing else: no call site, and upstream has no such member, so it was a +// symbol an application could learn and would then have to unlearn. Use `read`. export async function readSynced(targetInbox: NuriLike): Promise; export async function processInbox(targetInbox: NuriLike): Promise; export function watch( @@ -412,7 +419,7 @@ Consequences per function: - `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown** — `docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the address→repo association lives in `inboxes: PubKey → RepoId`, a table of the **verifier** — one per user, `verifier.rs:105`). *(Corrected 2026-08-10: this said the table was "session-local, rebuilt empty". It is initialized empty (`verifier.rs:520,2820`) and then repopulated at every load — `Verifier::load` → `add_repo_without_saving` → `add_repo_`, `verifier.rs:534-566,2871,2887` — with the inbox private key persisted per repo, `user_storage/repo.rs:61,171,207,362`. The property that matters is that it is **per verifier**, not that it is ephemeral.)* Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`. - `share` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated. -- `read` / `materialize` / `readSynced` / `processInbox` / `watch` — **stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient. +- `read` / `readSynced` / `processInbox` / `watch` — **stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient. - `readForDocument(doc)` — the owner's side of a document's inbox, named by the DOCUMENT. Same LEVEL-1 SHAPE ruling as `read`: it is the recipient's own processing, which has no consumer-facing JS surface upstream, and enumerating its deposits is emulation detail. It exists so an application never handles an inbox address. - `share(doc, toUser)` **refuses an unknown recipient** since 2026-08-10. It used to provision one: a mistyped name minted that name's stores and an inbox, and the cap landed where nobody looks. Upstream a deposit is sealed to an inbox pubkey that reached you through an inbound contact, so you cannot address a name you invented. - `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15. @@ -531,7 +538,7 @@ interface VirtualUserRecord { } // RegistrySession is INTERNAL since 2026-08-12 (shape kept here for the ruling below). interface RegistrySession { - sessionId: string; + sessionId: string | number; // relayed untouched — upstream's own type (§ 7) privateStoreId: string; protectedStoreId?: string; publicStoreId?: string; @@ -661,7 +668,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat - ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery". - ~~**`share` is importable from both entries.**~~ **FIXED 2026-08-07** with the entry merge: there is one entry and one `share`, under `inbox`. - **One entry means the import line no longer says what disappears.** Until 2026-08-07 a second import path (`/polyfill`) WAS the deletion list. It is now the `POLYFILL-ERA` block in `src/index.ts`, this appendix's note above, and the per-subject rulings in this document. That is a documentation-carried signal where it used to be a mechanical one — the appendix is pinned by a test, the grouping is not. -- **`inbox.read`/`materialize` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely. +- **`inbox.read` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely. - **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API. - **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `watchShape`'s design already assumes). - **The sync-returning `subscribeDoc` unsubscribe** vs the target's promise-resolved one (§ 8) — a deliberate, documented ergonomic delta; an adapter is one line at migration, but it is a delta. @@ -677,7 +684,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat ```text direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, NG, NgLike, Nuri, NuriLike, PrincipalId, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape docs: docCreate, sparqlQuery, sparqlUpdate -inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch +inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph ``` diff --git a/packages/polyfill/src/emulated-verifier/register-write.ts b/packages/polyfill/src/emulated-verifier/register-write.ts index 73c3477..156d16d 100644 --- a/packages/polyfill/src/emulated-verifier/register-write.ts +++ b/packages/polyfill/src/emulated-verifier/register-write.ts @@ -32,7 +32,7 @@ import type { Nuri } from "../model/types"; * nothing else. Never exported from the package. */ export async function registerUpdate( - sessionId: string, + sessionId: string | number, query: string, anchor: Nuri, label = "registerUpdate", @@ -66,7 +66,7 @@ export async function registerUpdate( * only caller, and reading is guarded separately (`inbox.read`). */ export async function depositInto( - sessionId: string, + sessionId: string | number, query: string, targetInbox: Nuri, label = "deposit", diff --git a/packages/polyfill/src/index.ts b/packages/polyfill/src/index.ts index 50f6ae9..b44a580 100644 --- a/packages/polyfill/src/index.ts +++ b/packages/polyfill/src/index.ts @@ -139,7 +139,15 @@ export type { EventuallyConfig } from "./shared-wallet/bootstrap"; export { ensureIdentity } from "./shared-wallet/access-gate"; export type { SharedWalletConfig } from "./shared-wallet/access-gate"; +import type { NG } from "@ng-org/web"; + import { makeNg } from "./surface/ng-proxy"; -/** SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`. */ -export const ng: Record = makeNg(); +/** + * SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`. + * + * Declared `NG` — upstream's own type for its `ng` (`index.d.ts:138`). It was published as + * `Record` until 2026-08-14, which announced a different surface from the one + * it forwards to: an application got no completion and no check on any of the 88 members. + */ +export const ng: NG = makeNg(); diff --git a/packages/polyfill/src/shared-wallet/account-registry.ts b/packages/polyfill/src/shared-wallet/account-registry.ts index 6fda803..6fa8e00 100644 --- a/packages/polyfill/src/shared-wallet/account-registry.ts +++ b/packages/polyfill/src/shared-wallet/account-registry.ts @@ -250,7 +250,8 @@ export function accountKey(id: string): string { /** Minimal session shape the registry needs — provided by the consumer. */ export interface RegistrySession { - sessionId: string; + /** Relayed untouched to `ng` — upstream declares it `string | number` (`index.d.ts:266`). */ + sessionId: string | number; /** The shared wallet's private store id — the pointer anchor. */ privateStoreId: string; /** The shared wallet's protected store id (native store). Optional: only the diff --git a/packages/polyfill/src/shared-wallet/physical.ts b/packages/polyfill/src/shared-wallet/physical.ts index eec3b1b..2782f65 100644 --- a/packages/polyfill/src/shared-wallet/physical.ts +++ b/packages/polyfill/src/shared-wallet/physical.ts @@ -55,7 +55,7 @@ import type { Nuri } from "../model/types"; * afterwards, which the caller decides by filing the cap among the caps that holder holds. */ export async function physicalCreate( - sessionId: string, + sessionId: string | number, crdt = "Graph", cls = "data:graph", dest = "store", @@ -81,7 +81,7 @@ export async function physicalCreate( * read a virtual user's content — that is `docs.sparqlQuery`, which is confined. */ export async function physicalQuery( - sessionId: string, + sessionId: string | number, query: string, base: string | undefined, anchor: Nuri, @@ -95,7 +95,7 @@ export async function physicalQuery( /** Write as the PHYSICAL user — the shim's own records. See {@link physicalQuery}. */ export async function physicalUpdate( - sessionId: string, + sessionId: string | number, query: string, anchor: Nuri, label = "physicalUpdate", diff --git a/packages/polyfill/src/shared-wallet/session.ts b/packages/polyfill/src/shared-wallet/session.ts index 39f70fc..849341d 100644 --- a/packages/polyfill/src/shared-wallet/session.ts +++ b/packages/polyfill/src/shared-wallet/session.ts @@ -95,15 +95,15 @@ export function sessionIsComing(): boolean { * * ── The session id is RELAYED, never rebuilt — and that is load-bearing ──── * Upstream declares it `string | number` (`Session`, `index.d.ts:266`) and the broker - * returns a NUMBER; the whole chain below here types it `string` and hands it to `ng.*`, + * returns a NUMBER; the chain below here now types it the same way and hands it to `ng.*`, * whose binding takes it as-is. So this reads the field and passes it on untouched. It is * not a detail: normalizing it to a string was written here first, and the applicative e2e * refused every call in the batch with `Deserialization error of session_id JsValue("1")` * — the wasm side deserializes the id by its own type, and a stringified number is not it. * - * The `string` in the declared shape is therefore inherited, not asserted: the inaccuracy - * is the chain's and predates this module (every consumer's thunk declared it the same way - * and relayed the same value). Widening it belongs to the chain, not to the capture. + * The chain used to narrow it to `string`, which made the value the SDK hands an application + * unpassable through this library. Widened to upstream's own declared type on 2026-08-14, at + * the capture and along every hop that relays it; the id is still only ever RELAYED. */ export function captureSession(event: unknown): boolean { if (typeof event !== "object" || event === null) return false; @@ -116,7 +116,7 @@ export function captureSession(event: unknown): boolean { protected_store_id: protectedStoreId, public_store_id: publicStoreId, } = session as { - session_id?: string; + session_id?: string | number; private_store_id?: string; protected_store_id?: string; public_store_id?: string; diff --git a/packages/polyfill/src/surface/docs.ts b/packages/polyfill/src/surface/docs.ts index 269f878..05d2aa5 100644 --- a/packages/polyfill/src/surface/docs.ts +++ b/packages/polyfill/src/surface/docs.ts @@ -44,8 +44,15 @@ function rowCount(result: unknown): number { * document in the (shared) private store: `docCreate(sid, "Graph", "data:graph", * "store")` (store_repo left undefined → private store). */ +// The session id is `string | number` because that is what upstream DECLARES for it +// (`Session.session_id`, `sdk/js/web/src/index.ts:16` and the installed `index.d.ts:266`), +// and the wasm side deserializes it as a `u64` (`sdk/js/lib-wasm/src/lib.rs:352-358` +// `sparql_query`, `:452-457` `sparql_update`, `:1575` `doc_create`). It only ever TRAVELS +// through this chain — never normalise it, and above all never stringify it: a JS string +// fails that deserialization, observed live as +// `Deserialization error of session_id JsValue("1")`. export async function docCreate( - sessionId: string, + sessionId: string | number, crdt: string, cls: string, dest: string, @@ -82,13 +89,21 @@ export async function docCreate( * * Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the * document NURI the update is scoped/base'd to (optional). + * + * Returns what the real method returns: upstream answers the COMMITS the update + * produced (`sdk/js/lib-wasm/src/lib.rs:481-483` serialises `AppResponseV0::Commits`; + * the installed `index.d.ts:297` types it `Promise`). This function already + * relayed that value at runtime — only the declared type said `void`, which threw the + * answer away for every caller. Typed `unknown` rather than `any`, exactly as + * {@link sparqlQuery} already renders the same upstream `Promise`: the value is + * the broker's to shape, and a caller that ignores it is unaffected. */ export async function sparqlUpdate( - sessionId: string, + sessionId: string | number, query: string, anchorLike?: NuriLike, label = "sparqlUpdate", -): Promise { +): Promise { const { ng } = getConfig(); const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate"); // The boundary, in two questions that are NOT the same one. @@ -117,7 +132,7 @@ export async function sparqlUpdate( * query base IRI (usually `undefined`); `anchor` is the document NURI to query. */ export async function sparqlQuery( - sessionId: string, + sessionId: string | number, query: string, base?: string, anchorLike?: NuriLike, diff --git a/packages/polyfill/src/surface/inbox.ts b/packages/polyfill/src/surface/inbox.ts index 070613e..45a8c38 100644 --- a/packages/polyfill/src/surface/inbox.ts +++ b/packages/polyfill/src/surface/inbox.ts @@ -84,7 +84,7 @@ const P = { /** The inbox documents live in the shared wallet, so we reuse the registry's * injected session provider for the sessionId. Disappears at migration. */ -async function sessionId(): Promise { +async function sessionId(): Promise { return (await getStoreRegistryDeps().getSession()).sessionId; } @@ -519,8 +519,10 @@ export async function read(targetInboxLike: NuriLike): Promise { return delivered; } -/** Alias for {@link read} — the name that reads as "process the inbox now". */ -export const materialize = read; +// `materialize` — a second published name for {@link read} — was REMOVED from the surface +// on 2026-08-14. It was an alias and nothing more: no call site anywhere, and upstream has +// no such member, so it was a symbol an application could learn and would have to unlearn. +// `read` is the name; the word "materialize" survives only as prose in the docs. /** * COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at diff --git a/packages/polyfill/src/surface/ng-proxy.ts b/packages/polyfill/src/surface/ng-proxy.ts index 1291412..79f0949 100644 --- a/packages/polyfill/src/surface/ng-proxy.ts +++ b/packages/polyfill/src/surface/ng-proxy.ts @@ -4,61 +4,79 @@ * surface stays identical to `@ng-org/web`'s `ng`. */ +import type { NG } from "@ng-org/web"; + import { getConfig, getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; import type { Nuri } from "../model/types"; -export function makeNg(): Record { - return new Proxy({} as Record, { - get(_target, prop: string) { +/** + * Typed `NG` — upstream's own type for `ng` (`NG = typeof NGModule`, 88 exported members, + * installed `index.d.ts:136-231`). The proxy adds no member and removes none, so this is + * the honest declaration; it used to be `Record`, which silently dropped all + * 88 signatures and let any misspelt member typecheck. + * + * The empty object is only the Proxy's TARGET: every property is answered by the `get` + * trap below, never by the target, so there is nothing to put in it. Typing the target is + * how `new Proxy` carries a type to its result — not an escape hatch, and the surface it + * announces is checked against upstream's for every caller. + */ +export function makeNg(): NG { + return new Proxy({} as NG, { + get(_target, prop: string | symbol) { const { ng } = getConfig(); + // The overrides below are keyed by NAME, so they are only consulted for a string + // property. A symbol (`Symbol.toStringTag`, `Symbol.asyncIterator`, …) skips them and + // falls to the same passthrough as everything else at the bottom — bound identically, + // so the proxy stays transparent for every kind of key. + if (typeof prop === "string") { + // session_start → open the SHARED wallet invisibly. + // + // `login` used to be listed here too. `@ng-org/web` exposes no such method — + // zero occurrences in the installed declarations and in `sdk/js/lib-wasm/src/lib.rs` + // — so the proxy FABRICATED a member: `ng.login` answered a function instead of + // `undefined`, and calling it threw. The one place this wrapper added to the SDK + // surface, against its own header. Removed 2026-08-03. + if (prop === "session_start") { + return (...args: any[]) => { + // TODO(polyfill): supply shared-wallet credentials so no wallet UI + // is shown. For now, passthrough. + return ng[prop]!(...args); + }; + } - // session_start → open the SHARED wallet invisibly. - // - // `login` used to be listed here too. `@ng-org/web` exposes no such method — - // zero occurrences in the installed declarations and in `sdk/js/lib-wasm/src/lib.rs` - // — so the proxy FABRICATED a member: `ng.login` answered a function instead of - // `undefined`, and calling it threw. The one place this wrapper added to the SDK - // surface, against its own header. Removed 2026-08-03. - if (prop === "session_start") { - return (...args: any[]) => { - // TODO(polyfill): supply shared-wallet credentials so no wallet UI - // is shown. For now, passthrough. - return ng[prop]!(...args); - }; + // sparql_update → write guard (emulated write-cap check). + // Mirrors the target broker/verifier: a write is refused unless the wallet + // holds the document's WRITE cap. Emulated per-document via CapRegistry. + // args = (session_id, query, anchor?) — `anchor` is the target doc NURI. + if (prop === "sparql_update") { + return (...args: any[]) => { + const anchor = args[2] as Nuri | undefined; + const caps = getCaps(); + // Passthrough (no regression) unless a WRITE policy exists AND this + // specific document is governed by it. Ungoverned docs (mono-store + // default, no cap declared) flow through exactly as before. + if ( + typeof anchor === "string" && + caps.hasWritePolicy() && + caps.governsWrite(anchor) && + !caps.canWrite(anchor, getCurrentUser()) + ) { + return Promise.reject( + new Error( + `[ng-eventually] write denied: current user lacks the write cap for ${anchor}`, + ), + ); + } + return ng.sparql_update!(...args); + }; + } + + // TODO(anticipated API): a sealed inbox deposit + capability operations — expose + // here with their anticipated signatures, emulated for now. } - // sparql_update → write guard (emulated write-cap check). - // Mirrors the target broker/verifier: a write is refused unless the wallet - // holds the document's WRITE cap. Emulated per-document via CapRegistry. - // args = (session_id, query, anchor?) — `anchor` is the target doc NURI. - if (prop === "sparql_update") { - return (...args: any[]) => { - const anchor = args[2] as Nuri | undefined; - const caps = getCaps(); - // Passthrough (no regression) unless a WRITE policy exists AND this - // specific document is governed by it. Ungoverned docs (mono-store - // default, no cap declared) flow through exactly as before. - if ( - typeof anchor === "string" && - caps.hasWritePolicy() && - caps.governsWrite(anchor) && - !caps.canWrite(anchor, getCurrentUser()) - ) { - return Promise.reject( - new Error( - `[ng-eventually] write denied: current user lacks the write cap for ${anchor}`, - ), - ); - } - return ng.sparql_update!(...args); - }; - } - - // TODO(anticipated API): a sealed inbox deposit + capability operations — expose - // here with their anticipated signatures, emulated for now. - // Everything else: passthrough to the real SDK, unchanged. - const real = ng[prop]; + const real = Reflect.get(ng, prop); return typeof real === "function" ? real.bind(ng) : real; }, }); diff --git a/packages/polyfill/src/surface/read-model.ts b/packages/polyfill/src/surface/read-model.ts index ef837c7..0ef3a86 100644 --- a/packages/polyfill/src/surface/read-model.ts +++ b/packages/polyfill/src/surface/read-model.ts @@ -97,7 +97,7 @@ function bindings( return anyRes.results?.bindings ?? []; } -async function sessionId(): Promise { +async function sessionId(): Promise { return (await getStoreRegistryDeps().getSession()).sessionId; } @@ -122,7 +122,7 @@ async function sessionId(): Promise { * store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO). */ async function readDoc( - sid: string, + sid: string | number, doc: Nuri, ): Promise>> { try { diff --git a/packages/polyfill/src/surface/subscribe.ts b/packages/polyfill/src/surface/subscribe.ts index 2357b1a..1fd14b9 100644 --- a/packages/polyfill/src/surface/subscribe.ts +++ b/packages/polyfill/src/surface/subscribe.ts @@ -79,7 +79,7 @@ export function docChangeType(resp: DocChange): DocChangeType { /** An unsubscribe function — idempotent (calling it twice is a no-op). */ export type Unsubscribe = () => void; -async function sessionId(): Promise { +async function sessionId(): Promise { return (await getStoreRegistryDeps().getSession()).sessionId; } diff --git a/packages/polyfill/test/docs.test.ts b/packages/polyfill/test/docs.test.ts index b9b7a67..81be203 100644 --- a/packages/polyfill/test/docs.test.ts +++ b/packages/polyfill/test/docs.test.ts @@ -29,10 +29,16 @@ import { configure } from "../src/index"; import { setCurrentUser } from "../src/shared-wallet/bootstrap"; import { resetCaps } from "../src/shared-wallet/bootstrap"; +// What a real `ng.sparql_update` answers: the COMMITS the update produced +// (`sdk/js/lib-wasm/src/lib.rs:481-483` serialises `AppResponseV0::Commits`). The fake +// used to answer `undefined` — a result the broker never returns — which is precisely +// what let the surface declare `Promise` unchallenged. +const COMMITS = [{ id: "did:ng:c:commit-1" }, { id: "did:ng:c:commit-2" }]; + function fakeNg() { return { doc_create: mock(async (..._a: unknown[]) => "did:ng:o:new-doc"), - sparql_update: mock(async (..._a: unknown[]) => undefined), + sparql_update: mock(async (..._a: unknown[]) => COMMITS), sparql_query: mock(async (..._a: unknown[]) => ({ results: { bindings: [] } })), // A sentinel: makeNg(), if ever used, would `.bind` and call THIS through // the JS Proxy. We assert the primitives call the raw fns above directly. @@ -83,6 +89,41 @@ test("sparqlQuery forwards (sessionId, query, base, anchor) and returns the raw ]); }); +// ── the session id: RELAYED, never converted ────────────────────────────────── +// Upstream declares it `string | number` (`Session.session_id`, `index.d.ts:266`) and the +// broker hands back a NUMBER, which the wasm binding deserializes as a `u64`. Stringifying +// it fails that deserialization for real (`Deserialization error of session_id JsValue("1")`), +// so what reaches the boundary must be the very value the caller passed — same `typeof`. +test("a NUMERIC session id reaches ng untouched, still a number", async () => { + const ng = inject(); + // Anchor the update and the read on the document just CREATED: creating it mints its + // cap, so the reach guard (process-wide once any cap exists) is satisfied the way a real + // application satisfies it — rather than by naming a document this user does not hold. + const created = await docCreate(1, "Graph", "data:graph", "store", undefined); + await sparqlUpdate(2, "INSERT DATA {}", created); + await sparqlQuery(3, "SELECT * {}", undefined, created); + + const createdSid = ng.doc_create.mock.calls[0]![0]; + expect(createdSid).toBe(1); + expect(typeof createdSid).toBe("number"); + + const updated = ng.sparql_update.mock.calls[0]![0]; + expect(updated).toBe(2); + expect(typeof updated).toBe("number"); + + const queried = ng.sparql_query.mock.calls[0]![0]; + expect(queried).toBe(3); + expect(typeof queried).toBe("number"); +}); + +test("sparqlUpdate hands back what the boundary returned", async () => { + // The commits must arrive at the caller unchanged — the surface used to declare + // `Promise` and throw this answer away. + inject(); + const returned = await sparqlUpdate("sid-c", "INSERT DATA {}", "did:ng:o:a"); + expect(returned).toBe(COMMITS); +}); + test("the primitives do NOT route through the public ng proxy (makeNg)", async () => { // makeNg builds a JS Proxy over the injected ng. If a primitive went through // it, calls would land on the proxy's `get` trap, not on our raw mock fns. diff --git a/packages/polyfill/test/inbox.test.ts b/packages/polyfill/test/inbox.test.ts index b1b72ff..6a5db8a 100644 --- a/packages/polyfill/test/inbox.test.ts +++ b/packages/polyfill/test/inbox.test.ts @@ -1,5 +1,6 @@ import { test, expect, mock, beforeEach, afterAll } from "bun:test"; -import { post, read, materialize, watch } from "../src/surface/inbox"; +import { post, read, watch } from "../src/surface/inbox"; +import * as polyfill from "../src/index"; import { userInbox, resetRegistryCache } from "../src/shared-wallet/account-registry"; import type { Deposit } from "../src/surface/inbox"; import { configure } from "../src/index"; @@ -249,14 +250,23 @@ test("from: null makes an anonymous deposit even when a current user is set", as expect(deposits[0]!.from).toBeNull(); }); -test("read returns deposits sorted by ts ascending and materialize is an alias", async () => { +test("read returns deposits sorted by ts ascending", async () => { await post(TARGET, { from: null, payload: "second", ts: 300 }); await post(TARGET, { from: null, payload: "first", ts: 100 }); await post(TARGET, { from: null, payload: "third", ts: 500 }); - const deposits = await materialize(TARGET); + const deposits = await read(TARGET); expect(deposits.map((d) => d.payload)).toEqual(["first", "second", "third"]); }); +test("the published inbox surface exposes `read` and no `materialize` alias", () => { + // `materialize` was a second published name for `read` — a symbol an application could + // learn and would have to unlearn, since upstream has no such member. This asserts the + // PUBLISHED entry (`src/index.ts`'s `inbox` namespace), which is what an app imports, + // not merely the module it re-exports. + expect(typeof polyfill.inbox.read).toBe("function"); + expect("materialize" in polyfill.inbox).toBe(false); +}); + test("read is scoped to one inbox — deposits in another inbox are not returned", async () => { // The OTHER inbox is obtained from the system, not invented. A made-up NURI would be // a target no deposit can legitimately reach (`inbox.post` refuses what is not an diff --git a/packages/polyfill/test/ng-proxy.test.ts b/packages/polyfill/test/ng-proxy.test.ts index eecd3b6..c54a3e6 100644 --- a/packages/polyfill/test/ng-proxy.test.ts +++ b/packages/polyfill/test/ng-proxy.test.ts @@ -83,6 +83,9 @@ test("write guard: passthrough when anchor is omitted (cannot scope the guard)", getCaps().grantWrite(DOC, "alice"); setCurrentUser("bob"); const proxy = makeNg(); - await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough + // Anchor explicitly `undefined` — upstream declares all three parameters (`index.d.ts:297`), + // and the guard reads `args[2]`, which is `undefined` whether the argument is omitted or + // passed as such. Same branch, same passthrough. + await proxy.sparql_update("sid", "INSERT DATA {}", undefined); // no anchor → passthrough expect(ng.sparql_update).toHaveBeenCalledTimes(1); });