From 12eba6eea62442730632f7c82b6b98ed8c8ade9e Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 14 Aug 2026 10:00:40 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20quatre=20=C3=A9carts=20entre=20la=20surf?= =?UTF-8?q?ace=20publi=C3=A9e=20et=20ce=20que=20NextGraph=20d=C3=A9clare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un audit de la surface contre la source amont en a trouvé cinq ; voici les quatre mécaniques. La cinquième — l'adresse d'inbox, qui traverse sept symboles — relève du dessin et reste ouverte. L'identifiant de session bloquait. Amont le déclare string | number (sdk/js/web/src/index.ts:16) et le binding désérialise un u64 ; nous exigions une chaîne. Une application ne pouvait donc pas passer la valeur que le SDK venait de lui remettre. Élargi à ce qu'amont déclare, sur toute la chaîne, et jamais converti : une chaîne échoue pour de vrai (Deserialization error of session_id JsValue("1"), observé). sparqlUpdate annonçait Promise alors qu'il relayait DÉJÀ les commits. C'était donc un mensonge de typage, pas un comportement — et la doublure de test qui rendait undefined, un état que le vrai broker ne produit jamais, est ce qui l'a laissé sans contradicteur. ng était publié en Record, ce qui perdait les 88 membres typés d'amont — 88, pas 77 : le chiffre de notre propre documentation était faux. Et materialize, second nom publié de read, sans appelant ni contrepartie amont, est retiré. docs/api-contract.md qualifiait docs.* de passthrough « 1:1 ». C'était faux sur les deux premiers points. Corrigé, pas complété : un document qui se déclare vérifié et qui ment est pire qu'un document absent, parce qu'on cesse d'aller voir. Une déviation assumée : amont type le retour en any, interdit ici ; on rend unknown, comme sparqlQuery le fait déjà pour le même any amont. --- .project/concepts/app-contract/_debt.md | 13 ++ .../app-contract/contract_polyfill-surface.md | 18 ++- .project/concepts/sign-in/_debt.md | 2 + docs/api-contract.md | 45 ++++--- .../src/emulated-verifier/register-write.ts | 4 +- packages/polyfill/src/index.ts | 12 +- .../src/shared-wallet/account-registry.ts | 3 +- .../polyfill/src/shared-wallet/physical.ts | 6 +- .../polyfill/src/shared-wallet/session.ts | 10 +- packages/polyfill/src/surface/docs.ts | 23 +++- packages/polyfill/src/surface/inbox.ts | 8 +- packages/polyfill/src/surface/ng-proxy.ts | 112 ++++++++++-------- packages/polyfill/src/surface/read-model.ts | 4 +- packages/polyfill/src/surface/subscribe.ts | 2 +- packages/polyfill/test/docs.test.ts | 43 ++++++- packages/polyfill/test/inbox.test.ts | 16 ++- packages/polyfill/test/ng-proxy.test.ts | 5 +- 17 files changed, 227 insertions(+), 99 deletions(-) create mode 100644 .project/concepts/app-contract/_debt.md 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); });