Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abfb619c76 | |||
| 3dfc231005 | |||
| 1b4fcb567e | |||
| 0e57f20ca4 | |||
| c61b181ed8 | |||
| 6a99585efc | |||
| c507e79f8a | |||
| fafac38907 | |||
| c7c6cb96ea | |||
| 7b35300723 | |||
| 6eaff0b985 | |||
| 33212a8b00 | |||
| 520c8c59a8 | |||
| 935cce4d7b | |||
| 6dfdf2f036 | |||
| 72bb72c0b7 | |||
| bf1e16e1f3 | |||
| 1957a8fe0c | |||
| a8d53010c2 | |||
| 76ae9ffbb7 | |||
| 90712e0ad0 | |||
| 43aadbeb45 | |||
| 6138d831da | |||
| 98ee511d3a | |||
| a33fb8a214 | |||
| f6d1734679 | |||
| b50591f5bd | |||
| 07dfe68473 | |||
| 9e7aeef269 | |||
| 0607843b81 | |||
| 1271d48e9f | |||
| cf3c7c7d8b | |||
| ed0f872f5d | |||
| 1ecf511e9d | |||
| f30685bdb9 | |||
| 7062364569 | |||
| 12eba6eea6 | |||
| e32b6d04fc | |||
| 0f1e493e93 | |||
| 8c8ade7a9e | |||
| 55714d0a23 | |||
| f77317c4d1 | |||
| dbd99738f0 | |||
| cc8a95d303 | |||
| 7a4d9b492f | |||
| 218c9ab6b3 | |||
| f5a3adc385 | |||
| c5b4703687 | |||
| 7c2e8d8f1f | |||
| 16e24f67f9 | |||
| 2726f4a26f | |||
| 3be8da2178 | |||
| 3547de202c | |||
| fc3c129bd3 | |||
| 9c487b59f3 | |||
| 737729c9ce | |||
| 49b046268e | |||
| 33b96fdc8d | |||
| 7076c0cca8 | |||
| f378c71739 | |||
| 0d9e2bbe97 | |||
| cdc09a1a1d | |||
| b7dc8ca2c3 | |||
| 30f6263db5 | |||
| 44a9b6ee04 | |||
| b5f05472d9 | |||
| 0b936d2119 | |||
| c5878c6126 | |||
| 0455a408b6 | |||
| b98fcaa77d | |||
| aa6bbc436e | |||
| 0eb25286c8 | |||
| 0832338201 | |||
| 32ef756b0b | |||
| 94173fd3d3 | |||
| cb56f91b5e | |||
| 3c981ffadb | |||
| 7672915bb9 | |||
| c8d02619b1 | |||
| da6ef4b8b8 | |||
| ebf866b1f2 | |||
| 54f8389e9e | |||
| d35e735c8b | |||
| 66a40fbb89 | |||
| c42236bc00 | |||
| 3257afe8c0 | |||
| b62bfe1e63 | |||
| 107f9d1633 | |||
| e01a8dbab1 | |||
| 36c0148750 | |||
| 0b37d17c2f | |||
| cd096de2b0 | |||
| 88914f50ae |
+4
-2
@@ -5,6 +5,8 @@ dist/
|
|||||||
bun.lockb
|
bun.lockb
|
||||||
|
|
||||||
# SDK e2e real-broker harness — dedicated wallet profile + bundle (never committed)
|
# SDK e2e real-broker harness — dedicated wallet profile + bundle (never committed)
|
||||||
packages/client/e2e/.playwright-profile-lib/
|
packages/polyfill/e2e/.dist/
|
||||||
packages/client/e2e/.dist/
|
|
||||||
*.ngw
|
*.ngw
|
||||||
|
|
||||||
|
# Per-developer contract access map — canonical identities are committed, local paths are not
|
||||||
|
.project/contracts.local.yaml
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- generated — edit .project/concepts/*/_overview.md vocabulary blocks, then run lint --fix; do not edit this file -->
|
||||||
|
|
||||||
|
## Project vocabulary — canonical terms: use VERBATIM in any language, marked `like this`
|
||||||
|
|
||||||
|
```text
|
||||||
|
«app-contract»
|
||||||
|
`reference` a NURI that names a document and grants nothing — what an application circulates (never: link, lien, share-link · see: contract_polyfill-surface)
|
||||||
|
`ReadCap` upstream's word for what opens a document — a reference carrying its secret (never: token, credential, permission)
|
||||||
|
`polyfill-era` a published symbol with no counterpart in the target SDK, deleted at migration (never: transitional, shim-only · see: knowledge_what-an-app-deletes-at-migration)
|
||||||
|
|
||||||
|
«e2e-harness»
|
||||||
|
`journey` one named end-to-end story in the applicative suite, reported pass or fail as a whole (never: scenario, test case, spec)
|
||||||
|
`batch` one run of a suite, which mints its own physical user and discards the previous one (never: session, campaign)
|
||||||
|
|
||||||
|
«sign-in»
|
||||||
|
`settle` decide which identity is acting, from the URL, storage, or the barrier — needs no session (never: resolve identity, determine user, login · see: knowledge_settling-is-not-connecting)
|
||||||
|
`barrier` the full-screen overlay this package mounts to hand out the wallet and take an identifier (never: modal, popup, login screen)
|
||||||
|
```
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
type: overview
|
||||||
|
summary: What an application may rely on from @ng-eventually/polyfill, and what it will have to delete
|
||||||
|
triggers:
|
||||||
|
keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing, inbox, deposit, IRI, urn, namespace, vocabulary, ontology, predicate]
|
||||||
|
paths:
|
||||||
|
- "packages/polyfill/src/index.ts"
|
||||||
|
- "packages/polyfill/src/surface/**"
|
||||||
|
- "packages/polyfill/src/emulated-verifier/machinery.ts"
|
||||||
|
- "packages/polyfill/src/emulated-verifier/index-deposit.ts"
|
||||||
|
- "packages/polyfill/README.md"
|
||||||
|
- "examples/notebook/**"
|
||||||
|
- "docs/api-contract.md"
|
||||||
|
vocabulary:
|
||||||
|
- term: reference
|
||||||
|
gloss: a NURI that names a document and grants nothing — what an application circulates
|
||||||
|
not: [link, lien, share-link]
|
||||||
|
see: contract_polyfill-surface
|
||||||
|
- term: ReadCap
|
||||||
|
gloss: upstream's word for what opens a document — a reference carrying its secret
|
||||||
|
not: [token, credential, permission]
|
||||||
|
- term: polyfill-era
|
||||||
|
gloss: a published symbol with no counterpart in the target SDK, deleted at migration
|
||||||
|
not: [transitional, shim-only]
|
||||||
|
see: knowledge_what-an-app-deletes-at-migration
|
||||||
|
---
|
||||||
|
|
||||||
|
# app-contract — the boundary between this library and the applications that use it
|
||||||
|
|
||||||
|
This library exists so an application can be **written today against the NextGraph that does not ship yet**, and keep its code when it does. Everything under this concept governs that boundary: what the package publishes, what a caller may rely on, what it must not, and what disappears at migration.
|
||||||
|
|
||||||
|
The distinguishing question, asked at every choice — published signature as much as internal mechanism: **does the target do this?** If it does it differently, we do it their way, whatever that costs. *Would a caller have to unlearn it?* comes second, and measures how bad a divergence is — never whether one is allowed. See `rule_no-divergence-from-nextgraph`.
|
||||||
|
|
||||||
|
This repo is the **provider** of `polyfill-surface`; consuming applications live in other repos, pull the engagement, and declare back what they actually consume — one `usage_<consumer>.md` beside the contract, in the interface folder. The per-symbol ruling, with an epistemic label on every target-side claim, stays here in `docs/api-contract.md` — that is maintainer material, not the engagement.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `polyfill-surface/contract_polyfill-surface` — the engagement itself, written from the caller's point of view.
|
||||||
|
- `rule_no-divergence-from-nextgraph` — the test that decides what may be built at all.
|
||||||
|
- `knowledge_what-an-app-deletes-at-migration` — the two fates a published symbol can have.
|
||||||
|
- `knowledge_what-an-iri-says-upstream` — the same two fates for a written *triple*: NextGraph's own vocabulary, ours, and which of our IRIs must outlive the migration.
|
||||||
|
- `knowledge_what-an-inbox-is-upstream` — what the target actually offers around inboxes, and where a choice of ours begins.
|
||||||
|
- `knowledge_who-processes-an-inbox` — the division of labour along a deposit's path: what this package applies, what it merely delivers, and what a layer above owes itself.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
type: bug
|
||||||
|
severity: major
|
||||||
|
summary: An inbox watch that failed to open is re-opened only by a narrow set of later events, so a session whose activity does not produce one stays unwatched and never converges
|
||||||
|
opened: 2026-08-17
|
||||||
|
last_checked: 2026-08-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# A lost inbox watch is never retried
|
||||||
|
|
||||||
|
While an identity is connected, every inbox it may read is watched, and a deposit is applied as it lands. When opening one of those watches **fails** — a broker hiccup at sign-in is enough — the failure is reported and the watch is dropped. It is then re-opened only by a later event, and the list of events that qualify is narrower than it looks:
|
||||||
|
|
||||||
|
- creating a document,
|
||||||
|
- opening an inbox on one,
|
||||||
|
- a `ReadCap` arriving through an inbox **still** being watched,
|
||||||
|
- the first read of a document in a public store.
|
||||||
|
|
||||||
|
**An ordinary write does not qualify.** A `sparqlUpdate` on a document the identity already owns fires none of these. So it is not only a read-only session that stays blind — it is any session whose activity stays inside what it already holds.
|
||||||
|
|
||||||
|
There is deliberately no polling: the deferred inbox processing states that as a design choice, and a timer here would be the wrong repair. What is missing is a recovery signal that does not depend on the identity performing a capability-bearing act.
|
||||||
|
|
||||||
|
**The visible consequence.** A document is shared with the affected identity while it sits connected, and nothing arrives. No error is raised, and the application sees an empty result rather than a failure — the same shape as having been given nothing at all.
|
||||||
|
|
||||||
|
**Workaround today:** a fresh `ensureIdentity()` re-enters and re-establishes the watches.
|
||||||
|
|
||||||
|
**Verifying it:** drive an identity to a state where one inbox watch fails to open, let the broker recover, deposit for that identity, and observe that nothing converges while it stays connected. A reproduction existed as a scratchpad probe during the work that found this and did not survive the session; rebuilding it from this description is the honest cost of that.
|
||||||
|
|
||||||
|
Predates the continuous-observation work — the earlier behaviour processed inboxes only at connection, so the same hiccup produced the same silence by a shorter route.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
type: bug
|
||||||
|
severity: normal
|
||||||
|
summary: When the account record cannot be resolved, the inbox enumeration reports a COMPLETE list holding zero inboxes, so a total failure is indistinguishable from an identity that legitimately has none
|
||||||
|
opened: 2026-08-17
|
||||||
|
last_checked: 2026-08-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# An empty inbox list can mean a failure
|
||||||
|
|
||||||
|
Enumerating the inboxes an identity may read reports, alongside the list, whether that list is **complete** or **short** — and a shortfall is logged so a lost watch is at least visible.
|
||||||
|
|
||||||
|
That signal has a hole. When resolving the account record yields nothing rather than raising, the enumeration reports a list that is **complete and empty**. Every inbox is missing, including the identity's own, and nothing distinguishes it from an identity that genuinely has none yet — which is a real and ordinary state, since a first-visit identity connects with no account.
|
||||||
|
|
||||||
|
So the worst outcome — watching nothing at all — is reported as the most benign one. This is the family this package has closed repeatedly: **a failure wearing the shape of an absence.**
|
||||||
|
|
||||||
|
The fix is not to make the enumeration throw. Reaching the registers is infrastructure and may fail; what is missing is that *resolving nothing* and *there being nothing* are the same answer here, and they are not the same event.
|
||||||
|
|
||||||
|
**Verifying it:** make the account resolution return nothing (rather than reject) and observe that the enumeration reports a complete list with no entries, and that no shortfall is logged.
|
||||||
|
|
||||||
|
Related, and distinct: [[bug_a-lost-inbox-watch-is-never-retried]] — that one is about a watch that failed to open; this one is about never learning there was a watch to open.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
type: bug
|
||||||
|
summary: A live read through the published `useShape` never starts against a broker deployed before `orm_start` was renamed `orm_start_graph` — it stays empty and pending forever, with one console error as the only trace
|
||||||
|
severity: major
|
||||||
|
opened: 2026-08-17
|
||||||
|
last_checked: 2026-08-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# `useShape` opens no live read against the deployed broker
|
||||||
|
|
||||||
|
The published `useShape` is a passthrough to `@ng-org/orm`'s hook (`packages/polyfill/src/surface/use-shape.ts`) — this package adds the read filter and forwards. Against the broker deployed today, the subscription that hook opens **never starts**: its set stays empty, its readiness never settles, no engine update ever arrives, and the only trace is one `console.error`. An application sees "this scope holds nothing", indefinitely, which is the exact confusion the rest of the read surface is built to prevent.
|
||||||
|
|
||||||
|
The cause is upstream, in NextGraph. **Nothing in this package can work around it** — the failure is a name lookup two hops below our surface.
|
||||||
|
|
||||||
|
## The mechanism — one method name, kept in three places by hand
|
||||||
|
|
||||||
|
VERIFIED 2026-08-17 by reading `/home/sylvain/projects/nextgraph/nextgraph-rs` at `213338f6`.
|
||||||
|
|
||||||
|
A live ORM read is one call, and it is streamed: the engine pushes into a callback rather than answering once. Nothing in the transport infers that — each hop looks the method up in a **hand-maintained table** mapping method name to the index of its callback argument, and a method missing from the table takes the plain request/response branch instead.
|
||||||
|
|
||||||
|
1. `sdk/js/orm/src/connector/GraphOrmSubscription.ts` — the `OrmSubscription` constructor is the only place a live graph read begins: `ng.orm_start_graph(scope.graphs, scope.subjects, shapeType, session.session_id, this.onBackendMessage)`. Five arguments, callback last. `useShape` reaches it through `OrmSubscription.getOrCreate` (`sdk/js/orm/src/frontendAdapters/react/useShape.ts`).
|
||||||
|
2. `sdk/js/web/src/index.ts` — the application-side `ng` proxy. `streamed_api` must contain the method, or `rpc` posts the arguments verbatim to the parent window, callback included. A function is not structured-cloneable, so that post throws.
|
||||||
|
3. `sdk/js/api-web/main.ts` — the broker-side proxy, an independent copy of the same table. Same fallback into `myWorker.postMessage`, same throw.
|
||||||
|
|
||||||
|
The hops between the two tables carry no table of their own and cannot repair a mismatch: `infra/ngnet/auth/src/main.ts` forwards `{method, args, streamed, port}` unchanged, and `engine/broker/auth/src/App.svelte` re-appends a callback **only** when `streamed` is true.
|
||||||
|
|
||||||
|
**The name changed.** Both tables keyed the method as `orm_start` until commit `d5ecd0fd` (2026-01-16, "refactor discrete orm for yjs") renamed it to `orm_start_graph` in both files at once — while the ORM had been calling `orm_start_graph`. A build made before that commit therefore does not recognise the method a live read needs.
|
||||||
|
|
||||||
|
**The application side is already correct**, and that is what isolates the defect. This package pins `@ng-org/web@0.1.2-alpha.13` (`packages/polyfill/package.json`), which is the version at `213338f6` — the rename is in it. `@ng-org/api-web` is `private: true` and has never been version-bumped past `0.1.2`: it is **not published**, it ships only inside the broker build. Its table is whatever the operator last deployed, and no consumer can pin it.
|
||||||
|
|
||||||
|
**Why it fails in silence:** `orm_start_graph` is awaited inside a `try` whose `catch` is `console.error(e)`, and `resolveReady()` is called on one path only — the arrival of initial data. A rejected start leaves the readiness promise pending forever and the set permanently empty. This is the same upstream property already recorded in `docs/api-contract.md` (a failed read *is* an eternal pending upstream), reached here through a different door.
|
||||||
|
|
||||||
|
## What is affected, and what is not
|
||||||
|
|
||||||
|
- **Affected:** the published `useShape`, and only it.
|
||||||
|
- **Not affected:** `watchShape`, `subscribeDoc`, `subscribeDocs`, `readUnion`. These open one `doc_subscribe` per document, and `doc_subscribe: 2` has been in both tables continuously under a stable name. `watchShape` is built on that path, not on the ORM subscription — it is a working reactive read today.
|
||||||
|
|
||||||
|
That is why the library's own suites and the reference application do not show it: nothing in this package calls `useShape` internally.
|
||||||
|
|
||||||
|
## Not verified
|
||||||
|
|
||||||
|
The deployed broker's table was **not read**. `https://nextgraph.net/redir/` returns a page containing none of the protocol markers, so it is not the bundle that holds it, and probing further was out of mandate. What is established is the mechanism and its exact precondition — a broker built before `d5ecd0fd` — not that the running deployment sits before it. The symptom was reported by the consuming application, not reproduced here.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
- **Do not build on live `useShape`.** Use `watchShape` for a reactive read, `readUnion` for a one-shot one. This is stated in the contract's `## Non-guarantees` so a consumer meets it without reading this leaf.
|
||||||
|
- **The fix is not ours.** It is a NextGraph deployment: rebuild and redeploy the broker from a source at or after `d5ecd0fd`. Nothing to change in this repository, and nothing to open upstream from here — report it, do not patch around it.
|
||||||
|
- **To check whether it is closed:** open a live `useShape` against the deployment and watch for an initial payload. A permanently empty set with one `console.error` naming `orm_start_graph` means the table is still stale.
|
||||||
|
|
||||||
|
## The shape worth remembering
|
||||||
|
|
||||||
|
A method name duplicated across three independently-deployed artifacts, with a **silent** fallback when they disagree, is a defect that cannot be caught by any one repository's tests. Renaming it in the two tables in one commit fixed the source; it did not fix anything already running. Anything this package routes through a streamed method inherits the same exposure — that is the reason `subscribeDocs` composing per-document `doc_subscribe` is worth more than it looks.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
type: knowledge
|
||||||
|
summary: The two fates a published symbol can have, and why the deletion list is now carried by a block and a test rather than by an import path
|
||||||
|
---
|
||||||
|
|
||||||
|
# What an application deletes at migration, and what it keeps
|
||||||
|
|
||||||
|
Every published symbol has exactly one of two fates, and knowing which is the whole point of this boundary.
|
||||||
|
|
||||||
|
**Replaced in place.** The build alias resolving `@ng-eventually/polyfill` is removed, the import resolves to the real SDK, and the application's code is unchanged. This is almost everything: `ng`, `useShape`, `watchShape`, `init`, `initNg`, `readUnion`, `subscribeDoc(s)`, `docs.*`, `inbox.*`, `storeRegistry.*`, and the types.
|
||||||
|
|
||||||
|
**Deleted.** The symbol has no counterpart at any level of the target, exists only because one shared wallet hosts every user, and goes with its call site. Today that is `configure` and its config type.
|
||||||
|
|
||||||
|
`ensureIdentity` sits between the two and is worth stating precisely: its **substance** is scaffolding — a barrier that hands out a shared wallet file and takes an identifier, a step that exists only because users share a wallet — while its **call site survives**, because an application still awaits a session before it renders. Its signature was designed for that: it takes no identifier, so the line does not change the day the wallet supplies the identity and the barrier stops appearing.
|
||||||
|
|
||||||
|
## Why the deletion list is not an import path any more
|
||||||
|
|
||||||
|
There were two entries until 2026-08-07, `.` and `./polyfill`, and the second one carried a signal worth naming: *what you import from that path is exactly what you will delete*. That was a mechanical guarantee — the compiler produced the list.
|
||||||
|
|
||||||
|
Merging them lost it. Nothing at an import line now distinguishes `configure`, which goes away, from `docs`, which is replaced. Three things carry it instead, and it is worth knowing that only the last two are enforced:
|
||||||
|
|
||||||
|
- the **`POLYFILL-ERA` block** in the package's entry module, which is the deletion list, kept short by construction;
|
||||||
|
- **`docs/api-contract.md`**, which rules on every symbol with an epistemic label (PASSTHROUGH / LEVEL-1 SHAPE / ASSUMPTION / NO COUNTERPART) and whose export inventory is pinned by a test — so it cannot go stale quietly, which a hand-kept list would;
|
||||||
|
- the **names themselves**, each built from the target's own vocabulary or carrying a marker saying why it exists only here, pinned by the same test.
|
||||||
|
|
||||||
|
The trade was deliberate and it is a real reduction in enforcement: a documentary signal where a mechanical one used to be. To verify the pinning is doing its job, look for the vocabulary test beside the package's other tests — it compares the contract's inventory against the entry's real exports, in both directions, and it caught five drifted sections the day it was extended to the rulings.
|
||||||
|
|
||||||
|
## The direction of travel
|
||||||
|
|
||||||
|
The polyfill-era list only shrinks. It has gone from four published calls to one, and each removal was a symbol an application should never have had: naming its own identity, awaiting the connection, wiring a second bootstrap, reaching a machinery accessor. A symbol added to that block is a promise to delete it later — so the question at every addition is whether the application genuinely needs it, or whether the library is passing on a problem of its own.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
type: knowledge
|
||||||
|
summary: What NextGraph actually offers around inboxes — a document may be given one, nothing above the engine exposes them, and most message variants are reserved names carrying nothing
|
||||||
|
last_checked: 2026-08-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# What an inbox is upstream
|
||||||
|
|
||||||
|
Read at the source in `nextgraph-rs`. Every claim here points at a symbol rather than a line, so it stays verifiable by `git grep` after the code moves.
|
||||||
|
|
||||||
|
## A document may have an inbox, and must be given one
|
||||||
|
|
||||||
|
`Repo` upstream **is** a document — not a store. Its `inbox` field is an `Option<PrivKey>`: at most one inbox per document, and a document has none until something creates it. The creating act is an **`AddInboxCap` commit**, verified and applied like any other — see `update_inbox_cap_v0` in the verifier and the `CommitVerifier for AddInboxCap` impl.
|
||||||
|
|
||||||
|
The automatic creation inside `complete_site_store` is narrower than it looks: it gives an inbox to the site's **non-private stores** only. Reading that as *"only stores can have inboxes"* is a mistake this project has now made twice — the site path is one caller of a general capability, not the rule.
|
||||||
|
|
||||||
|
So an inbox on an ordinary document is **aligned with the target**, not an emulation of something absent.
|
||||||
|
|
||||||
|
## Nothing above the engine exposes any of it
|
||||||
|
|
||||||
|
Run the cascade for *"post, observe, or process an inbox message"* and every level above the engine is silent. The ORM has no occurrence of `inbox` at all; neither the web API nor the wasm binding exposes one — the binding's single mention is an unused import — and there is no generic `app_request` escape hatch, since every command carries its own dedicated wrapper.
|
||||||
|
|
||||||
|
Anything built here therefore aligns on the **engine's model**, because levels 2 and 3 answer nothing.
|
||||||
|
|
||||||
|
## Most message variants are reserved names, not shapes
|
||||||
|
|
||||||
|
`InboxMsgContent` declares eight variants. `ContactDetails` and `SocialQuery` carry data and have processors. **`Link`, `Patch`, `ServiceRequest`, `ExtRequest`, `RemoteQuery` and `DialogRequest` carry nothing at all** — they are bare unit variants, so they cannot hold a reference, name a servicer, or describe an operation.
|
||||||
|
|
||||||
|
That distinction decides how much they bind us, and it is why the provenance taxonomy in [[rule_no-divergence-from-nextgraph]] separates a reserved name from a declared shape.
|
||||||
|
|
||||||
|
## Processing is closed, and so is posting
|
||||||
|
|
||||||
|
`process_inbox` is `pub(crate)`, its match ends in `NotImplemented`, and its only caller is `Verifier::inbox`. There is no trait, registry or hook: **nothing outside the engine can contribute a processor** for a message type.
|
||||||
|
|
||||||
|
Posting is no better wired. `AppRequestCommandV0::InboxPost` is declared with its constructor and has **no handler arm** — the identifier appears nowhere else in the tree.
|
||||||
|
|
||||||
|
## A message waits for its recipient, and there is no delegate
|
||||||
|
|
||||||
|
An inbox's readers are `UserId`s, and messages queue until that user comes online. The only *"someone else runs it"* notion upstream is `Headless(Credentials)` — a server-side verifier holding a **user's own** credentials, which is that user running elsewhere, not a service delegated over someone's inbox.
|
||||||
|
|
||||||
|
## What follows for us
|
||||||
|
|
||||||
|
The **container** is settled by the target: a document may be given an inbox, one at most. Build on that freely.
|
||||||
|
|
||||||
|
The **content** of a deposit and **who processes it, when** have no upstream answer — the variants that would carry them are empty, and the processor set admits nothing from outside. So these are ours to choose, and a choice here is a bet that must be named as one rather than presented as alignment.
|
||||||
|
|
||||||
|
The standing choice, deliberate and provisional: **a deposit is a reference to a document**. It is enough for what is built on it today, and it is expected to evolve — most plausibly toward whatever shape the target eventually gives its own variants.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
type: knowledge
|
||||||
|
summary: What NextGraph's own IRI vocabulary is, what this package writes instead, and why scaffolding triples and durable application data must not share a namespace
|
||||||
|
last_checked: 2026-08-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# What an IRI says upstream, and what ours says
|
||||||
|
|
||||||
|
Read at the source in `nextgraph-rs` (`213338f6`). Every claim points at a symbol or a module rather than a line, so `git grep` settles any doubt after the code moves. Two questions are answered here and they are not the same one: *what does the target's vocabulary look like?* and *what does a triple this package writes promise about its own future?*
|
||||||
|
|
||||||
|
## Upstream defines exactly one namespace, and it is the engine's
|
||||||
|
|
||||||
|
`NG_ONTOLOGY = "did:ng:x:ng#"` (`engine/verifier/src/types.rs`) is the only vocabulary namespace anything upstream declares as a constant. Three terms sit beside it — `NG_ONTOLOGY_ABOUT` (`#a`), `NG_ONTOLOGY_TITLE` (`#n`), `NG_ONTOLOGY_CLASS` (`#c`) — a document's description, its title, its class.
|
||||||
|
|
||||||
|
Those three are **not the whole namespace**. The same `did:ng:x:ng#` carries engine protocol state, written as inline string literals rather than named constants: `ng:site` / `ng:protected` and their `_inbox` counterparts, which `import_contact_from_qrcode` writes into a contact document (`engine/verifier/src/request_processor.rs`), and `ng:social_query_id` / `ng:social_query_forwarder` / `ng:social_query_started` / `ng:social_query_sparql`, which the social-query machinery reads and writes (`engine/verifier/src/inbox_processor.rs`). So the namespace is engine-owned in a broader sense than "document metadata": it is where the engine keeps what it needs to keep.
|
||||||
|
|
||||||
|
Everything else that looks like a NextGraph vocabulary lives **only in test fixtures**: `did:ng:x:contact#…`, `did:ng:x:core#…` and `did:ng:x:contact:class#…` in `sdk/rust/src/tests/big_contact_schema.json`, `contact_data.sparql`, `orm_create_patches.rs`, `orm_creation.rs`. No engine code writes them. There is no registry, no parser, no stability promise behind any of them.
|
||||||
|
|
||||||
|
## `x` is not an address — vocabulary and addresses share a scheme and nothing keeps them apart
|
||||||
|
|
||||||
|
The NURI grammar knows `o v b c d a g h j k l n s t u` and the bare `did:ng:i` — the regexes in `engine/net/src/types.rs` and every constructor of `NuriV0` (`engine/net/src/app_protocol.rs`). Neither `x` nor `z` nor `ex` is among them: a vocabulary IRI is simply a string that starts like an address and matches no rule.
|
||||||
|
|
||||||
|
The fixtures make the point sharper. They also use `did:ng:k:contact:tag#…` and `did:ng:k:contact:phoneNumber#…` for controlled values — and `k` **is** in the grammar, as the key half of a `:j:…:k:…` or `:c:…:k:…` pair. The letter is already spent; the vocabulary form matches nothing anyway. Nobody arbitrated that collision because nobody had to: no registry exists to arbitrate it in.
|
||||||
|
|
||||||
|
## `did:ng:x:core#value` is not "a field's value"
|
||||||
|
|
||||||
|
It is the payload slot of a **reified value node**, and reading it as a generic field accessor gets the model backwards. The shape, verifiable in `sdk/rust/src/tests/contact_data.sparql`:
|
||||||
|
|
||||||
|
- the entity carries `did:ng:x:contact#email` → a **value node** (its own IRI);
|
||||||
|
- the value node carries `did:ng:x:core#value` → the literal;
|
||||||
|
- the value node also carries the qualifiers: `core#source` (where this value came from), `core#type` (an IRI such as `did:ng:k:contact:phoneNumber#home`), `core#hidden`, `core#selected`.
|
||||||
|
|
||||||
|
**The field name lives in the parent's predicate**, never in the value node. And the payload slot splits by RDF term kind rather than by field: `core#value` holds a literal, `core#valueDateTime` / `core#valueDate` a typed date, `core#valueIRI` an IRI. `:class#` (as in `did:ng:x:contact:class#SocialContact`) is a convention observed in exactly one vocabulary, the contact fixture.
|
||||||
|
|
||||||
|
## There is no extension convention, and nothing upstream filters or reserves
|
||||||
|
|
||||||
|
No reserved segment, no procedure to claim one, no prohibition — nowhere in the tree. What the examples do instead is either `PREFIX ex: <did:ng:z:>` (`sdk/js/orm/src/tests/shapes/shex/testShape.shex`, the shex under `sdk/js/examples/`, `sdk/rust/src/tests/video_schema.json`) or plain web ontologies — vcard, foaf, schema.org, which the engine itself uses for a contact's name and email. One example spells `did:ng:n:g:x:social:contact#` (`sdk/js/examples/react-ldo/src/.shapes/contact.shex` and the generated files beside it), an older form matching neither the fixtures nor the grammar: the scheme is **not self-consistent across the tree**.
|
||||||
|
|
||||||
|
**Nothing upstream filters or reserves a namespace.** The engine stores and returns arbitrary IRIs. The single prefix-sensitive path is in `engine/verifier/src/commits/transaction.rs`, which matches `NG_ONTOLOGY_ABOUT` / `NG_ONTOLOGY_TITLE` among a commit's inserts and removes only to push a tab-info update when a title or description changed — and only for updates whose `branch_type.is_header()`. The triples stay in the store and stay queryable; nothing is hidden from a reader. **Separation upstream is by BRANCH, not by prefix** — a header branch is a different CRDT with its own topic, which is why upstream needs no reserved prefix at all.
|
||||||
|
|
||||||
|
The `urn:` strings that appear upstream are unit-test fixtures and nothing else: `urn:g`, `urn:child:dg`, `urn:child:sg` inside `#[cfg(test)] mod tests` in `engine/verifier/src/orm/graph/utils.rs`, and `urn:test:…` under `sdk/rust/src/tests/`. They are not vocabulary and imply nothing.
|
||||||
|
|
||||||
|
## What this package writes
|
||||||
|
|
||||||
|
**`urn:ng-eventually:` — `MACHINERY_NS`** (`packages/polyfill/src/emulated-verifier/machinery.ts`), in two families: `urn:ng-eventually:shim:…`, the compartments `shared-wallet/account-registry.ts` uses to emulate a repo's branches inside one document, and `urn:ng-eventually:inbox:…`, the deposit envelope and the deposit kinds `emulated-verifier/inbox-dispatch.ts` dispatches on.
|
||||||
|
|
||||||
|
**Triples under it are dropped on read.** `isMachinerySubject` is called from `readUnion` (`packages/polyfill/src/surface/read-model.ts`), which skips every row whose **subject** starts with the prefix before the caller ever sees it. By subject only — a machinery predicate on an ordinary subject comes straight through.
|
||||||
|
|
||||||
|
**That filtering has no upstream counterpart: it is an invention, and the source annotates it as one** (`kind=invention level=none`). It exists because this package has no branches, so a compartment upstream keeps in a separate CRDT has to live as a reserved subject in the same graph. Do not read our reservation as mirroring anything upstream — nothing there is filtered out of a document's own graph.
|
||||||
|
|
||||||
|
**The index arm writes another package's namespace.** `urn:ng-helpers:index:field` and `urn:ng-helpers:index:value` (`packages/polyfill/src/emulated-verifier/index-deposit.ts`) are the IRIs `@ng-helpers/indexing` already writes, kept letter for letter because renaming them would orphan every index already written. It is odd for this package to write a namespace it does not own. Recorded here as an oddity; it is not this leaf's to fix.
|
||||||
|
|
||||||
|
## Two kinds of triple, two fates — the distinction this leaf exists for
|
||||||
|
|
||||||
|
**Scaffolding** is what this package writes to emulate what the engine will do: the branch compartments, the deposit envelope. It is transitory. It disappears at migration together with the read filter that hides it, and being invisible to a caller is exactly right for it. `urn:ng-eventually:` is its home.
|
||||||
|
|
||||||
|
**Durable application data** is what an application means: an index entry written into a public document, read by third parties, which must **survive** migration. It is deliberately *not* under `urn:ng-eventually:` — which is precisely why the index IRIs pass the read filter and reach the caller.
|
||||||
|
|
||||||
|
Naming them alike would say the wrong thing about both: it would promise the scaffolding a future it does not have, and it would put the durable data one widened filter away from disappearing. The test at every new IRI: **does this triple die at migration, or does it have to outlive it?** Scaffolding goes under `urn:ng-eventually:`; anything an application or a third party reads must not. This is the triple-level twin of [[knowledge_what-an-app-deletes-at-migration]] — there, the two fates of a published symbol; here, the two fates of a written triple.
|
||||||
|
|
||||||
|
## What is settled, and what is a bet
|
||||||
|
|
||||||
|
**Settled, read at the source.** Nothing upstream reserves, filters or validates a namespace; the engine stores and returns arbitrary IRIs; there is no extension convention to comply with and no procedure for claiming a segment. **Nothing upstream binds our choice, and nothing upstream breaks if we keep a `urn:` scheme.**
|
||||||
|
|
||||||
|
**A bet.** That upstream will not later claim `urn:` or begin separating by prefix. Nothing says it will; nothing says it will not — and *the absence of an implementation says nothing about what the target will do* ([[rule_no-divergence-from-nextgraph]]). The exposure is small and it falls on the scaffolding side, which is the half designed to disappear.
|
||||||
|
|
||||||
|
**A bet, and the more expensive one.** Writing `urn:ng-helpers:` from here. Two packages now share one data format with no owner and no agreement about who may change it.
|
||||||
|
|
||||||
|
**Not a bet — a divergence, if it were ever done.** Putting our own terms under `did:ng:x:…`. That segment is the target's vocabulary space, `did:ng:x:ng#` is the engine's own, and `x` means nothing to the address grammar — so borrowing it would dress an invention in the target's vocabulary, which [[rule_no-divergence-from-nextgraph]] names as the most dangerous kind of all.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
type: knowledge
|
||||||
|
summary: Who does what along a deposit's path — the application deposits, this package DISPATCHES on what a deposit declares itself to be and applies it arm by arm, and upstream none of it is reachable yet
|
||||||
|
last_checked: 2026-08-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# Who processes an inbox
|
||||||
|
|
||||||
|
[[knowledge_what-an-inbox-is-upstream]] settles what the target *offers*: a document may be given an inbox, nothing above the engine exposes one, and the processor set admits nothing from outside. It says nothing about who does the work **here**. That is this leaf.
|
||||||
|
|
||||||
|
The question is hard to answer out loud — *"does the polyfill process the inbox automatically?"* took one agent a long exchange to fail at — because **processing means a different thing at each layer**. Naming the actor first makes it easy. Everything below is read off the code; symbols are cited so `git grep` settles any doubt.
|
||||||
|
|
||||||
|
## The application
|
||||||
|
|
||||||
|
It **deposits** by naming a document (`inbox.postToDocument`) or a person (`inbox.share`), **reads its own** with `inbox.readForDocument` / `readSyncedForDocument`, and may **watch** one with `inbox.watch`.
|
||||||
|
|
||||||
|
What it never does: **process an inbox so that a `ReadCap` sent to it arrives**. `inbox.processInbox` is published, but no application has to call it — connecting drains, and every arrival afterwards is applied. It never resolves someone else's inbox address (the only published call that returns one is `storeRegistry.openDocumentInbox`, on a document it owns), and it has no way to reach, hurry or disable the deferred stand-in below.
|
||||||
|
|
||||||
|
## `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
**At connection** — `emulated-verifier/connect.ts`, `connectedUser`: restore every durable register, then `processInbox` on each inbox this identity holds, then start the continuous observation. Failing to apply one queue is reported and denies nobody their session; failing to *reach* the queues rejects.
|
||||||
|
|
||||||
|
**Continuously, while the identity is connected** — `emulated-verifier/inbox-observer.ts`, `startObservingInboxes`, whose only caller is `connectedUser`: one subscription per inbox, and every push runs `processInbox` on it. *Which* inboxes exist is itself subscribed to, on two channels that do not overlap: the **register** (the User branch of the private store, where `openDocumentInbox` appends its record — so an inbox opened mid-session is picked up) and **what this identity holds** (`CapRegistry.onChange` — which is what makes a person's *first* session watched at all, since provisioning is lazy and they connect owning nothing). Push-driven throughout; it never polls. It stops on identity change, on disconnection, and on `configure` / `resetConfig` (`stopObservingInboxes`, called only from `shared-wallet/bootstrap`). Every step re-checks its holder, so a run that outlives its identity abandons rather than file into the wrong ring.
|
||||||
|
|
||||||
|
### Applying is a DISPATCH — one arm per declared type
|
||||||
|
|
||||||
|
`emulated-verifier/inbox-dispatch.ts`, `applyDeposit`. It is a `switch` over what a deposit DECLARES itself to be (`kindOf`), and the shape is the target's own: `Verifier::process_inbox(msg, content)` is literally `match content { … }`, one arm per `InboxMsgContent` variant, ending `_ => Err(VerifierError::NotImplemented)` (`engine/verifier/src/inbox_processor.rs`).
|
||||||
|
|
||||||
|
**What an arm owes**, read off upstream's clearest arm (`InboxMsgContent::ContactDetails`): **validate** the envelope — a missing `from_inbox` is `InvalidInboxPost`, a hard failure and not a skip; **build** its addresses from the typed content; **read** the recipient's own store to see whether the thing is already there (a SPARQL `ASK`); **decide idempotently** — `ContactAlreadyExists` rather than a duplicate; and only then **write**. An arm is a real program, not a branch.
|
||||||
|
|
||||||
|
The two arms this package has:
|
||||||
|
|
||||||
|
- **A `ReadCap` delivery** (`inbox-dispatch.LINK_KIND`, whose name is upstream's own word for the variant) — held for the owner's ring and filed durably with `branch-registers.addLink`, the emulated `AddLink { read_cap }`. A payload declaring itself one of these and carrying nothing that reads is REFUSED and reported, which is the envelope validation above; it used to be handed back as consumer data.
|
||||||
|
- **A bare `reference`** — a deposit whose payload IS a `did:ng:` reference and nothing else: no operation, no claim, no copy of any value. `emulated-verifier/index-deposit.ts`, `applyIndexDeposit`, resolves it and LOOKS: which document the inbox belongs to (the owner's own `AddInboxCap` pairing, `readInboxCapPairs`), what that document declares it indexes by (`urn:ng-helpers:index:field`, on the document's own NURI as subject), whether the reference is already in, what the referenced object says for that field — and only then writes ONE entry (`urn:ng-helpers:index:value`, subject = the object's NURI). Since anyone may deposit, a payload carrying an operation would be a licence to rewrite someone else's document, and a payload carrying a value would put in the index something the object does not say. **An index only ever grows**: nothing there removes an entry and nothing there can express a removal, which is what makes an empty read harmless — absent, unreadable and failed all mean *not added this time*, and a later deposit adds it. An unresolved reference is reported and costs nothing; an already-indexed object is passed over; a document declaring no field, or several, refuses rather than write something wrong — and that refusal leaves READING its entries untouched.
|
||||||
|
|
||||||
|
*(Those two IRIs are `@ng-helpers/indexing`'s namespace, kept letter for letter because renaming them is a data-format change nobody decided. This package writing another package's namespace is odd, and it is recorded as odd rather than acted on.)*
|
||||||
|
|
||||||
|
**A kind with no arm says so.** A payload declaring itself in this package's reserved `urn:ng-eventually:inbox:` namespace for a variant nothing here applies is REPORTED — upstream's `NotImplemented`, which is an answer. Until 2026-08-21 it was read, counted, and produced nothing at all: no effect and no reported failure, the "failure disguised as an absence" this package has closed repeatedly elsewhere.
|
||||||
|
|
||||||
|
**Consumer data is not a variant.** A payload that declares no kind of this package's is claimed by no arm and nothing is reported about it: it is not an unapplied message, it is not a message. That is why the RESERVED NAMESPACE is the discriminator and the mere presence of a `kind` field is not — `{ kind: "join" }` is a consumer's word for a consumer's message, and it stays one.
|
||||||
|
|
||||||
|
**What still reaches the caller.** Only a `ReadCap` is withheld: an application must never handle one. Everything else `read` found is returned unchanged, including the payloads an arm applies — a `reference` is public information, so withholding it would buy nothing. Two senses of *applied* stay apart: `read` learns a `ReadCap` **in memory, for this session**; only `processInbox` makes it **durable** and runs the dispatch. `inbox.watch` re-reads on every push, so it absorbs and files nothing.
|
||||||
|
|
||||||
|
**After a deposit whose recipient is absent.** A deposit that landed arms a deferred stand-in for the session the owner does not have. What it achieves is the only part written down outside the package: **deposits converge** — a share becomes readable without the recipient re-opening the page. *How* is deliberately not documented here. The mechanism is unpublished by design: nothing lets a caller ask for it, name another user's inbox, or turn it off, and the confidentiality of the whole shared-wallet emulation rests on its being unreachable. Its limit is stated rather than hidden — it lives in a page, so if the page goes away the deposit waits for its owner's next connection, which is the real path and not a repair.
|
||||||
|
|
||||||
|
**And it applies the registers, not the documents.** Running for an owner who is not the one holding the page, it takes the arms that write the verifier's own bookkeeping and stops there: writing application data into an absent owner's documents from a stranger's session is a different act, and this package does not take it. So a `reference` deposit is left — traced, not reported, because waiting is not failing — and the owner's own next connection applies it. Nothing is lost: see below.
|
||||||
|
|
||||||
|
**Nothing is ever consumed.** No path retires an applied deposit: an inbox here is re-readable where upstream it is a queue. That is what makes every abandon, every reported refusal and every watch that could not be opened harmless — what was not applied is still there for the next push or the next connection. It is also what makes re-applying safe: every arm is idempotent, so seeing every deposit again on every run lands on the same result.
|
||||||
|
|
||||||
|
## NextGraph itself
|
||||||
|
|
||||||
|
The **broker** routes a sealed message by inbox key (`inboxes: PubKey → RepoId`, `engine/verifier/src/verifier.rs`) and queues it while the recipient is away. The **recipient's own verifier** applies it inline as it arrives — `LocalBrokerMessage::Inbox` → `session.verifier.inbox(&msg, from_queue)` in `sdk/rust/src/local_broker.rs`; `from_queue` distinguishes the backlog handed over at connection from the messages that follow, and **both go through the same door**, which is why the continuous regime above is the rule and the connection drain the special case.
|
||||||
|
|
||||||
|
And **none of it is reachable from JS**: the processor set is closed, the variants that would carry a payload carry nothing, and the post command has no handler arm — all in [[knowledge_what-an-inbox-is-upstream]]. Every actor above except the broker and that verifier is therefore ours, emulating one thing: a session applying its own inbox, continuously.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
---
|
||||||
|
type: contract
|
||||||
|
summary: The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer
|
||||||
|
---
|
||||||
|
|
||||||
|
# contract_polyfill-surface — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This package is a polyfill of NextGraph's SDK.
|
||||||
|
|
||||||
|
This package covers placement (creating and listing an application's documents by scope), reading (a document's subjects, one-shot or reactive), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
|
||||||
|
|
||||||
|
### Deployment requirements
|
||||||
|
|
||||||
|
An application using this package must:
|
||||||
|
|
||||||
|
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
|
||||||
|
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first;
|
||||||
|
- **declare this package exactly once**, so that everything in the application resolves to one copy of it — see the single-copy rule under `## Non-guarantees`, which is the one packaging mistake this surface cannot protect you from.
|
||||||
|
|
||||||
|
**Obtaining it.** This package is not published to a public registry, and it is not distributed as built output: its published entry point is TypeScript source, so whatever builds the application is what compiles it, and a toolchain that accepts only JavaScript cannot consume it as it stands. By which channel the source reaches a given application is agreed with that application rather than fixed here; what this contract fixes is the version you pin and the rules below.
|
||||||
|
|
||||||
|
## Surface
|
||||||
|
|
||||||
|
Full typed shape: the package's `types` entry, `@ng-eventually/polyfill`. A type is published only when a published signature uses it. The load-bearing signatures:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ── bootstrap ────────────────────────────────────────────────────────────
|
||||||
|
export function configure(c: EventuallyConfig): void;
|
||||||
|
export interface EventuallyConfig {
|
||||||
|
ng: NgLike; // the `ng` object from @ng-org/web
|
||||||
|
useShape: UseShapeLike; // `useShape` from @ng-org/orm
|
||||||
|
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
|
||||||
|
debugAccessLog?: boolean;
|
||||||
|
init?: (...args: any[]) => any;
|
||||||
|
initNg?: (...args: any[]) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── identity — one await before the application renders ──────────────────
|
||||||
|
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
|
||||||
|
|
||||||
|
// ── addressing ───────────────────────────────────────────────────────────
|
||||||
|
export type Nuri = `did:ng:${string}`;
|
||||||
|
export type NuriLike = Nuri | string;
|
||||||
|
export type Scope = "public" | "protected" | "private";
|
||||||
|
|
||||||
|
// ── placement: where an application's documents live ─────────────────────
|
||||||
|
export const storeRegistry: { // no identity parameter — a session is one user's
|
||||||
|
createEntityDoc(scope: Scope): Promise<Nuri>;
|
||||||
|
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
|
||||||
|
resolveScopeGraph(scope: Scope): Promise<Nuri>;
|
||||||
|
resolveWriteGraph(scope: Scope): Promise<Nuri>;
|
||||||
|
openDocumentInbox(doc: NuriLike): Promise<Nuri>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── reading ──────────────────────────────────────────────────────────────
|
||||||
|
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
|
||||||
|
export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> }
|
||||||
|
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
|
||||||
|
// TWO positional arguments — the same pair `useShape` takes. `ShapeQuery` is what you
|
||||||
|
// READ BACK (the snapshot), never what you pass in.
|
||||||
|
export function watchShape<T = UnionSubject>(shapeType: unknown, scope: Scope): ShapeObservable<T>;
|
||||||
|
export interface ShapeObservable<T = UnionSubject> {
|
||||||
|
getSnapshot(): ShapeQuery<T>; // stable reference until the state changes
|
||||||
|
subscribe(onChange: () => void): () => void; // returns its own unsubscribe
|
||||||
|
refetch(): void; // re-resolve and re-read now; never polls
|
||||||
|
}
|
||||||
|
export interface ShapeQuery<T = UnionSubject> {
|
||||||
|
data: T[]; isPending: boolean; isSuccess: boolean; isError: boolean; error: unknown;
|
||||||
|
}
|
||||||
|
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
||||||
|
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
||||||
|
|
||||||
|
// ── low-level document / SPARQL primitives ───────────────────────────────
|
||||||
|
export const docs: {
|
||||||
|
// `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<Nuri>;
|
||||||
|
sparqlQuery(sessionId: string | number, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
|
||||||
|
// 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<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── inbox: giving to read, and depositing ────────────────────────────────
|
||||||
|
export const inbox: {
|
||||||
|
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
|
||||||
|
post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
|
||||||
|
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
|
||||||
|
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
|
||||||
|
readForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||||
|
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||||
|
readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||||
|
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||||
|
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
|
||||||
|
// `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: 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<string, any>` until 2026-08-14
|
||||||
|
export function init(...args: any[]): any; // likewise — not the `init` passed to `configure`
|
||||||
|
export function initNg(...args: any[]): any;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guarantees
|
||||||
|
|
||||||
|
Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. No type guard is published.
|
||||||
|
|
||||||
|
A returned reference carries no key — not `createEntityDoc`, not `listMyEntityDocs`, not `UnionSubject.subject` / `.graph`. A reference found inside a document yields a name, not a key.
|
||||||
|
|
||||||
|
You read a document whose key you hold: you created it, it was shared with you, or it sits in a public store, which serves its read key to whoever asks. No call answers "may I read this?".
|
||||||
|
|
||||||
|
What was shared with you becomes readable after `ensureIdentity()`.
|
||||||
|
|
||||||
|
`readUnion` returns one entry per distinct subject present in a document. `subject` is that subject's IRI exactly as written, and is a `string`, because a subject may be any IRI; `graph` is the document reference you passed in, and is the `Nuri` to hand back to this surface. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Several objects in one document are allowed. Recommended placement is one document per business entity: access is granted per document.
|
||||||
|
|
||||||
|
`urn:ng-eventually:` is reserved, and the reservation has two faces. In a document: triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned. In a deposit: two payload shapes are this package's own and it acts on them — a payload that IS a reference (a bare `did:ng:…` string and nothing else), and an object whose `kind` property is a string starting with `urn:ng-eventually:inbox:`. **Every other payload is yours, and is delivered to you untouched.**
|
||||||
|
|
||||||
|
**Do not use either shape to carry your own data.** Depositing one is still accepted and it is still returned to you by `inbox.read` and its variants — nothing is withheld — but this package now also acts on it, and what you observe is one of two things: it writes an entry into the document the inbox belongs to, if that document is one it can index; otherwise it reports a refusal on its own log stream (`console.error`, ungated, as below). That refusal does not clear on its own the way an unreachable inbox does: a deposit is not consumed, so the same refusal is reported again at every arrival on that inbox and at every connection, for as long as the deposit sits there. Carry your own data in an object with a `kind` of your own — `{ kind: "join", … }` is yours and stays yours — and never as a bare reference on its own.
|
||||||
|
|
||||||
|
Only a document's owner writes to it. Holding its read key never grants a write.
|
||||||
|
|
||||||
|
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
|
||||||
|
|
||||||
|
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
|
||||||
|
|
||||||
|
**You never resolve an inbox address, on either side.** You deposit by naming a document (`inbox.postToDocument`), and you read what was left on your own by naming it too — `inbox.readForDocument(doc)` at any time, or `inbox.readSyncedForDocument(doc)` on a page that has just loaded. The second is the one to call when an empty answer has to MEAN empty: a session that has just loaded has synced neither the document nor its inbox, and an unsynced read of either comes back empty with no error — so the ungated form can answer `[]` for a document whose inbox holds messages. `readSyncedForDocument` waits for both before answering. `inbox.readSynced` is the same guarantee on an inbox you already hold the address of, which no application does: it takes an address, so nothing here hands you one.
|
||||||
|
|
||||||
|
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
|
||||||
|
|
||||||
|
It resolves **only once that work has actually completed**: if what was shared with you could not be restored, it throws instead of returning, and a rejected call must not be rendered past — the interface would show an empty account rather than an empty screen. A single queue that could not be drained is reported and does not reject: reaching your queues is infrastructure and must succeed, applying one deposit is data and must never cost you the session. So a resolved call means your own capabilities are restored; it does not promise that every deposit waiting for you has been applied, and those that were not stay in their queue.
|
||||||
|
|
||||||
|
`ensureIdentity()` mounts a full-screen barrier on every top-level load, and takes it down itself — past the broker round-trip it stays down, provided the identifier reached the other side. A person who comes back to the page from that round-trip finds the barrier live again, prefilled, and confirming it hands the page over a second time. The application's own page is never reloaded and nothing outside the barrier is touched.
|
||||||
|
|
||||||
|
**What decides which identity you get.** No call takes an identifier — not `configure`, not `init`, not `ensureIdentity` — so an application never chooses one, never keeps one, and never hands one over. It is settled once per page load, from the page itself, and the answer depends on which side of the broker round-trip is asking:
|
||||||
|
|
||||||
|
- **Before the round-trip, on your own top-level page — the barrier decides.** Whatever is already known fills the field, and the person may change it; the identity is the value they confirm. Confirming publishes that value into the address bar as `?ng-id=` and records it in this browser.
|
||||||
|
- **After the round-trip, on the page the broker loads back — `?ng-id=` decides**, and the barrier stays down. The parameter also wins over anything the browser remembers, on either side: it is read first, and reading it replaces what was remembered. Arrive with neither — a URL that dropped the parameter, and a partition that remembers nothing — and the barrier asks on that side too.
|
||||||
|
- **Failing both — whatever this browsing context last recorded.** This is the only path on which an identity is adopted with nobody confirming it. If the address bar lost `?ng-id=` while a *different* identifier was on record here, that different identity is adopted, and nothing is raised anywhere; if nothing was on record either, the barrier asks again on that side too.
|
||||||
|
|
||||||
|
**`?ng-id=` exists because it is the only thing that crosses.** Your page before the round-trip and your page after it sit in two separate storage partitions — nothing the browser remembers on one side is visible on the other, and the address bar is the sole channel between them. The package writes the parameter itself, without navigating. **An application that owns its URL must let it survive**: a router that drops query parameters it does not know, or a redirect that rebuilds the URL, sends the round-trip off without the identifier, and the consequence is the silent mis-identification above rather than an error. In a context that can neither write the address bar nor use storage, the barrier simply asks on both sides.
|
||||||
|
|
||||||
|
**Being remembered is a prefill, never a decision.** A top-level reload asks again every time, with the field already filled — one click, no typing. A remembered identifier is therefore not a signed-in state, and two tabs, two browsers or two devices do not share one: each keeps its own record, and only a URL carrying `?ng-id=` puts a second context under the same identity.
|
||||||
|
|
||||||
|
**The session is the package's, not yours.** You never build one, and no call takes one. Call this package's `init` (not the one you passed to `configure`): it captures the session the SDK delivers to `init`'s callback and keeps it, then calls your callback with that same event untouched — so an application that wants the `session_id` for the `docs` primitives reads it there, and one that does not may pass no callback at all. Identity normalisation is the package's too: `@Alice`, `alice ` and `ALICE` are one person.
|
||||||
|
|
||||||
|
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
|
||||||
|
|
||||||
|
**`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about.
|
||||||
|
|
||||||
|
**A reactive read says "nothing" and "I could not find out" differently.** `watchShape` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds no document of that shape — the distinction the surface exists for. Until 2026-08-17 a scope whose listing did not answer published that very snapshot, so an interface showed "you have created nothing" for "the store did not answer"; it now publishes `isError` carrying the error. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
|
||||||
|
|
||||||
|
The same rule reaches what a call hands BACK, not only what it looked up first: **`listMyEntityDocs` returns a listing whose documents you can open, or it throws.** It reads which documents are in the store and what opens each, and it throws if either did not answer — including when the documents came back and their keys did not. Nothing about a keyless listing is visible to you: it is the same `Nuri[]`, and the difference would only appear at the next read, empty, long after the cause. An empty array therefore means this account created nothing.
|
||||||
|
|
||||||
|
**A deposit made while a person is looking at the page arrives while they are looking at it.** For as long as an identity is connected, every inbox it may read is watched and what lands in one is applied as it lands — its own inbox, and the inbox of every document it has opened one on, including a document whose inbox it opens later in the same session. So a `ReadCap` sent with `inbox.share` becomes usable in the recipient's live session with no reload and no call from the application, and a `watchShape` that was empty for want of that `ReadCap` re-reads and publishes the document it now opens. Until 2026-08-17 only the backlog waiting at connection was applied, and a deposit made in front of its recipient converged only when that person reloaded the page.
|
||||||
|
|
||||||
|
**The watching is in place by the time `ensureIdentity()` resolves**, whatever else that call made of its own work: a connection that could not restore something still rejects, and the identity it settled is watched all the same. It lasts exactly as long as that identity stays connected — changing identity or clearing it stops it, and whoever connects next is watched in their own right, so nothing of the previous one keeps applying.
|
||||||
|
|
||||||
|
**Failing to apply one inbox denies nothing.** It is reported on this package's own log stream (`console.error`, carrying this package's prefix) and never gated by `debugAccessLog` — a diagnostic may be opt-in, a failure may not. The deposit stays in its queue, so the next arrival on that inbox, or the next connection, applies it; the other inboxes were never involved, and nobody is refused anything.
|
||||||
|
|
||||||
|
**Any number of subscriptions on one document coexist.** Opening a document, watching an inbox and following a scope no longer silence one another. Until 2026-08-17 a second `subscribeDoc` on a document killed the first, silently — nothing rejected, the first caller's unsubscribe still appeared to work, and what an application saw was a view that stopped re-reading and an inbox that stopped notifying, with no trace anywhere near the cause. A subscriber that joins a document somebody else already opened is handed the initial `State` its own subscription would have pushed it, so joining late is not the same as never firing; and unsubscribing silences that caller and no other, including when it happens from inside a push.
|
||||||
|
|
||||||
|
## Non-guarantees
|
||||||
|
|
||||||
|
**No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name.
|
||||||
|
|
||||||
|
**Naming an identity proves nothing about who named it.** Any visitor may type any identifier at the barrier, or arrive on a URL that already carries one, and act as that identity — the wallet and its password are handed out on the barrier itself. The identifier is a choice of space, never a proof: anyone who knows one can act as it, so do not treat `ensureIdentity()`'s answer as an authenticated subject.
|
||||||
|
|
||||||
|
**No live read through `useShape` against a deployed broker.** The reactive subscription it opens is dispatched by method name at two hops below this package, and a broker deployed before that name last changed does not recognise it: the read never starts, its set stays empty, its readiness never settles, and one console error is the only trace — indistinguishable from "this scope is empty". The cause is upstream in NextGraph and the remedy is a broker redeployment; nothing in this package can work around it. Use `watchShape` for a reactive read and `readUnion` for a one-shot one — neither goes through that path.
|
||||||
|
|
||||||
|
**No revocation.** `inbox.share` cannot be undone.
|
||||||
|
|
||||||
|
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
|
||||||
|
|
||||||
|
**No delegated writing.** A received key never grants a write, and no call adds a writer to a document.
|
||||||
|
|
||||||
|
**No mailbox model.** Do not build on the raw deposit list.
|
||||||
|
|
||||||
|
**No cross-broker reference.** A returned reference resolves for users of the same broker.
|
||||||
|
|
||||||
|
**`openDocumentInbox` does not coalesce across PAGES.** Two tabs — or two sessions of the same person — that open the same document's inbox in the same moment can each create one, and the document is left with two: its owner drains one while deposits arrive in the other. Nothing raises, nothing reports it, and neither page can detect it afterwards. It is not an oversight to be patched later: a branch MERGES records rather than refusing the second, so there is no "create only if absent" to build the guarantee on, and the address a depositor reads is a separate record from the one the owner resolves — so the two cannot even be made to agree on which of the pair won. Open a document's inbox from one place: the page that creates the document, or one call the rest of the interface waits on.
|
||||||
|
|
||||||
|
**TWO COPIES OF THIS PACKAGE IN ONE APPLICATION MISBEHAVE SILENTLY, and nothing here can detect it.** What this package remembers it keeps in the package itself, not in any handle you hold: which documents are subscribed and who is listening for them, which identity the session settled on, which documents are open. None of that is shared between two copies. An application that ends up with two therefore runs two of everything — a document subscribed through one copy is invisible to the other, so changes to it simply never arrive; and the identity settled in one is not the identity the other acts as, so the same call writes as one user or the other depending on which copy it reached. Nothing raises, nothing warns, and no call can report it: from every entry point a second copy is indistinguishable from the first, and the symptoms surface far from the cause as missing updates and writes attributed to the wrong person. This is a packaging property, not a behaviour to code around — **declare this package once, as one dependency of the application**, and let your own code and every library built on it resolve to that copy. A library that builds on this package should declare it a *peer* rather than a dependency for exactly this reason, so that the application remains the one place it is named.
|
||||||
|
|
||||||
|
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
|
||||||
|
|
||||||
|
**A watch on one inbox that could not be opened does not come back on its own.** Opening it can fail — a broker that does not answer in that moment — and the failure is reported rather than passed over, but what follows is event-driven and this package deliberately never polls. The watch is opened again at the next moment this identity comes to hold something it did not: it creates a document, it opens an inbox on a document, a `ReadCap` reaches it through an inbox still being watched, it reads a public-store document for the first time. A session that does none of those goes on without that inbox — deposits made into it are not applied, nothing raises, and they wait unconsumed for the next connection. The exposed case is the identity that only ever READS, since it produces none of those events, where an identity that goes on creating recovers as a by-product of its own work. Awaiting `ensureIdentity()` again applies what is waiting — it drains every one of this identity's queues before it resolves, and shows no barrier a second time — but the watching itself comes back only on a fresh page.
|
||||||
|
|
||||||
|
## Change policy
|
||||||
|
|
||||||
|
**Semver, and majors are the normal case.** This surface converges on a NextGraph that does not ship yet, so most steps toward the target remove or narrow something — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
|
||||||
|
|
||||||
|
What each level means here, in this package's own terms:
|
||||||
|
|
||||||
|
- **major** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession` and `inbox.materialize` all left this way), **or** an existing call narrows: it now throws where it returned (`listMyEntityDocs` refuses a listing whose documents you could not open; `ensureIdentity` rejects rather than resolve on an incomplete restore), or it reports a state you did not have to handle before (`watchShape` publishing `isError` where it used to publish a synced-empty snapshot). A signature change a caller must react to counts; one that only accepts more than before does not.
|
||||||
|
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
|
||||||
|
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
|
||||||
|
|
||||||
|
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||||
|
|
||||||
|
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.3` is this package, `ng-e2e-helpers/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
|
||||||
|
|
||||||
|
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is `1.0.0-dev.3`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.3`, the string you pinned, never the version it is heading for. `1.0.0-dev.3` narrows an existing call rather than moving a signature: two payload shapes an application could carry freely are now this package's own, and one it cannot apply is reported — a state you did not have to handle before, which is a **major** by the rules above. It lands inside the pre-release line rather than on a new major number because `1.0.0` has not been cut, so there is nothing yet to break away from.
|
||||||
|
|
||||||
|
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it, and a symbol is never left published-but-dead as a courtesy. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
type: rule
|
||||||
|
summary: Never implement anything that diverges from NextGraph — ask "does the target do this?" first, and only then how badly a caller would be misled
|
||||||
|
---
|
||||||
|
|
||||||
|
# No divergence from NextGraph
|
||||||
|
|
||||||
|
**Rule:** Never implement anything that diverges from NextGraph. At every choice — a published signature as much as an internal mechanism — the **first** question is *does the target do this?* Verified in `nextgraph-rs`, or stated by its author. If the target does it differently, we do it their way, whatever that costs. Only where the target answers nothing at all do we invent, and then deliberately, documented, and named as a bet.
|
||||||
|
|
||||||
|
"Known" is narrow: read at the source, or stated by its author. Never inferred from what an npm package happens to expose, and **never inferred from an absent implementation** — *"the engine does not do X"* says nothing about whether the target will.
|
||||||
|
|
||||||
|
**A definition is a fact; its being unwired is an absence.** The two arrive together and the absence is the louder one, which is how it wins arguments it should lose. A type the target has declared but nothing constructs yet is the *strongest* thing to align on — implementing it is not inventing, it is realising a shape the target wrote down. Read the declaration and let the silence around it say nothing.
|
||||||
|
|
||||||
|
That distinction has its own place in the taxonomy below, because collapsing it into "invention" makes us believe we are further from the target than we are — and then a later reader, seeing "our own idea", feels free to change it.
|
||||||
|
|
||||||
|
**The second question measures gravity, not permission:** *would an application coding against this have to unlearn it?* Use it to rank what to fix first, and to judge an unavoidable divergence. Never use it to authorise one.
|
||||||
|
|
||||||
|
**Why the order matters — it is the failure this rule was rewritten for.** `readUnion` folded every triple of a document into one bag keyed by the document, so two entities written under two subjects came back merged and one written under another subject came back relabelled, silently. Asked the second question first, the answer was ambiguous: *one entity per document* is good placement practice anyway, so what exactly would a caller unlearn? The reasoning stalled there for hours. Asked the first question, it took one look: level 1 returns real subjects, and level 3's ORM carries `@id` **and** `@graph` on every object and generates the former when omitted — several objects per graph is the designed case. Divergence, done, no debate about perception.
|
||||||
|
|
||||||
|
Earlier instances, all of the same shape: *"every document has a native inbox"*, written from general reasoning, false, and already an implementation; and a per-document inbox pointed at its owner's inbox to absorb a measured cost, emulating a many-to-one relation the target cannot express.
|
||||||
|
|
||||||
|
**The pressure to deviate never announces itself as one.** It arrives as a cost, a latency, an ergonomic wrinkle — all real, all legitimate. That disguise is what makes it dangerous. When shape and cost conflict: keep the shape and attack the cost elsewhere (the lever is usually *who* pays and *when*). If the cost is genuinely unsolvable, say so rather than bending the model quietly.
|
||||||
|
|
||||||
|
**How to apply.** The tells, each of which has produced a real hole here:
|
||||||
|
|
||||||
|
- **A recommendation enforced by the code.** Ours may guide placement; it may not make the other arrangement invisible. A read path that silently normalises what it did not expect is the signature.
|
||||||
|
- **A symbol that makes the caller handle what the target will never hand it** — a document's key, an inbox address, a store id, its own identity.
|
||||||
|
- **A named exception stops being one the moment it is published.** A door documented as *"only this internal caller uses it"* is a door any application can open; the note is not a mechanism. Move it out of the published surface instead.
|
||||||
|
- **A symbol kept because it was already there is not a decision.** At every surface change, re-ask whether an application still needs each neighbouring symbol.
|
||||||
|
|
||||||
|
When a divergence is genuinely unavoidable it must be **deliberate, documented and invisible to the caller**. What is forbidden is the silent one, adopted because it was convenient.
|
||||||
|
|
||||||
|
## Every symbol says where it comes from
|
||||||
|
|
||||||
|
Answering *does the target do this?* once, in conversation, does not survive the week. So each symbol carries its own provenance, beside the code rather than in a document that drifts: the **level** that answers (1 the engine, 2 the binding, 3 the ORM), the upstream `path:symbol` it aligns on, and one of five kinds.
|
||||||
|
|
||||||
|
| kind | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `passthrough` | upstream's own symbol, same name and shape |
|
||||||
|
| `aligned` | our name or ergonomics, upstream's semantics and model |
|
||||||
|
| `declared-not-wired` | upstream **defines** the shape; nothing constructs it yet; we implement it |
|
||||||
|
| `reserved-name` | upstream has declared a NAME with no shape behind it — it binds nothing, so building on it is a bet |
|
||||||
|
| `invention` | nothing at any level answers — a deliberate, documented bet |
|
||||||
|
| `divergent` | upstream does this, and we do it differently |
|
||||||
|
|
||||||
|
A test requires the annotation on every published symbol; it cannot check that the citation is *true*, and that is fine — a citation is refutable by a reader in seconds, prose is not. The classification also applies to unpublished code that **claims to resemble the target**: the mistake this section exists for was made on an internal function, and being unexported protected nobody.
|
||||||
|
|
||||||
|
**A reserved name is not a declared shape, and the gap between them is the whole point.** `declared-not-wired` is strong *because the shape constrains us*: implementing it is realising something the target wrote down. A name with nothing behind it constrains nothing, so what we build under it is our own invention wearing the target's vocabulary — the most dangerous kind, because the borrowed name makes it read as alignment.
|
||||||
|
|
||||||
|
The tell is mechanical: **open the declaration and look for a payload.** Several of `InboxMsgContent`'s variants — `Link`, `Patch`, `ServiceRequest`, `ExtRequest`, `RemoteQuery`, `DialogRequest` — are bare unit variants carrying nothing at all, while `ContactDetails` and `SocialQuery` carry data and have processors. Presenting the first group as *"the strongest thing to align on"* was an error made here, in the same breath as citing this rule; see [[knowledge_what-an-inbox-is-upstream]] for what those variants do and do not offer.
|
||||||
|
|
||||||
|
**Why this exists.** A maintainer read that `AddLinkV0` is defined upstream but constructed nowhere, and reported our implementation of it as our own invention — reasoning from the absence while quoting, in the same session, the rule against exactly that. The correct reading was `declared-not-wired`, which is the best alignment available. Prose said otherwise elsewhere too: `docs/api-contract.md` claimed `docs.*` was a 1:1 passthrough while two of its signatures diverged, and stated a member count that was simply wrong. Both errors are the same one — a claim about the target that nothing forced anyone to check.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
type: overview
|
||||||
|
summary: What each end-to-end suite judges, the shortcut that kept two defects invisible, and the failure mode that is not ours to fix
|
||||||
|
triggers:
|
||||||
|
keywords: [e2e, harness, playwright, chromium, journey, suite, profile, broker page, notebook, wallet profile, ng-e2e-helpers]
|
||||||
|
paths:
|
||||||
|
- "packages/polyfill/e2e/**"
|
||||||
|
- "packages/ng-e2e-helpers/**"
|
||||||
|
vocabulary:
|
||||||
|
- term: journey
|
||||||
|
gloss: one named end-to-end story in the applicative suite, reported pass or fail as a whole
|
||||||
|
not: [scenario, test case, spec]
|
||||||
|
- term: batch
|
||||||
|
gloss: one run of a suite, which mints its own physical user and discards the previous one
|
||||||
|
not: [session, campaign]
|
||||||
|
---
|
||||||
|
|
||||||
|
# e2e-harness — the suites, and what they can and cannot tell you
|
||||||
|
|
||||||
|
Three suites drive real browsers against the real broker and the real wallet application. They are the only place the emulation meets what it emulates, so a green unit suite says nothing about them.
|
||||||
|
|
||||||
|
They are also expensive and, on a machine whose network moves, unreliable in ways that look exactly like product defects. Most of what this concept holds exists so an agent does not spend hours diagnosing a product bug that is a transport failure — or, worse, trust a green run that measured nothing.
|
||||||
|
|
||||||
|
The generic half of that machinery is a package of its own, `ng-e2e-helpers`, and this repo **publishes** it: a NextGraph application in any repository pulls `ng-e2e-helpers/contract_ng-e2e-helpers` and declares back what it consumes. Our own suites are one of those consumers, and declare it in the same folder — same pair, no boundary to cross.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `ng-e2e-helpers/contract_ng-e2e-helpers` — the engagement, written from the caller's point of view.
|
||||||
|
- `ng-e2e-helpers/usage_polyfill-e2e` — what our own suites actually call, and where the fit still costs them code.
|
||||||
|
- `knowledge_what-each-suite-judges` — which suite answers which question.
|
||||||
|
- `rule_never-shortcut-the-sign-in` — the shortcut that hid a 404 and a regression at once.
|
||||||
|
- `caveat_a-dropped-pipe-kills-a-run` — the failure mode that is not ours.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
type: caveat
|
||||||
|
summary: Chromium's control pipe drops without Playwright emitting close or disconnected, so a run dies mid-flight — it looks like a product defect and is not one
|
||||||
|
last_checked: 2026-08-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# A run can die from a dropped pipe, and it is not the product
|
||||||
|
|
||||||
|
Chromium's devtools pipe sometimes drops mid-run. It logs a terminated-pipe message and exits cleanly, and **Playwright emits neither `close` nor `disconnected`** — observed four times out of four. From the client's side the browser simply stops answering.
|
||||||
|
|
||||||
|
Before waits were bounded this was fatal in a specific way: the suite blocked in its own teardown, so it printed **neither its summary nor the failure already on its way out**. Hours went into diagnosing silence. Every wait is now bounded and names what it was waiting for, so a lost browser costs seconds and a report.
|
||||||
|
|
||||||
|
**How to recognize it.** The run dies without a coherent failure, or several unrelated interactions time out at once, or the summary is missing entirely. **Do not read a named deadline as a verdict on the transport.** A deadline says only that something did not happen in time; it never says why, and reaching for the environment is the comfortable answer because it absolves the code.
|
||||||
|
|
||||||
|
That reflex cost a full day here. Every actor sign-in was timing out, and it was blamed on the broker and on a churning host for hours. The real cause was one line of ours: the harness looked for the application frame with a substring match on the URL, and the broker's own login page carries the application address in its query string — so it matched the login page from the first instant, skipped the wallet click and the password as "already logged in", and handed back the wrong frame. What made it intermittent was a 2-second visibility probe on a button that painted in 1.0 to 1.6 seconds. All of it was readable in the code the whole time.
|
||||||
|
|
||||||
|
**It is not ours to fix.** It is not caused by how a child process is spawned, nor by a leftover holding the profile, nor by overlapping launches — all three were probed and ruled out. It looks like Playwright losing its file descriptors without telling its client. Worth reporting upstream.
|
||||||
|
|
||||||
|
## The host can be the cause too
|
||||||
|
|
||||||
|
A machine that reconfigures its network — a container in a crash-restart loop cycling its virtual interface, for instance — makes the applicative suite unreliable. The browser answers with a network-changed error, broker sockets fail, and every failure looks like a different product bug.
|
||||||
|
|
||||||
|
This has happened here: seven failures out of ten runs in one afternoon, all transport, none product. The check costs seconds — watch for repeated link events, and look for a container restarting.
|
||||||
|
|
||||||
|
But do not conclude the suite is unmeasurable: under that same churn it also ran green four times in a row. A red run under a moving network proves nothing, and neither does a green one. What decides is the SHAPE of the failure — a named deadline on a browser or broker operation points at the transport, a failed assertion carrying an unexpected value points at the code — and repetition: three consecutive green runs, or a failure that reproduces.
|
||||||
|
|
||||||
|
**The discipline that follows.** Read your own harness first, and only call it transport once you can name the mechanism. A suite that genuinely fails for transport reasons has measured nothing. Do not read it as a red baseline, do not chase it as a regression, and do not commit against it. Re-run it — and if the environment is known to be moving, say so alongside the result instead of letting a single run stand as the verdict.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
type: knowledge
|
||||||
|
summary: Which suite answers which question, what a batch costs, and why two runs must never overlap
|
||||||
|
---
|
||||||
|
|
||||||
|
# What each suite judges
|
||||||
|
|
||||||
|
**The polyfill suite** exercises the published surface against the real broker: capabilities, reads, inboxes, reactivity. It is the one that judges whether the emulation behaves like the target.
|
||||||
|
|
||||||
|
**The applicative suite** drives the example application through a browser, as a person would — several identities, several pages, assertions on what is on screen rather than on what the library returns. It judges whether an application built on this package actually works, including the sign-in a person really performs.
|
||||||
|
|
||||||
|
**The reactivity suite** isolates document subscription.
|
||||||
|
|
||||||
|
A unit suite cannot replace any of them, and none of them replaces the unit suite: they are slow, they depend on live external services, and they cannot enumerate a case space.
|
||||||
|
|
||||||
|
## What a batch costs, and why
|
||||||
|
|
||||||
|
Every run mints its own physical user by driving the wallet application's real interface, then discards the previous one. That is deliberate — identities must not leak between runs — and it puts a floor under every run that no test filter can remove.
|
||||||
|
|
||||||
|
Consequence worth knowing before optimizing anything: the setup runs before any `journey`, so filtering journeys saves journey time only — the floor stays.
|
||||||
|
|
||||||
|
## Two runs may overlap — and that was not free
|
||||||
|
|
||||||
|
They once could not: the profile was a single shared directory, and a run starting while another was alive destroyed it, which then failed in a way that read as a product defect. Several measurements were lost to it.
|
||||||
|
|
||||||
|
Now each run gets its own profile directory and kills the browser holding it when it ends, so concurrent runs — including a consuming application's suite, from its own checkout — are independent by construction. A lock once guarded the shared directory; it treated the symptom, and removing the sharing removed the need. Prefer that shape whenever two runs contend: make the contended thing per-run rather than serialising access to it.
|
||||||
|
|
||||||
|
## A bound must be larger than the sum of what it encloses
|
||||||
|
|
||||||
|
An enclosing deadline shorter than its own steps can only ever fire first, so every failure underneath it reports the *enclosing* name and none of them can name a cause. The sign-in bound sat at 180 s over steps totalling 270 s, and for days every failure said the same four words while the real step stayed anonymous. Days went into looking for a cause the harness was structurally incapable of reporting.
|
||||||
|
|
||||||
|
So: compute an enclosing bound from its parts rather than picking a number, and size every leaf bound from a **measured** healthy duration recorded beside it. A bare figure teaches nothing and rots without anyone noticing; a figure with its measurement lets the next reader tell a generous bound from a tight one.
|
||||||
|
|
||||||
|
## What a run must report whatever happens
|
||||||
|
|
||||||
|
Declare the journeys and their checks before anything can fail, so a run reports the same number of rows every time. When the count itself moves with the failure — journeys dying and taking their unreported checks with them — two runs are no longer comparable, and a shrinking total reads like a smaller problem instead of a bigger one.
|
||||||
|
|
||||||
|
The related trap that made it self-perpetuating: discarding the profile was once conditioned on a marker written at the *end* of a batch, so a run killed before writing it left a profile the next run happily reused — and inherited its breakage. Discarding now keys on the profile itself.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
---
|
||||||
|
type: contract
|
||||||
|
summary: The machinery ng-e2e-helpers offers a NextGraph application's end-to-end suite — wallets, the broker crossing, per-run profiles, bounds, and a bounded report
|
||||||
|
---
|
||||||
|
|
||||||
|
# contract_ng-e2e-helpers — `ng-e2e-helpers`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This package is the end-to-end testing machinery a NextGraph application needs to get a real person into itself: minting a wallet by driving the wallet application, crossing the broker, and coming back inside the iframe the application runs in — plus per-run browser profiles, bounds that turn a hang into a named failure, and a run report whose size does not depend on what failed.
|
||||||
|
|
||||||
|
It covers **only what is generic to NextGraph**. It knows nothing about any one application, and nothing about any compatibility layer: an application calling the NextGraph SDK directly is its intended consumer. What is specific to your repository — the page that carries your application, how you build your bundle, which journeys you run — is yours to write, and this package deliberately offers no place to put it.
|
||||||
|
|
||||||
|
It is not a test runner, not an assertion library, and not a fixture system. You keep your own runner and your own `main()`.
|
||||||
|
|
||||||
|
### Deployment requirements
|
||||||
|
|
||||||
|
`playwright` and `@ng-org/web` are **peer dependencies**, and you own both versions: browser binaries have to match the driver, and the SDK the export page opens a session with must be the one your application and your broker agree on. Import the browser types from the helpers that return them rather than from `playwright` directly — a second resolution of the driver produces a structurally different `BrowserContext`, and a context you opened then cannot be handed back to the helper that opens contexts.
|
||||||
|
|
||||||
|
A machine running this needs a real Chromium, network reach to the wallet application and to the broker, and a writable temporary directory for the per-run profiles.
|
||||||
|
|
||||||
|
## Surface
|
||||||
|
|
||||||
|
Full typed shape: the package's `types` entry, `ng-e2e-helpers`. It also ships one executable, `ng-mint-wallet`, for provisioning a deployment's wallet file outside any run. `BrowserContext`, `Frame`, and `Page` — every Playwright type this surface mentions, no more — are re-exported as types from the same entry point, so a consumer can type its own helper functions against these signatures without a second, independently-versioned import of `playwright`. The load-bearing signatures:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ── bounds: a hang becomes a named failure ───────────────────────────────────
|
||||||
|
export function within<T>(what: string, ms: number, task: () => Promise<T>): Promise<T>;
|
||||||
|
export class DeadlineExceeded extends Error {} // thrown by `within` — names `what`
|
||||||
|
export class BrowserGone extends Error {} // the browser died; not the task's fault
|
||||||
|
export function enclosingBound(steps: readonly number[], margin: number): number;
|
||||||
|
export function closeQuietly(what: string, close: () => Promise<unknown>): Promise<void>;
|
||||||
|
export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void;
|
||||||
|
export function browserLost(reason: string): void; // declare it, once
|
||||||
|
export function lossDeclared(): string | null; // has it been declared?
|
||||||
|
export function firstLine(e: unknown): string;
|
||||||
|
export const CLOSE_MS: number, CONTEXT_ACTION_MS: number, CONTEXT_NAVIGATION_MS: number;
|
||||||
|
|
||||||
|
// ── measurement: how a bound gets its number ─────────────────────────────────
|
||||||
|
export function measured<T>(what: string, bound: number, task: (ms: number) => Promise<T>): Promise<T>;
|
||||||
|
export function record(what: string, ms: number, ok: boolean, bound: number): void;
|
||||||
|
export function timingsWanted(): boolean; // true under E2E_TIMINGS=1
|
||||||
|
export function printTimings(): void;
|
||||||
|
|
||||||
|
// ── browser and profiles: one run owns its own ───────────────────────────────
|
||||||
|
export interface RunProfile { readonly dir: string; readonly purpose: string; discard(): void }
|
||||||
|
export function newRunProfile(purpose: string): RunProfile;
|
||||||
|
export function isAlive(pid: number): boolean;
|
||||||
|
export function launchWatchedContext(label: string, dir: string): Promise<BrowserContext>;
|
||||||
|
export function closeContext(label: string, ctx: BrowserContext): Promise<void>;
|
||||||
|
export function newPage(label: string, ctx: BrowserContext): Promise<Page>;
|
||||||
|
export const LAUNCH_MS: number, NEW_PAGE_MS: number;
|
||||||
|
|
||||||
|
// ── the wallet: minted, carried, imported ────────────────────────────────────
|
||||||
|
export interface WalletCredentials { readonly name: string; readonly password: string }
|
||||||
|
export const DEFAULT_WALLET_NAME: string; // the password never has a default
|
||||||
|
export function mintWalletProfile(purpose: string, c: WalletCredentials): Promise<RunProfile>;
|
||||||
|
export function mintWalletProfileKeepingContext(
|
||||||
|
purpose: string, c: WalletCredentials): Promise<{ ctx: BrowserContext; profile: RunProfile }>;
|
||||||
|
export function mintWalletBytes(password: string, name?: string): Promise<Uint8Array>;
|
||||||
|
export function createWalletInContext(ctx: BrowserContext, c: WalletCredentials): Promise<void>;
|
||||||
|
export function emptyProfileContext(
|
||||||
|
purpose: string): Promise<{ ctx: BrowserContext; profile: RunProfile }>;
|
||||||
|
export function exportWalletBytes(ctx: BrowserContext, walletPassword: string): Promise<Uint8Array>;
|
||||||
|
export function exportWalletFile(ctx: BrowserContext, ngwPath: string, pw: string): Promise<number>;
|
||||||
|
export function importWalletFile(page: Page, ngwPath: string, password: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── the broker crossing ──────────────────────────────────────────────────────
|
||||||
|
export function setupBrokerPage(page: Page, appUrl: string, walletPassword: string): Promise<Frame>;
|
||||||
|
export function completeBrokerLogin(page: Page, appUrl: string, pw: string): Promise<Frame>;
|
||||||
|
export const BROKER_LOGIN_MS: number, BROKER_ROUND_TRIP_MS: number;
|
||||||
|
export function brokerRedirectFor(appUrl: string): string;
|
||||||
|
export const BROKER_SCREENS: readonly BrokerScreenSpec[]; // the screens, in test order
|
||||||
|
export const WALLET_APP, WALLET_CREATION, WALLET_IMPORT; // the wallet application's own pages
|
||||||
|
export type BrokerScreen = "choose-broker" | "login-offered" | "wallet-list"
|
||||||
|
| "password" | "working" | "error";
|
||||||
|
export type { BrokerScreenSpec, ScreenSignature, ScreenResponse, TextPattern };
|
||||||
|
|
||||||
|
// ── serving your application to the browser ──────────────────────────────────
|
||||||
|
export function serveOnEphemeralPort(
|
||||||
|
handler: (req: IncomingMessage, res: ServerResponse) => void,
|
||||||
|
): Promise<{ url: string; close: () => void }>;
|
||||||
|
|
||||||
|
// ── failures that are not the application's ──────────────────────────────────
|
||||||
|
export function browserTrouble(label: string, ctx: BrowserContext): Promise<string | null>;
|
||||||
|
export function frameTrouble(id: string, page: Page, frame: Frame, marker: string): Promise<string | null>;
|
||||||
|
export const BROWSER_PROBE_MS: number, FRAME_PROBE_MS: number;
|
||||||
|
|
||||||
|
// ── the report ───────────────────────────────────────────────────────────────
|
||||||
|
export interface JourneyDeclaration { readonly name: string; readonly checks: readonly string[] }
|
||||||
|
export type Prerequisite = () => Promise<string | null> | (string | null);
|
||||||
|
export interface JourneySpec {
|
||||||
|
readonly name: string; // must be a declared journey
|
||||||
|
readonly needs?: readonly Prerequisite[]; // each answers null, or why it cannot start
|
||||||
|
readonly run: () => Promise<void>;
|
||||||
|
}
|
||||||
|
export interface SuiteOptions {
|
||||||
|
readonly label: string;
|
||||||
|
readonly journeys: readonly JourneyDeclaration[];
|
||||||
|
readonly journeyBound: number;
|
||||||
|
readonly diagnose?: () => Promise<string | null>;
|
||||||
|
}
|
||||||
|
export interface SuiteReport {
|
||||||
|
check(name: string, ok: boolean, detail?: string): void; // throws on an undeclared name
|
||||||
|
journey(spec: JourneySpec): Promise<void>;
|
||||||
|
finish(fatal: string | null): never; // prints, then leaves
|
||||||
|
}
|
||||||
|
export function declareSuite(options: SuiteOptions): SuiteReport;
|
||||||
|
|
||||||
|
// ── playwright types this surface mentions, re-exported so a second import isn't needed ─────
|
||||||
|
export type { BrowserContext, Frame, Page };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guarantees
|
||||||
|
|
||||||
|
**A bound names what it was waiting for.** `within(what, ms, task)` either settles the task or throws a `DeadlineExceeded` carrying `what` verbatim — never a bare timeout with no subject. `armSuiteDeadline` does the same for a whole run, and reports before leaving. `enclosingBound(steps, margin)` returns a number no smaller than the sum of the bounds it encloses, so an enclosure can never fire before the step that actually hung.
|
||||||
|
|
||||||
|
**A run owns its profile and discards it.** `newRunProfile` returns a directory belonging to this run alone; `discard()` kills whatever still holds it and removes it, is idempotent, and also runs when the process leaves — including when the run is killed. Two runs never share local state, so nothing a previous run left can make this one pass or fail.
|
||||||
|
|
||||||
|
**A minted wallet is new.** `mintWalletProfile`/`mintWalletBytes` walk the wallet application to create a wallet that did not exist a moment ago. `mintWalletBytes` keeps only the bytes and discards the profile it minted in; `mintWalletProfile` keeps the profile for the length of the run. The password is always a parameter and never has a default; the name defaults to `DEFAULT_WALLET_NAME` because nothing in NextGraph keys off it.
|
||||||
|
|
||||||
|
**The crossing dispatches on the screen it can see**, not on a fixed sequence: `setupBrokerPage` reads the page against `BROKER_SCREENS` in order, answers it, and returns the application's `Frame` once the crossing has completed. It identifies the application by ORIGIN. A terminal screen ends the crossing as a failure rather than an action.
|
||||||
|
|
||||||
|
**An empty-profile context starts with nothing local.** `emptyProfileContext` gives a context whose profile holds no wallet and no repo cache, which is the reconnection cold start: the wallet's repos are on the broker and not in this profile. `importWalletFile` then puts the wallet in — but not the repos' cache — so the next session over it still hits that cold start.
|
||||||
|
|
||||||
|
**A closed server is closed.** `serveOnEphemeralPort` tracks its open sockets and destroys them on `close()`, so a connection nobody hung up cannot be blamed on whatever goes wrong next.
|
||||||
|
|
||||||
|
**A known failure mode is named instead of the innocent operation.** `browserTrouble`/`frameTrouble` answer a string when the browser or the application frame is the actual cause, and `null` when they are not. `SuiteOptions.diagnose` puts that answer in FRONT of a journey's own reason, never in place of it. A frame that is attached, on the right URL, and empty is reported as trouble — that is the state a naive check misses.
|
||||||
|
|
||||||
|
**The report's size does not depend on what failed.** Journeys and their checks are declared up front to `declareSuite`; `check` throws on a name the journey did not declare, `journey` runs one journey bounded and isolated so it cannot change the report's shape, and `finish` reports every declared check the run did not get to before printing and exiting. A journey whose prerequisite is provably dead is reported as such instead of being driven.
|
||||||
|
|
||||||
|
**Errors are values where a caller can act on them.** `browserTrouble`/`frameTrouble`/`Prerequisite` answer `string | null`, never throw for the condition they detect. `DeadlineExceeded` and `BrowserGone` are exported classes, so `instanceof` is a supported way to tell a hang from a dead browser.
|
||||||
|
|
||||||
|
## Non-guarantees
|
||||||
|
|
||||||
|
**No assertion library, no runner, no fixtures.** `SuiteReport.check` records a boolean you computed; nothing here decides what is true.
|
||||||
|
|
||||||
|
**`finish` never returns** — it exits the process. Do not put cleanup after it; put it in the `discard()`/`closeQuietly` path.
|
||||||
|
|
||||||
|
**No message text is stable.** The strings from `browserTrouble`, `frameTrouble`, `DeadlineExceeded` and the report are for a human reading a run. Do not parse them, match on them, or branch on them.
|
||||||
|
|
||||||
|
**No timing promise.** Every exported `*_MS` constant is a bound sized from a measurement on one machine, not a service level: they change when the measurements change, and a run slower than one of them is a failed run, not a broken guarantee. `E2E_TIMINGS=1` reprints the measurements so you can resize your own.
|
||||||
|
|
||||||
|
**Nothing survives a run.** Profiles are discarded, and no artifact, cache, or wallet is carried from one run to the next. The one exception is a file you write yourself with `exportWalletFile` or `ng-mint-wallet`.
|
||||||
|
|
||||||
|
**No concurrency contract.** The suite machinery assumes one run per process. Two suites sharing a process share `armSuiteDeadline`, the timings table and the loss declaration.
|
||||||
|
|
||||||
|
**No wallet-application version pinning.** The crossing is driven against the wallet application as deployed at the broker you point it at. When that application changes its screens, `BROKER_SCREENS` is what has to be updated — a crossing that fails there is not necessarily your application's fault, and is not this package's promise to hold across upstream redesigns.
|
||||||
|
|
||||||
|
## Change policy
|
||||||
|
|
||||||
|
**Semver, and majors are the normal case.** This machinery tracks a NextGraph and a wallet application that are both still moving, so a screen inventory, a bound, or a call shape changes whenever they do — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
|
||||||
|
|
||||||
|
What each level means here, in this package's own terms:
|
||||||
|
|
||||||
|
- **major** — an exported symbol is removed or renamed, **or** an existing call narrows: it now throws where it returned, requires an argument that was optional, or returns something a caller must newly handle. A change to what a `SuiteReport` accepts (a check name, a journey shape) is a major, because your suite is written against it. A signature change a caller must react to counts; one that only accepts more than before does not.
|
||||||
|
- **minor** — a symbol is added and nothing existing moves: a new helper, a new screen in `BROKER_SCREENS`, a new optional field on `SuiteOptions`.
|
||||||
|
- **patch** — a fix that changes neither the exported surface nor anything above under `## Guarantees` — including a re-sized `*_MS` constant, which is explicitly disclaimed above.
|
||||||
|
|
||||||
|
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||||
|
|
||||||
|
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `ng-e2e-helpers/v1.0.0-dev.2` is this package, `polyfill/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
|
||||||
|
|
||||||
|
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it. What exists today is `1.0.0-dev.2`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: ng-e2e-helpers@1.0.0-dev.2`, the string you pinned, never the version it is heading for.
|
||||||
|
|
||||||
|
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
type: usage
|
||||||
|
summary: What the polyfill's own end-to-end suites call from ng-e2e-helpers, under which constraints, and where the fit still costs them code
|
||||||
|
against: ng-e2e-helpers@1.0.0-dev.2
|
||||||
|
---
|
||||||
|
|
||||||
|
# usage_polyfill-e2e — the polyfill's end-to-end suites
|
||||||
|
|
||||||
|
The consumer is the end-to-end suite of `@ng-eventually/polyfill`: four entry points under that package's `e2e/` — a batch of SDK checks against the broker, an applicative suite driving the example application, a reactivity probe, and a cold-start reproduction — plus the page module they share.
|
||||||
|
|
||||||
|
This consumer lives in the same repository as the provider. That changes nothing about what is declared here: the engagement is what these suites are written against, and anything they call that is NOT listed below is theirs to fix if it moves.
|
||||||
|
|
||||||
|
## Consumed surface
|
||||||
|
|
||||||
|
**Bounds** — `within`, `armSuiteDeadline`, `closeQuietly`, `enclosingBound`, `firstLine`. `within` is the most-called entry of the whole package here: every bridge call into the application iframe goes through it.
|
||||||
|
|
||||||
|
**Measurement** — `measured`. Nothing reads `record`, `timingsWanted` or `printTimings` directly; the `E2E_TIMINGS=1` output is consumed as printed output, not as a call.
|
||||||
|
|
||||||
|
**Browser and profiles** — `launchWatchedContext`, `closeContext`, `newPage`, and the type `RunProfile`. `newRunProfile` and `isAlive` are never called: profiles are always obtained through a wallet call.
|
||||||
|
|
||||||
|
**Wallet** — `mintWalletProfile` (the batch wallet, minted once per run), `mintWalletProfileKeepingContext` (the cold-start reproduction, which needs the very first application session over a never-used wallet), `emptyProfileContext` + `importWalletFile` (the reconnection journey), `exportWalletBytes` (serving the wallet from the application's own bundle), and the type `WalletCredentials`.
|
||||||
|
|
||||||
|
**Broker crossing** — `setupBrokerPage` in all four suites, `completeBrokerLogin` in the applicative one.
|
||||||
|
|
||||||
|
**Serving** — `serveOnEphemeralPort`, for both the SDK page and the example application's bundle.
|
||||||
|
|
||||||
|
**Known failure modes** — `browserTrouble`, `frameTrouble`.
|
||||||
|
|
||||||
|
**Report** — `declareSuite` and the types `JourneyDeclaration`, `Prerequisite`. Used by the applicative suite only.
|
||||||
|
|
||||||
|
**Constants** — `BROKER_ROUND_TRIP_MS`, `NEW_PAGE_MS`, both as inputs to `enclosingBound` rather than as waits of their own.
|
||||||
|
|
||||||
|
Everything else the package exports is offered and NOT consumed here — the screen inventory (`BROKER_SCREENS`, `WALLET_APP`, `WALLET_CREATION`, `WALLET_IMPORT`, `brokerRedirectFor`, and the screen types), `createWalletInContext`, `exportWalletFile`, `mintWalletBytes`, `DEFAULT_WALLET_NAME`, `browserLost`, `lossDeclared`, the exported error classes, and the remaining `*_MS` constants. It is safely evolvable as far as these suites are concerned.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
**The browser types come from the helpers.** `Frame`, `Page` and `BrowserContext` are taken from `ng-e2e-helpers`, which re-exports them, rather than from `playwright` directly — so these suites never depend on resolving the same copy of it, which is the position a consumer in another repository is in anyway.
|
||||||
|
|
||||||
|
**Every suite mints its own physical wallet, per run.** Nothing is carried between runs and no wallet is reused, so a run's result never depends on a previous one. The batch wallet's NAME is stable and its identity is not: two runs sharing the name share nothing else.
|
||||||
|
|
||||||
|
**The password is supplied, never defaulted.** These suites also read the barrier's displayed password back off its own screen and pass it to `importWalletFile`, which is the only way to prove that what the barrier shows is what opens the file. A defaulted password would make that check vacuous.
|
||||||
|
|
||||||
|
**An enclosing bound is at least the sum of what it encloses.** These suites compute every enclosure with `enclosingBound` rather than picking a round number, because an enclosure that fires first reports "the journey timed out" and never names the step that hung.
|
||||||
|
|
||||||
|
**A wait must not be entered unbounded.** Playwright's `frame.evaluate` waits forever, so no bridge call is made outside `within`.
|
||||||
|
|
||||||
|
## Frictions
|
||||||
|
|
||||||
|
**`frame.evaluate` has no bound, and three suites each wrote the same wrapper.** A `sdk(frame, method, …args)` helper — `within(…, BRIDGE_MS, () => frame.evaluate(…))` — is duplicated verbatim in three of the four entry points. The bound is the single most important one in each of them, and the package offers nothing to obtain it from. A bounded `evaluate` helper here would delete the duplication and stop each new suite re-deriving it.
|
||||||
|
|
||||||
|
**"Measured and bounded" is one intent and two calls.** Sizing a bound from its own measurement is the discipline this package prescribes, yet the applicative suite composes `measured(what, ms, (bound) => within(what, bound, task))` by hand — and the indexing layer's suite, in another repository, wrote the same line byte for byte. A helper that pairs them belongs on the engagement.
|
||||||
|
|
||||||
|
**`declareSuite` fits a journey suite and not a check batch.** Only the applicative suite uses it; the other three declare a local `record`/`check` pair and print their own summary, because their shape is a flat list of checks discovered as the run goes rather than named journeys declared up front. The up-front declaration is exactly what makes the report bounded, so this is not a request to relax it — but three of four suites falling outside it suggests the package covers one of two real shapes.
|
||||||
|
|
||||||
|
**The screen inventory is exported and unreachable in practice.** `BROKER_SCREENS` and the `WALLET_*` page descriptions are published, but a suite that crosses the broker with `setupBrokerPage` never needs them, and one that wanted to extend the crossing has no supported way to hand a modified inventory back in. As it stands they read as internals that happen to be exported.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
type: rule
|
||||||
|
summary: An end-to-end journey must reach its starting state through the system, never by pre-seeding what the sign-in would have produced — the shortcut keeps whole features untested while staying green
|
||||||
|
---
|
||||||
|
|
||||||
|
# Never hand a journey the state the sign-in would have produced
|
||||||
|
|
||||||
|
**Rule:** An end-to-end `journey` must arrive at its starting state **through the system**. Never pre-seed the identifier in the URL, never pre-import a wallet into the profile under test, never set the acting identity directly. If reaching a state through the system is awkward, that awkwardness is the finding — not a reason to skip it.
|
||||||
|
|
||||||
|
**Why.** Every applicative `journey` used to append the identifier to the application URL before loading it. The identity then resolved from the URL, the `barrier` never appeared, and the suite was green for months. It was green over:
|
||||||
|
|
||||||
|
- a download link pointing at a file **nobody served** — the barrier handed out a 404, and no test had ever clicked it. Worse, the test server answered every unknown path with the application's own HTML, so a missing file could not fail even in principle;
|
||||||
|
- an ordering defect that made the barrier unreachable for any first-time user — the application handed the page to the broker before the barrier could appear, leaving a newcomer at a login page with no wallet and no way to get one;
|
||||||
|
- and, later, a regression in sharing.
|
||||||
|
|
||||||
|
The first journey that actually walked a newcomer's path — empty profile, no wallet, real download, real import — found all of it immediately. The coverage hole and the defects protected each other: the shortcut existed because the real path was tedious, and the real path stayed broken because nothing walked it.
|
||||||
|
|
||||||
|
**How to apply.** The tell is mechanical: a `journey` that *writes* a value the system is supposed to produce. Ask *"by which call would a real person obtain this?"* — no answer means the feature is incomplete, not that the test needs help.
|
||||||
|
|
||||||
|
This is not a blanket ban on fixtures. Shared setup — building the application, minting the `batch` user, serving files — is legitimate and belongs before the journeys. What is banned is seeding **the specific state under test**: a journey about sign-in may not start signed in.
|
||||||
|
|
||||||
|
Journeys that legitimately need an already-signed-in actor may use the shared sign-in helper. That is exactly why at least one journey must not: without it, the helper's own path is the untested one.
|
||||||
|
|
||||||
|
Read alongside the global rules on covering the case space and on each actor obtaining its inputs through the system — same failure shape, and it has now produced real defects here twice.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
type: overview
|
||||||
|
summary: How a person gets from holding nothing to acting as an identity — the barrier, the broker hand-over, and the split between settling and connecting
|
||||||
|
triggers:
|
||||||
|
keywords: [sign-in, signin, identity, ensureIdentity, settleIdentity, barrier, gate, wallet, broker, redirect, ng-id, currentUser, session]
|
||||||
|
paths:
|
||||||
|
- "packages/polyfill/src/shared-wallet/**"
|
||||||
|
- "packages/polyfill/src/surface/lifecycle.ts"
|
||||||
|
- "packages/polyfill/src/emulated-verifier/connect.ts"
|
||||||
|
vocabulary:
|
||||||
|
- term: settle
|
||||||
|
gloss: decide which identity is acting, from the URL, storage, or the barrier — needs no session
|
||||||
|
not: [resolve identity, determine user, login]
|
||||||
|
see: knowledge_settling-is-not-connecting
|
||||||
|
- term: barrier
|
||||||
|
gloss: the full-screen overlay this package mounts to hand out the wallet and take an identifier
|
||||||
|
not: [modal, popup, login screen]
|
||||||
|
---
|
||||||
|
|
||||||
|
# sign-in — getting a person from nothing to acting
|
||||||
|
|
||||||
|
This concept covers everything between "someone opens the application" and "an identity is acting with a live session": the `barrier` this package mounts, the wallet a newcomer must obtain, the broker redirect, and the two distinct acts — `settle` and connect — that were once conflated and must stay apart.
|
||||||
|
|
||||||
|
None of it is app-facing. An application awaits one call and renders; it must never learn that a broker, an iframe or a redirect exist. What it may rely on lives in `app-contract`; what follows is how that promise is kept.
|
||||||
|
|
||||||
|
The whole thing is scaffolding: upstream, a person opens **their** wallet, it contains **their** site, and `session_start` takes an id that came from the wallet. There is nothing to name and nothing to choose. This concept exists because one shared wallet hosts several identities.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `knowledge_how-a-user-gets-in` — the flow end to end, and which layer owns each step.
|
||||||
|
- `knowledge_settling-is-not-connecting` — the split, and the cycle that forces it.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
type: knowledge
|
||||||
|
summary: The sign-in flow end to end, and which layer owns each step — in particular that the broker redirect belongs to @ng-org/web, not to this package
|
||||||
|
---
|
||||||
|
|
||||||
|
# How a user gets in
|
||||||
|
|
||||||
|
## The steps, and who owns them
|
||||||
|
|
||||||
|
A newcomer holds nothing. To act they need a wallet imported once into the wallet application, and an identifier naming their virtual space. The `barrier` hands out the first three — the wallet file, its password, a link to the wallet application — and takes the fourth.
|
||||||
|
|
||||||
|
Then the page is handed to the broker, which opens the wallet and reloads the application **inside an iframe it hosts**. Outside that iframe there is no session.
|
||||||
|
|
||||||
|
**The redirect is not ours.** `@ng-org/web`'s `init()` performs it as its first statement — same host, same URL shape, same frame check. Verified in its shipped bundle: when `window.self === window.top` it assigns `window.location.href` from a hard-coded `redirect` constant. This package must not reimplement it; doing so would double a function of the layer below and diverge from it in the meantime (see `app-contract/rule_no-divergence-from-nextgraph`).
|
||||||
|
|
||||||
|
What is genuinely ours is the one thing `init()` cannot do: **put the identifier into the URL before `init()` reads it**. `init()` hands over `window.location.href`; it does not know the parameter exists.
|
||||||
|
|
||||||
|
## When the `barrier` shows, and why it is not a question of identity
|
||||||
|
|
||||||
|
**Top-level: always. Inside the broker iframe: never** — there the identifier arrives in the URL and the page stands aside.
|
||||||
|
|
||||||
|
The discriminator is the frame, not whether an identity is known, and the difference is not ergonomic. An identity is state we can observe; whether the person still has a wallet in this browser is state we cannot — it lives in another origin's storage. A screen that decides on the identity is therefore *guessing* at the state that matters, and when it guesses "already set up" while NextGraph's side is gone, it hides the only controls that could repair the situation.
|
||||||
|
|
||||||
|
What that costs is not a degraded experience but a dead end: sent to the broker without a wallet, the person lands on a static page carrying **no return path to the application**. The browser's Back button is the only way out, and it only helps if the `barrier` is there to catch them on arrival.
|
||||||
|
|
||||||
|
A known identifier therefore **prefills the field; it never skips the screen**. And nothing detects whether the wallet is already there: all steps are always shown, and whoever already has one ignores them. No checkbox, no "I already have it" — knowing whether you imported a wallet into this browser is not something a person can be asked.
|
||||||
|
|
||||||
|
Two facts, observed on the live sites, close the alternatives: clicking the wallet application's import control opens a file chooser **in place** — no navigation, no new tab; and our own page receives **no signal at all** when the import succeeds (no opener, no message, no storage or focus event, no reload). Detecting the return is impossible, not merely fragile.
|
||||||
|
|
||||||
|
## Why the identifier travels by URL
|
||||||
|
|
||||||
|
The flow runs in **two contexts with separate storage partitions** — the top-level page and the broker iframe — because browsers partition storage by top-level site. A value written top-level is not the value the iframe reads.
|
||||||
|
|
||||||
|
What crosses is the URL: the redirect embeds the full application URL, query included, and reloads it in the iframe. Hence the resolution order, which must not be "simplified":
|
||||||
|
|
||||||
|
1. the identifier in the URL — it wins whenever present, being the only thing that crosses;
|
||||||
|
2. otherwise storage — same-partition convenience, and prefill on reload.
|
||||||
|
|
||||||
|
The parameter must therefore be written on **every** path that settles an identity, not only when the `barrier` asked. An identity coming from storage without the parameter being written is the silent failure: the iframe reads an empty identity, provisions a second virtual space, and the returning user lands somewhere empty with no error.
|
||||||
|
|
||||||
|
Writing to storage and writing to the address bar are two independent attempts, deliberately: a storage that refuses writes must not cost the round-trip its parameter.
|
||||||
|
|
||||||
|
## What survives migration
|
||||||
|
|
||||||
|
The `barrier` evaporates whole — handing out a shared wallet and naming a space exist only because the wallet is shared. The redirect does **not**: upstream's real multi-wallet flow has the same shape, and the barrier becomes the real per-user sign-in. The call site survives too, which is why the published call takes no identifier.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
type: knowledge
|
||||||
|
summary: Deciding which identity acts needs no session; connecting does — conflating them deadlocks one way and silently breaks sharing the other
|
||||||
|
---
|
||||||
|
|
||||||
|
# Settling is not connecting
|
||||||
|
|
||||||
|
Two acts of different nature hide behind "sign in":
|
||||||
|
|
||||||
|
- **`settle`** — decide which identity is acting, from the URL, from storage, or by asking at the `barrier`, then persist it. Pure DOM and storage. **No session required.**
|
||||||
|
- **connect** — put back in that identity's hands everything it can already open: what it OWNS as well as what was shared with it, drain its queues, and then keep applying what arrives in them for as long as it stays connected. **Requires a live session.**
|
||||||
|
|
||||||
|
The wallet keeps those two in different places, and connecting once replayed only the second — so an application that reloaded and went straight to a document it had made itself was refused its own document. Whatever else changes here, connecting must replay **every** durable register, not the one that happens to be read on the path being tested.
|
||||||
|
|
||||||
|
They must stay apart, and the reason is not tidiness.
|
||||||
|
|
||||||
|
## Why they cannot be one call
|
||||||
|
|
||||||
|
`init()` starts the broker redirect as its first statement when the page is top-level, so anything the barrier needs to show must already be in place. The `barrier` must therefore appear **before** `init()`.
|
||||||
|
|
||||||
|
But an identity cannot connect before `init()` either: connecting needs the session, which arrives only through `init()`'s callback. Await the whole of sign-in before `init()` and it deadlocks; call it after and the `barrier` never appears.
|
||||||
|
|
||||||
|
The way out is that only *half* of it has that dependency. Settling is awaited by this package's `init()` wrapper before it delegates; connecting stays in the published call, awaited where a session exists.
|
||||||
|
|
||||||
|
**The invariant is carried by the composition from `init()` onwards** — which is why an application must call this package's `init`, not the one it injected: calling it settles the identity whether or not the published call has run.
|
||||||
|
|
||||||
|
It does **not** cover calling the published sign-in strictly first and awaiting it: the connection work it adds waits for a session only `init()` resolves, so it deadlocks in silence. Half an invariant carried by a mechanism, half still owed to the caller — and the half still owed fails silently, which is the worst kind.
|
||||||
|
|
||||||
|
## The failure this cost
|
||||||
|
|
||||||
|
Settling once reached the session, because recording who is acting also fired the connection. At the time the session was assembled by the application: it built a promise around `init()`'s callback and handed the package a thunk reading it. The application called `init()` *from inside the executor building that promise*, so the thunk could not answer by construction. It threw, the account lookup answered null, and the connection run abandoned **without restoring or draining** — having already registered itself as in flight. The published call then joined that dead run and resolved having done nothing.
|
||||||
|
|
||||||
|
Symptom: a document shared with someone did not open for them. No error, just unreadable content.
|
||||||
|
|
||||||
|
Nothing had changed in the connection logic. What changed was *when* the identity was recorded. Before the split, nothing recorded an identity during module evaluation: a session existed, the run was healthy, and joining it was harmless.
|
||||||
|
|
||||||
|
Hence the shape of the fix — recording who acts and starting to connect are separate operations, and the session-free half only records. The session is now the package's own — its `init()` wrapper captures the event — so no application can wire this wrong again. To validate: nothing reachable from settling asks for a session.
|
||||||
|
|
||||||
|
## The lesson worth keeping
|
||||||
|
|
||||||
|
A "session-free" half that calls something which fires a session lookup is not session-free. When splitting on a dependency, check what the *side effects* of each remaining call reach, not only what the call itself does.
|
||||||
|
|
||||||
|
## Failing to reach a queue is not failing to apply one item in it
|
||||||
|
|
||||||
|
Making connection failures surface was right — a restore that did not happen makes shared documents silently invisible, and resolving as though it had is the defect family this project keeps finding. But applied to *every* step alike, that rule locked people out.
|
||||||
|
|
||||||
|
A deposit that cannot be applied is not consumed by failing. So a single unusable item denied the session, and denied it again at every future connection: not a missing share, a person who can never sign in. Strictly worse than the silence it replaced.
|
||||||
|
|
||||||
|
The distinction to keep: **reaching** the queues, and the restore itself, are infrastructure — if they fail, the session genuinely cannot proceed, and rejecting is right. **Applying one item** is data — it must be reported loudly, the remaining queues must still be drained, and the session must still be granted.
|
||||||
|
|
||||||
|
The general shape, worth carrying to any similar rule: when a blanket "every failure surfaces" is imposed, ask which failures are *retried by simply trying again later* and which are *permanent for the actor*. The permanent ones must never gate something the actor cannot otherwise obtain.
|
||||||
|
|
||||||
|
## Connecting does not end when the drain does
|
||||||
|
|
||||||
|
The drain is the backlog; being connected is a regime. An identity that stays connected has its inboxes watched throughout, so a deposit made in front of it is applied as it arrives rather than waiting for a reload, and an inbox opened later in the session joins the watched set.
|
||||||
|
|
||||||
|
That third step runs whatever became of the two before it, and the failure it was added for says why. A restore that rejected used to skip it, leaving an identity connected — recording who acts is synchronous and had already taken effect — with nothing watching its inboxes for the rest of the session. One broker hiccup while signing in then cost that person every deposit made from then on, in silence, long after the broker had recovered. What the caller is told has not moved: reaching the queues still rejects. What changed is that being connected now *means* being watched, whatever the connection made of its own work.
|
||||||
|
|
||||||
|
The counterpart is that the watching belongs to one identity and dies with it. Recording a different identity — or none, which is a disconnection and not merely "no new work" — stops it, because every step of it resolves the current holder as it runs: left going, it would read the previous identity's registers under the new one and file the previous one's `ReadCap`s into the new one's hands. Whoever connects next starts their own.
|
||||||
|
|
||||||
|
There is deliberately no timer anywhere in this: the regime is push-driven, and its known cost — a watch that failed to open is re-opened only by a later event, so a session that only reads never gets it back — is stated to applications under the contract's non-guarantees rather than papered over with polling.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Inter-repo contracts. `publish:` is this project's engagement toward its consumers —
|
||||||
|
# listing a leaf here IS the act of publishing it; an unlisted `contract_` leaf is a draft.
|
||||||
|
#
|
||||||
|
# This project is a PROVIDER of two interfaces, each hosted in an interface folder inside
|
||||||
|
# its owning concept, with one `usage_<consumer>.md` per consumer beside the engagement:
|
||||||
|
#
|
||||||
|
# polyfill-surface (concepts/app-contract/polyfill-surface/)
|
||||||
|
# consumers: the Festipod application (its own repo), `ng-helpers`
|
||||||
|
# ng-e2e-helpers (concepts/e2e-harness/ng-e2e-helpers/)
|
||||||
|
# consumers: this repo's own polyfill e2e suites (same repo — declared in place,
|
||||||
|
# nothing to pull), `ng-helpers`, any NextGraph application
|
||||||
|
#
|
||||||
|
# `consume:` here is the PROVIDER side of the pair: we pull each consumer's own declaration
|
||||||
|
# back, so we can see what is actually relied on and read its Frictions. We consume no
|
||||||
|
# engagement — the NextGraph clone this library aligns on is a read-only reference we verify
|
||||||
|
# against, not a contract anyone stamps.
|
||||||
|
#
|
||||||
|
# `pullFrom:` values are CANONICAL remote identities, because this file travels with the
|
||||||
|
# branch. Per-developer local access lives in `.project/contracts.local.yaml`, which is
|
||||||
|
# gitignored and must never be committed.
|
||||||
|
|
||||||
|
publish:
|
||||||
|
# paths are relative to `.project/`
|
||||||
|
polyfill-surface: concepts/app-contract/polyfill-surface/contract_polyfill-surface.md
|
||||||
|
ng-e2e-helpers: concepts/e2e-harness/ng-e2e-helpers/contract_ng-e2e-helpers.md
|
||||||
|
|
||||||
|
consume:
|
||||||
|
# Festipod is NOT listed: it has not declared a usage leaf, and we do not author one for
|
||||||
|
# a consumer whose repo is not ours. An interface with no declared consumer degrades to
|
||||||
|
# the one-document mode, which is what `polyfill-surface` is for Festipod today.
|
||||||
|
|
||||||
|
- contract: ng-helpers-uses-polyfill-surface
|
||||||
|
type: git
|
||||||
|
pullFrom: git@gitea.reconnexion.apps.gueraud.net:Sylvain/ng-helpers.git/.project/concepts/indexing/polyfill-surface/usage_ng-helpers.md
|
||||||
|
ref: main
|
||||||
|
into: concepts/app-contract/polyfill-surface/
|
||||||
|
|
||||||
|
- contract: ng-helpers-uses-ng-e2e-helpers
|
||||||
|
type: git
|
||||||
|
pullFrom: git@gitea.reconnexion.apps.gueraud.net:Sylvain/ng-helpers.git/.project/concepts/indexing/ng-e2e-helpers/usage_ng-helpers.md
|
||||||
|
ref: main
|
||||||
|
into: concepts/e2e-harness/ng-e2e-helpers/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
## Project vocabulary (always loaded)
|
||||||
|
|
||||||
|
@.project/VOCABULARY.md
|
||||||
@@ -51,25 +51,25 @@ is needed), and how this lib emulates it today.
|
|||||||
|
|
||||||
| Capability | What the consumer application does | Real NextGraph target | Current NextGraph status (why a workaround) | Current emulation |
|
| Capability | What the consumer application does | Real NextGraph target | Current NextGraph status (why a workaround) | Current emulation |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Multi-identity / per-identity wallet | Treats each identity id as its own wallet with its own documents | Each identity opens its own real wallet; native cross-wallet reads | Not-yet-implemented: the JS SDK exposes no cross-wallet read, so one session cannot read another identity's wallet | One shared wallet everyone opens; "identities" are virtual users — shim accounts keyed by an id, each mapped to its documents in `store-registry.ts` |
|
| Multi-identity / per-identity wallet | Treats each identity id as its own wallet with its own documents | Each identity opens its own real wallet; native cross-wallet reads | Not-yet-implemented: the JS SDK exposes no cross-wallet read, so one session cannot read another identity's wallet | One shared wallet everyone opens; "identities" are virtual users — shim accounts keyed by an id, each mapped to its documents in `shared-wallet/account-registry.ts` |
|
||||||
| Three native stores per identity | Places entities by scope `public` / `protected` / `private` | The identity's three real native stores hold the entity documents | Not-yet-implemented: `doc_create`/ORM can target only the private (and protected) native store today; a `public`/arbitrary `StoreRepo` is not JS-constructible | Three emulated scope-index documents per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the one shared private store, and scope is a logical label |
|
| Three native stores per identity | Places entities by scope `public` / `protected` / `private` | The identity's three real native stores hold the entity documents | Not-yet-implemented: `doc_create`/ORM can target only the private (and protected) native store today; a `public`/arbitrary `StoreRepo` is not JS-constructible | Three emulated scope-index documents per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the one shared private store, and scope is a logical label |
|
||||||
| Per-document read isolation | Nothing to declare: creating a document records its cap on its store, and its creator holds it. Reading is `capFor(doc)` — you hold the key or you do not read | The broker/verifier delivers only documents the wallet holds a ReadCap for; accessing a document without the cap yields an empty result in a union read (a targeted read of an unheld repo errors with `RepoNotFound`) | The model itself is the point: reading is key possession, and there is no read-ACL to introspect — a client cannot ask "may this identity read this doc?" because that question does not exist upstream | Caps recorded per identity: `AddRepo` on the store's emulated Store branch for documents it creates, `AddLink` on its User branch for caps received; `caps.ts` caches them for the session. A read filter (`read-filter.ts`) plus the boundary (`reach.ts`) keep only documents whose cap is held. The cap value is the stand-in `OK` — enforcement is P1b |
|
| Per-document read isolation | Nothing to declare: creating a document records its cap on its store, and its creator holds it. Reading is `capFor(doc)` — you hold the key or you do not read | The broker/verifier delivers only documents the wallet holds a ReadCap for; accessing a document without the cap yields an empty result in a union read (a targeted read of an unheld repo errors with `RepoNotFound`) | The model itself is the point: reading is key possession, and there is no read-ACL to introspect — a client cannot ask "may this identity read this doc?" because that question does not exist upstream | Caps recorded per identity: `AddRepo` on the store's emulated Store branch for documents it creates, `AddLink` on its User branch for caps received; `emulated-verifier/caps.ts` caches them for the session. A read filter (`emulated-verifier/read-filter.ts`) plus the boundary (`emulated-verifier/reach.ts`) keep only documents whose cap is held. The cap value is the stand-in `OK` — enforcement is P1b |
|
||||||
| Directed read sharing | Owns the relationship concept ("who is connected to whom") itself, and on acceptance shares one document's cap to the other's inbox (`shareCap(cap, theirInbox)`) | The cap sealed to the recipient's inbox key (`ContactDetails.read_cap`), opened by their own verifier while processing the inbox | Not-yet-implemented — a **gap, not a disagreement**: the field exists but the message construction is `unimplemented!()`, its only caller passes "without read_cap", and the receiver discards the cap. The shape is right; the implementation is absent | `shareCap` deposits the cap into the recipient's inbox document; the recipient's existing `inbox.watch` absorbs it into what they hold. No "receive" operation, and no principal is ever named to the registry |
|
| Directed read sharing | Owns the relationship concept ("who is connected to whom") itself, and on acceptance shares one document's cap to the other's inbox (`shareCap(cap, theirInbox)`) | The cap sealed to the recipient's inbox key (`ContactDetails.read_cap`), opened by their own verifier while processing the inbox | Not-yet-implemented — a **gap, not a disagreement**: the field exists but the message construction is `unimplemented!()`, its only caller passes "without read_cap", and the receiver discards the cap. The shape is right; the implementation is absent | `shareCap` deposits the cap into the recipient's inbox document; the recipient's existing `inbox.watch` absorbs it into what they hold. No "receive" operation, and no principal is ever named to the registry |
|
||||||
| Inbox (registration notifications) | `inbox.post` / `read` / `watch` | A message is sealed to the recipient's key and queued in their inbox; the recipient's own verifier unseals and applies each queued message inline while processing the inbox | Not reachable from JS: the verifier has no `InboxPost` arm, and no `inbox` method exists in `@ng-org/web`. (`inbox_post_link` is OUR proposed name from [`docs/fork-inbox-fallback.md`](docs/fork-inbox-fallback.md), not an announced NextGraph API — no such symbol exists in `nextgraph-rs`.) | Deposits written as RDF into an inbox document via SPARQL; `read`/`watch` read the deposits back — an in-lib stand-in for the recipient's own inbox processing |
|
| Inbox (registration notifications) | `inbox.post` / `read` / `watch` | A message is sealed to the recipient's key and queued in their inbox; the recipient's own verifier unseals and applies each queued message inline while processing the inbox | Not reachable from JS: the verifier has no `InboxPost` arm, and no `inbox` method exists in `@ng-org/web`. (`inbox_post_link` is OUR proposed name from [`docs/fork-inbox-fallback.md`](docs/fork-inbox-fallback.md), not an announced NextGraph API — no such symbol exists in `nextgraph-rs`.) | Deposits written as RDF into an inbox document via SPARQL; `read`/`watch` read the deposits back — an in-lib stand-in for the recipient's own inbox processing |
|
||||||
| ~~Discovery of all public events~~ **REMOVED 2026-07-30** | Circulates the link itself — into inboxes, or into a document the reader already holds | **There is no discovery.** You cannot discover, you can only follow links: publishing = place the data in your public store **and** circulate the link, seen only by those who received it (a foundation of local-first) | Not a gap to be filled — a global index is not a NextGraph shape, and it would pool data across wallets | Nothing. `discovery.ts` and its global index were removed: they emulated a capability the target will never have. See [`docs/readcap-and-nuri-model.md`](docs/readcap-and-nuri-model.md) §4ter-bis |
|
| ~~Discovery of all public events~~ **REMOVED 2026-07-30** | Circulates the link itself — into inboxes, or into a document the reader already holds | **There is no discovery.** You cannot discover, you can only follow links: publishing = place the data in your public store **and** circulate the link, seen only by those who received it (a foundation of local-first) | Not a gap to be filled — a global index is not a NextGraph shape, and it would pool data across wallets | Nothing. `discovery.ts` and its global index were removed: they emulated a capability the target will never have. See [`docs/readcap-and-nuri-model.md`](docs/readcap-and-nuri-model.md) §4ter-bis |
|
||||||
| Reads / listing | Lists the documents it needs, by scope, and reads them | Native per-wallet reads over the real per-identity stores | Bug/perf: an anchorless union query spans every named graph in the session store, which on a shared / accumulating wallet is O(wallet size) and stalls | A bounded, by-need set of per-doc anchored `sparql_query`s (each anchored to one repo's default graph), independent of wallet size |
|
| Reads / listing | Lists the documents it needs, by scope, and reads them | Native per-wallet reads over the real per-identity stores | Bug/perf: an anchorless union query spans every named graph in the session store, which on a shared / accumulating wallet is O(wallet size) and stalls | A bounded, by-need set of per-doc anchored `sparql_query`s (each anchored to one repo's default graph), independent of wallet size |
|
||||||
| Reactivity | Lists update on change | Native reactive reads | Not-yet-implemented: there is no reactive union query across graphs | Re-query the bounded per-doc anchored set on a lightweight change signal (`doc_subscribe` / ORM on an already-opened single store) |
|
| Reactivity | Lists update on change | Native reactive reads | Not-yet-implemented: there is no reactive union query across graphs | Re-query the bounded per-doc anchored set on a lightweight change signal (`doc_subscribe` / ORM on an already-opened single store) |
|
||||||
| Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) |
|
| Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) |
|
||||||
| Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`accounts.ts` `IdentityStore` persists it); the read filter and inbox `from` read it |
|
| Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`shared-wallet/virtualUsers.ts` `IdentityStore` persists it); the read filter and inbox `from` read it |
|
||||||
| Write-guard | Writes refused without the write cap | The broker/verifier enforces the write cap natively | Partial: the guard fires only on the public proxy, but the real write paths call the injected `ng` directly (the `DataCloneError` constraint), so it is best-effort today | A `sparql_update` override (`ng-proxy.ts`) checking the emulated write cap |
|
| Write-guard | Writes refused without the write cap | The broker/verifier enforces the write cap natively — writing is repo membership (`verify_permission`, reachable only from `Commit::verify`) | Not-yet-implemented for delegation: this library emulates no `AddMember`/`AddPermission`, so only a document's OWNER writes — a repo's upstream state until someone is added | An ownership check at the write door (`emulated-verifier/reach.ts` `assertMayWrite`, reading authorship from the Store branch). The older write-cap proxy (`surface/ng-proxy.ts`) is **inert** — `grantWrite` has no production caller, so its policy set is always empty |
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
| Package | Role |
|
| Package | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `@ng-eventually/client` | The SDK-identical wrapper the app imports instead of `@ng-org/web` / `@ng-org/orm`. It adds the polyfills the broker/verifier will do natively (shared-wallet identity, capability enforcement, anticipated cap/inbox methods). As NextGraph matures, the app points back at the real SDK (build alias removed) and this package falls away. |
|
| `@ng-eventually/polyfill` *(was `@ng-eventually/client` until 2026-08-07)* | The SDK-identical wrapper the app imports instead of `@ng-org/web` / `@ng-org/orm`. It adds the polyfills the broker/verifier will do natively (shared-wallet identity, capability enforcement, anticipated cap/inbox methods). As NextGraph matures, the app points back at the real SDK (build alias removed) and this package falls away. |
|
||||||
|
|
||||||
A global-index package is deferred. Data common to all of an application's users comes from a **singleton app**: a document or store shared by all users and hardcoded in the app, write-owned by the developer and delegable — but never to all users, so user contributions reach it **through an inbox** (nothing in NextGraph is freely writable by everyone). That is the direction the NextGraph developer has named; it is **not implemented**, and several points are still open (what exactly is hardcoded, how delegation travels, who materializes the inbox). So there is no second package for now — it will be introduced once the mechanism exists, and it will be separate from the client. See [`docs/nextgraph-current-state.md`](docs/nextgraph-current-state.md) § Apps & services.
|
A global-index package is deferred. Data common to all of an application's users comes from a **singleton app**: a document or store shared by all users and hardcoded in the app, write-owned by the developer and delegable — but never to all users, so user contributions reach it **through an inbox** (nothing in NextGraph is freely writable by everyone). That is the direction the NextGraph developer has named; it is **not implemented**, and several points are still open (what exactly is hardcoded, how delegation travels, who materializes the inbox). So there is no second package for now — it will be introduced once the mechanism exists, and it will be separate from the SDK wrapper. See [`docs/nextgraph-current-state.md`](docs/nextgraph-current-state.md) § Apps & services.
|
||||||
|
|
||||||
## Design principle
|
## Design principle
|
||||||
|
|
||||||
@@ -168,24 +168,29 @@ the unused list" is not a reason to investigate it.
|
|||||||
- Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet
|
- Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet
|
||||||
emulation, reads the deposits back (`read` / `materialize` / `watch`) in place
|
emulation, reads the deposits back (`read` / `materialize` / `watch`) in place
|
||||||
of the recipient's own inbox processing.
|
of the recipient's own inbox processing.
|
||||||
- Tests of the polyfill (against a real broker) live in this repo, so a consuming
|
- Tests of the polyfill (against a real broker) live in this repo, in **two** suites,
|
||||||
app can test its features against a clean, mocked API.
|
and the split is deliberate: `packages/polyfill/e2e/run.ts` (`test:e2e`) characterises the
|
||||||
|
primitives and the platform contracts, while `packages/polyfill/e2e/notebook.ts`
|
||||||
|
(`test:e2e:app`) drives the example application through the DOM, one browser page per
|
||||||
|
identity. Only the second can tell whether an application is *writable* — a harness
|
||||||
|
can pass a value between two identities through a variable, and an application cannot.
|
||||||
|
A consuming app can test its own features against a clean, mocked API.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Implemented. The polyfill mechanisms are wired against a real broker, not stubbed:
|
Implemented. The polyfill mechanisms are wired against a real broker, not stubbed:
|
||||||
|
|
||||||
- Shared-wallet shim — `store-registry.ts` (`(account, scope) → document NURI`,
|
- Shared-wallet shim — `shared-wallet/account-registry.ts` (`(account, scope) → document NURI`,
|
||||||
`createEntityDoc` / `listMyEntityDocs` + per-user stores, cross-device via the RDF
|
`createEntityDoc` / `listMyEntityDocs` + per-user stores, cross-device via the RDF
|
||||||
shim anchored in the private store).
|
shim anchored in the private store).
|
||||||
- Document / SPARQL primitive — `docs.ts`, calling the real injected `ng` directly
|
- Document / SPARQL primitive — `docs.ts`, calling the real injected `ng` directly
|
||||||
(avoids the `@ng-org` double-proxy `DataCloneError`).
|
(avoids the `@ng-org` double-proxy `DataCloneError`).
|
||||||
- Emulated ReadCaps — `caps.ts` (`CapRegistry`, per-document, directed grants) +
|
- Emulated ReadCaps — `emulated-verifier/caps.ts` (`CapRegistry`, per-document, directed grants) +
|
||||||
read filter `read-filter.ts` (reactive-set `Proxy` view), applied by
|
read filter `emulated-verifier/read-filter.ts` (reactive-set `Proxy` view), applied by
|
||||||
`use-shape.ts` only once a cap exists (`caps.isEnforcing()`).
|
`surface/use-shape.ts` only once a cap exists (`caps.isEnforcing()`).
|
||||||
- Write guard — `ng-proxy.ts` (`sparql_update` override, emulated write cap).
|
- Write guard — `surface/ng-proxy.ts` (`sparql_update` override, emulated write cap).
|
||||||
- Inbox — `inbox.ts` (`post` / `read` / `materialize` / `watch`).
|
- Inbox — `inbox.ts` (`post` / `read` / `materialize` / `watch`).
|
||||||
- Identity — `accounts.ts` (`IdentityStore`, injected storage).
|
- Identity — `shared-wallet/virtualUsers.ts` (`IdentityStore`, injected storage).
|
||||||
- SPARQL hardening — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`).
|
- SPARQL hardening — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`).
|
||||||
|
|
||||||
The remaining `TODO` markers are narrow: the shared-wallet credential passthrough
|
The remaining `TODO` markers are narrow: the shared-wallet credential passthrough
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "ng-eventually",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"examples/notebook": {
|
||||||
|
"name": "@ng-eventually/example-notebook",
|
||||||
|
"dependencies": {
|
||||||
|
"@ng-eventually/polyfill": "workspace:*",
|
||||||
|
"@ng-org/web": "0.1.2-alpha.13",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages/ng-e2e-helpers": {
|
||||||
|
"name": "ng-e2e-helpers",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@ng-org/web": "0.1.2-alpha.13",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@ng-org/web": ">=0.1.2-alpha.13",
|
||||||
|
"playwright": ">=1.40.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages/polyfill": {
|
||||||
|
"name": "@ng-eventually/polyfill",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
||||||
|
"@ng-org/shex-orm": "0.1.2-alpha.8",
|
||||||
|
"@ng-org/web": "0.1.2-alpha.13",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@ng-org/alien-deepsignals": "*",
|
||||||
|
"@ng-org/orm": "*",
|
||||||
|
"@ng-org/shex-orm": "*",
|
||||||
|
"@ng-org/web": "*",
|
||||||
|
},
|
||||||
|
"optionalPeers": [
|
||||||
|
"@ng-org/alien-deepsignals",
|
||||||
|
"@ng-org/orm",
|
||||||
|
"@ng-org/shex-orm",
|
||||||
|
"@ng-org/web",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@ldo/traverser-shexj": ["@ldo/traverser-shexj@1.0.0-alpha.28", "", { "dependencies": { "@ldo/type-traverser": "1.0.0-alpha.28" } }, "sha512-N06+LOWhv6//unPRLbFMd56MqPf5lO2ihZgle9hNLmxt6QJmNrZM3oXzHCL3TfDu4OT1/NUZp3kj2HmztQIZkg=="],
|
||||||
|
|
||||||
|
"@ldo/type-traverser": ["@ldo/type-traverser@1.0.0-alpha.28", "", { "dependencies": { "uuid": "8.3.2" } }, "sha512-pGMIVxLzoLjYVhADuVhg6r5ZDNleXZ9DcyIvLXo1/ADEocLnysg/Xjk9D/7l/Rw3WtDJrTFOOtBv8OnH+VPgKA=="],
|
||||||
|
|
||||||
|
"@ng-eventually/example-notebook": ["@ng-eventually/example-notebook@workspace:examples/notebook"],
|
||||||
|
|
||||||
|
"@ng-eventually/polyfill": ["@ng-eventually/polyfill@workspace:packages/polyfill"],
|
||||||
|
|
||||||
|
"@ng-org/alien-deepsignals": ["@ng-org/alien-deepsignals@0.1.2-alpha.11", "", { "dependencies": { "alien-signals": "2.0.8" } }, "sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ=="],
|
||||||
|
|
||||||
|
"@ng-org/shex-orm": ["@ng-org/shex-orm@0.1.2-alpha.8", "", { "dependencies": { "@ldo/traverser-shexj": "1.0.0-alpha.28", "@ldo/type-traverser": "1.0.0-alpha.28", "@shexjs/parser": "1.0.0-alpha.28", "commander": "14.0.3", "dts-dom": "3.6.0", "ejs": "3.1.10", "fs-extra": "10.1.0", "jsonld2graphobject": "0.0.5", "loading-cli": "1.1.2", "prettier": "3.9.5" }, "peerDependencies": { "typescript": "5.9.3" }, "bin": { "rdf-orm": "dist/cli.js" } }, "sha512-jogxiLfUtI3WkuF5O3PPwRBRWylpapk/JlvElGUqZnEavXXlEMmEGbK3VwqMUsbp1pweNA0sEKIFLIk3Lzt2Gg=="],
|
||||||
|
|
||||||
|
"@ng-org/web": ["@ng-org/web@0.1.2-alpha.13", "", { "dependencies": { "async-proxy": "0.4.1" } }, "sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ=="],
|
||||||
|
|
||||||
|
"@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "26.1.1" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="],
|
||||||
|
|
||||||
|
"@shexjs/parser": ["@shexjs/parser@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/util": "1.0.0-alpha.28", "@ts-jison/parser": "0.4.1-alpha.1" } }, "sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw=="],
|
||||||
|
|
||||||
|
"@shexjs/term": ["@shexjs/term@1.0.0-alpha.27", "", { "dependencies": { "@types/shexj": "2.1.7", "rdf-data-factory": "1.1.3", "relativize-url": "0.1.0" } }, "sha512-+D7P7pglRPTZC2RkwaQuq+cgBZImx+61JZtcN77uEJVqcGaIscQK5hScsKhAPIo16/I+4jhIUCEFojXqw6otpg=="],
|
||||||
|
|
||||||
|
"@shexjs/util": ["@shexjs/util@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/term": "1.0.0-alpha.27", "@shexjs/visitor": "1.0.0-alpha.27", "@types/shexj": "2.1.7", "hierarchy-closure": "1.2.2", "sync-request": "6.1.0" } }, "sha512-L8pBokTU/5eNRJPkC8R9SIgPw6/JDh/bHKdV5TZzf8/FkOMNJwKIy6UDHXM1I8FJ+c8u2gOOHp2MA+7b+md+0A=="],
|
||||||
|
|
||||||
|
"@shexjs/visitor": ["@shexjs/visitor@1.0.0-alpha.27", "", {}, "sha512-9s67A+f0ZZNw/SNxqoi1483CqUca8dbnHM6WDWsRH4+eXlQpQqwOZDxA8uKEaWeX4VcDrDwzWpr0WvK6EyDWIQ=="],
|
||||||
|
|
||||||
|
"@ts-jison/common": ["@ts-jison/common@0.4.1-alpha.1", "", {}, "sha512-SDbHzq+UMD+V3ciKVBHwCEgVqSeyQPTCjOsd/ZNTGySUVg4x3EauR9ZcEfdVFAsYRR38XWgDI+spq5LDY46KvQ=="],
|
||||||
|
|
||||||
|
"@ts-jison/lexer": ["@ts-jison/lexer@0.4.1-alpha.1", "", { "dependencies": { "@ts-jison/common": "0.4.1-alpha.1" } }, "sha512-5C1Wr+wixAzn2MOFtgy7KbT6N6j9mhmbjAtyvOqZKsikKtNOQj22MM5HxT+ooRexG2NbtxnDSXYdhHR1Lg58ow=="],
|
||||||
|
|
||||||
|
"@ts-jison/parser": ["@ts-jison/parser@0.4.1-alpha.1", "", { "dependencies": { "@ts-jison/common": "0.4.1-alpha.1", "@ts-jison/lexer": "0.4.1-alpha.1" } }, "sha512-xNj+qOez/7dju44LlYiTlCjxMzW5oek9EckUAElfln/GBK9vgMSk0swWcnacMr0TYbGjUQuXvL2wEgmDf5WajQ=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
|
"@types/concat-stream": ["@types/concat-stream@1.6.1", "", { "dependencies": { "@types/node": "8.10.66" } }, "sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA=="],
|
||||||
|
|
||||||
|
"@types/form-data": ["@types/form-data@0.0.33", "", { "dependencies": { "@types/node": "8.10.66" } }, "sha512-8BSvG1kGm83cyJITQMZSulnl6QV8jqAGreJsc5tPu1Jq0vTSOiY/k24Wx82JRpWwZSqrala6sd5rWi6aNXvqcw=="],
|
||||||
|
|
||||||
|
"@types/http-link-header": ["@types/http-link-header@1.0.7", "", { "dependencies": { "@types/node": "18.19.130" } }, "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw=="],
|
||||||
|
|
||||||
|
"@types/jsonld": ["@types/jsonld@1.5.15", "", {}, "sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||||
|
|
||||||
|
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
|
||||||
|
|
||||||
|
"@types/shexj": ["@types/shexj@2.1.7", "", {}, "sha512-pu/0vIZxFTMPVjTlo5MJKFkBL/EbAuFhtCXpmBB7ZdUiyNpc6pt8GxfyRPqdf6q2SsWu71a/vbhvGK2IZN2Eug=="],
|
||||||
|
|
||||||
|
"alien-signals": ["alien-signals@2.0.8", "", {}, "sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA=="],
|
||||||
|
|
||||||
|
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
|
||||||
|
|
||||||
|
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||||
|
|
||||||
|
"async-proxy": ["async-proxy@0.4.1", "", { "dependencies": { "object-path-operator": "3.0.0" } }, "sha512-4e+zNtoGL4+cnqib8v169CnKcRfAsAubp2EsjBhAA5jyW7jjI3t36rVvuqLwmhtliwf8JvSnxinE4ecQN+DK4w=="],
|
||||||
|
|
||||||
|
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
|
||||||
|
|
||||||
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "26.1.1" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "get-intrinsic": "1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
|
|
||||||
|
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
|
||||||
|
|
||||||
|
"colors-cli": ["colors-cli@1.0.33", "", { "bin": { "colors": "bin/colors" } }, "sha512-PWGsmoJFdOB0t+BeHgmtuoRZUQucOLl5ii81NBzOOGVxlgE04muFNHlR5j8i8MKbOPELBl3243AI6lGBTj5ICQ=="],
|
||||||
|
|
||||||
|
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||||
|
|
||||||
|
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
"concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "1.1.2", "inherits": "2.0.4", "readable-stream": "2.3.8", "typedarray": "0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="],
|
||||||
|
|
||||||
|
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||||
|
|
||||||
|
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
||||||
|
|
||||||
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
|
"dts-dom": ["dts-dom@3.6.0", "", {}, "sha512-on5jxTgt+A6r0Zyyz6ZRHXaAO7J1VPnOd6+AmvI1vH440AlAZZNc5rUHzgPuTjGlrVr1rOWQYNl7ZJK6rDohbw=="],
|
||||||
|
|
||||||
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
|
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "10.9.4" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||||
|
|
||||||
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||||
|
|
||||||
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "has-tostringtag": "1.0.2", "hasown": "2.0.4" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
|
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "5.1.9" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||||
|
|
||||||
|
"form-data": ["form-data@2.5.6", "", { "dependencies": { "asynckit": "0.4.0", "combined-stream": "1.0.8", "es-set-tostringtag": "2.1.0", "hasown": "2.0.4", "mime-types": "2.1.35", "safe-buffer": "5.2.1" } }, "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA=="],
|
||||||
|
|
||||||
|
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "4.2.11", "jsonfile": "6.2.1", "universalify": "2.0.1" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.2", "function-bind": "1.1.2", "get-proto": "1.0.1", "gopd": "1.2.0", "has-symbols": "1.1.0", "hasown": "2.0.4", "math-intrinsics": "1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
|
"get-port": ["get-port@3.2.0", "", {}, "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg=="],
|
||||||
|
|
||||||
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "1.0.1", "es-object-atoms": "1.1.2" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
|
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "1.1.0" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||||
|
|
||||||
|
"hierarchy-closure": ["hierarchy-closure@1.2.2", "", {}, "sha512-ZqZvsA6HyMqrmm49D3llYA8x8hqdyDDEkaTXcqwyO+fGQlzxoeXws/5ze11M40s4EoTw7GFxdTKIwj5YDOicLQ=="],
|
||||||
|
|
||||||
|
"http-basic": ["http-basic@8.1.3", "", { "dependencies": { "caseless": "0.12.0", "concat-stream": "1.6.2", "http-response-object": "3.0.2", "parse-cache-control": "1.0.1" } }, "sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw=="],
|
||||||
|
|
||||||
|
"http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="],
|
||||||
|
|
||||||
|
"http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "10.17.60" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||||
|
|
||||||
|
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "3.2.6", "filelist": "1.0.6", "picocolors": "1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
||||||
|
|
||||||
|
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "2.0.1" }, "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||||
|
|
||||||
|
"jsonld-context-parser": ["jsonld-context-parser@2.4.0", "", { "dependencies": { "@types/http-link-header": "1.0.7", "@types/node": "18.19.130", "cross-fetch": "3.2.0", "http-link-header": "1.1.4", "relative-to-absolute-iri": "1.0.8" }, "bin": { "jsonld-context-parse": "bin/jsonld-context-parse.js" } }, "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA=="],
|
||||||
|
|
||||||
|
"jsonld2graphobject": ["jsonld2graphobject@0.0.5", "", { "dependencies": { "@rdfjs/types": "1.1.2", "@types/jsonld": "1.5.15", "jsonld-context-parser": "2.4.0", "uuid": "8.3.2" } }, "sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw=="],
|
||||||
|
|
||||||
|
"loading-cli": ["loading-cli@1.1.2", "", { "dependencies": { "colors-cli": "1.0.33" } }, "sha512-M1ntfXHpdGoQxfaqKBOQPwSrTr9EIoTgj664Q9UVSbSnJvAFdribo+Ij//1jvACgrGHaTvfKoD9PG3NOxGj44g=="],
|
||||||
|
|
||||||
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
|
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.2" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||||
|
|
||||||
|
"ng-e2e-helpers": ["ng-e2e-helpers@workspace:packages/ng-e2e-helpers"],
|
||||||
|
|
||||||
|
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "5.0.0" } }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||||
|
|
||||||
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
|
"object-path-operator": ["object-path-operator@3.0.0", "", {}, "sha512-Z7dlPUeXqRU/lLfGerP24dPC66n7ehyXaTM81k71EFlsaaEjOHkf4/uq1WGicfGfiO7snYShneE1YZZUkyRiLQ=="],
|
||||||
|
|
||||||
|
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="],
|
||||||
|
|
||||||
|
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||||
|
|
||||||
|
"promise": ["promise@8.3.0", "", { "dependencies": { "asap": "2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="],
|
||||||
|
|
||||||
|
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "1.0.1", "side-channel": "1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
||||||
|
|
||||||
|
"rdf-data-factory": ["rdf-data-factory@1.1.3", "", { "dependencies": { "@rdfjs/types": "1.1.2" } }, "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ=="],
|
||||||
|
|
||||||
|
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "1.0.3", "inherits": "2.0.4", "isarray": "1.0.0", "process-nextick-args": "2.0.1", "safe-buffer": "5.1.2", "string_decoder": "1.1.1", "util-deprecate": "1.0.2" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||||
|
|
||||||
|
"relative-to-absolute-iri": ["relative-to-absolute-iri@1.0.8", "", {}, "sha512-U1TmhrhCmXKkDL9mI8gBbF5TN6TKcuv28k5+H3gMCAjoz0TyyHAICHlaGDZsTEBSu2Y3HhDKc8e6X9n33qeIqA=="],
|
||||||
|
|
||||||
|
"relativize-url": ["relativize-url@0.1.0", "", {}, "sha512-YXet4a9wQP96Ru9MQSfoRUzsCaeboLPXj+rVG1ulH4t54zqFHiNmW6FPl7V2dTxk9uHlW3yb9+1jWO44AdWisw=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4", "side-channel-list": "1.0.1", "side-channel-map": "1.0.1", "side-channel-weakmap": "1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||||
|
|
||||||
|
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||||
|
|
||||||
|
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||||
|
|
||||||
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4", "side-channel-map": "1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
|
|
||||||
|
"sync-request": ["sync-request@6.1.0", "", { "dependencies": { "http-response-object": "3.0.2", "sync-rpc": "1.3.7", "then-request": "6.0.2" } }, "sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw=="],
|
||||||
|
|
||||||
|
"sync-rpc": ["sync-rpc@1.3.7", "", { "dependencies": { "get-port": "3.2.0" } }, "sha512-YHciI7TUxL8EPqz/bg01sZfwuzQA0odao1wf1Ywdtw7j5vl30aQ6s+bLRTvgPPgzr94cg+WMm6Bxi/P7BJOxgw=="],
|
||||||
|
|
||||||
|
"then-request": ["then-request@6.0.2", "", { "dependencies": { "@types/concat-stream": "1.6.1", "@types/form-data": "0.0.33", "@types/node": "8.10.66", "@types/qs": "6.15.1", "caseless": "0.12.0", "concat-stream": "1.6.2", "form-data": "2.5.6", "http-basic": "8.1.3", "http-response-object": "3.0.2", "promise": "8.3.0", "qs": "6.15.3" } }, "sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA=="],
|
||||||
|
|
||||||
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
|
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||||
|
|
||||||
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "0.0.3", "webidl-conversions": "3.0.1" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
|
"@types/concat-stream/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="],
|
||||||
|
|
||||||
|
"@types/form-data/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="],
|
||||||
|
|
||||||
|
"@types/http-link-header/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "5.26.5" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||||
|
|
||||||
|
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
|
||||||
|
|
||||||
|
"jsonld-context-parser/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "5.26.5" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||||
|
|
||||||
|
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
|
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
|
"then-request/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="],
|
||||||
|
|
||||||
|
"@types/http-link-header/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
|
"jsonld-context-parser/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
+313
-183
@@ -1,8 +1,10 @@
|
|||||||
# API contract — what `@ng-eventually/client` exposes today, and what the future SDK should expose per subject
|
# API contract — what `@ng-eventually/polyfill` exposes today, and what the future SDK should expose per subject
|
||||||
|
|
||||||
**Scope: the APP-FACING contract only.** Everything reachable from the two published entry points, and nothing else. The library's internal modules — the shim machinery, the read paths, the boundary guards — are held to the same standard (as close as possible to what NextGraph does or plans) but have their own document, `docs/internal-contract.md`: a consumer never reads that one, a maintainer does. This split was made on 2026-08-03, together with the export change described in § 15.
|
> **Updated 2026-08-03, after the source layout was reorganised by migration fate** (`docs/source-layout-by-fate.md`). Paths, and three names, changed under this document: `readModel` became the directly-exported `readUnion`; `accounts` / `AccountRecord` / `AccountStorage` became `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage` (module `shared-wallet/virtual-users.ts`); `store-registry-api.ts` became `surface/placement.ts`. Two modules were created and are covered here: `emulated-verifier/branch-registers.ts` (the four durable registers, split out of the shim) and `shared-wallet/bootstrap.ts` (the injection store, split out of the `/polyfill` entry). The subject-by-subject rulings below are unaffected — what moved is where the code lives, not what it promises.
|
||||||
|
|
||||||
**Scope.** The real exported surface of `@ng-eventually/client` (verified against the `export` statements in `packages/client/src/index.ts` and `packages/client/src/polyfill.ts` — `package.json` maps exactly two entry points, `.` and `./polyfill`), and, for each subject, the target signature the future NextGraph JS SDK is expected to expose. Written 2026-08-03, verified against the `nextgraph-rs` clone (HEAD `213338f6`, 2026-05-16) and the installed `@ng-org/web@0.1.2-alpha.13` type declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`).
|
**Scope: the APP-FACING contract only.** Everything reachable from the published entry point, and nothing else. The library's internal modules — the shim machinery, the read paths, the boundary guards — are held to the same standard (as close as possible to what NextGraph does or plans) but have their own document, `docs/internal-contract.md`: a consumer never reads that one, a maintainer does. This split was made on 2026-08-03, together with the export change described in § 15.
|
||||||
|
|
||||||
|
**Scope.** The real exported surface of `@ng-eventually/polyfill` (verified against the `export` statements in `packages/polyfill/src/index.ts` — `package.json` maps exactly one entry point, `.`), and, for each subject, the target signature the future NextGraph JS SDK is expected to expose. Written 2026-08-03, verified against the `nextgraph-rs` clone (HEAD `213338f6`, 2026-05-16) and the installed `@ng-org/web@0.1.2-alpha.13` type declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`).
|
||||||
|
|
||||||
**How to read the epistemic labels.** Every target-side claim carries one of:
|
**How to read the epistemic labels.** Every target-side claim carries one of:
|
||||||
|
|
||||||
@@ -13,44 +15,68 @@
|
|||||||
|
|
||||||
Per the design principle (`README.md` § *Design principle*): an absent implementation is never treated as evidence about the future — "the engine does not do X" and "the SDK will not offer X" are kept apart throughout.
|
Per the design principle (`README.md` § *Design principle*): an absent implementation is never treated as evidence about the future — "the engine does not do X" and "the SDK will not offer X" are kept apart throughout.
|
||||||
|
|
||||||
|
### The per-symbol annotations are the authority; this document is the reasoning
|
||||||
|
|
||||||
|
Since 2026-08-16 every published symbol — and every internal one that claims to mirror the target — carries its provenance **in the source, beside the declaration**, pinned by `packages/polyfill/test/provenance.test.ts`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
// @provenance <name> kind=<kind> level=<1|2|3|none> ref=<path:symbol|none> — <note>
|
||||||
|
```
|
||||||
|
|
||||||
|
It exists because this document could not stop drifting from the code: it claimed a "1:1 passthrough" for `docs.*` that was false in two ways, and `emulated-verifier/branch-registers.readLinks` was classified as this library's own invention when it implements `AddLinkV0` — a type the engine **defines** and constructs nowhere. That second mistake is the one the annotations are designed against: *nothing constructs this upstream* is an **absence**, and an absence is not evidence that a thing is ours.
|
||||||
|
|
||||||
|
The `ref` cites a **symbol**, never a line number — three citations in this document had already rotted (§ 2, § 4, § 12 below).
|
||||||
|
|
||||||
|
The prose labels above map onto the annotation's closed `kind` set, and where they disagree **the annotation wins**:
|
||||||
|
|
||||||
|
| label here | annotation `kind` | |
|
||||||
|
|---|---|---|
|
||||||
|
| PASSTHROUGH | `passthrough` | only when the name and shape are upstream's OWN, so the call site survives migration untouched |
|
||||||
|
| PASSTHROUGH | `aligned` | when the semantics are upstream's but the name or the ergonomics are ours (`docCreate` for `doc_create`, a `label` parameter that is not forwarded) — § 7, § 8 |
|
||||||
|
| LEVEL-1 SHAPE | `aligned` / `declared-not-wired` | the two are NOT the same claim: `aligned` means the engine does this and we follow it; `declared-not-wired` means the engine **defines** the type and constructs it nowhere |
|
||||||
|
| ASSUMPTION / NO COUNTERPART | `invention` | nothing at any level answers; `ref` is `none`, deliberately |
|
||||||
|
| *(no prose label)* | `divergent` | upstream does this and we do it differently — the inbox-address publication, the re-readable deposit list |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Bootstrap and configuration
|
## 1. Bootstrap and configuration
|
||||||
|
|
||||||
### Today — `@ng-eventually/client/polyfill` (everything here is removed at migration)
|
### Today — `@ng-eventually/polyfill`: **one call**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// polyfill.ts:44
|
// shared-wallet/bootstrap.ts
|
||||||
export interface EventuallyConfig {
|
export interface EventuallyConfig {
|
||||||
ng: NgLike;
|
ng: NgLike; // the REAL @ng-org/web ng
|
||||||
useShape: UseShapeLike;
|
useShape: UseShapeLike; // the REAL @ng-org/orm useShape
|
||||||
sharedWallet?: { name: string; secret: string };
|
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||||
currentUser?: PrincipalId;
|
sharedWallet?: SharedWalletConfig; // the gate's, § 2bis
|
||||||
debugAccessLog?: boolean;
|
debugAccessLog?: boolean;
|
||||||
init?: (...args: any[]) => any;
|
init?: (...args: any[]) => any;
|
||||||
initNg?: (...args: any[]) => any;
|
initNg?: (...args: any[]) => any;
|
||||||
}
|
}
|
||||||
// polyfill.ts:99
|
|
||||||
export function configure(c: EventuallyConfig): void;
|
export function configure(c: EventuallyConfig): void;
|
||||||
// polyfill.ts:113 — tests only
|
|
||||||
export function resetConfig(): void;
|
|
||||||
|
|
||||||
// polyfill.ts:24
|
|
||||||
export interface StoreRegistryDeps {
|
|
||||||
getSession: () => Promise<RegistrySession>;
|
|
||||||
normalizeId?: (id: string) => string;
|
|
||||||
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
|
||||||
}
|
|
||||||
// polyfill.ts:124
|
|
||||||
export function configureStoreRegistry(deps: StoreRegistryDeps): void;
|
|
||||||
// polyfill.ts:161 — tests only
|
|
||||||
export function resetStoreRegistry(): void;
|
|
||||||
|
|
||||||
// polyfill.ts:106 / :153 — both tagged @internal, exported so the SDK-shaped wrappers can reach the injected SDK
|
|
||||||
export function getConfig(): EventuallyConfig;
|
|
||||||
export function getStoreRegistryDeps(): ResolvedRegistryDeps;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**The count is the contract here.** The agreed target was two polyfill-era calls, or one; it had drifted to four, and each extra one was a reason the LIBRARY has rather than a need an application has. Four became one on 2026-08-07:
|
||||||
|
|
||||||
|
| Was published | Where it went |
|
||||||
|
|---|---|
|
||||||
|
| `configureStoreRegistry` + `StoreRegistryDeps` | folded into `configure` — two bootstrap calls existed because the library has two internals, which is not a reason a caller should pay |
|
||||||
|
| `setCurrentUser` | the access gate sets the identity (§ 2bis). An application naming its own identity is the gesture that INVERTS the model; it must not have a published call to reach for |
|
||||||
|
| `connectedUser` | `ensureIdentity` awaits it. Upstream, opening the session IS the connection — no application awaits a second call |
|
||||||
|
| `getConfig`, `getStoreRegistryDeps`, `resetConfig`, `resetStoreRegistry` | internal wiring and test resets, reached by their internal path (2026-08-07, with the entry merge) |
|
||||||
|
|
||||||
|
**And two FIELDS of that one call, on 2026-08-12.** The count was already one; what was left inside it still made an application build things the target never asks anyone to build:
|
||||||
|
|
||||||
|
| Was published | Where it went |
|
||||||
|
|---|---|
|
||||||
|
| `getSession` (and the `RegistrySession` type with it, § 12) | the package's. Upstream a session is **returned** — `init()`'s callback delivers `{ status: "loggedin", session }` (`@ng-org/web` `dist/ngweb.js:124`, VERIFIED) and `session_start` hands one back; nowhere does an application ASSEMBLE one out of `session_id` / `private_store_id` / …. Every consumer wrapped `init()` in a promise and wrote the same unwrapping thunk, with nothing to migrate it to. The lib's `init` wrapper captures the event on its way through (§ 2) and holds the session (`shared-wallet/session.ts`) |
|
||||||
|
| `normalizeId` | the package's, as `normalizeIdentityId` — trim, strip a leading `@`, lowercase. The identities it keys are the shared wallet's own virtual users, so there was never a decision here for a consumer to make; and one rule in one place is what stops the barrier, the URL and storage keying onto three different spaces |
|
||||||
|
|
||||||
|
Both remain substitutable through `configureStoreRegistry` (`shared-wallet/bootstrap.ts`), which the published entry does not re-export: the unit suites have no browser and the e2e harness holds a session the broker handed it directly, and neither is an application.
|
||||||
|
|
||||||
|
So an application's whole bootstrap is `configure({ ng, useShape, init, sharedWallet })` plus `await ensureIdentity()` — and the second of those keeps its call site after migration.
|
||||||
|
|
||||||
### Target
|
### Target
|
||||||
|
|
||||||
**NO COUNTERPART, by design.** The whole subject is the polyfill bootstrap: it exists to inject the real SDK without a hard import (build-alias safety). At migration the consumer initializes the real SDK directly, with the two calls in § 2, and `configure` / `configureStoreRegistry` are deleted (`docs/migration-guide.md` § 7). Nothing in the target takes an "injected `ng`".
|
**NO COUNTERPART, by design.** The whole subject is the polyfill bootstrap: it exists to inject the real SDK without a hard import (build-alias safety). At migration the consumer initializes the real SDK directly, with the two calls in § 2, and `configure` / `configureStoreRegistry` are deleted (`docs/migration-guide.md` § 7). Nothing in the target takes an "injected `ng`".
|
||||||
@@ -59,10 +85,11 @@ export function getStoreRegistryDeps(): ResolvedRegistryDeps;
|
|||||||
|
|
||||||
## 2. Lifecycle
|
## 2. Lifecycle
|
||||||
|
|
||||||
### Today — `@ng-eventually/client`
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// lifecycle.ts:11 — forwards to the real @ng-org/web init injected at configure()
|
// lifecycle.ts:11 — settles the identity, wraps the callback, then forwards to the real
|
||||||
|
// @ng-org/web init injected at configure()
|
||||||
export function init(...args: any[]): any;
|
export function init(...args: any[]): any;
|
||||||
// lifecycle.ts:18 — forwards to the real @ng-org/orm initNg injected at configure()
|
// lifecycle.ts:18 — forwards to the real @ng-org/orm initNg injected at configure()
|
||||||
export function initNg(...args: any[]): any;
|
export function initNg(...args: any[]): any;
|
||||||
@@ -70,7 +97,7 @@ export function initNg(...args: any[]): any;
|
|||||||
|
|
||||||
### Target
|
### Target
|
||||||
|
|
||||||
**PASSTHROUGH, VERIFIED at both levels.** The wrapper's `...args: any[]` is deliberately shapeless; the real signatures it forwards to are:
|
**PASSTHROUGH, VERIFIED at both levels — with one argument touched, deliberately.** `init`'s callback in position 0 is wrapped since 2026-08-12: the wrapper reads the event, keeps the session it carries (§ 1), and calls the caller's callback with that same event, unchanged. Everything else — the remaining arguments, the return value, what the callback observes — passes straight through, so an application's call site is what it would write against the real SDK. This is the only place the capture can sit: it is the only one that knows both what the caller asked and what the SDK will answer, and the alternative was every consumer re-implementing it (which is what it replaces). The real signatures forwarded to are:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// level 2 — @ng-org/web: index.d.ts:108, source sdk/js/web/src/index.ts:51
|
// level 2 — @ng-org/web: index.d.ts:108, source sdk/js/web/src/index.ts:51
|
||||||
@@ -79,7 +106,7 @@ export declare const init: (callback: Function | null, singleton: boolean, acces
|
|||||||
// level 3 — @ng-org/orm: sdk/js/orm/src/connector/initNg.ts:51 (exported as initNg from core.ts)
|
// level 3 — @ng-org/orm: sdk/js/orm/src/connector/initNg.ts:51 (exported as initNg from core.ts)
|
||||||
export function initNgSignals(ngImpl: NG, session: Session): void;
|
export function initNgSignals(ngImpl: NG, session: Session): void;
|
||||||
|
|
||||||
// level 2 — the Session initNg consumes: index.d.ts:264-272
|
// level 2 — the Session initNg consumes: index.d.ts:265-273 (source sdk/js/web/src/index.ts:15-22)
|
||||||
export declare type Session = {
|
export declare type Session = {
|
||||||
session_id: string | number;
|
session_id: string | number;
|
||||||
protected_store_id: string;
|
protected_store_id: string;
|
||||||
@@ -94,33 +121,64 @@ Divergence: none in behaviour (pure forwarding), but the wrapper erases the para
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. The `ng` object
|
## 2bis. The access gate — `ensureIdentity`
|
||||||
|
|
||||||
### Today — `@ng-eventually/client`
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// index.ts:55
|
export async function ensureIdentity(): Promise<PrincipalId>; // shared-wallet/access-gate.ts
|
||||||
export const ng: Record<string, any>;
|
export interface SharedWalletConfig { fileUrl: string; password: string; importUrl?: string }
|
||||||
// type re-export, index.ts:50
|
|
||||||
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).
|
One call, before the application renders. It resolves the identity from the URL (`?ng-id=`), failing that from browser storage — and top-level it shows the barrier anyway, with whatever it found already in the field: download the shared wallet, here is its password, import it once, and name your space. Knowing who someone is says nothing about whether their browser still holds the wallet, and the barrier is the only place it is handed out. Past the broker round-trip, inside the iframe, a known identifier stands it down.
|
||||||
|
|
||||||
|
It takes no timeout and needs none: the session it waits for arrives through this package's `init` and nowhere else, so awaited before that call it throws, naming the call to make first (`shared-wallet/access-gate.ts`, `refuseAWaitNothingCanEnd`).
|
||||||
|
|
||||||
### Target
|
### 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.
|
**NO COUNTERPART in substance, and a surviving CALL SITE — this section exists because those two are not the same thing.**
|
||||||
|
|
||||||
The two overrides:
|
The substance is pure scaffolding. Every step it performs exists only because one wallet hosts several identities: upstream a user opens THEIR wallet, it contains THEIR site (`SensitiveWalletV0.personal_identity()`, `engine/wallet/src/types.rs:576-579`), and `session_start(wallet_name, user_id)` takes an id that came FROM the wallet. There is nothing to name and nothing to choose. The step that takes an identifier is the one that inverts the model, and it is the reason the whole gate is scaffolding.
|
||||||
|
|
||||||
|
The call site is a different matter. An application still has to wait for a session before it renders, and that will still be one awaited call at the same place. So the signature was designed to survive: **it takes no identifier and RETURNS one**, deliberately. Naming an identity is the part that disappears, so it must not be a parameter; but knowing which identity you are is something an application legitimately has upstream — it passes `user_id` to `session_start(wallet_name, user_id)` (`index.d.ts:276`), having got it from the wallet it opened. Here the gate chooses it, so the gate hands it back. Without that, the example application had to read the gate's own private storage key.
|
||||||
|
|
||||||
|
What a consumer must NOT conclude:
|
||||||
|
|
||||||
|
- that it may pass an identity in (it cannot — that is the point);
|
||||||
|
- that the barrier is a product screen. It is a technical gate, rendered in plain DOM inside a shadow root so no application stylesheet reshapes it and its own leaks nowhere. It is deliberately not bound to a UI framework: a screen that is going away must not make every consumer adopt one.
|
||||||
|
- that `SharedWalletConfig` describes a user setting. It describes what a DEPLOYMENT hands out, and it disappears with the gate. The library reads no environment variable, ever — the application resolves these values at its own build and passes them.
|
||||||
|
|
||||||
|
Why it lives here and not in the consumer application: the first consumer had ~300 lines of it — a gate component, a screen, a wallet module, an identity context, three BDD features. That is code an application would have to delete, and worse, code that teaches its authors a model NextGraph does not have (*"I name my identity"*).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The `ng` object
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 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: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`, **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<string, any>` 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 overrides:
|
||||||
|
|
||||||
- `session_start(wallet_name: string, user_id: any): Promise<any>` (`index.d.ts:276`) — target signature unchanged; only the emulated credential injection disappears.
|
- `session_start(wallet_name: string, user_id: any): Promise<any>` (`index.d.ts:276`) — target signature unchanged; only the emulated credential injection disappears.
|
||||||
- `sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>` (`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 P1b.
|
- `sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>` (`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Reactive typed reads — `useShape`
|
## 4. Reactive typed reads — `useShape`
|
||||||
|
|
||||||
### Today — `@ng-eventually/client`
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// use-shape.ts:12
|
// use-shape.ts:12
|
||||||
@@ -144,6 +202,10 @@ const useShape = <T extends BaseType>(
|
|||||||
) => DeepSignalSet<T>;
|
) => DeepSignalSet<T>;
|
||||||
|
|
||||||
// its Scope — sdk/js/orm/src/types.ts:25-38 (NOT this lib's Scope, see § 12)
|
// its Scope — sdk/js/orm/src/types.ts:25-38 (NOT this lib's Scope, see § 12)
|
||||||
|
// *(Corrected 2026-08-16: this said `sdk/js/orm/src/model/types.ts`, a path that does not
|
||||||
|
// exist in the clone. The type is at `sdk/js/orm/src/types.ts:25`. A citation nobody can
|
||||||
|
// follow reads as verified and is not — the reason the per-symbol annotations below cite a
|
||||||
|
// SYMBOL rather than a line.)*
|
||||||
export type Scope = {
|
export type Scope = {
|
||||||
graphs?: string[] | string;
|
graphs?: string[] | string;
|
||||||
subjects?: string[];
|
subjects?: string[];
|
||||||
@@ -158,7 +220,7 @@ Divergence to note: the wrapper types everything `unknown`, losing the generic `
|
|||||||
|
|
||||||
## 5. Reactive typed reads with load state — `watchShape`
|
## 5. Reactive typed reads with load state — `watchShape`
|
||||||
|
|
||||||
### Today — `@ng-eventually/client`
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// watch-shape.ts:73
|
// watch-shape.ts:73
|
||||||
@@ -182,9 +244,13 @@ export function watchShape<T = UnionSubject>(
|
|||||||
): ShapeObservable<T>;
|
): ShapeObservable<T>;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**A scope that did not answer is `isError`, never `isSuccess` with `data: []` — since 2026-08-17.** Step 1 of the pipeline asks `listMyEntityDocs` which documents are mine in this scope. That failure used to be caught and logged, and the empty set flowed on: a barrier over zero documents is trivially reached, so the surface published `{ data: [], isPending: false, isSuccess: true }` — byte for byte the synced-but-empty snapshot, which means the one distinction this module exists for was the one it destroyed. An application rendered "you have created nothing" for "the store did not answer". `listMyEntityDocs` had stopped handing out that reading the day before (see § 12); catching it here put it straight back one floor up. It now travels the LOAD-STATE channel, which is where "this is not an answer" already lives on this surface, and which an application must already read to tell pending from empty — so the third state costs it no new vocabulary.
|
||||||
|
|
||||||
|
**`data` survives an error rather than emptying.** A one-shot call rejects and is done; an observable has already handed a list to a subscriber that rendered it, and cannot un-emit. Collapsing `data` to `[]` on failure would put the empty answer back in the one field a view actually paints, for exactly the case that must never read as empty — so `data` keeps the last read that ANSWERED, `isSuccess` stays false so that array is never offered as a reply to the question that just failed, and the subscriber is notified so a view gating on the load state can say so. Before any answer there is nothing to keep and `data` is `[]`, published under `isError` and never under `isSuccess`.
|
||||||
|
|
||||||
### Target
|
### Target
|
||||||
|
|
||||||
**Partly ASSUMPTION — flagged deliberately.** `watch-shape.ts`'s header says it "anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will natively distinguish 'sync in progress' from 'synced but empty'". **No provenance for that plan exists in this repo's docs or in the `nextgraph-rs` clone** — treat the "planned upgrade" as an assumption, not a stated NextGraph direction. What IS verified at level 3 is that the distinction is *expressible* today, just not through the hook:
|
**Partly ASSUMPTION — flagged deliberately.** `surface/watch-shape.ts`'s header says it "anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will natively distinguish 'sync in progress' from 'synced but empty'". **No provenance for that plan exists in this repo's docs or in the `nextgraph-rs` clone** — treat the "planned upgrade" as an assumption, not a stated NextGraph direction. What IS verified at level 3 is that the distinction is *expressible* today, just not through the hook:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// level 3, VERIFIED — sdk/js/orm/src/connector/GraphOrmSubscription.ts:228,260,274
|
// level 3, VERIFIED — sdk/js/orm/src/connector/GraphOrmSubscription.ts:228,260,274
|
||||||
@@ -195,25 +261,29 @@ public close(): void;
|
|||||||
|
|
||||||
So the constraint on the bet: the target can already answer "synced?" (`readyPromise`), and `useShape` today returns "an empty set, if still loading" (its own doc comment, `useShape.ts:29-31`) — indistinguishable from synced-empty. `watchShape` surfaces the distinction with a TanStack-`useQuery`-minimal vocabulary (`isPending`/`isSuccess`/`isError`), which is a **shape of this library's choosing**. If the future hook exposes load state under different names, the consumer's binding code changes; the underlying distinction it teaches (pending ≠ empty) is target-expressible and safe to learn.
|
So the constraint on the bet: the target can already answer "synced?" (`readyPromise`), and `useShape` today returns "an empty set, if still loading" (its own doc comment, `useShape.ts:29-31`) — indistinguishable from synced-empty. `watchShape` surfaces the distinction with a TanStack-`useQuery`-minimal vocabulary (`isPending`/`isSuccess`/`isError`), which is a **shape of this library's choosing**. If the future hook exposes load state under different names, the consumer's binding code changes; the underlying distinction it teaches (pending ≠ empty) is target-expressible and safe to learn.
|
||||||
|
|
||||||
|
**But `readyPromise` answers two of those three questions, not three — VERIFIED 2026-08-17, and it bears on `isError` specifically.** It is constructed with `resolve` alone (`new Promise<void>((resolve) => { this.resolveReady = resolve; })`, `GraphOrmSubscription.ts` constructor), nothing anywhere rejects it, and `resolveReady()` is called on one path only — the arrival of initial data. The subscription that fails does not settle it: `orm_start_graph` is awaited inside a `try` whose `catch` is `console.error(e)`, so the promise stays pending forever and every `await this.readyPromise_` behind it hangs. Upstream's "I could not find out" IS its "still pending". So `isPending` and `isSuccess` map onto something real at level 3, while `isError` has **no counterpart at any level** — it is this library's third state, and the reason it exists is that swallowing a failed read into a plausible-looking result is the shape that took this project a week to close everywhere else. A consumer should keep reading it; if the future hook ships without one, the failure is the pending that never ends, which is a worse thing to have to render, not a reason to have learned less.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. One-shot listing — the read-model
|
## 6. One-shot listing — the read-model
|
||||||
|
|
||||||
### Today — `@ng-eventually/client`
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// read-model.ts:59
|
// read-model.ts:60
|
||||||
export interface UnionSubject {
|
export interface UnionSubject {
|
||||||
subject: string;
|
subject: string; // the subject IRI as the document carries it — any IRI, not a Nuri
|
||||||
graph: string;
|
graph: Nuri; // the document reference the caller passed, unchanged
|
||||||
props: Record<string, string[]>;
|
props: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
// read-model.ts:140
|
// read-model.ts:166
|
||||||
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]>;
|
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]>;
|
||||||
```
|
```
|
||||||
|
|
||||||
Behaviour: one anchored `sparql_query` per doc (default-graph body, no `GRAPH` wrapper), parallel, per-doc failure tolerance, cap filter applied inside, machinery subjects dropped.
|
Behaviour: one anchored `sparql_query` per doc (default-graph body, no `GRAPH` wrapper), parallel, per-doc failure tolerance, cap filter applied inside, machinery subjects dropped.
|
||||||
|
|
||||||
|
**Grouping is per (document, subject), and subjects come back as written.** A document holding several subjects yields several entries — one each, properties never merged across subjects; the same subject IRI seen in two documents stays two entries, told apart by `graph`. This matches level 3, where an object carries `@id` and `@graph` as two distinct read-only properties and the ORM fabricates an `@id` when the writer leaves it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`, `":q:"`) — several objects per graph is the provided case, and `@id` is what distinguishes them inside a `@graph`. Only `graph` is a `Nuri`; `subject` is typed `string` because an RDF subject may be any IRI. One document per business entity remains the recommended placement (a key is per repo, so isolating an entity needs a repo of its own), but that is a recommendation about writing — the read reports what is there rather than making the other arrangement invisible.
|
||||||
|
|
||||||
### Target
|
### Target
|
||||||
|
|
||||||
Two verified counterparts, one per level; neither returns `UnionSubject` — that grouping is lib-invented:
|
Two verified counterparts, one per level; neither returns `UnionSubject` — that grouping is lib-invented:
|
||||||
@@ -235,44 +305,48 @@ The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query
|
|||||||
|
|
||||||
## 7. Raw document / SPARQL primitives — `docs.*`
|
## 7. Raw document / SPARQL primitives — `docs.*`
|
||||||
|
|
||||||
### Today — `@ng-eventually/client` (namespace `docs`)
|
### Today — `@ng-eventually/polyfill` (namespace `docs`)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// docs.ts:46
|
// docs.ts:53
|
||||||
export async function docCreate(
|
export async function docCreate(
|
||||||
sessionId: string,
|
sessionId: string | number,
|
||||||
crdt: string,
|
crdt: string,
|
||||||
cls: string,
|
cls: string,
|
||||||
dest: string,
|
dest: string,
|
||||||
store?: unknown,
|
store?: unknown,
|
||||||
): Promise<Nuri>;
|
): Promise<Nuri>;
|
||||||
// docs.ts:85
|
// docs.ts:101
|
||||||
export async function sparqlUpdate(
|
export async function sparqlUpdate(
|
||||||
sessionId: string,
|
sessionId: string | number,
|
||||||
query: string,
|
query: string,
|
||||||
anchor?: Nuri,
|
anchor?: Nuri,
|
||||||
label = "sparqlUpdate",
|
label = "sparqlUpdate",
|
||||||
): Promise<void>;
|
): Promise<unknown>;
|
||||||
// docs.ts:130
|
// docs.ts:146
|
||||||
export async function sparqlQuery(
|
export async function sparqlQuery(
|
||||||
sessionId: string,
|
sessionId: string | number,
|
||||||
query: string,
|
query: string,
|
||||||
base?: string,
|
base?: string,
|
||||||
anchor?: Nuri,
|
anchor?: Nuri,
|
||||||
label = "sparqlQuery",
|
label = "sparqlQuery",
|
||||||
): Promise<unknown>;
|
): Promise<unknown>;
|
||||||
// docs.ts:113 — machinery, see § 15
|
// docs.ts:113 — machinery, see § 15
|
||||||
export async function depositInto(
|
// NOT published since 2026-08-07 — moved to `emulated-verifier/register-write.ts`.
|
||||||
sessionId: string,
|
// It skips the boundary by design ("the one write that legitimately crosses"), and a door
|
||||||
query: string,
|
// that skips a guard must not be one an application can open: holding nothing but a public
|
||||||
targetInbox: Nuri,
|
// document's bare reference, one could rewrite the inbox address posted on it and divert
|
||||||
label = "deposit",
|
// every deposit meant for its owner. Go through `inbox.post` / `inbox.share`.
|
||||||
): Promise<void>;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Target
|
### Target
|
||||||
|
|
||||||
**PASSTHROUGH (level 2, VERIFIED)** — these mirror the real methods 1:1 minus the trailing `label` (a lib-internal access-log tag, never forwarded):
|
**Annotated `aligned` (level 2, VERIFIED) — not `passthrough`, and the distinction is the point.** These forward to the real methods with the same arguments in the same order, but the names are ours (`docCreate` for `doc_create`) and they take a trailing `label` (a lib-internal access-log tag) the real signatures have no room for. So the *semantics* survive migration and the *call site* does not — which is exactly what `aligned` says and what a blanket "PASSTHROUGH" hid. *(This entry said PASSTHROUGH until 2026-08-16; it had already been caught claiming a 1:1 mirror, false in two ways, both 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<any>`). It was declared `Promise<void>` 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<any>`. A caller that ignores it is unaffected.
|
||||||
|
|
||||||
|
The real signatures forwarded to:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// index.d.ts:60 — the installed web SDK's doc_create
|
// index.d.ts:60 — the installed web SDK's doc_create
|
||||||
@@ -283,9 +357,9 @@ declare function sparql_update(session_id: any, sparql: string, nuri: any): Prom
|
|||||||
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
|
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
|
||||||
```
|
```
|
||||||
|
|
||||||
`depositInto` has **NO COUNTERPART as a SPARQL write**: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document.
|
`depositInto` has **NO COUNTERPART as a SPARQL write**: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document — and it is no longer published (see the block above).
|
||||||
|
|
||||||
**Store targeting — a nuance this repo's docs understate.** `docs/nextgraph-current-state.md` and `docs/migration-guide.md` say a public/arbitrary `StoreRepo` "is not JS-constructible". Verified in the clone, the picture is finer:
|
**Store targeting — finer than "not JS-constructible".** *(The other docs were corrected on 2026-08-03 to match this entry; they used to state the blanket form.)* Verified in the clone:
|
||||||
|
|
||||||
- The **web** wasm variant (`sdk/js/lib-wasm/src/lib.rs:1575`, `#[cfg(not(wasmpack_target = "nodejs"))]`) deserializes its 5th argument as `Option<StoreRepo>` via serde — so a value CAN be passed, but no JS helper exists to build the serde form, which keeps it out of practical reach. The published `.d.ts` documents this 5-arg form.
|
- The **web** wasm variant (`sdk/js/lib-wasm/src/lib.rs:1575`, `#[cfg(not(wasmpack_target = "nodejs"))]`) deserializes its 5th argument as `Option<StoreRepo>` via serde — so a value CAN be passed, but no JS helper exists to build the serde form, which keeps it out of practical reach. The published `.d.ts` documents this 5-arg form.
|
||||||
- The **nodejs** variant (`lib.rs:1618`, 6 args) takes `store_type: Option<String>` + `store_repo: Option<String>` and builds the store via `StoreRepo::from_type_and_repo(store_type, repo_id_str)` with `store_type ∈ "public" | "protected" | "private" | "group"` (`sdk/rust/src/local_broker.rs:2969-2987`, `engine/repo/src/types.rs:819-828`).
|
- The **nodejs** variant (`lib.rs:1618`, 6 args) takes `store_type: Option<String>` + `store_repo: Option<String>` and builds the store via `StoreRepo::from_type_and_repo(store_type, repo_id_str)` with `store_type ∈ "public" | "protected" | "private" | "group"` (`sdk/rust/src/local_broker.rs:2969-2987`, `engine/repo/src/types.rs:819-828`).
|
||||||
@@ -296,7 +370,7 @@ So the target's direction for scope placement is **already visible in the source
|
|||||||
|
|
||||||
## 8. Per-document subscription — `subscribeDoc`
|
## 8. Per-document subscription — `subscribeDoc`
|
||||||
|
|
||||||
### Today — `@ng-eventually/client`
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// subscribe.ts:47,60,79
|
// subscribe.ts:47,60,79
|
||||||
@@ -319,7 +393,7 @@ export function subscribeDocs(
|
|||||||
|
|
||||||
### Target
|
### Target
|
||||||
|
|
||||||
**PASSTHROUGH (level 2, VERIFIED) with two deliberate ergonomic deltas:**
|
**Annotated `aligned` (level 2, VERIFIED) — the deltas below are what makes it `aligned` rather than `passthrough`:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// index.d.ts:66, source sdk/js/lib-wasm/src/lib.rs:1908
|
// index.d.ts:66, source sdk/js/lib-wasm/src/lib.rs:1908
|
||||||
@@ -334,10 +408,9 @@ declare function doc_subscribe(repo_o: string, session_id: any, callback: Functi
|
|||||||
|
|
||||||
## 9. Inbox — deposits, and cap delivery
|
## 9. Inbox — deposits, and cap delivery
|
||||||
|
|
||||||
### Today — `@ng-eventually/client` (namespace `inbox`; `shareCap` also re-exported from `/polyfill`)
|
### Today — `@ng-eventually/polyfill` (namespace `inbox`)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// inbox.ts:48,58
|
|
||||||
export interface Deposit {
|
export interface Deposit {
|
||||||
from: PrincipalId | null;
|
from: PrincipalId | null;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
@@ -348,23 +421,19 @@ export interface PostOptions {
|
|||||||
payload: unknown;
|
payload: unknown;
|
||||||
ts?: number;
|
ts?: number;
|
||||||
}
|
}
|
||||||
// inbox.ts:140
|
export async function post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
|
||||||
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>;
|
export async function postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
|
||||||
// inbox.ts:215
|
export async function share(doc: NuriLike, toUser: string): Promise<void>;
|
||||||
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void>;
|
export async function read(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||||
// inbox.ts:282
|
export async function readForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||||
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>;
|
// `materialize` — a second published name for `read` — was REMOVED on 2026-08-14. It was
|
||||||
// inbox.ts:339
|
// an alias and nothing else: no call site, and upstream has no such member, so it was a
|
||||||
export async function read(targetInbox: Nuri): Promise<Deposit[]>;
|
// symbol an application could learn and would then have to unlearn. Use `read`.
|
||||||
// inbox.ts:419
|
export async function readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||||
export const materialize = read;
|
export async function readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||||
// inbox.ts:441
|
export async function processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||||
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]>;
|
|
||||||
// inbox.ts:466
|
|
||||||
export async function processInbox(targetInbox: Nuri): Promise<Deposit[]>;
|
|
||||||
// inbox.ts:492
|
|
||||||
export function watch(
|
export function watch(
|
||||||
targetInbox: Nuri,
|
targetInbox: NuriLike,
|
||||||
onDeposits: (deposits: Deposit[]) => void,
|
onDeposits: (deposits: Deposit[]) => void,
|
||||||
_opts?: { intervalMs?: number },
|
_opts?: { intervalMs?: number },
|
||||||
): () => void;
|
): () => void;
|
||||||
@@ -381,47 +450,63 @@ export function watch(
|
|||||||
|
|
||||||
Consequences per function:
|
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 verifier's `inboxes` table is session-local, rebuilt empty — `verifier.rs:520,2820`). Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
- `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`.
|
||||||
- `shareCap` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` 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.
|
- `share` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` 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` / `readSyncedForDocument` / `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. It is the WARM form (it delegates to `read`), so on a session that has just loaded it can answer `[]` for a document that has deposits.
|
||||||
|
- `readSyncedForDocument(doc)` — added 2026-08-17, the intersection the surface was missing: `readSynced`'s barrier on `readForDocument`'s address. **What an application no longer does: resolve an inbox address itself.** It had to, because materializing deposits needs both halves and only one call carried each — and resolving an address is the exact gesture § *Guarantees* says an application never performs. The document-addressed path crosses TWO repos and a cold session loses the answer at either: the address is read off the DOCUMENT's Header branch, so an unsynced document reads as "no inbox"; the deposits are read off the INBOX, which is what `readSynced` gates. This gates both, document first. Same LEVEL-1 SHAPE ruling as `read` and the same fate — it adds no divergent ACT, it composes two published ones so the caller does not have to hold an address to reach them (`packages/polyfill/test/cold-read-for-document.test.ts` pins the pair: on one cold state, `readForDocument` answers empty and this answers the message).
|
||||||
|
- `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.
|
- `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> **Known divergence, low impact today — `inbox.share` always deposits on the recipient's PROTECTED inbox.** Upstream the choice follows the profile through which the person was reached (`a_or_b = if details.profile.is_public() { "site" } else { "protected" }`, `engine/verifier/src/inbox_processor.rs:787`). This library has no notion of "which profile I know this person by", so it picks one. It flattens a distinction the model makes; it will be wrong the day an application shares with someone met through a public profile. Recorded rather than fixed, because the fix needs a notion nothing here has established — note that the `Identity` enum that would name it is entirely commented out upstream (`engine/repo/src/types.rs:586-595`), so there is no profile model to read yet.
|
||||||
|
|
||||||
## 10. Capabilities — possession, not ACL
|
## 10. Capabilities — possession, not ACL
|
||||||
|
|
||||||
### Today
|
### Today
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// @ng-eventually/client — nuri.ts:50,60 (type guards; the only doors from string to typed)
|
// @ng-eventually/polyfill — model/types.ts. The published cap surface is now ONE type.
|
||||||
export function isNuri(s: string): s is Nuri;
|
|
||||||
export function hasReadCap(s: string): s is ReadCap;
|
|
||||||
|
|
||||||
// @ng-eventually/client — types.ts:11,34
|
|
||||||
export type Nuri = `did:ng:${string}`;
|
export type Nuri = `did:ng:${string}`;
|
||||||
export type ReadCap = `did:ng:${string}:r:${string}`;
|
export type NuriLike = Nuri | string;
|
||||||
|
|
||||||
// @ng-eventually/client/polyfill — polyfill.ts:205
|
// NOT published, each deliberately:
|
||||||
export function capFor(nuri: Nuri): ReadCap | undefined;
|
// ReadCap — `did:ng:${string}:r:${string}`. Unpublished 2026-08-10, when
|
||||||
// polyfill.ts:193 — hands out the registry itself
|
// `export * from "./model/types"` became a named list. It remains the library's
|
||||||
export function getCaps(): CapRegistry;
|
// internal type for a cap-bearing reference, but NO published signature takes or
|
||||||
// polyfill.ts:215 — tests / fresh wallet only
|
// returns one: within `surface/inbox.ts` only two private helpers use it
|
||||||
export function resetCaps(): void;
|
// (`capsSeenIn`, `capOfPayload`), plus the emulated registers. Publishing it named
|
||||||
|
// the one value the model says must never be handed over on request (§ 0 of
|
||||||
|
// `readcap-and-nuri-model.md`) — while leaving no published call able to produce
|
||||||
|
// one, since `linkTo` was removed and `mintCap` is unreachable (§ 11). A type whose
|
||||||
|
// only possible use by a consumer is a cast is worse than no type. See § 14.
|
||||||
|
// InboxScope — unpublished the same day, same rule: its only user is
|
||||||
|
// `account-registry.userInbox(id, scope)`, which is not published (§ 12).
|
||||||
|
// isNuri / hasReadCap — the type guards (`model/nuri.ts`). Unpublished since the
|
||||||
|
// permissive-in change: every entry takes `NuriLike` and validates at the door, so
|
||||||
|
// a consumer holding a plain string narrows nothing. Publishing a guard would
|
||||||
|
// invite the cast it exists to prevent.
|
||||||
|
// hasCap(doc) — removed 2026-08-06. It read like "may I read this?", and a
|
||||||
|
// document in a public store answers `false` until something asks for its cap.
|
||||||
|
// getCaps / CapRegistry / resetCaps — the emulation's engine room and its test reset.
|
||||||
|
|
||||||
// @ng-eventually/client/polyfill — caps.ts:59 (class CapRegistry)
|
// INTERNAL — `emulated-verifier/caps.ts` (class CapRegistry). Never published; listed for the maintainer.
|
||||||
constructor(holder?: () => PrincipalId | null);
|
constructor(holder?: () => PrincipalId | null);
|
||||||
mint(nuri: Nuri): ReadCap;
|
mint(nuri: Nuri): ReadCap;
|
||||||
learn(cap: ReadCap): void;
|
learn(cap: ReadCap): void;
|
||||||
capFor(nuri: Nuri): ReadCap | undefined;
|
capFor(nuri: Nuri): ReadCap | undefined;
|
||||||
publishRepoLink(nuri: Nuri): ReadCap;
|
learnFromPublicStore(cap: ReadCap): void; // a cap the public store SERVED — read only
|
||||||
isPublished(nuri: Nuri): boolean;
|
isReadOnlyPublicCap(nuri: Nuri): boolean;
|
||||||
|
markInPublicStore(nuri: Nuri): void;
|
||||||
|
isInPublicStore(nuri: Nuri): boolean;
|
||||||
open(nuri: Nuri, scope: Scope): ReadCap;
|
open(nuri: Nuri, scope: Scope): ReadCap;
|
||||||
isEnforcing(): boolean;
|
isEnforcing(): boolean;
|
||||||
onChange(listener: () => void): () => void;
|
onChange(listener: () => void): () => void;
|
||||||
grantWrite(doc: Nuri, principal: PrincipalId): void; // decorative until P1b
|
grantWrite(doc: Nuri, principal: PrincipalId): void; // decorative until cap-enforcement
|
||||||
governsWrite(doc: Nuri): boolean; // decorative until P1b
|
governsWrite(doc: Nuri): boolean; // decorative until cap-enforcement
|
||||||
canWrite(doc: Nuri, principal: PrincipalId | null): boolean; // decorative until P1b
|
canWrite(doc: Nuri, principal: PrincipalId | null): boolean; // decorative until cap-enforcement
|
||||||
hasWritePolicy(): boolean; // decorative until P1b
|
hasWritePolicy(): boolean; // decorative until cap-enforcement
|
||||||
clear(): void;
|
clear(): void;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -429,93 +514,106 @@ clear(): void;
|
|||||||
|
|
||||||
**LEVEL-1 SHAPE.** There is no capability API at level 2 or 3 (no cap method in `index.d.ts`, none in the ORM), and there is **nothing to introspect upstream**: reading is key possession. The model, VERIFIED:
|
**LEVEL-1 SHAPE.** There is no capability API at level 2 or 3 (no cap method in `index.d.ts`, none in the ORM), and there is **nothing to introspect upstream**: reading is key possession. The model, VERIFIED:
|
||||||
|
|
||||||
- A ReadCap is the serialized `ObjectRef` — `format!("r:{}", base64_url::encode(&ser))` (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`). The lib's `ReadCap` template-literal grammar (`…:r:{cap}`) is upstream's, with the stand-in constant `OK` in place of the key material (P1b swaps the value, not the shape).
|
- A ReadCap is the serialized `ObjectRef` — `format!("r:{}", base64_url::encode(&ser))` (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`). **The `r:` segment and its encoding are upstream's**, reported by NextGraph's developer and verified in that function: id and key are serialized together into ONE opaque segment, unlike the `:k:` object/file/commit forms where they are two. The lib's `ReadCap` template-literal type uses that segment, with the stand-in constant `OK` in place of the key material.
|
||||||
|
- **"cap-enforcement swaps the value, not the shape" is a BET, and this section stated it as a fact until 2026-08-10.** What the source establishes is narrower, in three readings: (a) `readcap_nuri()` is produced as a **field value**, never concatenated onto a NURI — every call site fills `AppTabBranchInfo.readcap: Option<String>` (`engine/net/src/app_protocol.rs:1334`; `engine/verifier/src/verifier.rs:278,320`; `rocksdb_user_storage.rs:162,172`); (b) **no upstream parser accepts a repo NURI carrying `:r:`** — `NuriV0::new_from` (`app_protocol.rs:643-737`) tries `did:ng:i`, `RE_REPO_O`, `RE_FILE_READ_CAP`, `RE_REPO` and `RE_BRANCH`, and none of the regexes at `engine/net/src/types.rs:48-80` has an `r:` form; (c) the slot the type *declares* for a repo read cap is a **field** — `NuriV0.access: Vec<NgAccessV0>` with `NgAccessV0::ReadCap(ReadCap)` (`app_protocol.rs:54-62,192`) — itself constructed nowhere today (only `NgAccessV0::Key`, `:622`). Per the design principle none of that says the target will *not* parse a cap-bearing repo NURI; it says nothing parses one yet, so "the shape survives, only the value changes" is an assumption and not a passthrough. If the cap turns out to belong in a field, cap-enforcement moves it there instead of swapping a substring — a change the surface absorbs, because the value is opaque and nothing published parses it (§ 11).
|
||||||
- Caps live in two durable registers by origin: created documents → `AddRepo { read_cap }` on the store's Store branch (`engine/repo/src/types.rs:1890-1899`, committed by `doc_create` via `send_add_repo_to_store`, `engine/verifier/src/request_processor.rs:698`); received caps → `AddLink { read_cap }` on the private store's User branch (`types.rs:1939-1948`).
|
- Caps live in two durable registers by origin: created documents → `AddRepo { read_cap }` on the store's Store branch (`engine/repo/src/types.rs:1890-1899`, committed by `doc_create` via `send_add_repo_to_store`, `engine/verifier/src/request_processor.rs:698`); received caps → `AddLink { read_cap }` on the private store's User branch (`types.rs:1939-1948`).
|
||||||
- The one path that loads a repo from a cap is `pub(crate)` — `Verifier::load_repo_from_read_cap` (`engine/verifier/src/verifier.rs:2237`) — unexposed to JS.
|
- The one path that loads a repo from a cap is `pub(crate)` — `Verifier::load_repo_from_read_cap` (`engine/verifier/src/verifier.rs:2237`) — unexposed to JS.
|
||||||
|
|
||||||
`capFor(nuri)` asks the only question the model admits — "do I hold this document's key?" — and returning `undefined` is the whole possible answer. There is no "may principal P read D?" anywhere, and the future SDK cannot offer one without inventing an ACL the engine does not have. That absence is a **finding about the target's model**, not a missing feature: a consumer should never expect a cap-introspection API.
|
`capFor(nuri)` asks the only question the model admits — "do I hold this document's key?" — and returning `undefined` is the whole possible answer. There is no "may principal P read D?" anywhere, and the future SDK cannot offer one without inventing an ACL the engine does not have. That absence is a **finding about the target's model**, not a missing feature: a consumer should never expect a cap-introspection API.
|
||||||
|
|
||||||
The `CapRegistry` class itself is machinery (the in-memory record of what the connected holder holds — upstream's local user storage). The consumer-facing surface is `capFor` + the acts (`shareCap`, creating a document, processing one's inbox); see § 15.
|
The `CapRegistry` class itself is machinery (the in-memory record of what the connected holder holds — upstream's local user storage). It is not published at all: the consumer surface is the ACTS (creating a document, `inbox.share`, processing one's inbox — and, for a document in a public store, simply reading it), never a lookup; see § 15.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. NURI and SPARQL string utilities
|
## 11. NURI and SPARQL string utilities
|
||||||
|
|
||||||
### Today — `@ng-eventually/client`
|
### Today — nothing. The entry publishes **no** string utility and **no** type guard.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// sparql.ts:32,66,95
|
// NOT published — internal, and each for a stated reason:
|
||||||
export function escapeLiteral(value: string): string;
|
// surface/sparql.ts escapeLiteral, escapeIri, assertNuri
|
||||||
export function escapeIri(value: string): string;
|
// model/nuri.ts isNuri, hasReadCap, targetOf, parseNuri, toNuri
|
||||||
export function assertNuri<T extends string>(nuri: T): T;
|
// emulated-verifier/caps.ts mintCap (it lived in `model/nuri.ts` until the source
|
||||||
|
// layout was reorganised by migration fate; this list
|
||||||
|
// still said so until 2026-08-10)
|
||||||
```
|
```
|
||||||
|
|
||||||
(`isNuri` / `hasReadCap` are in § 10; `targetOf`, `parseNuri`, `mintCap` exist in `nuri.ts` but are **not** exported from either entry point — deliberately: nothing on the surface turns a bare reference into a cap.)
|
Two decisions meet here, and both point the same way.
|
||||||
|
|
||||||
|
**No guard, because the doors validate.** Every public entry takes `NuriLike` (`Nuri | string`) and runs `toNuri` itself — permissive in, precise out. A consumer holding a string from storage, a URL or a form passes it straight in; publishing a guard would invite the cast the types exist to prevent, and would put validation in the caller's hands where the door already does it.
|
||||||
|
|
||||||
|
**No `mintCap`, ever.** Nothing on the surface may turn a bare reference into a cap — that is the model's central invariant (§ 0 of `readcap-and-nuri-model.md`), so the function that could is unreachable from outside.
|
||||||
|
|
||||||
|
The escaping helpers were published until the surface was narrowed. Their removal costs a consumer nothing it will miss: they are generic injection-safety utilities, and neither `@ng-org/web` nor the ORM exposes an equivalent (re-verified against `index.d.ts` and `sdk/js/orm/src` — the engine escapes ad hoc where it builds SPARQL, e.g. `update_header`, `engine/verifier/src/request_processor.rs:196-208`). An application that interpolates SPARQL writes its own two-line escaper, against this lib or the real SDK alike.
|
||||||
|
|
||||||
### Target
|
### Target
|
||||||
|
|
||||||
**NO COUNTERPART at any level, and none expected.** Neither `@ng-org/web` nor the ORM exposes SPARQL escaping helpers (re-verified against `index.d.ts` and `sdk/js/orm/src`); the engine does its own ad-hoc literal escaping internally where it builds SPARQL (e.g. `update_header`, `engine/verifier/src/request_processor.rs:196-208`). These are generic injection-safety utilities, not SDK anticipation: they stay useful to any app that builds SPARQL by interpolation, against this lib or the real SDK. Nothing to unlearn; also nothing that migration replaces.
|
**NO COUNTERPART at any level, and none expected** — which is precisely why none of it is published: a symbol with no successor, on a surface that promises one, is the thing this document exists to catch.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Scope resolution, per-entity documents, and the store registry
|
## 12. Scope resolution, per-entity documents, and the store registry
|
||||||
|
|
||||||
### Today — `@ng-eventually/client` (namespace `storeRegistry`) — plus `Scope` from `types.ts`
|
### Today — `@ng-eventually/polyfill` (namespace `storeRegistry`) — plus `Scope` from `types.ts`
|
||||||
|
|
||||||
> **Narrowed 2026-08-03.** The entry used to re-export the WHOLE `store-registry` module. It now re-exports an app-facing slice (`src/store-registry-api.ts`): `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `walletInbox`, `openDocumentInbox`, `documentInboxAddress`. The rest — `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `resolveAccount`, `ensureAccount`, `reservedAccount`, `resetRegistryCache`, and the `AccountRecord` / `RegistrySession` types — is **no longer importable from `@ng-eventually/client`** and is covered by `docs/internal-contract.md`. The signatures below are kept for the record, marked accordingly.
|
> **Narrowed twice.** 2026-08-03 the entry stopped re-exporting the whole `store-registry` module and kept an app-facing slice (`src/surface/placement.ts`). 2026-08-05 that slice lost its two inbox-ADDRESS functions as well: an application deposits with `inbox.postToDocument(doc, …)` and shares with `inbox.share(doc, toUser)` — always naming a document or a person, never an address, because upstream an address is resolved from a profile and never handled by a caller. **Five functions remain published**, listed first below; everything after them is kept for the record and is covered by `docs/internal-contract.md`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// types.ts:38 — NB: NOT the ORM's Scope (a graphs/subjects filter); this is the store scope
|
// types.ts:38 — NB: NOT the ORM's Scope (a graphs/subjects filter); this is the store scope
|
||||||
export type Scope = "public" | "protected" | "private";
|
export type Scope = "public" | "protected" | "private";
|
||||||
|
|
||||||
// store-registry.ts:90,234
|
// store-registry.ts:90,234
|
||||||
export interface AccountRecord {
|
// VirtualUserRecord is INTERNAL (shape kept here for the ruling below).
|
||||||
|
interface VirtualUserRecord {
|
||||||
id: string;
|
id: string;
|
||||||
docPublic: Nuri;
|
docPublic: Nuri;
|
||||||
docProtected: Nuri;
|
docProtected: Nuri;
|
||||||
docPrivate: Nuri;
|
docPrivate: Nuri;
|
||||||
}
|
}
|
||||||
export interface RegistrySession {
|
// RegistrySession is INTERNAL since 2026-08-12 (shape kept here for the ruling below).
|
||||||
sessionId: string;
|
interface RegistrySession {
|
||||||
|
sessionId: string | number; // relayed untouched — upstream's own type (§ 7)
|
||||||
privateStoreId: string;
|
privateStoreId: string;
|
||||||
protectedStoreId?: string;
|
protectedStoreId?: string;
|
||||||
publicStoreId?: string;
|
publicStoreId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// consumer-facing, designed to survive migration (store-registry.ts:917, 1358, 1079, 735, 698)
|
// PUBLISHED — the whole `storeRegistry` namespace, and nothing else.
|
||||||
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri>;
|
// NO identity parameter, since 2026-08-10: a session belongs to one user, and the
|
||||||
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]>;
|
// target's own `doc_create(session_id, …)` carries no user at all. Passing one's own
|
||||||
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>;
|
// identity to every placement call was a gesture with no successor — and it forced an
|
||||||
|
// application to KNOW its identity, which it could only do by reading the access gate's
|
||||||
|
// private storage key. `ensureIdentity()` returns it now; these take it from the session.
|
||||||
|
export async function createEntityDoc(scope: Scope): Promise<Nuri>;
|
||||||
|
export async function listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
|
||||||
export async function resolveScopeGraph(scope: Scope): Promise<Nuri>;
|
export async function resolveScopeGraph(scope: Scope): Promise<Nuri>;
|
||||||
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>;
|
export async function resolveWriteGraph(scope: Scope): Promise<Nuri>;
|
||||||
|
export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
|
||||||
|
|
||||||
// inbox-side (store-registry.ts:772, 837, 1133, 1203, 1286)
|
// NOT published — internal, kept here because the target rulings below still cover them.
|
||||||
export async function walletInbox(id: string): Promise<Nuri>;
|
// userStoreDoc, userInbox, documentInboxAddress, isOwnInbox, myInboxes,
|
||||||
export async function isOwnInbox(nuri: Nuri): Promise<boolean>;
|
// addLink, readLinks, resolveAccount, ensureAccount, reservedAccount,
|
||||||
export async function openDocumentInbox(doc: Nuri): Promise<Nuri>;
|
// resetRegistryCache, and the VirtualUserRecord type.
|
||||||
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>;
|
// `RegistrySession` joined them on 2026-08-12: it was published for ONE reason — a consumer
|
||||||
export async function myInboxes(): Promise<Nuri[]>;
|
// typed the session thunk it injected with it — and that thunk is gone (§ 1). Upstream a
|
||||||
|
// session is RETURNED, never assembled, so no application has a session shape to declare.
|
||||||
// User-branch registers (store-registry.ts:1307, 1331)
|
|
||||||
export async function addLink(cap: ReadCap): Promise<void>;
|
|
||||||
export async function readLinks(): Promise<ReadCap[]>;
|
|
||||||
|
|
||||||
// shim machinery (store-registry.ts:542, 631, 213, 278)
|
|
||||||
export async function resolveAccount(id: string): Promise<AccountRecord | null>;
|
|
||||||
export async function ensureAccount(id: string): Promise<AccountRecord>;
|
|
||||||
export function reservedAccount(name: string): string;
|
|
||||||
export function resetRegistryCache(): void;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`listMyEntityDocs` answers a VERIFIED listing, or it rejects — since 2026-08-17.** It stands on two reads of the store document, and both propagate: the Main branch says which documents are in there, the Store branch says what opens each. Handing back the listing over a key read that never answered was tolerated until then, on the ground that the array was already in hand by that point — which is exactly what made it a half-truth rather than a shortcut, since nothing distinguishes a listing you can open from one you cannot. It is the same `Nuri[]`; the difference shows at the next read, empty, with the cause long gone. It is the ruling the connection path already runs on, applied to the last call that escaped it: **a rejection means "unknown", never "absent"**, and only "there was nothing to do" resolves quietly — so an empty array here means this account created no document in that scope, and never that the store went unread.
|
||||||
|
|
||||||
|
**`openDocumentInbox` coalesces concurrent asks — and one JS realm is the honest extent of it, since 2026-08-17.** The call reads the User branch to find out whether an inbox is already recorded and mints when the answer is no, with a dozen awaits between the two. Callers that arrive together therefore all read before any of them writes: each read ANSWERS, each answer is honestly "none", and each mints. Nothing fails, which is what sets this apart from the read-that-could-not-answer family swept through this document — no rejection-means-unknown ruling touches it, only coalescing does. Reported from an application: four simultaneous calls on one document registered three inboxes, after which the owner drained one while deposits arrived in another. Asks for the same `(holder, document)` now share one call.
|
||||||
|
|
||||||
|
**What that does NOT reach, and why it cannot be fixed here.** Two tabs share no in-flight map, so the durable fork survives — and it is not a matter of doing more work. Preventing it needs a conditional write ("record only if absent"), which no level of the target offers: a branch is an add-only CRDT, so two `AddInboxCap` records merge rather than one being refused. Reconciling it afterwards the way `canonicalDoc` reconciles a forked account pointer does not work either, because the two sides read different records — the owner resolves from `AddInboxCap` on its User branch, a depositor from the address published on the document's Header branch, and the latter is written DELETE-then-INSERT, so it is last-write-wins and need not name the same one. Making both sides agree would mean letting a document accumulate two addresses to pick a canonical one from, and that is a state the target model has no meaning for: `inboxes: PubKey → RepoId` is a function (`engine/verifier/src/verifier.rs:105`) and `repo.inbox` a single `Option<PrivKey>`. So the gap is stated in the app contract as a non-guarantee rather than papered over. At migration it closes on its own — upstream an inbox is a keypair created WITH the repo, not a document minted on demand, so there is no read-then-write to race.
|
||||||
|
|
||||||
### Target — split by what each piece maps to
|
### Target — split by what each piece maps to
|
||||||
|
|
||||||
- **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter disappears (the session IS the identity); expect `createEntityDoc(id, scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping.
|
- **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter is already gone from the published call (2026-08-10); expect `createEntityDoc(scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping.
|
||||||
- **`listMyEntityDocs(id, scope)` → level 1/2, VERIFIED mechanism.** Upstream the listing is the store's `ldp:contains` graph (written at `request_processor.rs:706-708`), readable with an anchored `sparql_query` on the store; the caps come back by replaying the Store branch (`AddRepo::verify` → `load_repo_from_read_cap`). The function's shape (give me my per-scope doc NURIs) survives; its implementation becomes one native read.
|
- **`listMyEntityDocs(id, scope)` → level 1/2, VERIFIED mechanism.** Upstream the listing is the store's `ldp:contains` graph (written at `request_processor.rs:706-708`), readable with an anchored `sparql_query` on the store; the caps come back by replaying the Store branch (`AddRepo::verify` → `load_repo_from_read_cap`). The function's shape (give me my per-scope doc NURIs) survives; its implementation becomes one native read.
|
||||||
- **`userStoreDoc(id, scope)` / `resolveScopeGraph(scope)` / `resolveWriteGraph(id, scope)` → level 2, VERIFIED.** The target answers these from the session: `did:ng:` + `session.private_store_id | protected_store_id | public_store_id` (`Session`, `index.d.ts:264-272`). The store IS the container; the per-scope index document disappears.
|
- **`userStoreDoc(id, scope)` / `resolveScopeGraph(scope)` / `resolveWriteGraph(id, scope)` → level 2, VERIFIED.** The target answers these from the session: `did:ng:` + `session.private_store_id | protected_store_id | public_store_id` (`Session`, `sdk/js/web/src/index.ts:Session`, installed `index.d.ts:265-273`). The store IS the container; the per-scope index document disappears.
|
||||||
- **`walletInbox(id)` → level 1, VERIFIED counterpart with a different granularity.** Upstream a user's inboxes are their public and protected STORE repos' inboxes — the only two `AddInboxCap` commits in the engine (`engine/verifier/src/site.rs:128,149`). An identity-level "my inbox" therefore maps to a store inbox; the resolution moves into the lib/SDK and the consumer's act (deposit to an address, process my own) is unchanged.
|
- **`userInbox(id)` → level 1, VERIFIED counterpart with a different granularity.** Upstream a user's inboxes are their public and protected STORE repos' inboxes — the only two `AddInboxCap` commits in the engine (`engine/verifier/src/site.rs:128,149`). An identity-level "my inbox" therefore maps to a store inbox; the resolution moves into the lib/SDK and the consumer's act (deposit to an address, process my own) is unchanged.
|
||||||
- **`openDocumentInbox(doc)` / `documentInboxAddress(doc)` → level 1, VERIFIED support, no exerciser.** Every `Repo` carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); `AddInboxCapV0` is keyed by `repo_id` with no is-store restriction (`engine/repo/src/types.rs:1973`; applied at `engine/verifier/src/verifier.rs:1920-1928`); but no code path creates one for a plain document (`doc_create` leaves `inbox: None`, `repo.rs:574`) and no level-2/3 API exposes any of it. So: the *capability* is engine-verified; the *functions* are invented surface; and the **address publication is a real, deliberate divergence** (upstream transmits addresses, never publishes them — § 9), with the ownership guard compensating our design, not mirroring an upstream rule.
|
- **`openDocumentInbox(doc)` / `documentInboxAddress(doc)` → level 1, VERIFIED support, no exerciser.** Every `Repo` carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); `AddInboxCapV0` is keyed by `repo_id` with no is-store restriction (`engine/repo/src/types.rs:1973`; applied at `engine/verifier/src/verifier.rs:1920-1928`); but no code path creates one for a plain document (`doc_create` → `new_repo_default` → `Store::create_repo_default` → `create_repo_with_keys`, which builds the `Repo` with `inbox: None` — `engine/verifier/src/verifier.rs:3004`, `engine/repo/src/store.rs:264,284,691`) and no level-2/3 API exposes any of it. So: the *capability* is engine-verified; the *functions* are invented surface; and the **address publication is a real, deliberate divergence** (upstream transmits addresses, never publishes them — § 9), with the ownership guard compensating our design, not mirroring an upstream rule.
|
||||||
- **`addLink(cap)` / `readLinks()` → level 1, VERIFIED model, no JS surface.** The emulated `AddLink { read_cap }` register (`engine/repo/src/types.rs:1939-1948` — *"so that a user can share with all its device a new Link they received"*, external repos only). Upstream this filing happens inside the verifier when it processes the inbox; the future SDK most likely never exposes these as calls, so consumers should not code against them (§ 15).
|
- **`addLink(cap)` / `readLinks()` → annotated `declared-not-wired` (level 1).** The emulated `AddLink { read_cap }` register (`engine/repo/src/types.rs:AddLinkV0` — *"so that a user can share with all its device a new Link they received"*, external repos only). **Verified 2026-08-16, and this is the load-bearing fact:** the type is DEFINED, its `CommitVerifier::verify` is a no-op `Ok(())` (`engine/verifier/src/commits/mod.rs`), and **nothing in the engine constructs one** — the only other occurrences are the `CommitBodyV0::AddLink` enum variant and two match arms classifying body kinds. Its sibling `AddRepoV0` *is* constructed (`Verifier::send_add_repo_to_store`), which is why the Store-branch register next door is annotated `aligned` and this one is not. A maintainer read that absence and concluded the function was this library's own invention; it is not — an absent implementation says nothing about the target. Upstream this filing happens inside the verifier when it processes the inbox; the future SDK most likely never exposes these as calls, so consumers should not code against them (§ 15).
|
||||||
- **`resolveAccount` / `ensureAccount` / `AccountRecord` / `RegistrySession` / `reservedAccount` / `resetRegistryCache` → NO COUNTERPART.** The shared-wallet shim (accounts directory, pointer → doc-shim indirection) has no image in the target — the target has no central directory of identities (`docs/migration-guide.md` § 3). The whole group disappears with the shim.
|
- **`resolveAccount` / `ensureAccount` / `VirtualUserRecord` / `RegistrySession` / `reservedAccount` / `resetRegistryCache` → NO COUNTERPART.** The shared-wallet shim (accounts directory, pointer → doc-shim indirection) has no image in the target — the target has no central directory of identities (`docs/migration-guide.md` § 3). The whole group disappears with the shim.
|
||||||
- **`isOwnInbox` / `myInboxes` → NO COUNTERPART as API.** Upstream the question "which inboxes may I read" is answered inside the verifier by the User branch's `AddInboxCap` records; nothing suggests a JS API for it. These exist for the emulated read guard and the connection drain.
|
- **`isOwnInbox` / `myInboxes` → NO COUNTERPART as API.** Upstream the question "which inboxes may I read" is answered inside the verifier by the User branch's `AddInboxCap` records; nothing suggests a JS API for it. These exist for the emulated read guard and the connection drain.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -525,25 +623,19 @@ export function resetRegistryCache(): void;
|
|||||||
### Today
|
### Today
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// @ng-eventually/client — accounts.ts (namespace accounts)
|
// PUBLISHED: nothing. Identity is established by `ensureIdentity()` (§ 2bis) and the
|
||||||
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id"; // :18
|
// connection is awaited inside it.
|
||||||
export interface AccountStorage { // :26
|
//
|
||||||
getItem(key: string): string | null;
|
// NOT published, and each removal is a gesture an application no longer performs:
|
||||||
setItem(key: string, value: string): void;
|
// setCurrentUser (2026-08-07) naming one's own identity — the step that inverts the
|
||||||
removeItem(key: string): void;
|
// model. The gate does it; the e2e harness, which plays
|
||||||
}
|
// several identities on one page, reaches it internally.
|
||||||
export class IdentityStore { // :37
|
// connectedUser (2026-08-07) awaited inside `ensureIdentity`; upstream, opening the
|
||||||
constructor(storage: AccountStorage | null, key?: string);
|
// session IS the connection.
|
||||||
get(): string | null;
|
// getCurrentUser (2026-08-05) an application knows who it signed in.
|
||||||
set(id: string): string | null;
|
// IdentityStore, browserIdentityStore, VirtualUserStorage, ACCOUNT_STORAGE_KEY
|
||||||
clear(): void;
|
// (2026-08-05) persisting an identity is the application's job
|
||||||
}
|
// upstream too; the gate persists what IT needs.
|
||||||
export function browserIdentityStore(key?: string): IdentityStore; // :89
|
|
||||||
|
|
||||||
// @ng-eventually/client/polyfill
|
|
||||||
export function setCurrentUser(id: PrincipalId | null): void; // polyfill.ts:171
|
|
||||||
export function getCurrentUser(): PrincipalId | null; // polyfill.ts:187
|
|
||||||
export async function connectedUser(): Promise<void>; // connect.ts:52
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Target
|
### Target
|
||||||
@@ -558,15 +650,15 @@ declare function user_connect(client_info: any, user_id: string, location?: stri
|
|||||||
declare function user_disconnect(user_id: string): Promise<void>;
|
declare function user_disconnect(user_id: string): Promise<void>;
|
||||||
```
|
```
|
||||||
|
|
||||||
- `accounts.*` (the persisted identity id) — **NO COUNTERPART**; removed at migration (`docs/migration-guide.md` § 5). It exists only because every virtual user shares one wallet. It is exported from the SDK entry, which is a placement wart (§ 15).
|
- `IdentityStore` / `browserIdentityStore` (the persisted identity id) — **NO COUNTERPART**; they exist only because every virtual user shares one wallet, and they are no longer published at all. Removed at migration (`docs/migration-guide.md` § 5).
|
||||||
- `setCurrentUser` / `getCurrentUser` — **NO COUNTERPART**; the relay of an identity the broker cannot see. Disappears with the shared wallet.
|
- `setCurrentUser` — **NO COUNTERPART**; the relay of an identity the broker cannot see. Disappears with the shared wallet, and is no longer published: the gate is the only caller an application needs.
|
||||||
- `connectedUser()` — the awaitable form of what the target does **automatically**: the recipient's verifier processes its inbox as messages arrive/at connection (`Verifier::inbox`, `engine/verifier/src/verifier.rs:1674`). VERIFIED at level 1 that no consumer call is needed upstream; the polyfill fires it from `setCurrentUser` for the same reason. A consumer should treat it as "await a deterministic start" (tests), not as an operation the future SDK will name.
|
- `connectedUser()` (internal since 2026-08-07) — the awaitable form of what the target does **automatically**: the recipient's verifier processes its inbox as messages arrive/at connection (`Verifier::inbox`, `engine/verifier/src/verifier.rs:1674`). VERIFIED at level 1 that no consumer call is needed upstream; the polyfill fires it from `setCurrentUser` for the same reason. A consumer should treat it as "await a deterministic start" (tests), not as an operation the future SDK will name.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Type re-exports
|
## 14. Type re-exports
|
||||||
|
|
||||||
`@ng-eventually/client` re-exports, type-only (erased at build, `index.ts:48-50`):
|
`@ng-eventually/polyfill` re-exports, type-only (erased at build, `src/index.ts`):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
|
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
|
||||||
@@ -576,27 +668,47 @@ export type { NG } from "@ng-org/web";
|
|||||||
|
|
||||||
**PASSTHROUGH (levels 2/3, VERIFIED)** — `ShapeType`/`BaseType` at `@ng-org/shex-orm` `dist/types.d.ts:5,12` (installed 0.1.2-alpha.8); `NG` at `index.d.ts:136`. At migration these imports point at the same packages directly; nothing changes for the consumer.
|
**PASSTHROUGH (levels 2/3, VERIFIED)** — `ShapeType`/`BaseType` at `@ng-org/shex-orm` `dist/types.d.ts:5,12` (installed 0.1.2-alpha.8); `NG` at `index.d.ts:136`. At migration these imports point at the same packages directly; nothing changes for the consumer.
|
||||||
|
|
||||||
|
### The library's own model types — published by NAME since 2026-08-10
|
||||||
|
|
||||||
|
The entry used to say `export * from "./model/types"`, a blanket re-export publishing eight types in one gesture. It now names them, under one rule:
|
||||||
|
|
||||||
|
> **A type is published only if a PUBLISHED SIGNATURE uses it.**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type { Nuri, NuriLike, Scope, PrincipalId, NgLike, UseShapeLike } from "./model/types";
|
||||||
|
```
|
||||||
|
|
||||||
|
Each one's warrant: `Nuri` is what every reference-returning call returns and `NuriLike` what every entry accepts (§ 10, § 11); `Scope` types `storeRegistry.*` and `watchShape` (§ 12, § 5); `PrincipalId` is `ensureIdentity`'s return and a field of `Deposit`, `PostOptions` and `EventuallyConfig` (§ 2bis, § 9, § 1); `NgLike` and `UseShapeLike` type the two injected objects in `EventuallyConfig` (§ 1).
|
||||||
|
|
||||||
|
Two types the blanket export published are now internal, each because **nothing published names it**:
|
||||||
|
|
||||||
|
- **`ReadCap`** — no published signature takes or returns one. Its users are two private helpers of `surface/inbox.ts` (`capsSeenIn`, `capOfPayload`) and the emulated registers. Publishing it advertised a value a consumer has no published call to obtain, and deliberately so: `linkTo` was removed precisely for handing one out (§ 0 of `readcap-and-nuri-model.md`), and `mintCap` is unreachable from outside (§ 11). The only use a consumer could make of it is a cast — which is what the surface's permissive-in / precise-out design exists to make unnecessary.
|
||||||
|
- **`InboxScope`** — used only by `account-registry.userInbox(id, scope)`, unpublished since 2026-08-05 (§ 12). An application never handles an inbox address, so it never names an inbox scope.
|
||||||
|
|
||||||
|
Both remain **defined** in `model/types.ts` and are used throughout the library; only their publication changed. Nothing about the target motivates either removal — this is a statement about *this* surface, and the same test that pins the appendix pins it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. Machinery on the surface — what a consumer should NOT code against
|
## 15. Machinery on the surface — what a consumer should NOT code against
|
||||||
|
|
||||||
Exported, but not SDK surface. Coding against these builds knowledge that migration deletes:
|
Exported, but not SDK surface. Coding against these builds knowledge that migration deletes:
|
||||||
|
|
||||||
- **`docs.depositInto`** — the named boundary-crossing write `inbox.post` uses. It is exported only because `inbox.ts` lives in another module; a consumer must always go through `inbox.post` / `inbox.shareCap`. Upstream a deposit is a sealed message, not a SPARQL update — this function's very signature is emulation.
|
- ~~**`docs.depositInto`**~~ **FIXED 2026-08-07.** It was published "only because `inbox.ts` lives in another module", with the note that a consumer must always go through `inbox.post`. That note is not a mechanism: an adversarial review drove through it — bare reference to a public document, rewrite its posted inbox address, divert its owner's deposits. It now lives in `emulated-verifier/register-write.ts`, which nothing exports.
|
||||||
- **`getConfig` / `getStoreRegistryDeps`** — tagged `@internal` in source, exported for the lib's own wrappers.
|
- **`getConfig` / `getStoreRegistryDeps`** — tagged `@internal` in source, exported for the lib's own wrappers.
|
||||||
- **`resetConfig` / `resetStoreRegistry` / `resetCaps` / `storeRegistry.resetRegistryCache`** — test/reset machinery. In particular `resetCaps` wipes EVERY holder's caps, which no product flow should ever do.
|
- **`resetConfig` / `resetStoreRegistry` / `resetCaps` / `storeRegistry.resetRegistryCache`** — test/reset machinery. In particular `resetCaps` wipes EVERY holder's caps, which no product flow should ever do.
|
||||||
- **`getCaps()` and the `CapRegistry` class** — the registry is the emulation's engine room. The consumer surface is `capFor` (possession lookup), `inbox.shareCap` (grant), and the acts that file caps implicitly (creating a document, processing one's inbox). `CapRegistry.grantWrite` / `governsWrite` / `canWrite` / `hasWritePolicy` are explicitly decorative until P1b — the guard they feed is bypassed by every internal writer.
|
- **`getCaps()` and the `CapRegistry` class** — the registry is the emulation's engine room. The consumer surface is the acts that file caps: creating a document, `inbox.share` (grant), processing one's inbox, and reading a document a public store serves. `CapRegistry.grantWrite` / `governsWrite` / `canWrite` / `hasWritePolicy` are explicitly decorative until cap-enforcement — the guard they feed is bypassed by every internal writer.
|
||||||
- ~~**`storeRegistry.reservedAccount`, `resolveAccount`, `ensureAccount`, `AccountRecord`, `RegistrySession`**~~ — **RESOLVED 2026-08-03**: no longer exported. Shim internals, now in `docs/internal-contract.md`. The consumer's legitimate touchpoint is `configureStoreRegistry` (bootstrap) plus the scope/entity resolvers.
|
- ~~**`storeRegistry.reservedAccount`, `resolveAccount`, `ensureAccount`, `VirtualUserRecord`, `RegistrySession`**~~ — **RESOLVED 2026-08-03**: no longer exported. Shim internals, now in `docs/internal-contract.md`. The consumer's legitimate touchpoint is `configureStoreRegistry` (bootstrap) plus the scope/entity resolvers.
|
||||||
- ~~**`storeRegistry.addLink` / `readLinks`**~~ — **RESOLVED 2026-08-03**: no longer exported. Consumers receive caps by processing their inbox (automated at connection); calling these directly baked in a register the verifier owns upstream.
|
- ~~**`storeRegistry.addLink` / `readLinks`**~~ — **RESOLVED 2026-08-03**: no longer exported. Consumers receive caps by processing their inbox (automated at connection); calling these directly baked in a register the verifier owns upstream.
|
||||||
- ~~**`accounts.*` on the SDK entry**~~ — **RESOLVED 2026-08-03**: moved to `/polyfill`, where its disappearance at migration is visible at the import line.
|
- ~~**`virtualUsers.*` on the SDK entry**~~ — **RESOLVED 2026-08-03**: moved to `/polyfill`, where its disappearance at migration is visible at the import line.
|
||||||
- **`inbox.watch`'s `_opts?: { intervalMs?: number }`** — accepted and ignored (no polling exists). Dead compatibility surface; do not pass it.
|
- **`inbox.watch`'s `_opts?: { intervalMs?: number }`** — accepted and ignored (no polling exists). Dead compatibility surface; do not pass it.
|
||||||
- **The `label` parameters** on `docs.sparqlUpdate` / `docs.sparqlQuery` / `docs.depositInto` — lib-internal access-log tags, never forwarded to `ng`. The real signatures have no such parameter.
|
- **The `label` parameters** on `docs.sparqlUpdate` / `docs.sparqlQuery` — lib-internal access-log tags, never forwarded to `ng`. The real signatures have no such parameter.
|
||||||
|
|
||||||
### Places the current surface teaches something to unlearn
|
### Places the current surface teaches something to unlearn
|
||||||
|
|
||||||
- ~~**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 `accounts` 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`, `readModel`, `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".
|
- ~~**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".
|
||||||
- **`shareCap` is importable from both entries** (`inbox.shareCap` on the SDK entry via `export * as inbox`, and a named re-export on `/polyfill`). The polyfill re-export exists "so the cap vocabulary stays on the polyfill side" — but the namespace export undoes that. Harmless functionally; blurs the same boundary.
|
- ~~**`share` is importable from both entries.**~~ **FIXED 2026-08-07** with the entry merge: there is one entry and one `share`, under `inbox`.
|
||||||
- **`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.
|
- **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` 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.
|
- **`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).
|
- **`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.
|
- **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.
|
||||||
@@ -605,8 +717,26 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
|
|||||||
|
|
||||||
## Appendix — full export inventory (for diffing)
|
## Appendix — full export inventory (for diffing)
|
||||||
|
|
||||||
`@ng-eventually/client` (from `index.ts`): types `Nuri`, `ReadCap`, `Scope`, `PrincipalId`, `NgLike`, `UseShapeLike`, `ShapeQuery`, `ShapeObservable`, `DocChange`, `DocChangeType`, `Unsubscribe`, `UnionSubject`, `AccountRecord`, `RegistrySession`, `AccountStorage`, `Deposit`, `PostOptions` (via namespaces), re-exported `ShapeType`, `BaseType`, `Schema`, `DeepSignalSet`, `NG`; values `ng`, `useShape`, `watchShape`, `init`, `initNg`, `subscribeDoc`, `subscribeDocs`, `docChangeType`, `escapeLiteral`, `escapeIri`, `assertNuri`, `isNuri`, `hasReadCap`; namespaces `inbox` (`post`, `postToDocument`, `shareCap`, `read`, `materialize`, `readSynced`, `processInbox`, `watch`), `docs` (`docCreate`, `sparqlUpdate`, `sparqlQuery`, `depositInto`), `readModel` (`readUnion`), `storeRegistry` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `resolveWriteGraph`, `resolveScopeGraph`, `walletInbox`, `isOwnInbox`, `createEntityDoc`, `userStoreDoc`, `openDocumentInbox`, `documentInboxAddress`, `myInboxes`, `addLink`, `readLinks`, `listMyEntityDocs`), `accounts` (`ACCOUNT_STORAGE_KEY`, `IdentityStore`, `browserIdentityStore`).
|
*Generated from the `export` statements, and pinned by `packages/polyfill/test/vocabulary.test.ts` — if this list and the code disagree, that test fails. It went stale once, still listing `storeRegistry`'s shim internals after the entry had been narrowed, which is what a hand-maintained inventory does.*
|
||||||
|
|
||||||
`@ng-eventually/client/polyfill` (from `polyfill.ts`): types `StoreRegistryDeps`, `EventuallyConfig`; values `configure`, `getConfig`, `resetConfig`, `configureStoreRegistry`, `getStoreRegistryDeps`, `resetStoreRegistry`, `setCurrentUser`, `getCurrentUser`, `getCaps`, `capFor`, `resetCaps`, `CapRegistry`, `shareCap`, `connectedUser`.
|
### `@ng-eventually/polyfill` — `src/index.ts` (the only entry since 2026-08-07)
|
||||||
|
|
||||||
Not exported from either entry (internal, listed to preempt "why isn't X documented"): `nuri.targetOf` / `parseNuri` / `mintCap`, `subscribePhysicalDoc`, `machinery.*`, `open-repo.*`, `read-filter.*`, `reach.*`, `physical.*`, `access-log.*`, `outbox-log.*`, `connect.startConnect`.
|
```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, post, postToDocument, processInbox, read, readForDocument, readSynced, readSyncedForDocument, share, watch
|
||||||
|
storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
|
||||||
|
```
|
||||||
|
|
||||||
|
**Of these, exactly ONE is polyfill-era with no target counterpart** — `configure` (plus
|
||||||
|
the type `EventuallyConfig`; `RegistrySession` left the surface on 2026-08-12 with the
|
||||||
|
session thunk that was its only reason to be there). It is the deletion list, and
|
||||||
|
`src/index.ts` groups it under a heading that says so. `ensureIdentity` is a second in
|
||||||
|
substance — the shared-wallet gate — but its *call site* survives (§ 2bis).
|
||||||
|
|
||||||
|
Nine symbols published before 2026-08-07 are gone from the surface: `configureStoreRegistry`
|
||||||
|
and `StoreRegistryDeps` (folded into `configure`), `setCurrentUser` and `connectedUser`
|
||||||
|
(§ 1), `getConfig` / `getStoreRegistryDeps` (internal wiring), `resetConfig` /
|
||||||
|
`resetStoreRegistry` / `resetCaps` (test resets), and the direct `share` re-export —
|
||||||
|
`inbox.share` was always the same function, and publishing it twice blurred the boundary
|
||||||
|
it was meant to mark.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
`caps.ts` emulates read rights as an **ACL** (`Map<Nuri, Set<PrincipalId>>`, `grantRead(doc, grantee)`) — **the inversion** of the real NextGraph model (key possession). Consequences: no notion of a **cap-less reference**, grant/revocation **instantaneous and total** (instead of durable sealing + re-key), and an API (`declareConnections`) that consumers have to **re-declare every session**. This divergence makes it impossible to properly build models that rest on the real semantics — in particular **anonymous presence** (naming/counting without reading).
|
`emulated-verifier/caps.ts` emulates read rights as an **ACL** (`Map<Nuri, Set<PrincipalId>>`, `grantRead(doc, grantee)`) — **the inversion** of the real NextGraph model (key possession). Consequences: no notion of a **cap-less reference**, grant/revocation **instantaneous and total** (instead of durable sealing + re-key), and an API (`declareConnections`) that consumers have to **re-declare every session**. This divergence makes it impossible to properly build models that rest on the real semantics — in particular **anonymous presence** (naming/counting without reading).
|
||||||
|
|
||||||
## Objective: shape-fidelity, NOT security
|
## Objective: shape-fidelity, NOT security
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ Erroneous content kept below as a record:
|
|||||||
|
|
||||||
**Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md), which records what landed where.**
|
**Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md), which records what landed where.**
|
||||||
|
|
||||||
The ACL inversion — *the central defect this whole chantier exists to fix* — is gone: `caps.ts` is a keyring, sharing is a per-document delivery to an inbox, and a bare reference reads nothing. **P1b is now the blocker for any privacy claim**: the emulated key is derived (hence reproducible) and the bypass inventory below is untouched.
|
The ACL inversion — *the central defect this whole chantier exists to fix* — is gone: `emulated-verifier/caps.ts` is a keyring, sharing is a per-document delivery to an inbox, and a bare reference reads nothing. **P1b is now the blocker for any privacy claim**: the emulated key is derived (hence reproducible) and the bypass inventory below is untouched.
|
||||||
|
|
||||||
In two lines: a single new type (`ReadCap`), a keyring (`capFor`), a per-document share to an inbox (`shareCap`) — and nothing else. The branded types, `resolveCapLess`, `receivedCaps`, `refOf`, `parseNuri` and `PrincipalId` were **discarded** after a double adversarial review; the reasons are in that note.
|
In two lines: a single new type (`ReadCap`), a keyring (`capFor`), a per-document share to an inbox (`shareCap`) — and nothing else. The branded types, `resolveCapLess`, `receivedCaps`, `refOf`, `parseNuri` and `PrincipalId` were **discarded** after a double adversarial review; the reasons are in that note.
|
||||||
|
|
||||||
@@ -142,4 +142,4 @@ An adversary refuted the brief (7 findings — the 7th marked *(Plausible)*). **
|
|||||||
|
|
||||||
**Consequence**: add **P0 (keyless-fetch spike)** up front and a **distinct WriteCap track**; requalify P2 (the real content = durability + cap-less + re-sharing, not "inverting the ACL"); record that **without crypto, read privacy is not applicable** (choose: real crypto vs masked projection).
|
**Consequence**: add **P0 (keyless-fetch spike)** up front and a **distinct WriteCap track**; requalify P2 (the real content = durability + cap-less + re-sharing, not "inverting the ACL"); record that **without crypto, read privacy is not applicable** (choose: real crypto vs masked projection).
|
||||||
|
|
||||||
Links: `readcap-and-nuri-model.md`, `packages/client/src/caps.ts`. On the consumer side, the Festipod brief "realign the sign-ups" depends on this effort.
|
Links: `readcap-and-nuri-model.md`, `packages/sdk/src/emulated-verifier/caps.ts`. On the consumer side, the Festipod brief "realign the sign-ups" depends on this effort.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ The spec below is unchanged — read it first. Everything from here to *Why this
|
|||||||
> **Superseded in places by later lots — read with [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md).** This report was accurate on 2026-07-28 and is kept as written; four of its statements have since been overtaken, and a fifth was wrong when written:
|
> **Superseded in places by later lots — read with [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md).** This report was accurate on 2026-07-28 and is kept as written; four of its statements have since been overtaken, and a fifth was wrong when written:
|
||||||
>
|
>
|
||||||
> - *"ReadCaps are NOT persisted as caps anywhere. There is no key store"* — **no longer true.** Both durable registers are now emulated: `shim:readCap` on the store's Store branch (`AddRepo`) and `shim:link` on its User branch (`AddLink`). Caps are read back, not recomputed.
|
> - *"ReadCaps are NOT persisted as caps anywhere. There is no key store"* — **no longer true.** Both durable registers are now emulated: `shim:readCap` on the store's Store branch (`AddRepo`) and `shim:link` on its User branch (`AddLink`). Caps are read back, not recomputed.
|
||||||
> - *"Processing inboxes … Not started"* — **done** (`src/connect.ts`), at both levels, including per-document inboxes.
|
> - *"Processing inboxes … Not started"* — **done** (`src/emulated-verifier/connect.ts`), at both levels, including per-document inboxes.
|
||||||
> - *"`Nuri` and `ReadCap` are plain strings"* — **superseded the same week**: they are template literal types, so the confusion the runtime guard catches is now also a compile error. The *Typing* section below records the change; the earlier sentences were not rewritten.
|
> - *"`Nuri` and `ReadCap` are plain strings"* — **superseded the same week**: they are template literal types, so the confusion the runtime guard catches is now also a compile error. The *Typing* section below records the change; the earlier sentences were not rewritten.
|
||||||
> - The `:k:` segment throughout — **a ReadCap is `r:`** (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`), reported by NextGraph's developer and verified. `:k:` belongs to objects, files and commits.
|
> - The `:k:` segment throughout — **a ReadCap is `r:`** (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`), reported by NextGraph's developer and verified. `:k:` belongs to objects, files and commits.
|
||||||
> - *"That branch lists the store's documents… It is the owner's keyring. Upstream, the keyring is the wallet"* — **wrong when written**, and it is the sentence that produced a global in-memory "keyring". There is no keyring object; the wallet holds one root key per user. See [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quater. The word *keyring* is left standing everywhere below because this report is kept as written; read it as *"what the holder holds"*, which is what the code now calls it.
|
> - *"That branch lists the store's documents… It is the owner's keyring. Upstream, the keyring is the wallet"* — **wrong when written**, and it is the sentence that produced a global in-memory "keyring". There is no keyring object; the wallet holds one root key per user. See [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quater. The word *keyring* is left standing everywhere below because this report is kept as written; read it as *"what the holder holds"*, which is what the code now calls it.
|
||||||
@@ -22,13 +22,13 @@ The spec below is unchanged — read it first. Everything from here to *Why this
|
|||||||
|
|
||||||
| Spec | Where |
|
| Spec | Where |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Nuri` / `ReadCap` (plain strings, `:r:` discriminant) | `packages/client/src/types.ts`, `src/nuri.ts` (internal parse/mint/derive) |
|
| `Nuri` / `ReadCap` (plain strings, `:r:` discriminant) | `packages/sdk/src/model/types.ts`, `src/model/nuri.ts` (internal parse/mint/derive) |
|
||||||
| Keyring, one per identity — `capFor` | `src/caps.ts` (`CapRegistry`), surfaced as `capFor` in `src/polyfill.ts` |
|
| Keyring, one per identity — `capFor` | `src/emulated-verifier/caps.ts` (`CapRegistry`), surfaced as `capFor` in `src/polyfill.ts` |
|
||||||
| Caps of my OWN documents (the emulated `AddRepo { read_cap }`) | `src/store-registry.ts` `fileOwnCaps`, called from `createEntityDoc` and `listMyEntityDocs` |
|
| Caps of my OWN documents (the emulated `AddRepo { read_cap }`) | `src/shared-wallet/account-registry.ts` `fileOwnCaps`, called from `createEntityDoc` and `listMyEntityDocs` |
|
||||||
| `shareCap(cap, toInbox)` + reception with no dedicated operation | `src/inbox.ts` (`shareCap`, and the inline absorption in `read`) |
|
| `shareCap(cap, toInbox)` + reception with no dedicated operation | `src/surface/inbox.ts` (`shareCap`, and the inline absorption in `read`) |
|
||||||
| `publishRepoLink` | `src/caps.ts`. *(The published-only guard it fed lived in `src/discovery.ts`, removed 2026-07-30 — see the boundary brief.)* |
|
| `publishRepoLink` | `src/emulated-verifier/caps.ts`. *(The published-only guard it fed lived in `src/discovery.ts`, removed 2026-07-30 — see the boundary brief.)* |
|
||||||
| Possession gate on reads | `src/read-model.ts` (`readUnion`), `src/read-filter.ts`, `src/use-shape.ts` |
|
| Possession gate on reads | `src/surface/read-model.ts` (`readUnion`), `src/emulated-verifier/read-filter.ts`, `src/surface/use-shape.ts` |
|
||||||
| Cap-mutation signal (a delivered cap re-triggers reads) | `CapRegistry.onChange` → `src/watch-shape.ts` |
|
| Cap-mutation signal (a delivered cap re-triggers reads) | `CapRegistry.onChange` → `src/surface/watch-shape.ts` |
|
||||||
| Acceptance test (§8) | `test/cross-user-access.test.ts` (see below); isolation end-to-end in `test/isolation-active.test.ts`. *Originally `test/watch-shape.test.ts` (e), on the discovery fold — dropped 2026-07-30 with `discovery.ts`; the property it proved is covered on the model's own terms by the cross-user scenario.* |
|
| Acceptance test (§8) | `test/cross-user-access.test.ts` (see below); isolation end-to-end in `test/isolation-active.test.ts`. *Originally `test/watch-shape.test.ts` (e), on the discovery fold — dropped 2026-07-30 with `discovery.ts`; the property it proved is covered on the model's own terms by the cross-user scenario.* |
|
||||||
| Cross-user scenario (§5 non-recursiveness) | `test/cross-user-access.test.ts` — see below |
|
| Cross-user scenario (§5 non-recursiveness) | `test/cross-user-access.test.ts` — see below |
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ One property this makes explicit and that is worth confirming: **the bare NURI o
|
|||||||
|
|
||||||
## The exact surface a consumer codes against
|
## The exact surface a consumer codes against
|
||||||
|
|
||||||
From `@ng-eventually/client/polyfill`:
|
From `@ng-eventually/sdk/polyfill`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
capFor(nuri: Nuri): ReadCap | undefined // the keyring lookup
|
capFor(nuri: Nuri): ReadCap | undefined // the keyring lookup
|
||||||
@@ -163,7 +163,7 @@ This is the point where the emulation is furthest from the eventual target, wher
|
|||||||
|
|
||||||
`inbox.read` had no guard and **absorbs caps into the reader's keyring**, so `inbox.read(someoneElsesInbox)` pocketed the caps addressed to them and directed sharing was defeatable by anyone who knew an inbox NURI. The inbox was never guarded before either, but before P1a it carried nothing that granted access.
|
`inbox.read` had no guard and **absorbs caps into the reader's keyring**, so `inbox.read(someoneElsesInbox)` pocketed the caps addressed to them and directed sharing was defeatable by anyone who knew an inbox NURI. The inbox was never guarded before either, but before P1a it carried nothing that granted access.
|
||||||
|
|
||||||
Fixed in step 2 of [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md): an inbox now BELONGS to a virtual user (`storeRegistry.walletInbox`), and only its owner may read it. Depositing into anyone's inbox stays open — that is the one legitimate cross-wallet act, and the only way a link crosses between wallets at all.
|
Fixed in step 2 of [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md): an inbox now BELONGS to a virtual user (`storeRegistry.userInbox`), and only its owner may read it. Depositing into anyone's inbox stays open — that is the one legitimate cross-wallet act, and the only way a link crosses between wallets at all.
|
||||||
|
|
||||||
## Follow-up decided by the PO — to plan, NOT in this lot
|
## Follow-up decided by the PO — to plan, NOT in this lot
|
||||||
|
|
||||||
@@ -192,13 +192,13 @@ Not started. It changes the consumer contract in the right direction (one less o
|
|||||||
- **Unit suite green — 146 tests**, typecheck clean on `src`, `test` and the e2e harness.
|
- **Unit suite green — 146 tests**, typecheck clean on `src`, `test` and the e2e harness.
|
||||||
- The typing was verified from a **consumer's** point of view, not just the library's: a synthetic app compiled against the entry points shows the two real mistakes (`shareCap(bareNuri, …)` and passing a raw `string` from storage) as compile errors, while every correct path — `capFor(doc)` → `shareCap(cap, inbox)`, and narrowing with the exported guards — needs no cast.
|
- The typing was verified from a **consumer's** point of view, not just the library's: a synthetic app compiled against the entry points shows the two real mistakes (`shareCap(bareNuri, …)` and passing a raw `string` from storage) as compile errors, while every correct path — `capFor(doc)` → `shareCap(cap, inbox)`, and narrowing with the exported guards — needs no cast.
|
||||||
- The acceptance test was **mutation-checked**: reverting both gardes (the discovery fold and the `readUnion` possession gate) makes `watch-shape.test.ts` (e) fail with the bare-referenced document reappearing. The test has teeth.
|
- The acceptance test was **mutation-checked**: reverting both gardes (the discovery fold and the `readUnion` possession gate) makes `watch-shape.test.ts` (e) fail with the bare-referenced document reappearing. The test has teeth.
|
||||||
- **The e2e ran against the live broker (`nextgraph.eu`) on 2026-08-03 — 39 passed, 0 failed.** The first run was 22/8, and the eight refusals were not test noise: they exposed a **real hole in the surface**. `docs.docCreate` filed no cap for the creator, so a consumer could create a document through the public primitive and then be refused reading or writing it. Upstream that cannot happen — `doc_create` commits `AddRepo { read_cap }` to the store's Store branch, so the creator holds it from the first instant. Fixed at `packages/client/src/docs.ts:73`, and deliberately NOT replicated in `physical.ts`: the shim's own documents belong to no user, and `store-registry` files their caps where it knows whose they are. The remaining failures were the harness acting as a second identity without establishing it (`createEntityDoc(id, …)` with someone else connected) or reading an arbitrary document as an inbox; both are now `setCurrentUser` + `walletInbox`, which is what a consumer must do too.
|
- **The e2e ran against the live broker (`nextgraph.eu`) on 2026-08-03 — 39 passed, 0 failed.** The first run was 22/8, and the eight refusals were not test noise: they exposed a **real hole in the surface**. `docs.docCreate` filed no cap for the creator, so a consumer could create a document through the public primitive and then be refused reading or writing it. Upstream that cannot happen — `doc_create` commits `AddRepo { read_cap }` to the store's Store branch, so the creator holds it from the first instant. Fixed at `packages/sdk/src/surface/docs.ts:73`, and deliberately NOT replicated in `shared-wallet/physical.ts`: the shim's own documents belong to no user, and `store-registry` files their caps where it knows whose they are. The remaining failures were the harness acting as a second identity without establishing it (`createEntityDoc(id, …)` with someone else connected) or reading an arbitrary document as an inbox; both are now `setCurrentUser` + `userInbox`, which is what a consumer must do too.
|
||||||
- **An e2e run against a persistent wallet must use a FRESH identity per run.** The second run was green and the third was not, on unchanged code: moving the inbox tests onto `walletInbox(id)` made the inbox *stable for its owner* — which is the point of an inbox — so a fixed id accumulates every past run's deposits and `deposits.length === 2` drifts to 4. Green-then-red on identical code is the tell. The disposable thing is the **user**, not the inbox: `run.ts` now stamps `@inbox-user-`/`@watcher-`/`@friend-` with `Date.now()`, as it already did for `@alice-`. Any future step that resolves a durable per-user document (inbox, stores, Links) inherits this constraint.
|
- **An e2e run against a persistent wallet must use a FRESH identity per run.** The second run was green and the third was not, on unchanged code: moving the inbox tests onto `userInbox(id)` made the inbox *stable for its owner* — which is the point of an inbox — so a fixed id accumulates every past run's deposits and `deposits.length === 2` drifts to 4. Green-then-red on identical code is the tell. The disposable thing is the **user**, not the inbox: `run.ts` now stamps `@inbox-user-`/`@watcher-`/`@friend-` with `Date.now()`, as it already did for `@alice-`. Any future step that resolves a durable per-user document (inbox, stores, Links) inherits this constraint.
|
||||||
- **The cap registry is process-wide and `bun test` shares modules across files**, so suites that read without declaring caps now reset explicitly (`read-model.test.ts`, `watch-shape.test.ts`). Worth knowing before adding a suite.
|
- **The cap registry is process-wide and `bun test` shares modules across files**, so suites that read without declaring caps now reset explicitly (`read-model.test.ts`, `watch-shape.test.ts`). Worth knowing before adding a suite.
|
||||||
|
|
||||||
## Documentation state
|
## Documentation state
|
||||||
|
|
||||||
The permanent documentation was updated in the same pass (root `README.md`, `packages/client/README.md`, `docs/simulation.md`, `docs/migration-guide.md` §1 + the assumed `declareConnections` break, `docs/read-model.md`, `docs/readcap-and-nuri-model.md` §5, `docs/nextgraph-current-state.md`, `packages/client/docs/sdk-reference.md`). **If the review changes the surface, those are the files to re-align** — they describe the code as it stands now, not a validated state.
|
The permanent documentation was updated in the same pass (root `README.md`, `packages/sdk/README.md`, `docs/simulation.md`, `docs/migration-guide.md` §1 + the assumed `declareConnections` break, `docs/read-model.md`, `docs/readcap-and-nuri-model.md` §5, `docs/nextgraph-current-state.md`, `packages/sdk/docs/sdk-reference.md`). **If the review changes the surface, those are the files to re-align** — they describe the code as it stands now, not a validated state.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ Written 2026-07-27, after two adversarial reviews and three corrections from the
|
|||||||
|
|
||||||
## Why this lot exists
|
## Why this lot exists
|
||||||
|
|
||||||
`caps.ts` currently models read rights as an **ACL** — a `Map<doc, Set<principal>>` plus `grantRead(doc, grantee)`. That is the **exact inversion** of the real model, where reading is **key possession**: whoever holds the key reads, and there is no authorization list anywhere.
|
`emulated-verifier/caps.ts` currently models read rights as an **ACL** — a `Map<doc, Set<principal>>` plus `grantRead(doc, grantee)`. That is the **exact inversion** of the real model, where reading is **key possession**: whoever holds the key reads, and there is no authorization list anywhere.
|
||||||
|
|
||||||
This is not a security problem — the library is deliberately insecure and that is accepted (see `../vision.md`). It is a **shape** problem, and shape is the only thing this library exists to get right. A consumer coded against an ACL is coded against a model that will never exist, and will have to be rewritten.
|
This is not a security problem — the library is deliberately insecure and that is accepted (see `../vision.md`). It is a **shape** problem, and shape is the only thing this library exists to get right. A consumer coded against an ACL is coded against a model that will never exist, and will have to be rewritten.
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
>
|
>
|
||||||
> And four more, all confirmed:
|
> And four more, all confirmed:
|
||||||
>
|
>
|
||||||
> 4. **D4 would delete a working recovery path.** Inbox deposits are never removed (`packages/client/src/inbox.ts`), so a second device/tab recovers its caps by re-reading. localStorage-without-re-reading loses them permanently, and contradicts P1a's delivered doctrine that per-process rebuild "is correct".
|
> 4. **D4 would delete a working recovery path.** Inbox deposits are never removed (`packages/sdk/src/surface/inbox.ts`), so a second device/tab recovers its caps by re-reading. localStorage-without-re-reading loses them permanently, and contradicts P1a's delivered doctrine that per-process rebuild "is correct".
|
||||||
> 5. **D3 is false outside entity documents.** `capFor(scopeIndexDoc)` and `capFor(walletInbox)` are undefined before *and after* `listMyEntityDocs` — their caps can only ever be derived. Yet the boundary brief requires them reachable. Upstream that root comes from the wallet plus `AddSignerCap` on the User branch — a level the fact table omitted entirely.
|
> 5. **D3 is false outside entity documents.** `capFor(scopeIndexDoc)` and `capFor(userInbox)` are undefined before *and after* `listMyEntityDocs` — their caps can only ever be derived. Yet the boundary brief requires them reachable. Upstream that root comes from the wallet plus `AddSignerCap` on the User branch — a level the fact table omitted entirely.
|
||||||
> 6. **`doc_create` writes four times, not two** (+ the class quad on the Header branch, + `AddSignerCap` on the User branch).
|
> 6. **`doc_create` writes four times, not two** (+ the class quad on the Header branch, + `AddSignerCap` on the User branch).
|
||||||
> 7. **Ordering defect: D2 before the boundary guard opens cap harvesting.** Once caps are triples in `scopeIndexDoc(bob,…)`, and both `scopeIndexDoc` and `docs.sparqlQuery` are exported, `setCurrentUser("mallory")` reads Bob's caps. Today `mintCap` is unexported, so a NURI yields nothing. **The guard must land before the caps become triples.**
|
> 7. **Ordering defect: D2 before the boundary guard opens cap harvesting.** Once caps are triples in `scopeIndexDoc(bob,…)`, and both `scopeIndexDoc` and `docs.sparqlQuery` are exported, `setCurrentUser("mallory")` reads Bob's caps. Today `mintCap` is unexported, so a NURI yields nothing. **The guard must land before the caps become triples.**
|
||||||
>
|
>
|
||||||
@@ -69,7 +69,7 @@ All read in `nextgraph-rs` (`git 213338f6`) on 2026-07-30, recorded in full in [
|
|||||||
| scope index / scope container (`scopeIndexDoc`, `readScopeIndex`, `indexDocOf`, `INDEX_SUBJECT`) | **store** (`storeDoc`, `readStore`, …) | the thing that lists a user's documents IS a store |
|
| scope index / scope container (`scopeIndexDoc`, `readScopeIndex`, `indexDocOf`, `INDEX_SUBJECT`) | **store** (`storeDoc`, `readStore`, …) | the thing that lists a user's documents IS a store |
|
||||||
| `shim:contains` | `ldp:contains` | NextGraph's own predicate for exactly this |
|
| `shim:contains` | `ldp:contains` | NextGraph's own predicate for exactly this |
|
||||||
|
|
||||||
`docPublic` / `docProtected` / `docPrivate` on `AccountRecord` already read as stores; keep them, or rename to `publicStore` / `protectedStore` / `privateStore` for symmetry.
|
`docPublic` / `docProtected` / `docPrivate` on `VirtualUserRecord` already read as stores; keep them, or rename to `publicStore` / `protectedStore` / `privateStore` for symmetry.
|
||||||
|
|
||||||
### D2 — Emulate the Store branch as a distinct SUBJECT, not a distinct graph or document
|
### D2 — Emulate the Store branch as a distinct SUBJECT, not a distinct graph or document
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ The in-memory `CapRegistry` then stops being "the keyring" and becomes what it a
|
|||||||
|
|
||||||
Verified: there is no received-caps register upstream, and inventing one would expose a shape the target does not have. What upstream does is persist the `read_cap` of every **opened** repo in local user storage.
|
Verified: there is no received-caps register upstream, and inventing one would expose a shape the target does not have. What upstream does is persist the `read_cap` of every **opened** repo in local user storage.
|
||||||
|
|
||||||
So the emulation is a **local, per-virtual-user store** — the same nature as `accounts.ts`'s existing `IdentityStore` (localStorage). This ends "re-read the inbox every session to recover caps", which the PO identified as the wrong model: an inbox is a queue you consume, not a store you re-read.
|
So the emulation is a **local, per-virtual-user store** — the same nature as `shared-wallet/virtualUsers.ts`'s existing `IdentityStore` (localStorage). This ends "re-read the inbox every session to recover caps", which the PO identified as the wrong model: an inbox is a queue you consume, not a store you re-read.
|
||||||
|
|
||||||
*Open*: whether to do D4 in this lot or after the boundary lot. It is the piece with the most design risk, and it is not needed for D1–D3 to be correct.
|
*Open*: whether to do D4 in this lot or after the boundary lot. It is the piece with the most design risk, and it is not needed for D1–D3 to be correct.
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ So the emulation is a **local, per-virtual-user store** — the same nature as `
|
|||||||
|
|
||||||
## What this breaks
|
## What this breaks
|
||||||
|
|
||||||
`storeRegistry`'s exported names change (`scopeIndexDoc`, `listEntityDocs`, `AccountRecord` fields). `shim:contains` becomes `ldp:contains`, so **existing dev wallets stop resolving their documents** — acceptable for dev data, and consistent with how the pointer/doc-shim migration was handled before, but it must be stated rather than discovered.
|
`storeRegistry`'s exported names change (`scopeIndexDoc`, `listEntityDocs`, `VirtualUserRecord` fields). `shim:contains` becomes `ldp:contains`, so **existing dev wallets stop resolving their documents** — acceptable for dev data, and consistent with how the pointer/doc-shim migration was handled before, but it must be stated rather than discovered.
|
||||||
|
|
||||||
## Risks I want challenged
|
## Risks I want challenged
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Nothing else touches `ng`. (`open-repo` only tests whether `doc_subscribe` exist
|
|||||||
| `storeRegistry.listEntityDocs` / `resolveReadGraphs` | fan-out over every account | **removed** — cross-wallet enumeration, and its former justification (feeding discovery) is gone too |
|
| `storeRegistry.listEntityDocs` / `resolveReadGraphs` | fan-out over every account | **removed** — cross-wallet enumeration, and its former justification (feeding discovery) is gone too |
|
||||||
| `storeRegistry.allAccounts` / `loadShim` | enumerates every virtual user | **removed**, or reduced to the reserved-account resolution that infrastructure needs |
|
| `storeRegistry.allAccounts` / `loadShim` | enumerates every virtual user | **removed**, or reduced to the reserved-account resolution that infrastructure needs |
|
||||||
| `storeRegistry.ensureAccount(id)` | any id | the current identity, plus the reserved accounts |
|
| `storeRegistry.ensureAccount(id)` | any id | the current identity, plus the reserved accounts |
|
||||||
| `readModel.readUnion` | ✅ already guarded (P1a) | unchanged |
|
| `readUnion` | ✅ already guarded (P1a) | unchanged |
|
||||||
|
|
||||||
## The breach P1a opened, and which this lot must close first
|
## The breach P1a opened, and which this lot must close first
|
||||||
|
|
||||||
@@ -113,13 +113,13 @@ Applies to every exported surface, including ones added later: **if it is expose
|
|||||||
## Order of work
|
## Order of work
|
||||||
|
|
||||||
1. ~~**Remove `discovery.***~~ — **DONE 2026-07-30.** `src/discovery.ts` and `test/discovery.test.ts` deleted; `INDEX_ACCOUNT`, `watchShape`'s public-scope fold and its discovery-index container subscription, `nurisFromRef`, the `submitToIndex` guard, and the e2e discovery block all removed. P1a's acceptance test did not need re-basing: `test/cross-user-access.test.ts` already proves the same property (a bare reference reads nothing, the link reads the document) on the model's own terms — following a link — so `watch-shape.test.ts` (e), which proved it on the discovery fold, was dropped. Docs realigned: the ADR is marked superseded, `read-model.md` now describes ONE regime (follow, never enumerate), and the root README's capability row records the removal.
|
1. ~~**Remove `discovery.***~~ — **DONE 2026-07-30.** `src/discovery.ts` and `test/discovery.test.ts` deleted; `INDEX_ACCOUNT`, `watchShape`'s public-scope fold and its discovery-index container subscription, `nurisFromRef`, the `submitToIndex` guard, and the e2e discovery block all removed. P1a's acceptance test did not need re-basing: `test/cross-user-access.test.ts` already proves the same property (a bare reference reads nothing, the link reads the document) on the model's own terms — following a link — so `watch-shape.test.ts` (e), which proved it on the discovery fold, was dropped. Docs realigned: the ADR is marked superseded, `read-model.md` now describes ONE regime (follow, never enumerate), and the root README's capability row records the removal.
|
||||||
2. ~~**"My inbox" + the inbox read guard**~~ — **DONE 2026-07-30.** `storeRegistry.walletInbox(id)` gives every virtual user its own inbox document, created on first sight and recorded in the doc-shim under its own predicate (`shim:docInbox`), read by its OWN query so an account record written before this existed still resolves — the fixed account SELECT did not grow a fourth required field. `isOwnInbox(nuri)` is the predicate; `inbox.read` / `readSynced` (hence `watch`, which reads through it) refuse an inbox that is not the connected wallet's, and refuse outright when no identity is set. **Depositing stays open** — `post` / `shareCap` are untouched, because that is the one legitimate cross-wallet act. The shared `resolveInboxAnchor` (a reserved account's document, an inbox COMMON to every wallet) was removed: it was unused by the library and violated *nothing common*. Locked by `test/isolation-active.test.ts` *an inbox may be DEPOSITED into by anyone, and READ only by its owner*, which walks the exact breach — Alice deposits, cannot read back; Mallory knowing the NURI absorbs nothing; anonymous is refused; Bob reads his own and only then does the cap land.
|
2. ~~**"My inbox" + the inbox read guard**~~ — **DONE 2026-07-30.** `storeRegistry.userInbox(id)` gives every virtual user its own inbox document, created on first sight and recorded in the doc-shim under its own predicate (`shim:docInbox`), read by its OWN query so an account record written before this existed still resolves — the fixed account SELECT did not grow a fourth required field. `isOwnInbox(nuri)` is the predicate; `inbox.read` / `readSynced` (hence `watch`, which reads through it) refuse an inbox that is not the connected wallet's, and refuse outright when no identity is set. **Depositing stays open** — `post` / `shareCap` are untouched, because that is the one legitimate cross-wallet act. The shared `resolveInboxAnchor` (a reserved account's document, an inbox COMMON to every wallet) was removed: it was unused by the library and violated *nothing common*. Locked by `test/isolation-active.test.ts` *an inbox may be DEPOSITED into by anyone, and READ only by its owner*, which walks the exact breach — Alice deposits, cannot read back; Mallory knowing the NURI absorbs nothing; anonymous is refused; Bob reads his own and only then does the cap land.
|
||||||
|
|
||||||
*Not done, and deliberately*: per-DOCUMENT inboxes. ~~Upstream every document has one~~ — **false, corrected 2026-08-03**: no document has an inbox upstream, and neither does the private store (see step 7's correction). Here only the wallet does. **The PO has ruled they must come** (2026-07-30) — *"it can come in a second step, but it must come"* — so this is a commitment, not an option. The guard predicate (`isOwnInbox`) is where they plug in: it answers "is this inbox mine?", which extends to "…one of my documents' inboxes" without changing a single caller.
|
*Not done, and deliberately*: per-DOCUMENT inboxes. ~~Upstream every document has one~~ — **false, corrected 2026-08-03**: no document has an inbox upstream, and neither does the private store (see step 7's correction). Here only the wallet does. **The PO has ruled they must come** (2026-07-30) — *"it can come in a second step, but it must come"* — so this is a commitment, not an option. The guard predicate (`isOwnInbox`) is where they plug in: it answers "is this inbox mine?", which extends to "…one of my documents' inboxes" without changing a single caller.
|
||||||
|
|
||||||
### Two defects this step surfaced — the first still open, the second closed by steps 5–6
|
### Two defects this step surfaced — the first still open, the second closed by steps 5–6
|
||||||
|
|
||||||
**`walletInbox(id)` is a directory, and directories do not exist.** It resolves ANY wallet's inbox from its identity id, and it is exported (`storeRegistry.*` is re-exported from the SDK entry). But you cannot look someone up in NextGraph — you cannot discover, you can only follow links. Their inbox NURI reaches you because *they gave it to you*, not because you resolved it from a name. Resolving **my own** inbox is legitimate plumbing; resolving **anyone's** is the same shape as the discovery index just removed. Fix: the public surface becomes "my inbox" (no argument), and reaching someone else's requires a NURI you were given. Resolution-by-id stays internal, for the shim and the tests.
|
**`userInbox(id)` is a directory, and directories do not exist.** It resolves ANY wallet's inbox from its identity id, and it is exported (`storeRegistry.*` is re-exported from the SDK entry). But you cannot look someone up in NextGraph — you cannot discover, you can only follow links. Their inbox NURI reaches you because *they gave it to you*, not because you resolved it from a name. Resolving **my own** inbox is legitimate plumbing; resolving **anyone's** is the same shape as the discovery index just removed. Fix: the public surface becomes "my inbox" (no argument), and reaching someone else's requires a NURI you were given. Resolution-by-id stays internal, for the shim and the tests.
|
||||||
|
|
||||||
**The keyring is not stored anywhere, and the shape is wrong — fix it now, not at P1b.** It is an in-memory `Map<accountKey, Map<Nuri, ReadCap>>`, rebuilt from scratch each session. Nothing persists a cap *as a cap*. PO directive, 2026-07-30:
|
**The keyring is not stored anywhere, and the shape is wrong — fix it now, not at P1b.** It is an in-memory `Map<accountKey, Map<Nuri, ReadCap>>`, rebuilt from scratch each session. Nothing persists a cap *as a cap*. PO directive, 2026-07-30:
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ So this is not a P1b concern, it is a **shape** concern — the one thing this l
|
|||||||
|
|
||||||
- **My own documents** → the cap goes **beside the NURI in the scope container**, which is the emulation of `AddRepo { read_cap }` on a branch of the store. Today the container stores only `shim:contains <nuri>` and the cap is **re-derived** from that NURI; it gains a `shim:readCap` beside it. Deriving then stops being how a cap is *recovered* and becomes merely how the stand-in value is *minted* — the single function P1b replaces.
|
- **My own documents** → the cap goes **beside the NURI in the scope container**, which is the emulation of `AddRepo { read_cap }` on a branch of the store. Today the container stores only `shim:contains <nuri>` and the cap is **re-derived** from that NURI; it gains a `shim:readCap` beside it. Deriving then stops being how a cap is *recovered* and becomes merely how the stand-in value is *minted* — the single function P1b replaces.
|
||||||
- **Caps I received** → into the emulation of `AddLink { read_cap }` on the **User branch** of the private store. Verified 2026-07-30 (see [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies): that register exists, it is explicitly for **external repos**, and its stated purpose is to *"share with all its device a new Link they received"* — wallet-resident and cross-device. So a received cap belongs **inside the virtual user**, like everything else. *(Two earlier versions of this note were wrong and are recorded in [`2026-07-30-users-stores-branches.md`](2026-07-30-users-stores-branches.md): the first blamed key derivation and proposed a "keyring document"; the second concluded no register existed at all and proposed browser-local storage — which would have put library data OUTSIDE even the physical user. Both were refuted; `AddLink` is the answer.)*
|
- **Caps I received** → into the emulation of `AddLink { read_cap }` on the **User branch** of the private store. Verified 2026-07-30 (see [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies): that register exists, it is explicitly for **external repos**, and its stated purpose is to *"share with all its device a new Link they received"* — wallet-resident and cross-device. So a received cap belongs **inside the virtual user**, like everything else. *(Two earlier versions of this note were wrong and are recorded in [`2026-07-30-users-stores-branches.md`](2026-07-30-users-stores-branches.md): the first blamed key derivation and proposed a "keyring document"; the second concluded no register existed at all and proposed browser-local storage — which would have put library data OUTSIDE even the physical user. Both were refuted; `AddLink` is the answer.)*
|
||||||
3. ~~**The guard at the four passage points**~~ — **DONE 2026-07-30.** `src/reach.ts` holds the boundary as **two rules on one criterion — possession — implemented in two places** (PO directive):
|
3. ~~**The guard at the four passage points**~~ — **DONE 2026-07-30.** `src/emulated-verifier/reach.ts` holds the boundary as **two rules on one criterion — possession — implemented in two places** (PO directive):
|
||||||
|
|
||||||
- **Rule 1, authorization**, at the passage points (`assertMayReach`, called from `docs.sparqlQuery` / `sparqlUpdate`): nothing reaches `ng` unless the connected user possesses the document's cap.
|
- **Rule 1, authorization**, at the passage points (`assertMayReach`, called from `docs.sparqlQuery` / `sparqlUpdate`): nothing reaches `ng` unless the connected user possesses the document's cap.
|
||||||
- **Rule 2, do not even attempt**, at the callers (`mustNotAttempt`, applied in `read-model.readUnion`, which now filters BEFORE opening or reading): a reader that holds no cap does not issue the operation at all. Upstream you cannot even address a repo you have no cap for, so asking about one is not "a read that will be refused" — it is a read with no meaning.
|
- **Rule 2, do not even attempt**, at the callers (`mustNotAttempt`, applied in `read-model.readUnion`, which now filters BEFORE opening or reading): a reader that holds no cap does not issue the operation at all. Upstream you cannot even address a repo you have no cap for, so asking about one is not "a read that will be refused" — it is a read with no meaning.
|
||||||
@@ -149,7 +149,7 @@ The PO's framing, which replaced the exemption list entirely:
|
|||||||
|
|
||||||
> Clearly distinguish what is polyfill machinery (and therefore the PHYSICAL user) from what is a virtual user's operation. Use different functions, probably grouped in different namespaces — because one API is exposed to the app and the other must never be.
|
> Clearly distinguish what is polyfill machinery (and therefore the PHYSICAL user) from what is a virtual user's operation. Use different functions, probably grouped in different namespaces — because one API is exposed to the app and the other must never be.
|
||||||
|
|
||||||
`src/physical.ts` now holds `physicalCreate` / `physicalQuery` / `physicalUpdate`, with `ensurePhysicalRepoOpen` and `subscribePhysicalDoc` as their open/subscribe counterparts. They are unguarded, and **never exported from the package** — a test asserts it, because a regression there is silent and total.
|
`src/shared-wallet/physical.ts` now holds `physicalCreate` / `physicalQuery` / `physicalUpdate`, with `ensurePhysicalRepoOpen` and `subscribePhysicalDoc` as their open/subscribe counterparts. They are unguarded, and **never exported from the package** — a test asserts it, because a regression there is silent and total.
|
||||||
|
|
||||||
The dividing line:
|
The dividing line:
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
>
|
>
|
||||||
> **`inbox.postToDocument(doc, { payload })`** is the one call an app makes: it names the DOCUMENT, never an inbox, and **throws** when the document has no inbox rather than returning quietly — a deposit that vanishes without an error is the bug this whole path exists to close. There is deliberately **no target-document field on a deposit**, for the reason above.
|
> **`inbox.postToDocument(doc, { payload })`** is the one call an app makes: it names the DOCUMENT, never an inbox, and **throws** when the document has no inbox rather than returning quietly — a deposit that vanishes without an error is the bug this whole path exists to close. There is deliberately **no target-document field on a deposit**, for the reason above.
|
||||||
>
|
>
|
||||||
> **Where the address lives.** On the document's emulated **Header branch** (`urn:ng-eventually:shim:headerBranch`), beside the content rather than in it — the same subject-as-compartment shape already used for the Store and User branches. `read-model` now drops every subject under the reserved `urn:ng-eventually:` namespace (`src/machinery.ts`), so the address cannot surface as one of the entity's properties. That filter is by SUBJECT, so it covers every emulated compartment present and future.
|
> **Where the address lives.** On the document's emulated **Header branch** (`urn:ng-eventually:shim:headerBranch`), beside the content rather than in it — the same subject-as-compartment shape already used for the Store and User branches. `read-model` now drops every subject under the reserved `urn:ng-eventually:` namespace (`src/emulated-verifier/machinery.ts`), so the address cannot surface as one of the entity's properties. That filter is by SUBJECT, so it covers every emulated compartment present and future.
|
||||||
>
|
>
|
||||||
> **The open question, answered.** *"Is 'a document has an inbox addressable by any holder' upstream, or this library's invention?"* — **upstream, as a capability**; ours only as a JS surface. The engine lets any repo have an inbox: `inbox: Option<PrivKey>` is a field of every `Repo` (`engine/repo/src/repo.rs:126`), `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`), and `update_inbox_cap_v0` applies it with **no `is_store` check** (`engine/verifier/src/verifier.rs:1920`), at any time (User-branch commit, `commit.rs:1043-1050`, whose type documents the update case). What no code path does is CREATE one for a document: `new_store_default` attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None` (`repo.rs:574`), and the engine's only two `AddInboxCap` commits are for the public and protected STORE repos (`site.rs:128,149`). "Does not" is not "cannot" — an earlier version of this note called the feature an anticipation on exactly that confusion. The half-split is upstream's too — a depositor seals with the inbox PUBLIC key (`engine/net/src/types.rs:4299`) and only the owner holds the private half — which is why an address is public by nature and belongs on the document, not on the owner's User branch.
|
> **The open question, answered.** *"Is 'a document has an inbox addressable by any holder' upstream, or this library's invention?"* — **upstream, as a capability**; ours only as a JS surface. The engine lets any repo have an inbox: `inbox: Option<PrivKey>` is a field of every `Repo` (`engine/repo/src/repo.rs:126`), `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`), and `update_inbox_cap_v0` applies it with **no `is_store` check** (`engine/verifier/src/verifier.rs:1920`), at any time (User-branch commit, `commit.rs:1043-1050`, whose type documents the update case). What no code path does is CREATE one for a document: `new_store_default` attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None` (`repo.rs:574`), and the engine's only two `AddInboxCap` commits are for the public and protected STORE repos (`site.rs:128,149`). "Does not" is not "cannot" — an earlier version of this note called the feature an anticipation on exactly that confusion. The half-split is upstream's too — a depositor seals with the inbox PUBLIC key (`engine/net/src/types.rs:4299`) and only the owner holds the private half — which is why an address is public by nature and belongs on the document, not on the owner's User branch.
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# What a consumer's tests need from the contract, and cannot find in it
|
||||||
|
|
||||||
|
Raised by the first consumer (Festipod) on 2026-08-10, while migrating onto `@ng-eventually/sdk` against `contract_sdk-surface` @ `30f6263`. Three findings.
|
||||||
|
|
||||||
|
## 1. The contract does not say how an identity comes to be established
|
||||||
|
|
||||||
|
The first version of this brief asked for a test entry exposing identity switching, on the strength of the API contract's remark that *"the e2e harness plays several identities on one page and reaches it by its internal path, which is what a harness is allowed to do and an application is not"* — true of this library's own harness, and unreachable for a consumer's, since `packages/sdk/package.json` maps exactly one entry and the resolver refuses a deep import (verified: `Cannot find module '@ng-eventually/sdk/src/shared-wallet/access-gate'`).
|
||||||
|
|
||||||
|
**That request is withdrawn**, and the reason is worth recording because it is the library's own argument turned around. The consumer decided that its tests take no shortcut through the SDK and validate the application's behaviour rather than the SDK's. Under that rule, "two identities on one page" is not a capability to restore: it is not something a user does, it exists only because one wallet hosts several identities, and a test that used it would be testing the emulation. Multi-user behaviour gets tested the way it is lived — several browser contexts, each signing in as itself. So the surface is right as it stands, and the library should not add a testing entry on this consumer's account.
|
||||||
|
|
||||||
|
**A larger claim was drafted here and is retracted before it could mislead.** It said the contract offers no way to determine which identity comes up, and that a consumer therefore has no reproducible test isolation. That is wrong: `EventuallyConfig` publishes both `sharedWallet` and `currentUser`, so bootstrap is exactly where a caller says which wallet to open and, when it must, which identity to come up as. The consumer had simply not wired those two fields yet — its own migration was incomplete, and the library's error message said so precisely (*"no shared wallet configured. Pass `sharedWallet` to `configure()`"*). A good error message did the work the brief was trying to do.
|
||||||
|
|
||||||
|
What remains is small and is prose, not surface. `## Guarantees` describes `ensureIdentity()` as *"the whole of signing in… it resolves who you are"* without ever connecting that sentence to the two `EventuallyConfig` fields that determine the answer. The `## Surface` block carries them as bare lines in a config interface, where nothing marks them as the inputs to the one call a consumer must get right before anything renders. Naming that link — *these fields are what `ensureIdentity()` resolves from* — would have saved a wrong diagnosis here, and it is one sentence.
|
||||||
|
|
||||||
|
## 2. `watchShape`'s published signature contradicts its own types
|
||||||
|
|
||||||
|
`## Surface` publishes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function watchShape(query: ShapeQuery): ShapeObservable;
|
||||||
|
```
|
||||||
|
|
||||||
|
`ShapeQuery` is the **result** type (`{ data, isPending, isSuccess, isError, error }`, per `docs/api-contract.md` § 5), so as written the call takes its own return value. The signature the consumer has always called, and the one § 5 documents, is `watchShape<T>(shapeType, scope)` — two positional arguments. One of the two documents is wrong; the contract is the one consumers read.
|
||||||
|
|
||||||
|
## 3. There is no synced read for the per-document form
|
||||||
|
|
||||||
|
The inbox surface publishes `readSynced(targetInbox)` and `readForDocument(doc)`, but not their intersection. The consumer's materialization path depends on the **synced** guarantee specifically (`read` and `readSynced` differ by contract), and it addresses by document. Today it must therefore resolve an address itself to get the synced form — which is the exact gesture § *Guarantees* says an application never performs (*"an application never handles a key or an inbox address"*).
|
||||||
|
|
||||||
|
Either `readForDocument` carries the synced guarantee, or a `readSyncedForDocument(doc)` completes the pair. As it stands the document-addressed path is strictly weaker than the address-addressed one, and the contract does not say that is intentional.
|
||||||
|
|
||||||
|
## 4. Identity was taken out of the application's hands, but three published calls still demand one
|
||||||
|
|
||||||
|
This is the finding that actually cost the migration, and it is one incoherence seen from two sides.
|
||||||
|
|
||||||
|
`ensureIdentity()` returns `void`, and nothing else answers *"who am I"* — `getCurrentUser` was removed on the sound argument that an application knows who it signed in. Under the previous surface that was true: the application named the identity, so it held the value. It no longer names it, and the gate that resolves it hands nothing back. So the premise the removal rested on has quietly stopped holding.
|
||||||
|
|
||||||
|
Meanwhile `storeRegistry.createEntityDoc(id, scope)`, `listMyEntityDocs(id, scope)` and `resolveWriteGraph(id, scope)` all take a mandatory `id: string`, and **the contract never says what it is**. Two readings were open. **The library has now answered, by refusing — and the answer is measured, not argued.**
|
||||||
|
|
||||||
|
The consumer bet on the harmless reading (`id` = a collection key inside the already-connected identity, which `listMy…` suggests) and routed all its call sites through one constant, because the other reading offered it no legal move at all. Running the broker-backed suite settles it in one line. Each scenario opens as its own identity — verified, the access log shows `test-…-2`, `-3`, `-4`, `-5`, `-6`, `-7` — and every one of them fails on **the same document**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[test-…-2][polyfill] createEntityDoc cap append failed:
|
||||||
|
[ng-eventually] createEntityDoc:addRepo: refused — the connected user does not hold
|
||||||
|
this document's cap. Naming a document does not grant access to it.
|
||||||
|
"did:ng:o:TFYUlJQnOkYUlC4T9XtGOBoz70opW_IO21VJ_ouF_Y0A:v:…"
|
||||||
|
```
|
||||||
|
|
||||||
|
The first identity creates that document and owns it; every later identity names the same one and is refused. So **`id` is per-identity**, and a constant collapses every user onto one owner's document.
|
||||||
|
|
||||||
|
Two things follow, and the second is the blocker.
|
||||||
|
|
||||||
|
**The good news, worth saying plainly: the guarantee held.** The failure mode feared here was a silent merge — every user's documents in one collection, no error, isolation quietly gone. That is not what happened. *"Writing is ownership"* refused loudly and named the reason, and a consumer's wrong guess became a red test instead of a data leak. This is the surface doing exactly what it promises.
|
||||||
|
|
||||||
|
**The blocker: the three calls require the connected identity, and no published call returns it.** `ensureIdentity()` resolves it and returns `void`; `getCurrentUser` was removed on the argument that an application knows who it signed in — which was true while the application named the identity and stopped being true the moment it no longer does. So the consumer must pass a value it has no way to obtain. It currently works only where a deployment plants one (its test environment, through `configure({ currentUser })`); a real deployment plants nothing, and there the application is stuck.
|
||||||
|
|
||||||
|
The pair has to close, either way round: the three calls stop taking an `id` (the session IS the identity — what the API contract already predicts for the target), or the surface answers *"who am I"* again. Right now it does neither, and this is no longer a documentation nicety — it is the one thing standing between a consumer and a working multi-user deployment.
|
||||||
|
|
||||||
|
## 6. The barrier is the library's, and nothing can test that a first-time device gets in
|
||||||
|
|
||||||
|
The consumer has now deleted its own access screen and relies on the barrier `ensureIdentity()` shows — which is right, and is what the library asked for when it absorbed ~300 lines of gate from this same application.
|
||||||
|
|
||||||
|
Its one end-to-end check of a first-time device — a real browser with an empty profile, offered the wallet file and its password, importing it, and coming back signed in — drove that deleted screen's own DOM, so it went with the screen. The consumer still needs that coverage: a first-time device getting in is the single most consequential path a real user takes, and it is currently covered by nothing.
|
||||||
|
|
||||||
|
**The consumer is not asking for testids, and is rebuilding the check on its own side.** An end-to-end test should interact the way a person does — visible text and roles — rather than reach for identifiers planted inside someone else's component, so driving your barrier that way needs nothing published and is the more honest test besides.
|
||||||
|
|
||||||
|
**One question, because it decides whether that is possible at all**: is the barrier reachable by ordinary browser automation? A screen rendered inside a **shadow root**, or in its own iframe, is invisible to a locator that searches the light DOM, and no amount of "test it like a user" gets past that. If it is isolated, then either the isolation needs a documented way through for automation, or the flow is yours to cover and the contract should say so plainly — because right now each side can reasonably assume the other has it.
|
||||||
|
|
||||||
|
**Unrelated signature seen in the same run, reported without diagnosis** (78 occurrences, none fatal — the scenarios fail on the cap refusal above, not on this): `call_sdk Deserialization error of config Error: invalid type: JsValue(Function), expected any valid JSON value`. It appears only once `sharedWallet` is configured and `ensureIdentity()` actually opens a wallet — it was absent from every earlier run. The consumer passes `sharedWallet` as three plain strings, per `## Surface`, so whatever crosses that boundary as a function does not come from its call site.
|
||||||
|
|
||||||
|
A second, smaller consequence of the same hole: the consumer's `currentUserId` now has to be read out of its own profile document, so it is empty until that read lands, where it used to be available synchronously and invariant. An action taken in that window is silently dropped instead of written.
|
||||||
|
|
||||||
|
## 5. "Permissive in" is stated as a guarantee but is not uniform
|
||||||
|
|
||||||
|
`## Guarantees` opens with *"Every entry accepts `NuriLike` and validates at the door"*, and makes a point of it: a value read from storage, a URL or a form goes straight in, no guard to call, no cast to write. The signature block does not honour it uniformly — `storeRegistry.openDocumentInbox(doc: Nuri)` takes the precise type where its own deposit-side counterpart `inbox.readForDocument(doc: NuriLike)` takes the permissive one. Verified by the consumer's typechecker, not read from your source.
|
||||||
|
|
||||||
|
Either the guarantee is narrower than stated (say which entries it covers), or `openDocumentInbox` should widen. As written, a consumer that trusts the sentence gets a type error at exactly one call site and has no way to tell whether that is the rule or the exception.
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
**Date:** 2026-06-16 · **Status:** SUPERSEDED 2026-07-30 (see the block above). *Originally: mechanism accepted; target owner undecided.*
|
**Date:** 2026-06-16 · **Status:** SUPERSEDED 2026-07-30 (see the block above). *Originally: mechanism accepted; target owner undecided.*
|
||||||
Ported here for the discovery mechanism it defines — the piece this lib
|
Ported here for the discovery mechanism it defines — the piece this lib
|
||||||
realizes (`inbox.ts` post/materialize/watch; `store-registry.ts` fan-out). The
|
realizes (`inbox.ts` post/materialize/watch; `shared-wallet/account-registry.ts` fan-out). The
|
||||||
product intent (what a consumer application *should* surface) is the consumer
|
product intent (what a consumer application *should* surface) is the consumer
|
||||||
application's concern, not this lib's; only the mechanism is recorded here.
|
application's concern, not this lib's; only the mechanism is recorded here.
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ later. This is why a global-index package is a deferred separate package in this
|
|||||||
## Polyfill reality — the fan-out drift is now RESOLVED (special-account index)
|
## Polyfill reality — the fan-out drift is now RESOLVED (special-account index)
|
||||||
|
|
||||||
The shared-wallet polyfill originally shipped a cross-account fan-out over
|
The shared-wallet polyfill originally shipped a cross-account fan-out over
|
||||||
every account's public documents (`store-registry.ts` `listEntityDocs('public')`
|
every account's public documents (`shared-wallet/account-registry.ts` `listEntityDocs('public')`
|
||||||
/ `resolveReadGraphs`) — one account saw another's public entity without any
|
/ `resolveReadGraphs`) — one account saw another's public entity without any
|
||||||
relationship to its creator. This ADR classified that per-account fan-out as a drift
|
relationship to its creator. This ADR classified that per-account fan-out as a drift
|
||||||
to be replaced by the single global index.
|
to be replaced by the single global index.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ breaks writes with `RepoNotFound`). See the scope rule in
|
|||||||
|
|
||||||
*The decision stands; the mechanism named in it has been replaced.* Opening was
|
*The decision stands; the mechanism named in it has been replaced.* Opening was
|
||||||
`orm_start_graph` when this was written. It is now `ensureRepoOpen` — `doc_subscribe`
|
`orm_start_graph` when this was written. It is now `ensureRepoOpen` — `doc_subscribe`
|
||||||
plus a wait for the first `State` (`packages/client/src/open-repo.ts:167`) — after
|
plus a wait for the first `State` (`packages/polyfill/src/emulated-verifier/open-repo.ts:167`) — after
|
||||||
`orm_start_graph` was found to hang on a fan-out (`subscribe.ts:28,181`). What must be
|
`orm_start_graph` was found to hang on a fan-out (`subscribe.ts:28,181`). What must be
|
||||||
read here is the invariant *"open the repo, by its store NURI, before writing"*, not the
|
read here is the invariant *"open the repo, by its store NURI, before writing"*, not the
|
||||||
call that used to implement it.
|
call that used to implement it.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind how
|
**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind how
|
||||||
the consumer application presents identity selection as a perceived login, and why
|
the consumer application presents identity selection as a perceived login, and why
|
||||||
the lib's identity store (`accounts.ts`) must never touch NextGraph. The lib itself
|
the lib's identity store (`shared-wallet/virtualUsers.ts`) must never touch NextGraph. The lib itself
|
||||||
no longer frames this as a login: it receives an identity id, set at wallet-import
|
no longer frames this as a login: it receives an identity id, set at wallet-import
|
||||||
time; the perceived-login UX lives entirely in the consumer application.
|
time; the perceived-login UX lives entirely in the consumer application.
|
||||||
|
|
||||||
@@ -63,8 +63,8 @@ barrier becomes the real per-user login — the flow shape does not change.
|
|||||||
|
|
||||||
## How this lib realizes it
|
## How this lib realizes it
|
||||||
|
|
||||||
`accounts.ts` is an `IdentityStore`: `set(id)` / `clear()` / `get()` only read/write
|
`shared-wallet/virtualUsers.ts` is an `IdentityStore`: `set(id)` / `clear()` / `get()` only read/write
|
||||||
the identity id in an injected `AccountStorage`; they never call NG. The id is set at
|
the identity id in an injected `VirtualUserStorage`; they never call NG. The id is set at
|
||||||
wallet-import time and relayed via the lib's current-identity call; the perceived
|
wallet-import time and relayed via the lib's current-identity call; the perceived
|
||||||
login is the consumer application's. See the identity store in
|
login is the consumer application's. See the identity store in
|
||||||
[`../simulation.md`](../simulation.md).
|
[`../simulation.md`](../simulation.md).
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Document links — what travels when an application shares a document
|
||||||
|
|
||||||
|
**Established 2026-08-06**, by direct reading of the `nextgraph-rs` clone (HEAD `213338f6`, 2026-05-16), the installed `@ng-org/web@0.1.2-alpha.13` declarations, and this repo's docs. Layer numbers per `README.md` § *The three references* (1 = engine, 2 = wasm binding / Rust SDK, 3 = ORM). `file:line` references are dated — navigate by symbol when they drift. Epistemic labels follow `docs/api-contract.md`.
|
||||||
|
|
||||||
|
**The question.** Alice writes a note; she wants Bob to open it. Bob is not necessarily known to her, and the sharing may cross a channel NextGraph knows nothing about — an email, a printed QR code, a web page. Does the model contain a value that travels, and if so, what does an application legitimately hold and pass around?
|
||||||
|
|
||||||
|
**The verdict.** Yes — and it is not a side feature: circulating a link is the model's only distribution mechanism ("you cannot discover, you can only follow links" — PO, 2026-07-30, `docs/readcap-and-nuri-model.md` §4ter-bis). The engine declares a complete link family (`NgLink`) whose doc comments describe out-of-band circulation as the normal case, and even names a durable "PermaLink" governed by a permission. But **nothing constructs any of it yet**, the repo-level *string* form an application would put in an email does not exist at any layer, and the value differs by scope: a link to a protected document carries the key, a link to a public document carries none.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Does a real application need this?
|
||||||
|
|
||||||
|
Yes, and not as a convenience. The two candidate paths in the framing collapse into one gesture at the model level: *publishing is two acts — place the data, then circulate the link* (§4ter-bis), and the inbox is merely one channel for that circulation ("post it into inboxes, **or** put it somewhere already reachable by the people concerned"). Directed delivery (path 1) is the same Link payload sealed to a known recipient's inbox; the email/QR case (path 2) is the same payload over a channel the recipient's identity never touches. Since there is no discovery — no index, no registry, nothing to find — a document that never has a link circulated for it has **no audience at all**. An application that lets users share anything therefore handles links; the only question is what the value is.
|
||||||
|
|
||||||
|
## 2. What the model actually offers
|
||||||
|
|
||||||
|
### Exists and is exercised
|
||||||
|
|
||||||
|
- **A self-contained share-URL for objects, commits and files.** Grammar: `did:ng[:o:{repo}]:v:{overlay}(:[cj]:{id}:k:{key})+[:s:{id}:k:{key}]:l:{locator}` (`RE_OBJECTS`, `engine/net/src/types.rs:63-65`); parser `NuriV0::new_for_readcaps` (`engine/net/src/app_protocol.rs:493-548`); one caller, `ngcli get` (`bin/ngcli/src/main.rs:430-460`), which connects **anonymously** to the broker named in the URL's `:l:` locator, fetches blocks with `ExtObjectGetV0`, and decrypts locally with the keys carried in the URL. Level 1 + CLI, working end to end: a stranger holding only the string reads the content. It addresses *frozen* content (objects, files, a commit) — not a live document.
|
||||||
|
- **Shareable values at level 2 — for profiles and wallets, not documents.** `get_qrcode_for_profile(session_id, _public, size)` and its receiving half `import_contact_from_qrcode` (`index.d.ts:96,106`), payload `NgQRCodeProfileSharingV0 { inbox, profile, name, email }` (`engine/net/src/types.rs:5227-5233`); `wallet_export_get_qrcode` (`index.d.ts:332`; `sdk/rust/src/local_broker.rs:2074`, with textcode and rendezvous variants). Precedent that "a value crosses an out-of-band channel" is SDK-real — no document instance of it exists.
|
||||||
|
- **NURIs are URL-shaped and the app opens them.** The app layer routes `/did:ng:*` to a `NURI` component (`app/ui-common/src/App.svelte:66`); the component is absent from this clone (app mid-rebuild), and its historical version (git `077ba626`, old `ng-app/src/routes/NURI.svelte`) deep-links only documents the **connected user already holds**. An in-app precedent for "a document reference is a URL", not for stranger-opening.
|
||||||
|
|
||||||
|
### Exists, unexercised — the declared model
|
||||||
|
|
||||||
|
- **The link family.** `NgLinkV0 = Repo | PublicRepo | Branch | Object` (`engine/net/src/types.rs:5206-5217`). `RepoLinkV0 { id, read_cap, overlay, peers }` (`:5062-5079`), whose comment states the flow: *"In most cases, the link is shared and then the recipient opens it and subscribes soon afterward"* (`:5059`). `PublicRepoLinkV0 { repo, branch?, heads, snapshot?, public_store, peers }` — **no `read_cap` field** (`:5105-5127`). `ReadBranchLinkV0` (`:5142`), `ObjectLinkV0` (`:5176`). **Zero constructors and zero consumers in the entire workspace** (grep over `engine/`, `sdk/`, `bin/`, `app/`): the family is a declared model, not a feature.
|
||||||
|
- **The NURI type has exactly a link's slots — the string grammar does not.** `NuriV0 { target, overlay, access: Vec<NgAccessV0>, locator: Option<Locator> }` (`engine/net/src/app_protocol.rs:181-195`); `NgAccessV0::ReadCap(ReadCap)` (`:55`) has **zero consumers anywhere**. The `r:` segment (a repo-level ReadCap, `BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`) is *produced* for display (`engine/verifier/src/verifier.rs:278,320`) and **parsed by nothing**; `NuriV0::new_from` (`app_protocol.rs:643-737`) accepts only cap-less forms plus the file/commit `:k:` forms. The only parsed cap+locator grammar is the object form above.
|
||||||
|
- **The recipient's mechanics exist and work — short of the external case.** `Verifier::load_repo_from_read_cap` (`engine/verifier/src/verifier.rs:2237`, `pub(crate)`) is exercised for the user's own stores and the `AddRepo` replay (`engine/verifier/src/commits/mod.rs:660`); the one call site that would open an **external** repo from a received cap is commented out (`engine/verifier/src/inbox_processor.rs:469-481`, social-query forwarder). The durable filing register for a received link exists: `AddLink { read_cap }` on the User branch, *"so that a user can share with all its device a new Link they received"*, external repos only (`engine/repo/src/types.rs:1934-1950`).
|
||||||
|
- **Serving the public store to strangers is broker-plumbed and client-disabled.** `PinRepoV0.expose_outer` exists through the broker's storage (`engine/net/src/types.rs:3123`; `engine/broker/src/server_storage/core/overlay.rs:103-127`), but the client hard-codes `expose_outer: false` in both `PinRepo` constructors (`engine/net/src/actors/client/pin_repo.rs:51,79`), and `ExtTopicSyncReq` — the anonymous branch-sync a `PublicRepoLink` needs — is declared and `unimplemented!()` (`engine/net/src/types.rs:4523,4533`). Only `ExtObjectGet` and `WalletGetExport` are servable ext requests today.
|
||||||
|
- **Durable links are named, not built.** `PermaCap` is a TODO (`engine/repo/src/types.rs:578`), and `PermissionV0::PermaShare` — *"can create and answer to PermaCap (PermaLink)"* — already reserves the governance of creating one (`:1761`).
|
||||||
|
|
||||||
|
### Does not exist
|
||||||
|
|
||||||
|
- No function at level 2 or 3 produces or accepts a document link (all 77 `index.d.ts` exports checked; `sdk/rust/src/local_broker.rs` has only the wallet QR/textcode/rendezvous trio). `doc_fetch_repo_subscribe` (`index.d.ts:64`, `sdk/js/lib-wasm/src/lib.rs:1900`) opens documents the session **already holds** — an unknown repo errors `RepoNotFound` (`engine/verifier/src/request_processor.rs:155,163`) — it is not a stranger-open.
|
||||||
|
- No string serialization of `NgLink`, and no repo-level cap-bearing NURI that any parser accepts. Per the design principle, none of these absences says anything about the future SDK; the declared types above say a great deal.
|
||||||
|
|
||||||
|
### What the receiving side actually needs — a reference alone is NOT enough
|
||||||
|
|
||||||
|
A bare `did:ng:o:{id}`, or even `o:{id}:v:{overlay}`, gives a stranger nothing: no locator → no broker to ask; no cap → nothing decrypts, and addressing itself presupposes the cap (the outer overlay is never registered, and there is no existence probe — `docs/readcap-and-nuri-model.md` §4bis, the refuted-hypothesis block). What a stranger needs to resolve **and join** is precisely `RepoLinkV0`'s field list — id + read_cap + overlay + peers — or, public, `PublicRepoLinkV0`'s find-and-join set. The `peers`/locator half is not decoration; it is the join, and it is why the polyfill's single-broker world makes locators invisible today.
|
||||||
|
|
||||||
|
## 3. Public vs protected — the distinction is in the model
|
||||||
|
|
||||||
|
The two link structures differ on exactly one thing: **the key**. `RepoLinkV0` carries `read_cap`; `PublicRepoLinkV0` carries none — *"The latest ReadCap of the branch … will be downloaded from the outerOverlay, if the peer brokers listed below allow it"*, and *"This link is durable, because the public site are served differently by brokers"* (`engine/net/src/types.rs:5100-5104`). Public-ness is therefore a property of the **store and of how brokers serve it** (outer-overlay exposure), not a capability that circulates. This matches the official docs' store table (docs-intent, transcribed in `docs/nextgraph-current-state.md:44-58`): Public read = *"Everyone, no capability"*, *"equivalent to your website … without the need for special permissions"*; Protected = *"share … but they will need a special link and permission"*.
|
||||||
|
|
||||||
|
Two nuances against over-reading:
|
||||||
|
|
||||||
|
- It is not that no key *exists*. Today every branch — public included — has its ReadCap and its content is encrypted; the public link merely does not **carry** the key, because any holder of the link may fetch the current one from the outer overlay. The stated direction of not encrypting public-store content at all is PO-confirmed but explicitly not to be depended on by any surface (§4ter).
|
||||||
|
- The durability asymmetry follows from what the value contains: a public link *names* and stays valid across cap refresh; a protected link *carries* and goes stale when the cap is rotated (§4 below).
|
||||||
|
|
||||||
|
What follows for a public document, and is easy to get wrong: **there is nothing per-reader**. No grant, no per-reader revocation, no audience list — the only lever is coarse (unpublish/re-key the store's serving). An application UI that enumerates or revokes "the readers of my public document" is built on nothing.
|
||||||
|
|
||||||
|
### The polyfill's public emulation, read against this
|
||||||
|
|
||||||
|
**Rewritten 2026-08-06**, when the emulation changed. It used to refuse a cap-less reference for every scope, which was over-strict in the safe direction but left an application unable to express *"circulate widely, the reference is enough"* — the one act the model makes cheap. `emulated-verifier/public-store.ts` now emulates the declared mechanism: a document in a public store exposes its ReadCap, and any reader's first door fetches it. Possession still decides everything; what changed is that for a public document the cap is **obtainable** instead of having to be handed over.
|
||||||
|
|
||||||
|
**This aligns on a DECLARED model, not on current behaviour, and the difference is worth stating.** What is read in source: `PublicRepoLinkV0` carries no `read_cap` and its comment says *"The latest ReadCap of the branch will be downloaded from the outerOverlay, if the peer brokers listed below allow it […] the public site are served differently by brokers"* (`engine/net/src/types.rs:5098-5124`); the broker's `expose_outer` plumbing exists (`engine/broker/src/server_storage/core/overlay.rs:103-133`). What is *not* wired today, per the inventory above: the client hard-codes `expose_outer: false` in both `PinRepo` constructors, and `ExtTopicSyncReq` — the anonymous branch-sync such a link needs — is `unimplemented!()`. So the emulation follows the model the engine declares, in a place the engine does not yet serve. That is the intended posture for this library (an absent implementation says nothing about what the target will do), and it is a bet, named here as one.
|
||||||
|
|
||||||
|
Three things that remain true and must stay visible wherever the public emulation is documented:
|
||||||
|
|
||||||
|
1. **Per-reader semantics for public documents** must not be inferred from the cap-per-holder bookkeeping — upstream has none. No grant, no per-reader revocation, no audience list.
|
||||||
|
2. The `:r:` segment inside anything naming a *public* document is emulation detail: upstream's public link carries no key material, because the key is fetched. Harmless as long as the value stays opaque to the consumer — which is the contract to enforce.
|
||||||
|
3. **Reading is not writing.** The cap a public store serves grants reading only; `caps.learnFromPublicStore` files it apart and `docs.sparqlUpdate` refuses a write on it. Upstream a public store never makes a repo world-writable — writing needs the write cap, and `verify_permission` fires on WRITE only.
|
||||||
|
|
||||||
|
The surface consequence: the *act* — obtain a link, circulate it — is the same for both scopes upstream (`NgLinkV0` is one enum over both), so one producing function covering both is target-shaped; what differs is the semantics attached to the **value** (durability, revocability, the absence of per-reader anything), and that belongs in documentation, not in a second function.
|
||||||
|
|
||||||
|
## 4. Durability — what an application may store
|
||||||
|
|
||||||
|
- **Protected links go stale by design.** *"Those capabilities are not durable: They can be refreshed … and previously shared Caps will become obsolete/revoked"* (`engine/net/src/types.rs:5055-5058`). Rotation re-delivers the new cap **to the inboxes of those who keep access, at the rotating party's choice** (PO DIRECTION, `docs/readcap-and-nuri-model.md` §3) — never through the stored link. So a protected link in an email opened months later may no longer open; an application must treat open-from-link as fallible and fall back to a person-channel re-request. The declared future answer for stored-unopened links is `PermaCap`/PermaLink — a TODO (`engine/repo/src/types.rs:578,1761`).
|
||||||
|
- **Public links are durable** by construction (`:5103`): they carry nothing that rotation invalidates.
|
||||||
|
- **The polyfill emulates no revocation** (readcap doc §5, P3), so nothing goes stale today; consumers must not read permanence into that.
|
||||||
|
|
||||||
|
## 5. Recommendation for the polyfill
|
||||||
|
|
||||||
|
**SUPERSEDED on its first point, 2026-08-06 — `linkTo` was REMOVED, and the removal stands.** This section used to open: *"The surface already exists: `linkTo(doc: NuriLike): ReadCap` … `inbox.share(doc, toUser)` for directed delivery. **Keep `linkTo` — the act is the right one**"*. What that recommendation missed is the single thing § 0 of `docs/readcap-and-nuri-model.md` exists to hold: a call that returns a document's **key** where a caller asked for its **reference** converts *"whoever has the reference AND the key reads"* into *"whoever has the reference reads"* — for that document and for every document it mentions — so confidentiality can no longer be composed inside anything one circulates. The *act* (circulate a value that opens a document) may still be right; making it the answer to "give me the link to my document" was not. The reasoning is recorded where the function was, in the `No linkTo here` block of `packages/polyfill/src/surface/placement.ts`.
|
||||||
|
|
||||||
|
What an application does instead, today: it names a document with the bare reference it already holds — every published call returns one — and grants access with `inbox.share(doc, toUser)` (`packages/polyfill/src/surface/inbox.ts`). The four points below are kept and re-read against that: 1 and 4 stand as written for **any** future link-producing surface; 2 is void with the function; 3 is unchanged and still open.
|
||||||
|
|
||||||
|
1. **Label any such call LEVEL-1 SHAPE in `docs/api-contract.md`.** What supports it: the `NgLink` family and its stated sharing flow, the `PermaShare` permission, the exercised object-URL and profile-QR precedents, and the PO doctrine that circulation is the only distribution. The model's own stated flows are unusable without *some* produce-a-link affordance, which is as much confidence as an unbuilt feature allows. What cannot be promised: the SDK's name for it, sync vs async (upstream link-building needs overlay + peers from the session, so async is plausible — same adapter-sized delta class as `subscribeDoc`'s sync unsubscribe), and whether the value is a NURI string or a structured link. Therefore: **the returned value is opaque**; a consumer that stores it, transmits it, and hands it back unmodified learns nothing to unlearn; a consumer that parses it does.
|
||||||
|
2. ~~**Fix the comment-vs-code mismatch in `linkTo`.**~~ **VOID — the function is gone.** The observation it rested on survives and is worth keeping: `RepoLinkV0`-with-key IS the protected-document link, and circulating it out-of-band is the documented normal case (`:5059`), so a protected link carrying its key is not in itself a violation — with the § 4 durability caveat. What made `linkTo` wrong was not that the value carried a key; it was that a caller got one **by asking for a reference**. Handing over a key must be its own act, which is what `inbox.share` is.
|
||||||
|
3. **The recipient verb is missing.** Nothing exported ingests an out-of-band link: `learn` is reached only by inbox processing and the connection drain (`packages/polyfill/src/surface/inbox.ts:410`, `packages/polyfill/src/emulated-verifier/connect.ts:68`), and `getCaps()` is documented machinery (api-contract §15). The model names the recipient act precisely — open the link: load the repo from its read cap, file `AddLink` durably on the User branch, subscribe (`:5059`; `engine/repo/src/types.rs:1934-1950`; `verifier.rs:2237`). Suggested surface, same epistemic label as point 1: `openLink(link: string): Promise<Nuri>` — files the cap in the emulated registers and returns the cap-less target for use in reads. Without it, path 2 has a producer and no consumer, and the multi-actor test where Bob *obtains* the document through calls (never through a shared variable) cannot be written — the exact failure mode `rules/engineering/multi-actor-tests-obtain-not-receive.md` records.
|
||||||
|
4. **Do not add**: link options (expiry, audience, revoke-this-link), per-reader introspection for public documents, or any API that parses or inspects a link's insides — nothing upstream supports any of them, and each teaches a lever the model does not have.
|
||||||
|
|
||||||
|
## 6. The question for the NextGraph developer
|
||||||
|
|
||||||
|
What will an application hold and pass around to share **one document** out-of-band (email, QR) — a NURI string with `r:`/`l:` segments, a serialized `NgLink`, something else — and which SDK calls will produce it and open it? And for a *public* document, is the intended value the key-less `PublicRepoLink` form, i.e. should applications treat public sharing as never involving a per-reader capability?
|
||||||
@@ -37,7 +37,7 @@ Interpretation (**plausible mechanism, not settled**): the write was pushed into
|
|||||||
|
|
||||||
## What the SDK exposes but does not consume
|
## What the SDK exposes but does not consume
|
||||||
|
|
||||||
`disconnections_subscribe` **does fire** on this failure — but neither the polyfill (`@ng-eventually/client`) nor the consumer app subscribes to it. The signal exists, nobody listens to it; on the app side, no mechanism retries or warns the user.
|
`disconnections_subscribe` **does fire** on this failure — but neither the polyfill (`@ng-eventually/polyfill`) nor the consumer app subscribes to it. The signal exists, nobody listens to it; on the app side, no mechanism retries or warns the user.
|
||||||
|
|
||||||
## Scope & not reproduced
|
## Scope & not reproduced
|
||||||
|
|
||||||
|
|||||||
+45
-43
@@ -1,14 +1,16 @@
|
|||||||
# Internal contract — what `@ng-eventually/client` keeps off its surface, and what NextGraph does or would do about each subject
|
# Internal contract — what `@ng-eventually/polyfill` keeps off its surface, and what NextGraph does or would do about each subject
|
||||||
|
|
||||||
**Scope.** The complement of [`docs/api-contract.md`](./api-contract.md): every module export under `packages/client/src/` that is NOT reachable from the two published entry points (`package.json` maps exactly `.` → `src/index.ts` and `./polyfill` → `src/polyfill.ts`). A consumer never reads this document; a maintainer does. The internal code is held to the same standard as the surface — as close as possible to what NextGraph does or plans — so every subject below carries the same target-side analysis. Written 2026-08-04, verified against the `nextgraph-rs` clone (HEAD `213338f6`) and the installed `@ng-org/web@0.1.2-alpha.13` declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`).
|
> **Updated 2026-08-03, after the source layout was reorganised by migration fate** (`docs/source-layout-by-fate.md`). Paths, and three names, changed under this document: `readModel` became the directly-exported `readUnion`; `accounts` / `AccountRecord` / `AccountStorage` became `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage` (module `shared-wallet/virtual-users.ts`); `store-registry-api.ts` became `surface/placement.ts`. Two modules were created and are covered here: `emulated-verifier/branch-registers.ts` (the four durable registers, split out of the shim) and `shared-wallet/bootstrap.ts` (the injection store, split out of the `/polyfill` entry). The subject-by-subject rulings below are unaffected — what moved is where the code lives, not what it promises.
|
||||||
|
|
||||||
**How the boundary was computed — mechanically, from the `export` statements.** `index.ts` re-exports wholesale (`export *` / `export * as ns`) from `types.ts`, `inbox.ts`, `docs.ts`, `read-model.ts`, and by name everything `use-shape.ts`, `watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, plus `isNuri`/`hasReadCap` from `nuri.ts` and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`store-registry-api.ts` slice only** (7 functions: `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `walletInbox`, `openDocumentInbox`, `documentInboxAddress`). `polyfill.ts` re-exports `CapRegistry` from `caps.ts`, `shareCap` from `inbox.ts`, `connectedUser` from `connect.ts`, `* as accounts` from `accounts.ts`, and the types `AccountStorage`, `AccountRecord`, `RegistrySession`. Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `access-log.ts`, `machinery.ts`, `ng-proxy.ts`, `open-repo.ts`, `outbox-log.ts`, `physical.ts`, `reach.ts`, `read-filter.ts`. Four are internal in part: `nuri.ts`, `connect.ts`, `subscribe.ts`, `store-registry.ts`.
|
**Scope.** The complement of [`docs/api-contract.md`](./api-contract.md): every module export under `packages/polyfill/src/` that is NOT reachable from the published entry point (`package.json` maps exactly `.` → `src/index.ts`). A consumer never reads this document; a maintainer does. The internal code is held to the same standard as the surface — as close as possible to what NextGraph does or plans — so every subject below carries the same target-side analysis. Written 2026-08-04, verified against the `nextgraph-rs` clone (HEAD `213338f6`) and the installed `@ng-org/web@0.1.2-alpha.13` declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`).
|
||||||
|
|
||||||
|
**How the boundary was computed — mechanically, from the `export` statements.** `index.ts` re-exports wholesale (`export *` / `export * as ns`) from `types.ts`, `inbox.ts`, `docs.ts`, `surface/read-model.ts`, and by name everything `surface/use-shape.ts`, `surface/watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`surface/placement.ts` slice only** (`createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `openDocumentInbox`). `model/nuri.ts` is now internal in full — the guards `isNuri`/`hasReadCap` stopped being published when the entries became permissive-in (`NuriLike` validated at the door). *(The second entry, `polyfill.ts`, was merged into `index.ts` on 2026-08-07, and the polyfill-era block was then cut to ONE published call, `configure`. `configureStoreRegistry`, `setCurrentUser` and `connectedUser` became internal the same day — folded, replaced by the gate, and awaited inside it respectively; the machinery accessors and test resets are internal too. All four are inventoried below.)* Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `shared-wallet/access-log.ts`, `emulated-verifier/machinery.ts`, `surface/ng-proxy.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/outbox-log.ts`, `shared-wallet/physical.ts`, `emulated-verifier/reach.ts`, `emulated-verifier/read-filter.ts`. Four are internal in part: `nuri.ts`, `emulated-verifier/connect.ts`, `subscribe.ts`, `shared-wallet/account-registry.ts`.
|
||||||
|
|
||||||
**Labels** are those of `docs/api-contract.md`: **PASSTHROUGH (level 3/2, VERIFIED)**, **LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED)**, **ASSUMPTION**, **NO COUNTERPART**. Level numbers per `README.md` § *The three references*: 3 = JS ORM, 2 = wasm binding (`@ng-org/web`), 1 = Rust engine. One label recurs here that the surface contract rarely needs: **NO COUNTERPART, shared-wallet machinery** — the code below the emulation's floor, which the target has no image of because the target has no shared wallet. Per the design principle, an absent implementation is never treated as evidence about the future.
|
**Labels** are those of `docs/api-contract.md`: **PASSTHROUGH (level 3/2, VERIFIED)**, **LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED)**, **ASSUMPTION**, **NO COUNTERPART**. Level numbers per `README.md` § *The three references*: 3 = JS ORM, 2 = wasm binding (`@ng-org/web`), 1 = Rust engine. One label recurs here that the surface contract rarely needs: **NO COUNTERPART, shared-wallet machinery** — the code below the emulation's floor, which the target has no image of because the target has no shared wallet. Per the design principle, an absent implementation is never treated as evidence about the future.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. The wrapped `ng` factory — `ng-proxy.ts`
|
## 1. The wrapped `ng` factory — `surface/ng-proxy.ts`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// ng-proxy.ts:10
|
// ng-proxy.ts:10
|
||||||
@@ -22,23 +24,23 @@ Builds the published `ng` Proxy (consumed once, `index.ts:61`): forwards every p
|
|||||||
- **Defect — the `login` arm fabricates a member (see Findings F1).** `@ng-org/web` has no `login`: none among the exports of `index.d.ts` (re-verified), and no `fn login` in `sdk/js/lib-wasm/src/lib.rs`. The proxy nevertheless returns a function for `prop === "login"` (`ng-proxy.ts:16-22`), so `typeof ng.login === "function"` on the wrapper while the real SDK yields `undefined` — the one place the proxy adds a member, contradicting its own header and the surface contract's "adds no member and removes none" (§ 3). Calling it throws at runtime (`ng[prop]` is undefined). **ASSUMPTION with no provenance** — no target layer names a `login`.
|
- **Defect — the `login` arm fabricates a member (see Findings F1).** `@ng-org/web` has no `login`: none among the exports of `index.d.ts` (re-verified), and no `fn login` in `sdk/js/lib-wasm/src/lib.rs`. The proxy nevertheless returns a function for `prop === "login"` (`ng-proxy.ts:16-22`), so `typeof ng.login === "function"` on the wrapper while the real SDK yields `undefined` — the one place the proxy adds a member, contradicting its own header and the surface contract's "adds no member and removes none" (§ 3). Calling it throws at runtime (`ng[prop]` is undefined). **ASSUMPTION with no provenance** — no target layer names a `login`.
|
||||||
- Disappears at migration (the whole module).
|
- Disappears at migration (the whole module).
|
||||||
|
|
||||||
## 2. NURI internals — the unexported slice of `nuri.ts`
|
## 2. NURI internals — the unexported slice of `nuri.ts`, plus the minting point
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// nuri.ts:73
|
// model/nuri.ts
|
||||||
export function targetOf(nuri: Nuri): Nuri;
|
export function targetOf(nuri: Nuri): Nuri;
|
||||||
// nuri.ts:82
|
|
||||||
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap };
|
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap };
|
||||||
// nuri.ts:116
|
// emulated-verifier/caps.ts — NOT `model/nuri.ts`; it moved when the source layout was
|
||||||
|
// reorganised by migration fate, and this block said `nuri.ts` until 2026-08-10.
|
||||||
export function mintCap(nuri: Nuri): ReadCap;
|
export function mintCap(nuri: Nuri): ReadCap;
|
||||||
```
|
```
|
||||||
|
|
||||||
`targetOf` strips a `:r:` cap segment to the naming form; `parseNuri` is the parsed pair; `mintCap` builds the cap-bearing form with the stand-in value `OK`. Kept off the surface deliberately: nothing published turns a bare reference into a cap.
|
`targetOf` strips a `:r:` cap segment to the naming form; `parseNuri` is the parsed pair; `mintCap` builds the cap-bearing form with the stand-in value `OK` (`STAND_IN_CAP`). Kept off the surface deliberately: nothing published turns a bare reference into a cap.
|
||||||
|
|
||||||
- `targetOf` / `parseNuri` — **LEVEL-1 SHAPE, model VERIFIED**: a 1:1 mirror of upstream's one-type-with-optional-access NURI. The ReadCap encoding they discriminate on is `r:{base64url(serde_bare(ObjectRef))}` (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`), distinct from the `:k:` object/commit forms (`object_nuri`/`commit_nuri`, `types.rs:510-514`). No JS surface parses NURIs at level 2 or 3 — the real SDK takes plain strings — so these helpers never surface in signatures and survive only as internals.
|
- `targetOf` / `parseNuri` — **LEVEL-1 SHAPE, model VERIFIED**: they transcribe upstream's one-type-with-optional-access NURI, on the **two** fields this library uses. Not a "1:1 mirror" of `NuriV0`, as this line claimed until 2026-08-10: that type has TEN fields — `identity, target, entire_store, objects, signature, branch, overlay, access, topic, locator` (`engine/net/src/app_protocol.rs:181-194`) — of which `parseNuri` carries `target` and the cap half of `access`. The other eight have no counterpart here (the missing `locator` is a stated gap, `docs/readcap-and-nuri-model.md` § 4sexies). The ReadCap encoding they discriminate on is `r:{base64url(serde_bare(ObjectRef))}` (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`), distinct from the `:k:` object/commit forms (`object_nuri`/`commit_nuri`, `types.rs:510-514`). No JS surface parses NURIs at level 2 or 3 — the real SDK takes plain strings — so these helpers never surface in signatures and survive only as internals.
|
||||||
- `mintCap` — **NO COUNTERPART as an operation, and that is the point**: upstream a ReadCap is produced by the engine when a repo is created, never derived from a bare reference by a caller. `mintCap` exists solely because the emulation needs a cap VALUE at creation time and P1b has not yet supplied real key material; the constant `OK` pretends nothing (`nuri.ts:87-103`). It has exactly two call sites (`store-registry.ts` `createEntityDoc`; `caps.ts` internals) — the minting points of the emulation. At P1b the constant becomes a real key; at migration the function is deleted (the engine mints).
|
- `mintCap` — **NO COUNTERPART as an operation, and that is the point**: upstream a ReadCap is produced by the engine when a repo is created, never derived from a bare reference by a caller. `mintCap` exists solely because the emulation needs a cap VALUE at creation time and cap-enforcement has not yet supplied real key material; the constant `OK` pretends nothing (`STAND_IN_CAP`, `emulated-verifier/caps.ts`). It has exactly two call sites (`shared-wallet/account-registry.ts` `createEntityDoc`; `emulated-verifier/caps.ts` internals) — the minting points of the emulation. With cap-enforcement the constant becomes a real key; at migration the function is deleted (the engine mints).
|
||||||
|
|
||||||
## 3. The reach boundary — `reach.ts`
|
## 3. The reach boundary — `emulated-verifier/reach.ts`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// reach.ts:66
|
// reach.ts:66
|
||||||
@@ -58,10 +60,10 @@ export function mustNotAttempt(nuri: Nuri): boolean;
|
|||||||
The single predicate deciding whether the CONNECTED virtual user may touch a document at all: cap possession, or explicitly-declared infrastructure (the store-root and doc-shim). `assertMayReach` guards the passage points (rule 1, throw on refusal); `mustNotAttempt` guards the callers (rule 2, do not even issue the operation). Inert until the first cap exists (`caps.isEnforcing()`).
|
The single predicate deciding whether the CONNECTED virtual user may touch a document at all: cap possession, or explicitly-declared infrastructure (the store-root and doc-shim). `assertMayReach` guards the passage points (rule 1, throw on refusal); `mustNotAttempt` guards the callers (rule 2, do not even issue the operation). Inert until the first cap exists (`caps.isEnforcing()`).
|
||||||
|
|
||||||
- **NO COUNTERPART, shared-wallet machinery — the emulated stand-in for the wallet boundary itself.** In the target the boundary is cryptographic, not a predicate: a repo whose cap the wallet does not hold is never decrypted, a targeted read of it errors `RepoNotFound` (`resolve_target_for_sparql`, `engine/verifier/src/request_processor.rs:264,269`), and the only path that loads a repo from a cap is `pub(crate)` (`Verifier::load_repo_from_read_cap`, `engine/verifier/src/verifier.rs:2237`). The two-rules split (refuse at the gate AND do not attempt) is redundancy this lib chose; upstream only "cannot" exists — there is nothing to refuse because the request cannot be formed.
|
- **NO COUNTERPART, shared-wallet machinery — the emulated stand-in for the wallet boundary itself.** In the target the boundary is cryptographic, not a predicate: a repo whose cap the wallet does not hold is never decrypted, a targeted read of it errors `RepoNotFound` (`resolve_target_for_sparql`, `engine/verifier/src/request_processor.rs:264,269`), and the only path that loads a repo from a cap is `pub(crate)` (`Verifier::load_repo_from_read_cap`, `engine/verifier/src/verifier.rs:2237`). The two-rules split (refuse at the gate AND do not attempt) is redundancy this lib chose; upstream only "cannot" exists — there is nothing to refuse because the request cannot be formed.
|
||||||
- The infrastructure exemption (`declareInfrastructure`, registered by `store-registry.ts` for the store-root and doc-shim only) has **no image in the target**: there is no shim to exempt. Registration-not-pattern-matching is a lib-internal safety choice.
|
- The infrastructure exemption (`declareInfrastructure`, registered by `shared-wallet/account-registry.ts` for the store-root and doc-shim only) has **no image in the target**: there is no shim to exempt. Registration-not-pattern-matching is a lib-internal safety choice.
|
||||||
- Everything here disappears at migration; the durable lesson it protects (naming a document does not grant access) is the target's own model.
|
- Everything here disappears at migration; the durable lesson it protects (naming a document does not grant access) is the target's own model.
|
||||||
|
|
||||||
## 4. The physical user's primitives — `physical.ts`
|
## 4. The physical user's primitives — `shared-wallet/physical.ts`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// physical.ts:54
|
// physical.ts:54
|
||||||
@@ -84,11 +86,11 @@ The unguarded counterparts of `docs.docCreate` / `sparqlQuery` / `sparqlUpdate`,
|
|||||||
export function subscribePhysicalDoc(nuri: Nuri, onChange: (r: DocChange, type: DocChangeType) => void): Unsubscribe;
|
export function subscribePhysicalDoc(nuri: Nuri, onChange: (r: DocChange, type: DocChangeType) => void): Unsubscribe;
|
||||||
```
|
```
|
||||||
|
|
||||||
`subscribeDoc` minus the reach guard — the machinery's door to `doc_subscribe`, used by `open-repo.ts` to hold shim repos open. Same wire behaviour as the published `subscribeDoc` (analysed in `docs/api-contract.md` § 8, target `doc_subscribe`, `index.d.ts:66`, `sdk/js/lib-wasm/src/lib.rs:1908`).
|
`subscribeDoc` minus the reach guard — the machinery's door to `doc_subscribe`, used by `emulated-verifier/open-repo.ts` to hold shim repos open. Same wire behaviour as the published `subscribeDoc` (analysed in `docs/api-contract.md` § 8, target `doc_subscribe`, `index.d.ts:66`, `sdk/js/lib-wasm/src/lib.rs:1908`).
|
||||||
|
|
||||||
- **NO COUNTERPART, shared-wallet machinery** — the guarded/unguarded pair collapses to one call when the wallet is the boundary. Disappears with `physical.ts`.
|
- **NO COUNTERPART, shared-wallet machinery** — the guarded/unguarded pair collapses to one call when the wallet is the boundary. Disappears with `shared-wallet/physical.ts`.
|
||||||
|
|
||||||
## 6. Bootstrap repo opening — `open-repo.ts`
|
## 6. Bootstrap repo opening — `emulated-verifier/open-repo.ts`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// open-repo.ts:75
|
// open-repo.ts:75
|
||||||
@@ -111,11 +113,11 @@ Heals the cold-start defect of the anchored read path: on a fresh session a not-
|
|||||||
|
|
||||||
- The opening mechanism is **level 2, VERIFIED as a composition**: `doc_subscribe` exists (`sdk/js/lib-wasm/src/lib.rs:1908`), and the push variants `TabInfo`/`State`/`Patch` are the engine's `AppResponseV0` (`engine/net/src/app_protocol.rs:1354-1358`). The ORDER (TabInfo first, then the initial State) and "first State = presence guaranteed, absence definitive" are **empirical, pinned by the in-repo e2e CONTRACT-3 probe — an ASSUMPTION about ordering as far as upstream is concerned**: no upstream statement fixes the push order, so a future reordering upstream would silently break the barrier. Bound: the e2e probe fails loudly if the order changes.
|
- The opening mechanism is **level 2, VERIFIED as a composition**: `doc_subscribe` exists (`sdk/js/lib-wasm/src/lib.rs:1908`), and the push variants `TabInfo`/`State`/`Patch` are the engine's `AppResponseV0` (`engine/net/src/app_protocol.rs:1354-1358`). The ORDER (TabInfo first, then the initial State) and "first State = presence guaranteed, absence definitive" are **empirical, pinned by the in-repo e2e CONTRACT-3 probe — an ASSUMPTION about ordering as far as upstream is concerned**: no upstream statement fixes the push order, so a future reordering upstream would silently break the barrier. Bound: the e2e probe fails loudly if the order changes.
|
||||||
- "Hold a live subscription to keep the repo open" — **ASSUMPTION** (nothing upstream documents subscription lifetime as what retains a repo in `self.repos`); observed to work, bounded by the same probe.
|
- "Hold a live subscription to keep the repo open" — **ASSUMPTION** (nothing upstream documents subscription lifetime as what retains a repo in `self.repos`); observed to work, bounded by the same probe.
|
||||||
- **Defect — the header's mechanism claim is contradicted at the source (see Findings F2).** `open-repo.ts:10-12` says an anchored `sparql_query` on a repo absent from `self.repos` "silently returns 0 rows (never a `RepoNotFound`)". Verified upstream: absence from `self.repos` yields `Err(NgError::RepoNotFound)` (`request_processor.rs:264,269`), the ReadQuery arm converts it into `AppResponse::error` (`:1293-1296`), and the web binding REJECTS the JS promise with it (`sdk/js/lib-wasm/src/lib.rs:606`). The observed 0-rows-no-error behaviour has two candidate explanations that the source does support: a persistent verifier reloads every known repo into `self.repos` at `Verifier::load` (`engine/verifier/src/verifier.rs:535-560`) so the repo is present-but-unsynced (a genuine 0-row read), and/or the lib's own per-doc tolerance (`readUserStore`, `read-model.ts` per-doc catch) converts a rejection into an empty result. The healed symptom is real and the fix correct; the stated mechanism is not established, and a maintainer reasoning from it would mispredict behaviour whenever the verifier is not persistent.
|
- **Defect — the header's mechanism claim is contradicted at the source (see Findings F2).** `open-repo.ts:10-12` says an anchored `sparql_query` on a repo absent from `self.repos` "silently returns 0 rows (never a `RepoNotFound`)". Verified upstream: absence from `self.repos` yields `Err(NgError::RepoNotFound)` (`request_processor.rs:264,269`), the ReadQuery arm converts it into `AppResponse::error` (`:1293-1296`), and the web binding REJECTS the JS promise with it (`sdk/js/lib-wasm/src/lib.rs:606`). The observed 0-rows-no-error behaviour has two candidate explanations that the source does support: a persistent verifier reloads every known repo into `self.repos` at `Verifier::load` (`engine/verifier/src/verifier.rs:535-560`) so the repo is present-but-unsynced (a genuine 0-row read), and/or the lib's own per-doc tolerance (`readUserStore`, `surface/read-model.ts` per-doc catch) converts a rejection into an empty result. The healed symptom is real and the fix correct; the stated mechanism is not established, and a maintainer reasoning from it would mispredict behaviour whenever the verifier is not persistent.
|
||||||
- `SyncState` and `getSyncState` are lib-invented vocabulary — **NO COUNTERPART** (upstream has no consumer-facing "sync state of a repo" API at any level; `OrmSubscription.readyPromise`, `sdk/js/orm/src/connector/GraphOrmSubscription.ts:260`, is the closest level-3 signal, per-subscription not per-repo).
|
- `SyncState` and `getSyncState` are lib-invented vocabulary — **NO COUNTERPART** (upstream has no consumer-facing "sync state of a repo" API at any level; `OrmSubscription.readyPromise`, `sdk/js/orm/src/connector/GraphOrmSubscription.ts:260`, is the closest level-3 signal, per-subscription not per-repo).
|
||||||
- At migration the whole module becomes "open the store by cap at bootstrap" (native) and is removed with the shim.
|
- At migration the whole module becomes "open the store by cap at bootstrap" (native) and is removed with the shim.
|
||||||
|
|
||||||
## 7. The read filter — `read-filter.ts`
|
## 7. The read filter — `emulated-verifier/read-filter.ts`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// read-filter.ts:47
|
// read-filter.ts:47
|
||||||
@@ -124,27 +126,27 @@ export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[];
|
|||||||
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S;
|
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S;
|
||||||
```
|
```
|
||||||
|
|
||||||
The polyfill of capability-based read access: a Proxy view over the reactive set keeping only items whose `@graph` document the current holder holds; applied by `use-shape.ts` once `caps.isEnforcing()`.
|
The polyfill of capability-based read access: a Proxy view over the reactive set keeping only items whose `@graph` document the current holder holds; applied by `surface/use-shape.ts` once `caps.isEnforcing()`.
|
||||||
|
|
||||||
- **NO COUNTERPART, by design — it stands in for cryptographic non-delivery.** In the target the broker/verifier simply never yields what the wallet holds no cap for (targeted read errors, `request_processor.rs:264,269`; union read yields nothing for undecrypted repos — the § 4 analysis of `docs/api-contract.md`). There is no post-hoc filter to migrate to; the module is deleted.
|
- **NO COUNTERPART, by design — it stands in for cryptographic non-delivery.** In the target the broker/verifier simply never yields what the wallet holds no cap for (targeted read errors, `request_processor.rs:264,269`; union read yields nothing for undecrypted repos — the § 4 analysis of `docs/api-contract.md`). There is no post-hoc filter to migrate to; the module is deleted.
|
||||||
- The `@graph` key it filters on is **level 3, VERIFIED**: the ORM annotates every object with its graph NURI (`sdk/js/orm/src/frontendAdapters/react/useShape.ts:41`, `sdk/js/orm/src/types.ts:19`). Items with no `@graph` are kept — a lib policy choice (they name no document), not an upstream rule.
|
- The `@graph` key it filters on is **level 3, VERIFIED**: the ORM annotates every object with its graph NURI (`sdk/js/orm/src/frontendAdapters/react/useShape.ts:41`, `sdk/js/orm/src/model/types.ts:19`). Items with no `@graph` are kept — a lib policy choice (they name no document), not an upstream rule.
|
||||||
- Access unit = the DOCUMENT, not the item — faithful to the model (a ReadCap opens a repo, `types.rs:518-521`), and the reason the filter is all-or-nothing per document.
|
- Access unit = the DOCUMENT, not the item — faithful to the model (a ReadCap opens a repo, `types.rs:518-521`), and the reason the filter is all-or-nothing per document.
|
||||||
|
|
||||||
## 8. Connection trigger — the unexported slice of `connect.ts`
|
## 8. Connection trigger — the unexported slice of `emulated-verifier/connect.ts`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// connect.ts:91
|
// connect.ts:91
|
||||||
export function startConnect(): void;
|
export function startConnect(): void;
|
||||||
```
|
```
|
||||||
|
|
||||||
Fire-and-forget wrapper over the published `connectedUser()` (restore Links, then drain every inbox), called by `setCurrentUser` so inbox processing is the library's job, not the app's.
|
Fire-and-forget wrapper over `connectedUser()` (internal since 2026-08-07, awaited inside `ensureIdentity`) (restore Links, then drain every inbox), called by `setCurrentUser` so inbox processing is the library's job, not the app's.
|
||||||
|
|
||||||
- **LEVEL-1 SHAPE for the timing, VERIFIED**: upstream the recipient's verifier processes inbox messages as they arrive, with no consumer call (`Verifier::inbox` → `process_inbox`, `engine/verifier/src/verifier.rs:1674-1690`); firing on connection is the emulation's equivalent moment. The restore-before-drain order is a lib choice; upstream "restore" does not exist as a step (applied caps are already in the User branch replay).
|
- **LEVEL-1 SHAPE for the timing, VERIFIED**: upstream the recipient's verifier processes inbox messages as they arrive, with no consumer call (`Verifier::inbox` → `process_inbox`, `engine/verifier/src/verifier.rs:1674-1690`); firing on connection is the emulation's equivalent moment. The restore-before-drain order is a lib choice; upstream "restore" does not exist as a step (applied caps are already in the User branch replay).
|
||||||
- `startConnect` itself disappears at migration; the automatic-processing behaviour it fabricates is native.
|
- `startConnect` itself disappears at migration; the automatic-processing behaviour it fabricates is native.
|
||||||
|
|
||||||
## 9. The shim registry — the unexported slice of `store-registry.ts`
|
## 9. The shim registry — the unexported slice of `shared-wallet/account-registry.ts`
|
||||||
|
|
||||||
The sharpest boundary case: `store-registry-api.ts` publishes the 7 app-facing calls; the 9 exports below stay internal (importable by the lib's modules, unit tests and the e2e harness, not by an application through the package entries). The types `AccountRecord` (`store-registry.ts:90`) and `RegistrySession` (`:234`) are published via `/polyfill` and covered by the surface contract.
|
The sharpest boundary case: `surface/placement.ts` publishes the 7 app-facing calls; the 9 exports below stay internal (importable by the lib's modules, unit tests and the e2e harness, not by an application through the package entries). The type `RegistrySession` is published by the entry (a consumer types its injected `getSession` with it) and covered by the surface contract; `VirtualUserRecord` is internal.
|
||||||
|
|
||||||
### 9a. Account shim — provision, resolve, reserved names, cache
|
### 9a. Account shim — provision, resolve, reserved names, cache
|
||||||
|
|
||||||
@@ -154,15 +156,15 @@ export function reservedAccount(name: string): string;
|
|||||||
// store-registry.ts:278
|
// store-registry.ts:278
|
||||||
export function resetRegistryCache(): void;
|
export function resetRegistryCache(): void;
|
||||||
// store-registry.ts:542
|
// store-registry.ts:542
|
||||||
export async function resolveAccount(id: string): Promise<AccountRecord | null>;
|
export async function resolveAccount(id: string): Promise<VirtualUserRecord | null>;
|
||||||
// store-registry.ts:631
|
// store-registry.ts:631
|
||||||
export async function ensureAccount(id: string): Promise<AccountRecord>;
|
export async function ensureAccount(id: string): Promise<VirtualUserRecord>;
|
||||||
```
|
```
|
||||||
|
|
||||||
`resolveAccount` — barrier-authoritative O(1) lookup of one account's record in the doc-shim; `ensureAccount` — resolve-or-provision (creates the three scope docs on first sight, concurrency-deduped); `reservedAccount` — NUL-prefixed sentinel namespace for lib-internal accounts; `resetRegistryCache` — test/wallet-switch reset.
|
`resolveAccount` — barrier-authoritative O(1) lookup of one account's record in the doc-shim; `ensureAccount` — resolve-or-provision (creates the three scope docs on first sight, concurrency-deduped); `reservedAccount` — NUL-prefixed sentinel namespace for lib-internal accounts; `resetRegistryCache` — test/wallet-switch reset.
|
||||||
|
|
||||||
- **NO COUNTERPART, shared-wallet machinery — the whole group.** The target has no directory of identities to resolve or provision: a user's site (three stores + their inboxes) is created once at wallet creation (`engine/verifier/src/site.rs` — the site-creation flow committing the stores and the two store-inbox `AddInboxCap`s at `:128,149`), and "which user" is the session. `ensureAccount`'s provision-on-first-sight has no target analogue and is exactly what `connectedUser` refuses to trigger (`connect.ts:60-65`). All of it disappears with the shim (`docs/migration-guide.md` § 3).
|
- **NO COUNTERPART, shared-wallet machinery — the whole group.** The target has no directory of identities to resolve or provision: a user's site (three stores + their inboxes) is created once at wallet creation (`engine/verifier/src/site.rs` — the site-creation flow committing the stores and the two store-inbox `AddInboxCap`s at `:128,149`), and "which user" is the session. `ensureAccount`'s provision-on-first-sight has no target analogue and is exactly what `connectedUser` refuses to trigger (`connect.ts:60-65`). All of it disappears with the shim (`docs/migration-guide.md` § 3).
|
||||||
- `reservedAccount`'s collision-safety rests on an **ASSUMPTION about a consumer-injected function**: the comment (`store-registry.ts:200-206`) asserts the injected `normalizeId` can never produce a U+0000-prefixed key, but `normalizeId` is injected by the consumer and the lib's own default is a bare `trim()` (`polyfill.ts:145`), which does not strip U+0000. Bound: a consumer id would have to begin with a literal NUL to collide — implausible from any UI, but the property is the consumer's to keep, not the lib's (see Findings F5).
|
- `reservedAccount`'s collision-safety rests on an **ASSUMPTION about a consumer-injected function**: the comment (`store-registry.ts:200-206`) asserts the injected `normalizeId` can never produce a U+0000-prefixed key, but `normalizeId` is injected by the consumer and the lib's own default is a bare `trim()` (`shared-wallet/bootstrap.ts`), which does not strip U+0000. Bound: a consumer id would have to begin with a literal NUL to collide — implausible from any UI, but the property is the consumer's to keep, not the lib's (see Findings F5).
|
||||||
|
|
||||||
### 9b. Scope-index resolution
|
### 9b. Scope-index resolution
|
||||||
|
|
||||||
@@ -199,10 +201,10 @@ export async function readLinks(): Promise<ReadCap[]>;
|
|||||||
|
|
||||||
File / read back a received cap on the emulated User branch of the private store — what makes a received cap durable across sessions.
|
File / read back a received cap on the emulated User branch of the private store — what makes a received cap durable across sessions.
|
||||||
|
|
||||||
- **LEVEL-1 SHAPE, model VERIFIED; no JS surface anywhere.** The record emulated is `AddLink { read_cap }` on the User branch — *"so that a user can share with all its device a new Link they received"*, external repos only (`engine/repo/src/types.rs:1934-1950`). Upstream the filing happens inside the verifier while processing the inbox; the future SDK most likely never exposes these as calls (the surface contract's § 12 finding, restated here because these are now internal-only: the one caller is `connect.ts` / `inbox.ts`, which is exactly where upstream's verifier sits).
|
- **LEVEL-1 SHAPE, model VERIFIED; no JS surface anywhere.** The record emulated is `AddLink { read_cap }` on the User branch — *"so that a user can share with all its device a new Link they received"*, external repos only (`engine/repo/src/types.rs:1934-1950`). Upstream the filing happens inside the verifier while processing the inbox; the future SDK most likely never exposes these as calls (the surface contract's § 12 finding, restated here because these are now internal-only: the one caller is `emulated-verifier/connect.ts` / `inbox.ts`, which is exactly where upstream's verifier sits).
|
||||||
- At migration both are deleted; the verifier files and replays.
|
- At migration both are deleted; the verifier files and replays.
|
||||||
|
|
||||||
## 10. The machinery namespace — `machinery.ts`
|
## 10. The machinery namespace — `emulated-verifier/machinery.ts`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// machinery.ts:32
|
// machinery.ts:32
|
||||||
@@ -215,7 +217,7 @@ The URN prefix every triple the lib writes for itself lives under, and the one p
|
|||||||
|
|
||||||
- **NO COUNTERPART, by design — the seam where the emulation pays for having no branches.** Upstream the separation is structural: a compartment is a different BRANCH with its own CRDT and topic (`BranchType`, `engine/repo/src/types.rs:1536-1551`; the Store/User/Overlay branches carry no triples at all, `BranchCrdt::None`, `types.rs:1420`), so machinery cannot appear in a content read and no subject filter exists to write. The namespace, the filter, and the four `shim:*` compartment subjects it protects all disappear at migration.
|
- **NO COUNTERPART, by design — the seam where the emulation pays for having no branches.** Upstream the separation is structural: a compartment is a different BRANCH with its own CRDT and topic (`BranchType`, `engine/repo/src/types.rs:1536-1551`; the Store/User/Overlay branches carry no triples at all, `BranchCrdt::None`, `types.rs:1420`), so machinery cannot appear in a content read and no subject filter exists to write. The namespace, the filter, and the four `shim:*` compartment subjects it protects all disappear at migration.
|
||||||
|
|
||||||
## 11. Diagnostics — `access-log.ts` and `outbox-log.ts`
|
## 11. Diagnostics — `shared-wallet/access-log.ts` and `shared-wallet/outbox-log.ts`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// access-log.ts:25,45,50,63,76,87,99,116
|
// access-log.ts:25,45,50,63,76,87,99,116
|
||||||
@@ -232,38 +234,38 @@ export function logAccess(op: AccessOp, nuri: string, label: string, extra?: str
|
|||||||
export function inspectOutbox(): void;
|
export function inspectOutbox(): void;
|
||||||
```
|
```
|
||||||
|
|
||||||
`access-log.ts` — the off-by-default per-identity access trace for the shared-wallet isolation leak (toggled by `configure({ debugAccessLog })` or `NG_EVENTUALLY_ACCESS_LOG=1`). `outbox-log.ts` — a read-only count of the real SDK's offline write outbox at session bootstrap, warning when non-empty.
|
`shared-wallet/access-log.ts` — the off-by-default per-identity access trace for the shared-wallet isolation leak (toggled by `configure({ debugAccessLog })` or `NG_EVENTUALLY_ACCESS_LOG=1`). `shared-wallet/outbox-log.ts` — a read-only count of the real SDK's offline write outbox at session bootstrap, warning when non-empty.
|
||||||
|
|
||||||
- `access-log.ts` — **NO COUNTERPART, shared-wallet machinery**: the leak it makes visible cannot exist in the target (isolation is per-wallet), and the "active identity" it prefixes is the relayed virtual id that disappears with `setCurrentUser`. Deleted at migration.
|
- `shared-wallet/access-log.ts` — **NO COUNTERPART, shared-wallet machinery**: the leak it makes visible cannot exist in the target (isolation is per-wallet), and the "active identity" it prefixes is the relayed virtual id that disappears with `setCurrentUser`. Deleted at migration.
|
||||||
- `outbox-log.ts` — **NO COUNTERPART as API, but every fact it relies on is level-2 VERIFIED** in the clone: the outbox is persisted through `JsStorageConfig` (`sdk/rust/src/local_broker.rs:89-100`), keyed `ng_peer_last_seq@<peerId>` (`:119,141`) and `ng_outboxes@<peerId>@start` / `@<idx>` with zero-padded `{:05}` indexes (`:163-213`, pad at `:183,210`); the real `outbox_read_function` DRAINS on read (`session_del` per key plus the start key, `:218-224`) — which is why the probe only counts and never touches; and the storage callbacks land in browser `sessionStorage` (`sdk/js/api-web/main.ts:47,57,66`), whose access-denied error string is the one `convert_error` handles (`main.ts:18-22`). The probe reads a private persistence format of the injected SDK — acknowledged in its header as out-of-contract, hence count-only. Deleted with the rest of the trace instrumentation at migration.
|
- `shared-wallet/outbox-log.ts` — **NO COUNTERPART as API, but every fact it relies on is level-2 VERIFIED** in the clone: the outbox is persisted through `JsStorageConfig` (`sdk/rust/src/local_broker.rs:89-100`), keyed `ng_peer_last_seq@<peerId>` (`:119,141`) and `ng_outboxes@<peerId>@start` / `@<idx>` with zero-padded `{:05}` indexes (`:163-213`, pad at `:183,210`); the real `outbox_read_function` DRAINS on read (`session_del` per key plus the start key, `:218-224`) — which is why the probe only counts and never touches; and the storage callbacks land in browser `sessionStorage` (`sdk/js/api-web/main.ts:47,57,66`), whose access-denied error string is the one `convert_error` handles (`main.ts:18-22`). The probe reads a private persistence format of the injected SDK — acknowledged in its header as out-of-contract, hence count-only. Deleted with the rest of the trace instrumentation at migration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Findings — defects and migration risks
|
## Findings — defects and migration risks
|
||||||
|
|
||||||
**F1 — `ng-proxy.ts` fabricates a `login` member the real SDK does not have.** `ng-proxy.ts:16-22` intercepts `prop === "login"`, but `@ng-org/web` exports no `login` (none in `index.d.ts`, re-verified against the full `declare function` list; no `fn login` in `sdk/js/lib-wasm/src/lib.rs`). On the wrapper `ng.login` is a function; on the real SDK it is `undefined`; calling it throws. This contradicts the module's own "surface stays identical" header and `docs/api-contract.md` § 3's "the proxy adds no member and removes none". No target layer names a `login` — the arm is an unprovenanced assumption. Cheap fix: drop the `login` case (keep `session_start`), or gate it on `typeof ng.login === "function"`.
|
**F1 — `surface/ng-proxy.ts` fabricates a `login` member the real SDK does not have.** `ng-proxy.ts:16-22` intercepts `prop === "login"`, but `@ng-org/web` exports no `login` (none in `index.d.ts`, re-verified against the full `declare function` list; no `fn login` in `sdk/js/lib-wasm/src/lib.rs`). On the wrapper `ng.login` is a function; on the real SDK it is `undefined`; calling it throws. This contradicts the module's own "surface stays identical" header and `docs/api-contract.md` § 3's "the proxy adds no member and removes none". No target layer names a `login` — the arm is an unprovenanced assumption. Cheap fix: drop the `login` case (keep `session_start`), or gate it on `typeof ng.login === "function"`.
|
||||||
|
|
||||||
**F2 — `open-repo.ts`'s stated mechanism is contradicted at the source.** The header (`open-repo.ts:10-12`) asserts an anchored `sparql_query` on a repo absent from `self.repos` "silently returns 0 rows (never a `RepoNotFound`)". Upstream, absence from `self.repos` errors `RepoNotFound` (`engine/verifier/src/request_processor.rs:264,269`), the ReadQuery arm wraps it as `AppResponse::error` (`:1293-1296`), and the web binding rejects the JS promise (`sdk/js/lib-wasm/src/lib.rs:606`). The observed behaviour is real but its cause is one (or both) of: the repo WAS in `self.repos` (a persistent verifier reloads all known repos at `Verifier::load`, `engine/verifier/src/verifier.rs:535-560`) and read 0 rows because unsynced; or the lib's own catch-and-continue layers absorbed the rejection. The fix (open before reading) is correct either way; the diagnosis in the header should not be relied on, and mispredicts non-persistent-verifier behaviour.
|
**F2 — `emulated-verifier/open-repo.ts`'s stated mechanism is contradicted at the source.** The header (`open-repo.ts:10-12`) asserts an anchored `sparql_query` on a repo absent from `self.repos` "silently returns 0 rows (never a `RepoNotFound`)". Upstream, absence from `self.repos` errors `RepoNotFound` (`engine/verifier/src/request_processor.rs:264,269`), the ReadQuery arm wraps it as `AppResponse::error` (`:1293-1296`), and the web binding rejects the JS promise (`sdk/js/lib-wasm/src/lib.rs:606`). The observed behaviour is real but its cause is one (or both) of: the repo WAS in `self.repos` (a persistent verifier reloads all known repos at `Verifier::load`, `engine/verifier/src/verifier.rs:535-560`) and read 0 rows because unsynced; or the lib's own catch-and-continue layers absorbed the rejection. The fix (open before reading) is correct either way; the diagnosis in the header should not be relied on, and mispredicts non-persistent-verifier behaviour.
|
||||||
|
|
||||||
**F3 — incomplete citation in `subscribe.ts`.** `subscribe.ts:31` cites the ORM fan-out abort as "`initialize.rs:125-128`" with no path. The file is `engine/verifier/src/orm/graph/initialize.rs`; lines 125-128 are the graph loop calling `self.open_for_target(&nuri.target, true).await?` — verified, the `?` propagates `RepoNotFound` and aborts the whole subscription. Substance correct; the bare filename is unfindable without this note.
|
**F3 — incomplete citation in `subscribe.ts`.** `subscribe.ts:31` cites the ORM fan-out abort as "`initialize.rs:125-128`" with no path. The file is `engine/verifier/src/orm/graph/initialize.rs`; lines 125-128 are the graph loop calling `self.open_for_target(&nuri.target, true).await?` — verified, the `?` propagates `RepoNotFound` and aborts the whole subscription. Substance correct; the bare filename is unfindable without this note.
|
||||||
|
|
||||||
**F4 — `docs/api-contract.md` lags the `store-registry-api.ts` split.** Its § 12 and appendix still list `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `reservedAccount`, `resetRegistryCache` as the SDK entry's `storeRegistry` namespace, and § 13/§ 15 place `accounts.*` on the SDK entry — since the split (`index.ts:34` routes through `store-registry-api.ts`; `polyfill.ts:238` carries `accounts`) those are internal or `/polyfill`. That file is being edited concurrently; noted here, deliberately not fixed by this document.
|
**F4 — `docs/api-contract.md` lags the `surface/placement.ts` split.** Its § 12 and appendix still list `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `reservedAccount`, `resetRegistryCache` as the SDK entry's `storeRegistry` namespace, and § 13/§ 15 place `virtualUsers.*` on the SDK entry — since the split (`index.ts` routes through `surface/placement.ts`) those are internal. That file is being edited concurrently; noted here, deliberately not fixed by this document.
|
||||||
|
|
||||||
**F5 — `reservedAccount`'s collision guarantee is asserted about code the lib does not own.** `store-registry.ts:200-206` states the injected `normalizeId` "strips a leading `@`, trims, and lowercases, so a NUL prefix is unreachable" — that describes ONE consumer's normalizer, not a contract; the lib's own default is `id.trim()` (`polyfill.ts:145`), which passes U+0000 through. The reserved namespace is disjoint only if every consumer's normalizer keeps it so. Either document the requirement on `StoreRegistryDeps.normalizeId`, or reject NUL-prefixed raw ids at `accountKey`.
|
**F5 — `reservedAccount`'s collision guarantee is asserted about code the lib does not own.** `store-registry.ts:200-206` states the injected `normalizeId` "strips a leading `@`, trims, and lowercases, so a NUL prefix is unreachable" — that describes ONE consumer's normalizer, not a contract; the lib's own default is `id.trim()` (`shared-wallet/bootstrap.ts`), which passes U+0000 through. The reserved namespace is disjoint only if every consumer's normalizer keeps it so. Either document the requirement on `StoreRegistryDeps.normalizeId`, or reject NUL-prefixed raw ids at `accountKey`.
|
||||||
|
|
||||||
**Migration-risk flags (shapes that will not travel):**
|
**Migration-risk flags (shapes that will not travel):**
|
||||||
|
|
||||||
- **The `(document, inbox)` pair is persisted as a space-joined string literal** (`"${doc} ${inbox}"`, written `store-registry.ts:1174`, parsed by `split(" ")` at `:1268`). Upstream the record is the typed `AddInboxCapV0 { repo_id, overlay, priv_key }` (`engine/repo/src/types.rs:1969-1981`). Internal-only and replaced wholesale at migration, but it is the one shim record with an ad hoc micro-format a future reader must know to parse.
|
- **The `(document, inbox)` pair is persisted as a space-joined string literal** (`"${doc} ${inbox}"`, written `store-registry.ts:1174`, parsed by `split(" ")` at `:1268`). Upstream the record is the typed `AddInboxCapV0 { repo_id, overlay, priv_key }` (`engine/repo/src/types.rs:1969-1981`). Internal-only and replaced wholesale at migration, but it is the one shim record with an ad hoc micro-format a future reader must know to parse.
|
||||||
- **`isOwnInbox` / `myInboxes`** encode questions the target answers only inside the verifier (§ 9c) — any new internal caller added to them deepens a dependency that has no successor API; keep callers to the read guard and the connection drain.
|
- **`isOwnInbox` / `myInboxes`** encode questions the target answers only inside the verifier (§ 9c) — any new internal caller added to them deepens a dependency that has no successor API; keep callers to the read guard and the connection drain.
|
||||||
- **The sync barrier is empirical** (§ 6): "TabInfo before the first State" and "held subscription keeps the repo open" are pinned by the in-repo e2e probe, not by any upstream statement. If upstream changes push ordering or repo retention, `open-repo.ts` is the module that breaks first; the probe is the tripwire.
|
- **The sync barrier is empirical** (§ 6): "TabInfo before the first State" and "held subscription keeps the repo open" are pinned by the in-repo e2e probe, not by any upstream statement. If upstream changes push ordering or repo retention, `emulated-verifier/open-repo.ts` is the module that breaks first; the probe is the tripwire.
|
||||||
- **`ensureAccount`'s provision-on-first-sight** (§ 9a) is a behaviour with no target image; `connect.ts` already refuses to trigger it. Any future internal path that provisions as a side effect of resolving would be teaching the emulation something the target contradicts (creation is an explicit act at wallet/site creation, `engine/verifier/src/site.rs`).
|
- **`ensureAccount`'s provision-on-first-sight** (§ 9a) is a behaviour with no target image; `emulated-verifier/connect.ts` already refuses to trigger it. Any future internal path that provisions as a side effect of resolving would be teaching the emulation something the target contradicts (creation is an explicit act at wallet/site creation, `engine/verifier/src/site.rs`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Appendix — full internal export inventory (for diffing)
|
## Appendix — full internal export inventory (for diffing)
|
||||||
|
|
||||||
Fully internal modules: `access-log.ts` (`AccessOp`, `setAccessLog`, `enabled`, `activeIdentity`, `accessLogPrefix`, `logStage`, `shortNuri`, `logAccess`); `machinery.ts` (`MACHINERY_NS`, `isMachinerySubject`); `ng-proxy.ts` (`makeNg`); `open-repo.ts` (`SyncState`, `setOpenTimeoutForTests`, `resetOpenedRepos`, `getSyncState`, `ensureRepoOpen`, `ensurePhysicalRepoOpen`, `ensureReposOpen`); `outbox-log.ts` (`inspectOutbox`); `physical.ts` (`physicalCreate`, `physicalQuery`, `physicalUpdate`); `reach.ts` (`declareInfrastructure`, `isInfrastructure`, `resetInfrastructure`, `mayReach`, `assertMayReach`, `mustNotAttempt`); `read-filter.ts` (`filterReadable`, `makeReadFilteredView`).
|
Fully internal modules: `shared-wallet/access-log.ts` (`AccessOp`, `setAccessLog`, `enabled`, `activeIdentity`, `accessLogPrefix`, `logStage`, `shortNuri`, `logAccess`); `emulated-verifier/machinery.ts` (`MACHINERY_NS`, `isMachinerySubject`); `surface/ng-proxy.ts` (`makeNg`); `emulated-verifier/open-repo.ts` (`SyncState`, `setOpenTimeoutForTests`, `resetOpenedRepos`, `getSyncState`, `ensureRepoOpen`, `ensurePhysicalRepoOpen`, `ensureReposOpen`); `shared-wallet/outbox-log.ts` (`inspectOutbox`); `shared-wallet/physical.ts` (`physicalCreate`, `physicalQuery`, `physicalUpdate`); `emulated-verifier/reach.ts` (`declareInfrastructure`, `isInfrastructure`, `resetInfrastructure`, `mayReach`, `assertMayReach`, `mustNotAttempt`); `emulated-verifier/read-filter.ts` (`filterReadable`, `makeReadFilteredView`).
|
||||||
|
|
||||||
Internal slices of partially-published modules: `nuri.ts` (`targetOf`, `parseNuri`, `mintCap`); `connect.ts` (`startConnect`); `subscribe.ts` (`subscribePhysicalDoc`); `store-registry.ts` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`).
|
Internal slices of partially-published modules: `model/nuri.ts` (`targetOf`, `parseNuri`) and `emulated-verifier/caps.ts` (`mintCap`); `emulated-verifier/connect.ts` (`startConnect`); `subscribe.ts` (`subscribePhysicalDoc`); `shared-wallet/account-registry.ts` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`).
|
||||||
|
|
||||||
Modules with no internal exports (everything they export is published): `types.ts`, `docs.ts`, `inbox.ts`, `read-model.ts`, `accounts.ts`, `caps.ts`, `sparql.ts`, `lifecycle.ts`, `use-shape.ts`, `watch-shape.ts`, `store-registry-api.ts`, and the two entry points.
|
Modules with no internal exports (everything they export is published): `types.ts`, `docs.ts`, `inbox.ts`, `surface/read-model.ts`, `shared-wallet/virtualUsers.ts`, `emulated-verifier/caps.ts`, `sparql.ts`, `lifecycle.ts`, `surface/use-shape.ts`, `surface/watch-shape.ts`, `surface/placement.ts`, and the entry point.
|
||||||
|
|||||||
+20
-16
@@ -14,30 +14,34 @@ has no clear target image, that is a drift signal (see
|
|||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
### 1. Emulated ReadCaps → real capabilities
|
### 1. Emulated ReadCaps → real capabilities
|
||||||
The shape is already the target's (P1a): a `ReadCap` is the document's key, a
|
The shape is already the target's (the cap-surface work): a `ReadCap` is the document's key, a
|
||||||
each identity holds a set of caps, and there is no read-ACL anywhere. So
|
each identity holds a set of caps, and there is no read-ACL anywhere. So
|
||||||
this step swaps the *emulated* key for the real one, not the model:
|
this step swaps the *emulated* key for the real one, not the model:
|
||||||
|
|
||||||
- `caps.ts`'s per-identity record becomes the verifier's own local user storage —
|
- `emulated-verifier/caps.ts`'s per-identity record becomes the verifier's own local user storage —
|
||||||
it was always the cache, not the register. The two durable registers we emulate
|
it was always the cache, not the register. The two durable registers we emulate
|
||||||
(`readCap` on the store's Store branch, `link` on its User branch) become the real
|
(`readCap` on the store's Store branch, `link` on its User branch) become the real
|
||||||
`AddRepo` / `AddLink` commits. Remove the emulation; the wallet and the branches
|
`AddRepo` / `AddLink` commits. Remove the emulation; the wallet and the branches
|
||||||
already hold them.
|
already hold them.
|
||||||
- `nuri.ts`'s stand-in cap value — the constant `OK` — becomes the real
|
- the stand-in cap value — the constant `OK` (`STAND_IN_CAP`,
|
||||||
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because
|
`emulated-verifier/caps.ts`) — becomes the real
|
||||||
|
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`, in that
|
||||||
|
same module since the source layout was reorganised — this said `nuri.ts` until
|
||||||
|
2026-08-10), because
|
||||||
every path now READS a stored cap instead of recomputing one. `hasReadCap` /
|
every path now READS a stored cap instead of recomputing one. `hasReadCap` /
|
||||||
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
|
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
|
||||||
- `shareCap(cap, toInbox)` becomes the native sealed delivery (whatever the SDK ends up naming it — see the note below
|
- `inbox.share(doc, toUser)` becomes the native sealed delivery (whatever the SDK ends up naming it — see the note below
|
||||||
and `ContactDetails.read_cap`), and `inbox.read`'s inline absorption becomes the
|
and `ContactDetails.read_cap`), and `inbox.read`'s inline absorption becomes the
|
||||||
recipient's own verifier applying queued messages. **The consumer's call does not
|
recipient's own verifier applying queued messages. **The consumer's call does not
|
||||||
change.**
|
change.**
|
||||||
- `publishRepoLink` becomes `RepoLinkV0`.
|
- `caps.markInPublicStore` and the whole of `emulated-verifier/public-store.ts` disappear: which store a document sits in stops being a fact we record, and serving a public store's repos becomes the broker's job (`expose_outer`, the ReadCap downloaded from the outer overlay — `PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). Nothing an application calls changes: it circulates bare references now, and will still.
|
||||||
- The read filter (`read-filter.ts`) and the possession gate in
|
- `assertMayWrite` goes with it — refusing a write on a cap the public store served is a stand-in for the write cap this emulation does not have.
|
||||||
|
- The read filter (`emulated-verifier/read-filter.ts`) and the possession gate in
|
||||||
`read-model.readUnion` are then dead code — the broker only delivers documents
|
`read-model.readUnion` are then dead code — the broker only delivers documents
|
||||||
whose cap the wallet holds. Remove them.
|
whose cap the wallet holds. Remove them.
|
||||||
- The write guard (`ng-proxy.ts` `sparql_update` override) is a separate axis and
|
- The write guard (`surface/ng-proxy.ts` `sparql_update` override) is a separate axis and
|
||||||
is decorative today (every internal writer bypasses the proxy); it belongs to the
|
is decorative today (every internal writer bypasses the proxy); it belongs to the
|
||||||
P1b batch, not here.
|
cap-enforcement batch, not here.
|
||||||
|
|
||||||
The access unit is already the document (`@graph`), matching the native per-repo cap
|
The access unit is already the document (`@graph`), matching the native per-repo cap
|
||||||
model, so this is a key-material step, not a reshape.
|
model, so this is a key-material step, not a reshape.
|
||||||
@@ -48,7 +52,7 @@ private store, and the `public|protected|private` scope is a logical label
|
|||||||
in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
|
in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
|
||||||
|
|
||||||
- `doc_create` cannot target a non-private native store today — verified:
|
- `doc_create` cannot target a non-private native store today — verified:
|
||||||
`StoreRepo` is not JS-constructible from the SDK, so there is no way to pass
|
`StoreRepo` is not constructible from the WEB build of the SDK, so there is no way to pass
|
||||||
a public/protected store as the create destination (`docCreate`'s trailing
|
a public/protected store as the create destination (`docCreate`'s trailing
|
||||||
`store` arg is left `undefined` → private store). The private store works only
|
`store` arg is left `undefined` → private store). The private store works only
|
||||||
because it opens without `RepoNotFound`.
|
because it opens without `RepoNotFound`.
|
||||||
@@ -56,7 +60,7 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
|
|||||||
`getNativeStore(scope)`-style resolver returning the real store to pass as the
|
`getNativeStore(scope)`-style resolver returning the real store to pass as the
|
||||||
`docCreate` destination, so the logical scope label becomes a real store
|
`docCreate` destination, so the logical scope label becomes a real store
|
||||||
placement. (No such helper exists yet — it is blocked on the SDK gap above.)
|
placement. (No such helper exists yet — it is blocked on the SDK gap above.)
|
||||||
- At that point `store-registry.ts` maps `(account, scope)` to the user's real
|
- At that point `shared-wallet/account-registry.ts` maps `(account, scope)` to the user's real
|
||||||
store NURI instead of a document in the shared wallet; the per-scope index
|
store NURI instead of a document in the shared wallet; the per-scope index
|
||||||
document (the store-container emulation) is replaced by the store itself. The
|
document (the store-container emulation) is replaced by the store itself. The
|
||||||
surface facing the consumer application (`createEntityDoc`, `listMyEntityDocs`,
|
surface facing the consumer application (`createEntityDoc`, `listMyEntityDocs`,
|
||||||
@@ -67,7 +71,7 @@ The `sharedWalletShim` (account → 3 scope-document NURIs, held in a subscribab
|
|||||||
doc-shim reached via a write-once pointer in the store-root — see
|
doc-shim reached via a write-once pointer in the store-root — see
|
||||||
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim
|
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim
|
||||||
indirection*) has no target equivalent — the target has no central directory. Remove
|
indirection*) has no target equivalent — the target has no central directory. Remove
|
||||||
it entirely: `store-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim
|
it entirely: `shared-wallet/account-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim
|
||||||
resolution, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out;
|
resolution, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out;
|
||||||
per-user wallets replace the shared one.
|
per-user wallets replace the shared one.
|
||||||
|
|
||||||
@@ -79,7 +83,7 @@ when it processes its inbox — there is no separate curator to build; the in-li
|
|||||||
emulation simply goes away. *(There is no global index to replace the cross-account fan-out: both were removed on 2026-07-30 — you cannot discover in NextGraph, you follow links.)*
|
emulation simply goes away. *(There is no global index to replace the cross-account fan-out: both were removed on 2026-07-30 — you cannot discover in NextGraph, you follow links.)*
|
||||||
|
|
||||||
### 5. Retire the identity store → real per-user login
|
### 5. Retire the identity store → real per-user login
|
||||||
Remove `accounts.ts` (the `IdentityStore` that persists the identity id in
|
Remove `shared-wallet/virtualUsers.ts` (the `IdentityStore` that persists the identity id in
|
||||||
`localStorage`) and the app-level "Connexion" screen. The technical broker gate
|
`localStorage`) and the app-level "Connexion" screen. The technical broker gate
|
||||||
becomes the real per-user login
|
becomes the real per-user login
|
||||||
(see [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
|
(see [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
|
||||||
@@ -95,15 +99,15 @@ The consumer application imports `@ng-org/web` / `@ng-org/orm` resolved to this
|
|||||||
via a build alias during the polyfill period. Removing the alias makes those imports
|
via a build alias during the polyfill period. Removing the alias makes those imports
|
||||||
resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical, so
|
resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical, so
|
||||||
no consumer code changes. The one non-SDK call — `configure(...)` /
|
no consumer code changes. The one non-SDK call — `configure(...)` /
|
||||||
`@ng-eventually/client/polyfill` — is deleted. The lib itself disappears.
|
the POLYFILL-ERA block of `@ng-eventually/polyfill` — is deleted. The lib itself disappears.
|
||||||
|
|
||||||
## The one break already taken: `declareConnections`
|
## The one break already taken: `declareConnections`
|
||||||
|
|
||||||
P1a broke the consumer once, deliberately and early, so that migration would not.
|
The cap-surface work broke the consumer once, deliberately and early, so that migration would not.
|
||||||
The old surface was an ACL held in memory, which forced the consumer to re-declare
|
The old surface was an ACL held in memory, which forced the consumer to re-declare
|
||||||
every grant on every session (`declareConnections`). That call **disappears**: with
|
every grant on every session (`declareConnections`). That call **disappears**: with
|
||||||
delivered caps the grant moves to the moment a connection is *accepted* — one
|
delivered caps the grant moves to the moment a connection is *accepted* — one
|
||||||
`shareCap(capFor(doc), theirInbox)` per document shared — and it persists, because
|
`inbox.share(doc, toUser)` per document shared — and it persists, because
|
||||||
the delivery lives in the recipient's inbox rather than in a map that empties at
|
the delivery lives in the recipient's inbox rather than in a map that empties at
|
||||||
reload. There is no analogue of `protectedDocsOf` + the re-derivation loop.
|
reload. There is no analogue of `protectedDocsOf` + the re-derivation loop.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Current-state NextGraph — what the SDK/broker do and do NOT expose
|
# Current-state NextGraph — what the SDK/broker do and do NOT expose
|
||||||
|
|
||||||
**Owner:** this library. `@ng-eventually/client` exists because the *current*
|
**Owner:** this library. `@ng-eventually/polyfill` exists because the *current*
|
||||||
NextGraph JS SDK is immature. This file is the authoritative reference on what
|
NextGraph JS SDK is immature. This file is the authoritative reference on what
|
||||||
today's SDK/broker actually give us — the ground truth every polyfill in this
|
today's SDK/broker actually give us — the ground truth every polyfill in this
|
||||||
lib compensates for. Read [`simulation.md`](./simulation.md) for how we emulate
|
lib compensates for. Read [`simulation.md`](./simulation.md) for how we emulate
|
||||||
@@ -86,7 +86,7 @@ users/quorum (write/permissions), **not** read-cap possession. (Repos of a
|
|||||||
> Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)):
|
> Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)):
|
||||||
> the read access unit is the repo = each item's `@graph` — a per-document
|
> the read access unit is the repo = each item's `@graph` — a per-document
|
||||||
> filter, never per-store and never per-item. This is exactly what
|
> filter, never per-store and never per-item. This is exactly what
|
||||||
> `caps.ts` (`CapRegistry`) and `read-filter.ts` model: no store-level
|
> `emulated-verifier/caps.ts` (`CapRegistry`) and `emulated-verifier/read-filter.ts` model: no store-level
|
||||||
> inheritance, purely per-document caps. In a mono-store layout (all items in one
|
> inheritance, purely per-document caps. In a mono-store layout (all items in one
|
||||||
> repo) the filter is therefore all-or-nothing on that document — which *is* the
|
> repo) the filter is therefore all-or-nothing on that document — which *is* the
|
||||||
> native behaviour, and why fine-grained isolation requires one document per
|
> native behaviour, and why fine-grained isolation requires one document per
|
||||||
@@ -108,6 +108,12 @@ stores.
|
|||||||
|
|
||||||
## Capability sharing / NURI
|
## Capability sharing / NURI
|
||||||
|
|
||||||
|
**What a NURI transports, and the two acts that cover every use of it, are in
|
||||||
|
[`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4sexies** — including why a
|
||||||
|
key-less reference is the ordinary case rather than a degenerate one, and why nothing is
|
||||||
|
checked at access time. The analysis of what exists vs what is merely declared is in
|
||||||
|
[`document-links.md`](./document-links.md).
|
||||||
|
|
||||||
Sharing transmits a **NURI** embedding the crypto capability (read and/or write).
|
Sharing transmits a **NURI** embedding the crypto capability (read and/or write).
|
||||||
No central ACL: holding the NURI *is* the right. *"adding permissions can be done
|
No central ACL: holding the NURI *is* the right. *"adding permissions can be done
|
||||||
offline"*; *"removing permissions … requires a SyncSignature"* (synchronous).
|
offline"*; *"removing permissions … requires a SyncSignature"* (synchronous).
|
||||||
@@ -117,10 +123,20 @@ offline"*; *"removing permissions … requires a SyncSignature"* (synchronous).
|
|||||||
**Only two repos have an inbox today: a user's public and protected STORES.** Not
|
**Only two repos have an inbox today: a user's public and protected STORES.** Not
|
||||||
documents, and not the private store. `new_store_default` attaches one solely `if
|
documents, and not the private store. `new_store_default` attaches one solely `if
|
||||||
!private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` goes through
|
!private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` goes through
|
||||||
`new_repo_default`, which leaves `inbox: None` (`engine/repo/src/repo.rs:574`). The only
|
`new_repo_default` (`verifier.rs:3004`, called at `request_processor.rs:689`) →
|
||||||
|
`Store::create_repo_default` (`engine/repo/src/store.rs:264`) →
|
||||||
|
`create_repo_with_keys` (`store.rs:284`), which builds the `Repo` with `inbox: None`
|
||||||
|
(`store.rs:691`). The only
|
||||||
`AddInboxCap` commits in the whole engine are the two in `engine/verifier/src/site.rs:128,149`
|
`AddInboxCap` commits in the whole engine are the two in `engine/verifier/src/site.rs:128,149`
|
||||||
— one for the public store repo, one for the protected one.
|
— one for the public store repo, one for the protected one.
|
||||||
|
|
||||||
|
*(Citation corrected 2026-08-10. This pointed at `engine/repo/src/repo.rs:574`, which is
|
||||||
|
inside `Repo::new_with_member` (`repo.rs:543`) — a constructor no production path
|
||||||
|
reaches: its callers are `Repo::new_with_perms`, gated `#[cfg(any(test, feature =
|
||||||
|
"testing"))]` (`repo.rs:186-192`), and `#[cfg(test)]` blocks in `branch.rs:387,490` and
|
||||||
|
`commit.rs:1659,1849,1919`. The claim itself was right; it was being proved by a test
|
||||||
|
fixture.)*
|
||||||
|
|
||||||
**But the engine SUPPORTS an inbox on any repo — "does not" and "cannot" are different
|
**But the engine SUPPORTS an inbox on any repo — "does not" and "cannot" are different
|
||||||
statements.** `inbox: Option<PrivKey>` is a field of EVERY `Repo`
|
statements.** `inbox: Option<PrivKey>` is a field of EVERY `Repo`
|
||||||
(`engine/repo/src/repo.rs:126`), not of a store structure. `AddInboxCapV0` is keyed by
|
(`engine/repo/src/repo.rs:126`), not of a store structure. `AddInboxCapV0` is keyed by
|
||||||
@@ -285,7 +301,7 @@ from JS today a repo becomes queryable ONLY by being `doc_create`d in this sessi
|
|||||||
|
|
||||||
**Consequence for this lib's mono-wallet polyfill:** every account's documents are
|
**Consequence for this lib's mono-wallet polyfill:** every account's documents are
|
||||||
`doc_create`d in the one shared wallet within the same session, so they are all
|
`doc_create`d in the one shared wallet within the same session, so they are all
|
||||||
already in `self.repos`. `read-model.ts` reads the bounded, by-need set of docs
|
already in `self.repos`. `surface/read-model.ts` reads the bounded, by-need set of docs
|
||||||
with one anchored `sparql_query` per doc (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`,
|
with one anchored `sparql_query` per doc (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`,
|
||||||
anchor = the doc NURI): the anchor resolves that same-session repo directly (no
|
anchor = the doc NURI): the anchor resolves that same-session repo directly (no
|
||||||
separate open needed) and restricts the query to its graph, so it is O(1) per doc,
|
separate open needed) and restricts the query to its graph, so it is O(1) per doc,
|
||||||
@@ -317,7 +333,7 @@ has both**:
|
|||||||
- **Subscribable with a sync BARRIER.** `doc_subscribe(nuri)` delivers `TabInfo` then
|
- **Subscribable with a sync BARRIER.** `doc_subscribe(nuri)` delivers `TabInfo` then
|
||||||
an initial **`State`** (`verifier.rs:470`/`:476`); that first `State` is the sync
|
an initial **`State`** (`verifier.rs:470`/`:476`); that first `State` is the sync
|
||||||
barrier — **after it, presence is guaranteed and absence is definitive** (pinned
|
barrier — **after it, presence is guaranteed and absence is definitive** (pinned
|
||||||
empirically by CONTRACT 3 in `packages/client/e2e/`). But this barrier exists only
|
empirically by CONTRACT 3 in `packages/polyfill/e2e/`). But this barrier exists only
|
||||||
for a repo `doc_subscribe` can open, i.e. a `did:ng:o:<RepoID>` repo. A **store-root
|
for a repo `doc_subscribe` can open, i.e. a `did:ng:o:<RepoID>` repo. A **store-root
|
||||||
has no first-`State` barrier**: an anchored read on it can return 0 rows during
|
has no first-`State` barrier**: an anchored read on it can return 0 rows during
|
||||||
sync-lag with no signal distinguishing "still syncing" from "genuinely empty".
|
sync-lag with no signal distinguishing "still syncing" from "genuinely empty".
|
||||||
@@ -346,14 +362,14 @@ account map fell into (see next section).
|
|||||||
|
|
||||||
### The pointer → doc-shim indirection (how the polyfill shim resolves accounts)
|
### The pointer → doc-shim indirection (how the polyfill shim resolves accounts)
|
||||||
|
|
||||||
`store-registry.ts` keeps a map `identifier → {docPublic, docProtected, docPrivate}`
|
`shared-wallet/account-registry.ts` keeps a map `identifier → {docPublic, docProtected, docPrivate}`
|
||||||
(the "shim", the account→document trust root). It must be reachable by a fresh
|
(the "shim", the account→document trust root). It must be reachable by a fresh
|
||||||
reconnecting session (findable) AND authoritative on a cold read (so a fresh page
|
reconnecting session (findable) AND authoritative on a cold read (so a fresh page
|
||||||
does not mistake sync-lag for "account absent" and PROVISION a fork). Since no single
|
does not mistake sync-lag for "account absent" and PROVISION a fork). Since no single
|
||||||
document is both (previous section), the shim uses an **indirection**:
|
document is both (previous section), the shim uses an **indirection**:
|
||||||
|
|
||||||
1. **doc-shim** — a `doc_create`d graph document (`did:ng:o:...`, hence a first-`State`
|
1. **doc-shim** — a `doc_create`d graph document (`did:ng:o:...`, hence a first-`State`
|
||||||
barrier). **All `AccountRecord`s live inside it.** Because it is subscribable, an
|
barrier). **All `VirtualUserRecord`s live inside it.** Because it is subscribable, an
|
||||||
anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0
|
anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0
|
||||||
means the account is genuinely absent.
|
means the account is genuinely absent.
|
||||||
2. **pointer** — a single well-known, **write-once** triple in the store-root graph,
|
2. **pointer** — a single well-known, **write-once** triple in the store-root graph,
|
||||||
@@ -741,7 +757,7 @@ chain never runs and consumers keep a stale value until the next connection
|
|||||||
delivers a fresh initial `State`. REMOTE commits DO push correctly (verified:
|
delivers a fresh initial `State`. REMOTE commits DO push correctly (verified:
|
||||||
cross-browser reactive update works). Verdict pending a live instrumented run.
|
cross-browser reactive update works). Verdict pending a live instrumented run.
|
||||||
Full write-up (suspect link, instrumentation, planned polyfill-side fix):
|
Full write-up (suspect link, instrumentation, planned polyfill-side fix):
|
||||||
[`../packages/client/docs/sdk-reference.md`](../packages/client/docs/sdk-reference.md)
|
[`../packages/polyfill/docs/polyfill-reference.md`](../packages/polyfill/docs/polyfill-reference.md)
|
||||||
§ *Current emulation status*.
|
§ *Current emulation status*.
|
||||||
|
|
||||||
### Cold-start anchored read returns 0 rows instead of an error — symptom VERIFIED, mechanism INFERRED, healed polyfill-side
|
### Cold-start anchored read returns 0 rows instead of an error — symptom VERIFIED, mechanism INFERRED, healed polyfill-side
|
||||||
@@ -750,9 +766,9 @@ On a FRESH session over the SAME persistent wallet (reconnect, new page, re-logi
|
|||||||
anchored `sparql_query` against a document written in an earlier session comes back with
|
anchored `sparql_query` against a document written in an earlier session comes back with
|
||||||
**0 rows and no error** — persisted documents read as empty. Observed on every anchored
|
**0 rows and no error** — persisted documents read as empty. Observed on every anchored
|
||||||
reader of the polyfill and healed identically in each (`ensureRepoOpen` before the read,
|
reader of the polyfill and healed identically in each (`ensureRepoOpen` before the read,
|
||||||
`packages/client/src/open-repo.ts`): the user's own documents,
|
`packages/polyfill/src/emulated-verifier/open-repo.ts`): the user's own documents,
|
||||||
the user's store (`store-registry.ts` `readUserStore`), the by-need doc batch
|
the user's store (`shared-wallet/account-registry.ts` `readUserStore`), the by-need doc batch
|
||||||
(`read-model.ts` `readUnion`), and the store-root pointer read (`store-registry.ts`
|
(`surface/read-model.ts` `readUnion`), and the store-root pointer read (`shared-wallet/account-registry.ts`
|
||||||
`resolvePointer`). The heal is `doc_subscribe(nuri)` → await the first `State` (the sync
|
`resolvePointer`). The heal is `doc_subscribe(nuri)` → await the first `State` (the sync
|
||||||
barrier) → THEN the anchored read, and it is verified to return the data.
|
barrier) → THEN the anchored read, and it is verified to return the data.
|
||||||
|
|
||||||
@@ -786,7 +802,7 @@ is a single account subject carrying MULTIPLE values for one scope predicate (ob
|
|||||||
five `shim:docPublic`), after which a writer and a later reader can resolve DIFFERENT
|
five `shim:docPublic`), after which a writer and a later reader can resolve DIFFERENT
|
||||||
scope docs and the reader's anchored read returns 0.
|
scope docs and the reader's anchored read returns 0.
|
||||||
|
|
||||||
Two polyfill-side guards, both in `packages/client/src/store-registry.ts`: `ensureInFlight`
|
Two polyfill-side guards, both in `packages/polyfill/src/shared-wallet/account-registry.ts`: `ensureInFlight`
|
||||||
(a bounded promise map keyed by account, so concurrent `ensureAccount` calls share ONE
|
(a bounded promise map keyed by account, so concurrent `ensureAccount` calls share ONE
|
||||||
resolve-or-provision) prevents new forks; `canonicalDoc` (pick the lexicographically
|
resolve-or-provision) prevents new forks; `canonicalDoc` (pick the lexicographically
|
||||||
smallest NURI among all distinct values for a scope predicate — NURIs are
|
smallest NURI among all distinct values for a scope predicate — NURIs are
|
||||||
@@ -821,7 +837,7 @@ semantics of the replay path itself (VERIFIED by reading `send_outbox`).
|
|||||||
|
|
||||||
**Consequence for this lib:** a queued write can be dropped without any observable error,
|
**Consequence for this lib:** a queued write can be dropped without any observable error,
|
||||||
and one unknown topic can take the rest of the queue with it. The polyfill's own
|
and one unknown topic can take the rest of the queue with it. The polyfill's own
|
||||||
`outbox-log.ts` does not record anything: it exports a single `inspectOutbox()` that
|
`shared-wallet/outbox-log.ts` does not record anything: it exports a single `inspectOutbox()` that
|
||||||
READS the SDK's own `sessionStorage` outbox and logs how many peers still have queued
|
READS the SDK's own `sessionStorage` outbox and logs how many peers still have queued
|
||||||
writes. It observes the symptom; it holds nothing it could replay, and no
|
writes. It observes the symptom; it holds nothing it could replay, and no
|
||||||
write-durability confirmation exists to await — so "the write returned" is not "the write
|
write-durability confirmation exists to await — so "the write returned" is not "the write
|
||||||
|
|||||||
+9
-9
@@ -5,7 +5,7 @@ shared wallet. This is a design decision, grounded entirely in the query
|
|||||||
capability documented in
|
capability documented in
|
||||||
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The query
|
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The query
|
||||||
capability*. The consumer application never sees any of this: it asks
|
capability*. The consumer application never sees any of this: it asks
|
||||||
`@ng-eventually/client` for its lists by need and trusts the answer — the whole
|
`@ng-eventually/polyfill` for its lists by need and trusts the answer — the whole
|
||||||
read mechanism lives here, in the polyfill.
|
read mechanism lives here, in the polyfill.
|
||||||
|
|
||||||
> The rule in one line: read each by-need doc with its own anchored
|
> The rule in one line: read each by-need doc with its own anchored
|
||||||
@@ -54,11 +54,11 @@ globally enumerable, and nothing is meant to be.
|
|||||||
### Everything = follow a graph, never enumerate across accounts
|
### Everything = follow a graph, never enumerate across accounts
|
||||||
|
|
||||||
My participations / my profile, protected data an owner has granted me, my
|
My participations / my profile, protected data an owner has granted me, my
|
||||||
notifications — none of these is enumerated across accounts. Each is reached by
|
notifications — none of these is enumerated across virtualUsers. Each is reached by
|
||||||
what is already reachable to me:
|
what is already reachable to me:
|
||||||
|
|
||||||
- my own docs (always in `self.repos`, and whose caps I hold);
|
- my own docs (always in `self.repos`, and whose caps I hold);
|
||||||
- docs whose cap an owner has delivered to my inbox (`shareCap` — see the
|
- docs whose cap an owner has delivered to my inbox (`inbox.share` — see the
|
||||||
per-document ReadCap in [`simulation.md`](./simulation.md));
|
per-document ReadCap in [`simulation.md`](./simulation.md));
|
||||||
- my inbox (deposits addressed to me).
|
- my inbox (deposits addressed to me).
|
||||||
|
|
||||||
@@ -104,14 +104,14 @@ There is no reactive union query. So reactivity is assembled:
|
|||||||
- keep a lightweight reactive subscription — `doc_subscribe`, or the ORM on an
|
- keep a lightweight reactive subscription — `doc_subscribe`, or the ORM on an
|
||||||
already-opened single store (never a per-entity fan-out) — on the synced docs;
|
already-opened single store (never a per-entity fan-out) — on the synced docs;
|
||||||
- on its change signal, re-run the bounded set of per-doc anchored
|
- on its change signal, re-run the bounded set of per-doc anchored
|
||||||
`sparql_query`s (`readModel.readUnion`) — never an anchorless union-scan.
|
`sparql_query`s (`readUnion`) — never an anchorless union-scan.
|
||||||
|
|
||||||
Keep the reactive ORM strictly to already-opened single stores; it is a change
|
Keep the reactive ORM strictly to already-opened single stores; it is a change
|
||||||
*signal* source here, not the list source.
|
*signal* source here, not the list source.
|
||||||
|
|
||||||
## The boundary with the consumer application
|
## The boundary with the consumer application
|
||||||
|
|
||||||
The consumer application asks the SDK for its lists by need and trusts the returned
|
The consumer application asks the polyfill for its lists by need and trusts the returned
|
||||||
set. It never constructs a NURI, never picks the union-vs-anchor mode, never touches
|
set. It never constructs a NURI, never picks the union-vs-anchor mode, never touches
|
||||||
the ORM. The domain-shaped list helpers (e.g. "my meeting points", "events") live in
|
the ORM. The domain-shaped list helpers (e.g. "my meeting points", "events") live in
|
||||||
the consumer application, not the lib; the lib exposes the generic by-need read.
|
the consumer application, not the lib; the lib exposes the generic by-need read.
|
||||||
@@ -165,14 +165,14 @@ Step (4) has a nuance worth recording, and it is exactly why the read path uses
|
|||||||
graph — so an explicit `GRAPH ?g` body spans every opened graph independently of
|
graph — so an explicit `GRAPH ?g` body spans every opened graph independently of
|
||||||
the anchor. The anchor's "one repo" restriction is observable only for a body that
|
the anchor. The anchor's "one repo" restriction is observable only for a body that
|
||||||
reads the **default graph** (no `GRAPH` wrapper). That is precisely why the per-doc
|
reads the **default graph** (no `GRAPH` wrapper). That is precisely why the per-doc
|
||||||
read in `read-model.ts` uses the anchored default-graph body
|
read in `surface/read-model.ts` uses the anchored default-graph body
|
||||||
`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`: the anchor makes that one repo the default
|
`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`: the anchor makes that one repo the default
|
||||||
graph, so the read is bounded to it — O(1) per doc, independent of wallet size —
|
graph, so the read is bounded to it — O(1) per doc, independent of wallet size —
|
||||||
and never iterates the other named graphs. (A repo absent from `self.repos` throws
|
and never iterates the other named graphs. (A repo absent from `self.repos` throws
|
||||||
`RepoNotFound` and is skipped per-doc, see the VERIFIED note above — the read cannot
|
`RepoNotFound` and is skipped per-doc, see the VERIFIED note above — the read cannot
|
||||||
sync an unknown repo.)
|
sync an unknown repo.)
|
||||||
|
|
||||||
> **Re-confirmed by the standing e2e harness (`packages/client/e2e/`, broker
|
> **Re-confirmed by the standing e2e harness (`packages/polyfill/e2e/`, broker
|
||||||
> `@ng-org/web 0.1.2-alpha.13`).** The `docRoundTrip` check measures all three shapes
|
> `@ng-org/web 0.1.2-alpha.13`).** The `docRoundTrip` check measures all three shapes
|
||||||
> anchored to a doc D: (a) a no-GRAPH default-graph write round-trips; (b) an explicit
|
> anchored to a doc D: (a) a no-GRAPH default-graph write round-trips; (b) an explicit
|
||||||
> `INSERT DATA { GRAPH <D> {…} }` — a **constant** plain NURI — **also** round-trips
|
> `INSERT DATA { GRAPH <D> {…} }` — a **constant** plain NURI — **also** round-trips
|
||||||
@@ -185,9 +185,9 @@ sync an unknown repo.)
|
|||||||
> is per-doc anchored, preserved above), **not** a **constant** `GRAPH <D>` write,
|
> is per-doc anchored, preserved above), **not** a **constant** `GRAPH <D>` write,
|
||||||
> which lands in D's repo. Re-run that harness to re-verify against a newer broker.
|
> which lands in D's repo. Re-run that harness to re-verify against a newer broker.
|
||||||
|
|
||||||
## Implementation — `read-model.ts`
|
## Implementation — `surface/read-model.ts`
|
||||||
|
|
||||||
`readModel.readUnion(docs)` implements this: for each requested doc NURI (the
|
`readUnion(docs)` implements this: for each requested doc NURI (the
|
||||||
bounded by-need set), run — in parallel, tolerant per-doc (a doc that fails is
|
bounded by-need set), run — in parallel, tolerant per-doc (a doc that fails is
|
||||||
skipped, never aborting the batch like the ORM fan-out would) — one anchored
|
skipped, never aborting the batch like the ORM fan-out would) — one anchored
|
||||||
`SELECT ?s ?p ?o WHERE { ?s ?p ?o }` with `anchor = docNuri`. The anchor restricts
|
`SELECT ?s ?p ?o WHERE { ?s ?p ?o }` with `anchor = docNuri`. The anchor restricts
|
||||||
|
|||||||
+126
-18
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Established 2026-07-20**, VERIFIED by direct reading of the `nextgraph-rs` Rust core (except for points marked INFERRED). The `file:line` references are dated — line numbers are volatile, navigate by symbol/regex.
|
**Established 2026-07-20**, VERIFIED by direct reading of the `nextgraph-rs` Rust core (except for points marked INFERRED). The `file:line` references are dated — line numbers are volatile, navigate by symbol/regex.
|
||||||
|
|
||||||
Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `caps.ts` emulation (an ACL until P1a — the inverse of the real model; realigned 2026-07-28, see §5). This is the basis for the item "align ReadCap/WriteCap with NextGraph".
|
Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `emulated-verifier/caps.ts` emulation (an ACL until the cap-surface work — the inverse of the real model; realigned 2026-07-28, see §5). This is the basis for the item "align ReadCap/WriteCap with NextGraph".
|
||||||
|
|
||||||
> ## How to use this document — verify, never infer
|
> ## How to use this document — verify, never infer
|
||||||
>
|
>
|
||||||
@@ -16,10 +16,47 @@ Purpose: to give the ground truth of NextGraph's access-rights model, in order t
|
|||||||
>
|
>
|
||||||
> - **A comment describing the CURRENT state is not the intent.** §3's DIRECTION block exists because `RepoLinkV0`'s comment was read as the target model. It is not.
|
> - **A comment describing the CURRENT state is not the intent.** §3's DIRECTION block exists because `RepoLinkV0`'s comment was read as the target model. It is not.
|
||||||
> - **A word you recognise probably does not mean what you think.** `branch` is not git's. `wallet` is only a keyring — what we call a virtual user is a **user** (a *site*). Check the type before using the word.
|
> - **A word you recognise probably does not mean what you think.** `branch` is not git's. `wallet` is only a keyring — what we call a virtual user is a **user** (a *site*). Check the type before using the word.
|
||||||
|
> - **The access rule is "whoever has the reference AND the key reads" — never "whoever has the reference reads".** An agent forgets this with remarkable consistency, because client-server reflexes supply a missing half: somewhere a server checks. Here nobody checks (§0 below). Getting it wrong does not fail — it silently grants. Every time you hand out a value, ask which of the two you just handed.
|
||||||
|
> - **NextGraph's naming ALWAYS wins over any rule of ours — including the one that follows.** Where upstream has a word for something, that is the word, full stop. `publisher` is a case in point: it appears 126 times in the engine (`as_publisher`, `publisher_advert`) for a **pub/sub role on a topic**, and it must be used, unchanged, whenever that is what is meant.
|
||||||
|
> - **What is banned is OUR loose "publish a document"**, which has no upstream referent and covers three different acts: *placing a document in a public store*, *making it findable*, and (in this emulation only) *handing out a key*. Every design discussion that used it drifted between them. NextGraph knows only the first — a document IS IN a public store, and brokers serve it accordingly. Say which act you mean. If you catch yourself writing "publish a document", you have not yet decided which one. This never licenses renaming something upstream calls `publisher`.
|
||||||
> - **"I looked and it is not there" is not a finding.** §4quinquies once stated that no register existed for received caps, after checking one code path. `AddLink` had been sitting next to `AddRepo` in the same file the whole time. Absence needs at least as much evidence as presence — and an implementation *cache* (like local user storage) is never the model: it is what the model fills.
|
> - **"I looked and it is not there" is not a finding.** §4quinquies once stated that no register existed for received caps, after checking one code path. `AddLink` had been sitting next to `AddRepo` in the same file the whole time. Absence needs at least as much evidence as presence — and an implementation *cache* (like local user storage) is never the model: it is what the model fills.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0. The security model, and why an agent keeps losing it
|
||||||
|
|
||||||
|
**This is the one thing to hold on to.** Every design mistake this file records — a cap minted where a reference sufficed, a link that granted what it was meant to merely name, an "is it allowed?" call that cannot exist — comes from letting go of it.
|
||||||
|
|
||||||
|
### The rule
|
||||||
|
|
||||||
|
> A document is readable by whoever holds **the reference AND the key**. Not by whoever holds the reference.
|
||||||
|
|
||||||
|
There is no third party in that sentence. No server, no ACL, no check at access time: the engine verifies a permission on WRITE only (`verify_permission` → `PermissionDenied` in `Commit::verify`), never on read. Content is encrypted; the key IS the right. That is what local-first means here — there is nobody to ask, so possession is the whole mechanism.
|
||||||
|
|
||||||
|
### Why this is easy to lose
|
||||||
|
|
||||||
|
Client-server reflexes supply the missing half without asking. In that world a link is an identifier, harmless on its own, and access is decided elsewhere — so handing out a URL feels safe, and "may Bob read this?" is a question with an answer. Both instincts are wrong here, and neither announces itself: code written on them **does not fail**, it silently grants. There is no error to notice, no test that goes red on its own.
|
||||||
|
|
||||||
|
### What the rule buys — composable confidentiality
|
||||||
|
|
||||||
|
Because a reference grants nothing, **a reference is not recursive**: a widely-circulated document may point at a restricted one. The reference says *it exists*; the key says *who reads it*.
|
||||||
|
|
||||||
|
That is what lets confidentiality be composed inside one document:
|
||||||
|
|
||||||
|
- a public summary pointing at restricted chapters,
|
||||||
|
- a public event pointing at its participant list,
|
||||||
|
- a profile pointing at data reserved for a circle.
|
||||||
|
|
||||||
|
The author circulates one document and still decides, per referenced document, who gets the key. **Remove non-recursiveness and this whole construction collapses**: everything a circulated document mentions becomes readable by all its readers, and no level of confidentiality can exist inside a document one shares.
|
||||||
|
|
||||||
|
`test/cross-user-access.test.ts` is this property's test, and it is the suite's centrepiece: Alice's document in a public store REFERENCES her protected one; Bob follows the reference, names the target, reads nothing of it. It writes the reference **bare** — which is exactly what an application must do.
|
||||||
|
|
||||||
|
### The failure mode to watch for
|
||||||
|
|
||||||
|
Any call that returns a key where a reference was asked for. It looks like a convenience ("give me the link to my document"), it type-checks, the tests stay green — and it converts "whoever has the reference and the key" into "whoever has the reference", for that document and every document it mentions. `linkTo` (added and removed 2026-08-06) was exactly this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. A ReadCap = possession of a key, NOT a per-identity ACL
|
## 1. A ReadCap = possession of a key, NOT a per-identity ACL
|
||||||
|
|
||||||
A **ReadCap is fundamentally a cryptographic key that one holds**, not an ACL entry tied to a wallet. "Whoever holds the key can read."
|
A **ReadCap is fundamentally a cryptographic key that one holds**, not an ACL entry tied to a wallet. "Whoever holds the key can read."
|
||||||
@@ -124,7 +161,7 @@ Consistent with the rest of the model: no role and no list, only "do you hold th
|
|||||||
- There is **no existence command at the SDK level**.
|
- There is **no existence command at the SDK level**.
|
||||||
- The only probe (`BlocksExist`) is **internal to the crate**, requires `BlockId`s **and** an already **loaded** repo, and addresses the **inner** overlay — which is derived from the **read secret**.
|
- The only probe (`BlocksExist`) is **internal to the crate**, requires `BlockId`s **and** an already **loaded** repo, and addresses the **inner** overlay — which is derived from the **read secret**.
|
||||||
- A cap-less reference carries a RepoId and the **outer** overlay: no `BlockId` to probe. And the outer is never registered anyway (`expose_outer` hard-coded to `false`, with no SDK parameter).
|
- A cap-less reference carries a RepoId and the **outer** overlay: no `BlockId` to probe. And the outer is never registered anyway (`expose_outer` hard-coded to `false`, with no SDK parameter).
|
||||||
- The only primitive accessible to a non-member (`ExtObjectGet`) requires the ObjectIds **and their keys**.
|
- The primitive a non-member can reach (`ExtObjectGet`) requires the **ObjectIds**, which one only holds once one can already read. *(Corrected 2026-08-10 — this line used to say "the ObjectIds **and their keys**", and to call `ExtObjectGet` the **only** such primitive. Both are wrong at the source: `ExtObjectGetV0 { overlay, ids: Vec<ObjectId>, include_files }` has **no key field** at all (`engine/net/src/types.rs:4492-4501`), and `ExtRequestContentV0` has **three** variants — `WalletGetExport`, `ExtObjectGet`, `ExtTopicSyncReq` (`:4520-4526`), the last of which falls into `unimplemented!()` (`:4533`). The conclusion is unchanged and rests on addressing, not on keys: blocks come back **encrypted**, and naming them needs ObjectIds a non-holder does not have — the formulation `docs/nextgraph-current-state.md` § "The `Ext` protocol serves blocks with no control" already carried.)*
|
||||||
|
|
||||||
> **Addressing itself presupposes the cap.** Proving a document's existence without holding its key is not constructible today, and nothing indicates that it is planned.
|
> **Addressing itself presupposes the cap.** Proving a document's existence without holding its key is not constructible today, and nothing indicates that it is planned.
|
||||||
|
|
||||||
@@ -221,16 +258,18 @@ A **wallet is only a keyring**. What we have been calling a "virtual user" is, u
|
|||||||
|
|
||||||
### Giving access is a **Link** — one word, three places, all already named
|
### Giving access is a **Link** — one word, three places, all already named
|
||||||
|
|
||||||
**VERIFIED 2026-07-30.** The delivery message, the register and the record all exist upstream under the same word, which is what a shape being real looks like:
|
**VERIFIED 2026-07-30, state column corrected 2026-08-10.** The delivery message, the register and the record all exist upstream under the same word, which is what a shape being real looks like. What none of them is, is *implemented* — the table said so of three rows, and re-reading the source refuted it:
|
||||||
|
|
||||||
| Step | Upstream | State |
|
| Step | Upstream | State |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| The message deposited in the recipient's inbox | `InboxMsgContent::Link` (`engine/net/src/types.rs:4249-4261`) | **declared, payload-less** — a variant with no fields, i.e. specified and not implemented |
|
| The message deposited in the recipient's inbox | `InboxMsgContent::Link` (`engine/net/src/types.rs:4249-4261`) | **declared, payload-less** — a variant with no fields, i.e. specified and not implemented |
|
||||||
| Where the recipient files it on processing | `AddLink { read_cap }` on the **User branch** of the private store (`engine/repo/src/types.rs:1934-1950`) | implemented (verifier arm `commits/mod.rs:681`) |
|
| Where the recipient files it on processing | `AddLink { read_cap }` on the **User branch** of the private store (`engine/repo/src/types.rs:1934-1950`) | **declared, stubbed** — the verifier arm is a no-op `Ok(())` (`engine/verifier/src/commits/mod.rs:681-693`), and nothing in the workspace constructs one |
|
||||||
| Withdrawing it | `RemoveLink`, ORset (`engine/repo/src/types.rs:1952`) | implemented |
|
| Withdrawing it | `RemoveLink`, ORset (`engine/repo/src/types.rs:1952`) | **declared, stubbed** — the same no-op `Ok(())` arm (`commits/mod.rs:695-707`) |
|
||||||
| What circulates | `RepoLinkV0 { read_cap, … }` (`engine/net/src/types.rs:5062`) | implemented |
|
| What circulates | `RepoLinkV0 { read_cap, … }` (`engine/net/src/types.rs:5062-5078`) | **declared only** — zero constructors and zero consumers in the workspace (only the `RepoLink` wrapper and its two accessors, `:5082-5097`) |
|
||||||
|
|
||||||
So: **deposit a Link into the recipient's inbox; on connection the recipient processes the inbox and files it with `AddLink` on their User branch.** That is the whole gesture, and every piece of it has a name.
|
**What makes "stub" the right word rather than a quibble**: the neighbouring arm in the same file does real work. `CommitVerifier for AddRepo` calls `load_repo_from_read_cap` then `add_doc` (`commits/mod.rs:644-664`); `AddLink` and `RemoveLink`, twenty lines below, return `Ok(())` with `#[allow(unused_variables)]` on every parameter. Same trait, same file, opposite states — so "there is a verifier arm" cannot be read as "the register works".
|
||||||
|
|
||||||
|
So: **deposit a Link into the recipient's inbox; on connection the recipient processes the inbox and files it with `AddLink` on their User branch.** That is the whole gesture, and every piece of it has a **name** — which is not the same as having a behaviour. Per this document's own rule, none of that says what the target will do; it says the gesture is fully specified and none of it runs.
|
||||||
|
|
||||||
Two consequences worth stating, because both are easy to get wrong:
|
Two consequences worth stating, because both are easy to get wrong:
|
||||||
|
|
||||||
@@ -249,48 +288,117 @@ There IS a register, and it is a fourth commit type next to `AddRepo`:
|
|||||||
pub struct AddLinkV0 { pub read_cap: ReadCap, /* … */ }
|
pub struct AddLinkV0 { pub read_cap: ReadCap, /* … */ }
|
||||||
```
|
```
|
||||||
|
|
||||||
`engine/repo/src/types.rs:1934-1950`, with `RemoveLink` as its ORset counterpart (`:1952`) and a verifier arm at `engine/verifier/src/commits/mod.rs:681`. So:
|
`engine/repo/src/types.rs:1934-1950`, with `RemoveLink` as its ORset counterpart (`:1952`) and a verifier arm — a no-op `Ok(())` one — at `engine/verifier/src/commits/mod.rs:681-693`. So:
|
||||||
|
|
||||||
- it lives on the **User branch** — created only on the **private store** (`engine/repo/src/store.rs:448-452`; the public/protected stores get an `Overlay` branch instead), which also carries `AddInboxCap { repo_id, overlay, priv_key }` — *"so that a user can share with all its device"* (`engine/repo/src/types.rs:1969-1981`). So the User branch answers two questions with one mechanism: **which caps I received**, and **which inboxes I may read**;
|
- it lives on the **User branch** — created only on the **private store** (`engine/repo/src/store.rs:448-452`; the public/protected stores get an `Overlay` branch instead), which also carries `AddInboxCap { repo_id, overlay, priv_key }` — *"so that a user can share with all its device"* (`engine/repo/src/types.rs:1969-1981`). So the User branch answers two questions with one mechanism: **which caps I received**, and **which inboxes I may read**;
|
||||||
- it is explicitly for **external repos** — someone else's documents, exactly the received-cap case;
|
- it is explicitly for **external repos** — someone else's documents, exactly the received-cap case;
|
||||||
- and its stated purpose is to **share the link with all of the user's devices**. It is wallet-resident and cross-device, not a local cache.
|
- and its stated purpose is to **share the link with all of the user's devices**. It is wallet-resident and cross-device, not a local cache.
|
||||||
|
|
||||||
**Level 3 (local user storage) is therefore a cache, not the register.** The register is level 2': `AddLink` on the User branch of the private store.
|
**Level 3 (local user storage) is therefore a cache, not the register.** The register — the durable, cross-device record — is level 2': `AddLink` on the User branch of the private store.
|
||||||
|
|
||||||
What remains true, and is a separate matter — the *delivery* path is unimplemented:
|
**But "the register exists, only the delivery is missing" is FALSE, and this section said it until 2026-08-10.** Both ends are declared and stubbed, as the table above now records: nothing constructs an `AddLink` commit anywhere in the workspace, and the arm that would apply one returns `Ok(())`. So the corrected statement is: `AddLink` on the User branch is **where a received cap belongs in the model** — a placement the source states unambiguously and this library aligns on — and no part of the gesture runs today, neither the road nor the destination. Per the design principle that gap says nothing about the target; it forbids only calling the register "implemented".
|
||||||
|
|
||||||
- `InboxMsgContent::ContactDetails` processing (`engine/verifier/src/inbox_processor.rs:778-847`) creates a contact document holding the profile, inbox, name and email — and **never reads `details.read_cap`**. Confirmed on sight: the receiver discards it. So the cap never reaches the User branch today — the register exists, the road to it does not.
|
The delivery half, in detail:
|
||||||
|
|
||||||
|
- `InboxMsgContent::ContactDetails` processing (`engine/verifier/src/inbox_processor.rs:778-847`) creates a contact document holding the profile, inbox, name and email — and **never reads `details.read_cap`**. Confirmed on sight: the receiver discards it. So no cap reaches the User branch today.
|
||||||
- `RepoLinkV0` states the intended flow (`engine/net/src/types.rs:5055-5061`): *"the link is shared and then the recipient opens it and subscribes soon afterward"*. **The key IS kept**: opening the repo persists its `read_cap` in local user storage, so the next session decrypts fine. What is not durable is the key's **validity** — a `RootCapRefresh` (§3) mints a new one, and receiving it depends on **the rotating party choosing to send it to you** (§3's DIRECTION block), not on any subscription state.
|
- `RepoLinkV0` states the intended flow (`engine/net/src/types.rs:5055-5061`): *"the link is shared and then the recipient opens it and subscribes soon afterward"*. **The key IS kept**: opening the repo persists its `read_cap` in local user storage, so the next session decrypts fine. What is not durable is the key's **validity** — a `RootCapRefresh` (§3) mints a new one, and receiving it depends on **the rotating party choosing to send it to you** (§3's DIRECTION block), not on any subscription state.
|
||||||
|
|
||||||
> **Do not write "only a subscriber receives the new key".** That reads the `RepoLinkV0` comment as intent, which §3 already forbids. **Subscribing is a purely LOCAL act** — automatic pull of changes — and the other party records nothing about it; there is no subscriber list to send to. Who gets a rotated key is the rotating party's decision, delivered to an inbox.
|
> **Do not write "only a subscriber receives the new key".** That reads the `RepoLinkV0` comment as intent, which §3 already forbids. **Subscribing is a purely LOCAL act** — automatic pull of changes — and the other party records nothing about it; there is no subscriber list to send to. Who gets a rotated key is the rotating party's decision, delivered to an inbox.
|
||||||
- `PermaCap` — still a **TODO** (`engine/repo/src/types.rs:578`) — covers exactly the gap that leaves: a link *"stored on disk and kept there unopened for a long period"*, i.e. never loaded, therefore never subscribed, therefore missing every refresh.
|
- `PermaCap` — still a **TODO** (`engine/repo/src/types.rs:578`) — covers exactly the gap that leaves: a link *"stored on disk and kept there unopened for a long period"*, i.e. never loaded, therefore never subscribed, therefore missing every refresh.
|
||||||
|
|
||||||
> **So there are TWO registers, by origin**: `AddRepo` on the **Store** branch for the documents a user creates in that store, and `AddLink` on the **User** branch of the private store for caps received for someone else's documents. Local user storage caches both. Opening a repo persists its cap locally, but that is the cache filling — not the durable record.
|
> **So there are TWO registers, by origin**: `AddRepo` on the **Store** branch for the documents a user creates in that store, and `AddLink` on the **User** branch of the private store for caps received for someone else's documents. (`AddRepo` runs; `AddLink` is declared and stubbed — see the table above. The *split by origin* is the model's, whatever each half's state.) Local user storage caches both. Opening a repo persists its cap locally, but that is the cache filling — not the durable record.
|
||||||
|
|
||||||
*Consequence for this library*: **both durable registers are now emulated** (2026-07-30) — `AddRepo` as a `shim:readCap` record on a distinct subject of the store document (`storeBranch`), `AddLink` as `shim:link` on another (`userBranch`) — and the in-memory `CapRegistry` is what it always was, level 3: the cache. Caps are READ back from those records, never recomputed. What stays an invention is representing branches as RDF subjects at all: upstream both branches carry `BranchCrdt::None` and hold service commits, not triples. What is faithful is that the key sits beside the document, and that the listing (`contains`, the Main branch) is separate from the keys.
|
*Consequence for this library*: **both durable registers are now emulated** (2026-07-30) — `AddRepo` as a `shim:readCap` record on a distinct subject of the store document (`storeBranch`), `AddLink` as `shim:link` on another (`userBranch`) — and the in-memory `CapRegistry` is what it always was, level 3: the cache. Caps are READ back from those records, never recomputed. What stays an invention is representing branches as RDF subjects at all: upstream both branches carry `BranchCrdt::None` and hold service commits, not triples. What is faithful is that the key sits beside the document, and that the listing (`contains`, the Main branch) is separate from the keys.
|
||||||
|
|
||||||
|
## 4sexies. What a NURI TRANSPORTS — and the TWO acts that cover every use of it
|
||||||
|
|
||||||
|
**VERIFIED 2026-08-06** by reading `nextgraph-rs`, after a design discussion kept stalling on an assumed split between "a NURI" and "a link". There is no such split, and getting that wrong is what made this library ship a `linkTo` that was wrong three ways at once.
|
||||||
|
|
||||||
|
### A NURI and a Link are one thing in two shapes
|
||||||
|
|
||||||
|
`NuriV0` (`engine/net/src/app_protocol.rs:181-194`) carries exactly what a link carries:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
NuriV0 { identity, target, entire_store, objects, signature,
|
||||||
|
branch, overlay, access: Vec<NgAccessV0>, topic, locator: Option<Locator> }
|
||||||
|
```
|
||||||
|
|
||||||
|
`NgLinkV0 = Repo | PublicRepo | Branch | Object` (`engine/net/src/types.rs:5206-5211`) is the structured form of the same information; the NURI is its URI form. So "should we hand out a NURI or a link?" is not a question — they are the same thing, and the real question is **what one puts in the `access` and `locator` slots**.
|
||||||
|
|
||||||
|
*Caveat, and it matters for anything printed on paper:* the **type** has all the slots, the **string grammar does not expose them all**. A self-contained share URL exists for objects, commits and files (`…:v:{overlay}(:[cj]:{id}:k:{key})+:l:{locator}`); the repo case is not exercised. See [`document-links.md`](./document-links.md).
|
||||||
|
|
||||||
|
### `access` is a LIST of access forms, not a key flag
|
||||||
|
|
||||||
|
```rust
|
||||||
|
NgAccessV0 = ReadCap(ReadCap) | Token(Digest) | ExtRequest(Vec<u8>)
|
||||||
|
| Key(BlockKey) | Inbox(PrivKey) | Topic(PrivKey)
|
||||||
|
```
|
||||||
|
|
||||||
|
(`app_protocol.rs:54-62`.) An **empty** `access` is therefore a legitimate, meaningful state: the reference NAMES its target and grants nothing. That is not a degenerate link — see below, it is the common case.
|
||||||
|
|
||||||
|
### There are TWO acts, and they cover everything
|
||||||
|
|
||||||
|
An earlier version of this section listed four "needs" (name, let read, let find, join). That was wrong: it confused what an act DOES with what its result depends on. A sender does exactly two things, and every use case is one of them:
|
||||||
|
|
||||||
|
| Act | What is transmitted | What the recipient can do |
|
||||||
|
|---|---|---|
|
||||||
|
| **Transmit the reference** | `target` (+ `overlay`/`locator`), `access` EMPTY | Name the document. **And read it, if it sits in a public store** — same value transmitted, different outcome, decided by where the document is and not by what was sent. |
|
||||||
|
| **Transmit the reference AND the key** | the above + `ReadCap` in `access` | Read it. Irreversible: nothing is checked later, and there is no revoking a key already handed out (only rotation, which also cuts off legitimate recipients — `PermaShare`/PermaCap, `engine/repo/src/types.rs:1761`, is the reserved answer, unimplemented). |
|
||||||
|
|
||||||
|
"Let someone find a public document" is therefore not a third act: it is the first one, applied to a document that happens to be in a public store. And "join" is not an act at all — the `locator` is about the reference being COMPLETE enough to be resolved by a stranger (no locator → no broker to ask → nothing opens, key or no key). It belongs to the recipient's side.
|
||||||
|
|
||||||
|
The consequence for anything this library exposes: **a call either hands over the key or it does not**, and that must be the caller's explicit intent — never a side effect of asking for a reference.
|
||||||
|
|
||||||
|
### Three confusions worth naming, because each one was made here
|
||||||
|
|
||||||
|
**"No key" does not mean "public".** A key-less reference to a PROTECTED document withholds access deliberately — the recipient can name it, not read it. A `PublicRepoLinkV0` (`types.rs:5105-5127`) carries no `read_cap` for an unrelated reason: the content is served publicly, so there is nothing to hand over. Same shape, opposite situations.
|
||||||
|
|
||||||
|
**Naming is not reading, and it is the DEFAULT act.** This is what `test/cross-user-access.test.ts` proves: Alice has a document in a public store that REFERENCES her protected one; Bob follows the public link, finds the reference, and can name the protected document while reading nothing of it. Publication is not recursive. Withholding the key is the ordinary, reversible gesture; including it is the exceptional, irreversible one.
|
||||||
|
|
||||||
|
**Nothing is checked at access time.** Reading IS possession — the engine verifies a permission on WRITE only (`verify_permission` → `PermissionDenied` in `Commit::verify`), never on read. So a reference cannot be "neutral about rights, resolved later": what you put in it IS the grant. There is no revoking a link you have circulated, only rotating the key (and `RepoLinkV0`'s own comment says shared caps go stale on refresh — `PermaShare` / PermaCap, `engine/repo/src/types.rs:1761`, is the reserved answer to that, unimplemented).
|
||||||
|
|
||||||
|
### Where this library stands, act by act
|
||||||
|
|
||||||
|
- **Transmit the reference** — covered, with no dedicated call: every reference the surface returns is bare (`createEntityDoc`, `docCreate`, `listMyEntityDocs`, `UnionSubject.subject`/`.graph`). An application cites what it already holds. Faithful.
|
||||||
|
- **Transmit the reference and the key** — `inbox.share(doc, toUser)`. Names the document and the person; the key is looked up and sealed into a deposit, and the recipient applies it by connecting, with nothing to call. Faithful in shape. One recorded divergence: the deposit always goes to the recipient's PROTECTED inbox, where upstream the choice follows the profile the person was reached by (`engine/verifier/src/inbox_processor.rs:787`).
|
||||||
|
|
||||||
|
**And the property that makes the first act worth anything — a public store SERVES its documents' caps.** `PublicRepoLinkV0` (`engine/net/src/types.rs:5098-5124`) carries `repo`, `public_store` and `peers` and no `read_cap`, and its own comment says why: *"The latest ReadCap of the branch (or main branch) will be **downloaded from the outerOverlay**, if the peer brokers listed below allow it. […] This link is durable, because the public site are **served differently by brokers**."* The key is not something a sender hands over; it is something the network gives to whoever asks, because the broker pinned the outer overlay (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`).
|
||||||
|
|
||||||
|
That is emulated, since 2026-08-06, in `emulated-verifier/public-store.ts` — and emulated **without touching the guard**. Possession remains the one criterion: a public document is readable not because `mayReach` makes an exception, but because its cap is *obtainable* — the library asks, files what it gets, and from there the ordinary path applies. Every read door asks first (`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`).
|
||||||
|
|
||||||
|
Where the emulation shows its seams, stated rather than hidden:
|
||||||
|
|
||||||
|
- Upstream nothing is WRITTEN to make a repo public — the store is public and the broker serves it. Here one broker serves every virtual user identically, so the cap is recorded on the document's Header branch and read back through the machinery's unguarded door. Fetching, not enumerating: a reader asks the document it already names.
|
||||||
|
- A reader therefore learns a document is public by asking THAT document. One it has never heard of stays invisible, where upstream a broker would serve it just the same. That limits discovery, not access.
|
||||||
|
- The cap a public store serves is a READ grant, and this emulation says so: `caps.learnFromPublicStore` files it apart, and `docs.sparqlUpdate` refuses a write on it (`assertMayWrite`). Without that, a bare reference would buy a write, which upstream it never does — writing needs the write cap, and no store hands that out.
|
||||||
|
- `useShape` cannot ask (its signature is the real ORM's, with no await to spend), so a public document reached through it alone, read nowhere first, is filtered out. Recorded in `emulated-verifier/read-filter.ts`.
|
||||||
|
- **No `locator` anywhere**, and the emulation's topology is why it does not show. Upstream a reference must be complete enough for a stranger to resolve — without a `locator` there is no broker to ask, and nothing opens, key or no key (`NuriV0.locator`, `engine/net/src/app_protocol.rs:181-194`). Here every virtual user is on the same broker, so the question never arises and no reference this library produces carries one. An application must not conclude that a bare reference travels anywhere: **it travels between users of one deployment**. The day two deployments have to exchange one, the locator is what will be missing, and nothing in the emulation will have prepared it.
|
||||||
|
|
||||||
|
One thing a consumer must not conclude from the emulation: that a public document has anything per-reader. Upstream there is no grant, no revoke and no audience on it — there is nothing to build a UI around.
|
||||||
|
|
||||||
## 5. What the polyfill emulates (caps.ts) — and where it still diverges
|
## 5. What the polyfill emulates (caps.ts) — and where it still diverges
|
||||||
|
|
||||||
**Realigned 2026-07-28 (batch P1a).** `packages/client/src/caps.ts` used to model `readers: Map<Nuri, Set<PrincipalId>>` + `grantRead(doc, grantee)` — a per-document **ACL of principals**, the exact INVERSION of the real model. It now records, **per identity**, the caps that identity holds (`Map<Nuri, ReadCap>`) — whose only question is `capFor(nuri)` — and `nuri.ts` carries the cap-less / cap-bearing distinction on the `r:` segment. The durable registers are emulated in `store-registry.ts` (`readCap` on the Store branch, `link` on the User branch); this in-memory record is their cache.
|
**Realigned 2026-07-28 (the cap-surface batch).** `packages/polyfill/src/emulated-verifier/caps.ts` used to model `readers: Map<Nuri, Set<PrincipalId>>` + `grantRead(doc, grantee)` — a per-document **ACL of principals**, the exact INVERSION of the real model. It now records, **per identity**, the caps that identity holds (`Map<Nuri, ReadCap>`) — whose only question is `capFor(nuri)` — and `nuri.ts` carries the cap-less / cap-bearing distinction on the `r:` segment. The durable registers are emulated in `shared-wallet/account-registry.ts` (`readCap` on the Store branch, `link` on the User branch); this in-memory record is their cache.
|
||||||
|
|
||||||
| | Real NextGraph | caps.ts emulation (post-P1a) |
|
| | Real NextGraph | caps.ts emulation (post-cap-surface) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Nature | possession of a **key** | possession of a **key** — recorded per identity, indexed by the cap-less NURI |
|
| Nature | possession of a **key** | possession of a **key** — recorded per identity, indexed by the cap-less NURI |
|
||||||
| Grant | seal the key (crypto_box) to the inbox | `shareCap(cap, toInbox)` → an inbox deposit, absorbed inline on read |
|
| Grant | seal the key (crypto_box) to the inbox | `inbox.share(doc, toUser)` → an inbox deposit, absorbed inline on read |
|
||||||
| Durability | **durable** (key delivered once) | durable **in shape**: creation and re-listing refile own caps from the scope index (the emulated `AddRepo` branch); a delivered cap persists in the recipient's inbox document |
|
| Durability | **durable** (key delivered once) | durable **in shape**: creation and re-listing refile own caps from the scope index (the emulated `AddRepo` branch); a delivered cap persists in the recipient's inbox document |
|
||||||
| Revocation | coarse **re-key**, non-retroactive | **not emulated** (P3). Nothing pretends to revoke |
|
| Revocation | coarse **re-key**, non-retroactive | **not emulated** (P3). Nothing pretends to revoke |
|
||||||
| Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
|
| Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
|
||||||
| Ref. without rights | **cap-less NURI** (no `r:` segment) | same — `Nuri` names, `ReadCap` names and reads |
|
| Ref. without rights | **cap-less NURI** (no `r:` segment) | same — `Nuri` names, `ReadCap` names and reads |
|
||||||
|
| Public store | the broker serves the outer overlay; the ReadCap is **downloaded** from it | `public-store.ts` — the cap is exposed on the document and fetched through the machinery's door, then held like any other. Filed apart (`learnFromPublicStore`) so it grants reading and **not** writing |
|
||||||
|
|
||||||
**The divergence that REMAINS**: the stand-in cap value is the constant `OK` rather than a secret. The read paths that once consulted no cap at all are now confined to the connected virtual user (`reach.ts`, 2026-07-30) — `docs.sparqlQuery`/`sparqlUpdate` and `subscribeDoc` are guarded, the inbox is read only by its owner, and the shim's own machinery moved to unguarded primitives that are never exported. So what is left for **P1b** is per-document encryption: replacing one constant with a real key. Until then, nothing may be claimed "anonymous" or "private".
|
**The divergence that REMAINS**: the stand-in cap value is the constant `OK` rather than a secret. The read paths that once consulted no cap at all are now confined to the connected virtual user (`emulated-verifier/reach.ts`, 2026-07-30) — `docs.sparqlQuery`/`sparqlUpdate` and `subscribeDoc` are guarded, the inbox is read only by its owner, and the shim's own machinery moved to unguarded primitives that are never exported. So what is left for **cap-enforcement** is per-document encryption: replacing one constant with a real key. Until then, nothing may be claimed "anonymous" or "private".
|
||||||
|
|
||||||
**App-facing**: `declareConnections` (on the consumer side), which re-declared "my connections read my protected entities" **every session**, was an artifact of the ephemeral ACL — **it disappears**. The grant moves to the moment a connection is accepted (`shareCap` once, per document), which is a consumer **re-architecture**, not an API swap.
|
**App-facing**: `declareConnections` (on the consumer side), which re-declared "my connections read my protected entities" **every session**, was an artifact of the ephemeral ACL — **it disappears**. The grant moves to the moment a connection is accepted (`inbox.share` once, per document), which is a consumer **re-architecture**, not an API swap.
|
||||||
|
|
||||||
## 6. Implications for consumers (e.g. Festipod)
|
## 6. Implications for consumers (e.g. Festipod)
|
||||||
|
|
||||||
- "**protected scope = my network can read**" is **not** an ACL checked by the broker: it is "I have **sealed my read key** to each of my connections". The "scope = ACL" mental model is wrong at the NextGraph level.
|
- "**protected scope = my network can read**" is **not** an ACL checked by the broker: it is "I have **sealed my read key** to each of my connections". The "scope = ACL" mental model is wrong at the NextGraph level.
|
||||||
- **Anonymous references are possible**: putting a **cap-less NURI** in a third party's collection lets that third party **name/count** without **reading the identity**; the cap-bearing one is sealed separately to the authorized parties only. (Basis for a presence model of the form "self-owned participation + curated cap-less Set + cap sealed to the connections".)
|
- **Anonymous references are possible**: putting a **cap-less NURI** in a third party's collection lets that third party **name/count** without **reading the identity**; the cap-bearing one is sealed separately to the authorized parties only. (Basis for a presence model of the form "self-owned participation + curated cap-less Set + cap sealed to the connections".)
|
||||||
- **Alignment DONE for the surface (P1a, 2026-07-28)**: the emulated ACL is gone, replaced by per-identity cap possession + per-document delivery to an inbox; `declareConnections`-as-a-re-declared-ACL has disappeared. What remains for the real cap operations is swapping the stand-in key value (`OK`) for the real one and closing the bypasses (P1b) — a key-material step, not a reshape. See `migration-guide.md` §1.
|
- **Alignment DONE for the surface (cap-surface, 2026-07-28)**: the emulated ACL is gone, replaced by per-identity cap possession + per-document delivery to an inbox; `declareConnections`-as-a-re-declared-ACL has disappeared. What remains for the real cap operations is swapping the stand-in key value (`OK`) for the real one and closing the bypasses (cap-enforcement) — a key-material step, not a reshape. See `migration-guide.md` §1.
|
||||||
|
|
||||||
## Caveats / gaps
|
## Caveats / gaps
|
||||||
|
|
||||||
|
|||||||
+53
-51
@@ -11,7 +11,7 @@
|
|||||||
> file for *how* each emulation works; read those two for *what is fake* and *what
|
> file for *how* each emulation works; read those two for *what is fake* and *what
|
||||||
> replaces it*.
|
> replaces it*.
|
||||||
|
|
||||||
The consumer application writes against `@ng-eventually/client` as if NextGraph
|
The consumer application writes against `@ng-eventually/polyfill` as if NextGraph
|
||||||
already shipped per-entity documents in public/protected/private stores, capabilities
|
already shipped per-entity documents in public/protected/private stores, capabilities
|
||||||
and inboxes. It hasn't (see [`nextgraph-current-state.md`](./nextgraph-current-state.md)).
|
and inboxes. It hasn't (see [`nextgraph-current-state.md`](./nextgraph-current-state.md)).
|
||||||
This file is the lib's own engineering doctrine on how it fabricates that mature
|
This file is the lib's own engineering doctrine on how it fabricates that mature
|
||||||
@@ -52,10 +52,10 @@ Because the emulation runs on ONE shared wallet, distinguish two levels:
|
|||||||
|
|
||||||
- **A virtual user** — the library's emulation of one identity: the documents the
|
- **A virtual user** — the library's emulation of one identity: the documents the
|
||||||
shim attributes to that account (its three store documents in
|
shim attributes to that account (its three store documents in
|
||||||
`store-registry.ts`). This is what "the user owns", and over it "list my
|
`shared-wallet/account-registry.ts`). This is what "the user owns", and over it "list my
|
||||||
documents" is meaningful and bounded.
|
documents" is meaningful and bounded.
|
||||||
|
|
||||||
**Which API you use follows that line, and it is enforced** (see `physical.ts`):
|
**Which API you use follows that line, and it is enforced** (see `shared-wallet/physical.ts`):
|
||||||
machinery operating on the *index of virtual users* — the store-root pointer, the
|
machinery operating on the *index of virtual users* — the store-root pointer, the
|
||||||
doc-shim, the account records — goes through unguarded primitives that are never
|
doc-shim, the account records — goes through unguarded primitives that are never
|
||||||
exported from the package. Everything touching a virtual user's own content goes
|
exported from the package. Everything touching a virtual user's own content goes
|
||||||
@@ -70,7 +70,7 @@ ones whose cap you were given.
|
|||||||
|
|
||||||
At migration each virtual user becomes a real user with its own wallet; the
|
At migration each virtual user becomes a real user with its own wallet; the
|
||||||
physical/virtual distinction, the "never enumerate the physical one" rule, and
|
physical/virtual distinction, the "never enumerate the physical one" rule, and
|
||||||
`physical.ts` all dissolve into native per-user reads.
|
`shared-wallet/physical.ts` all dissolve into native per-user reads.
|
||||||
|
|
||||||
## Two axes, never conflate them (store ≠ document)
|
## Two axes, never conflate them (store ≠ document)
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ terminology historically fused:
|
|||||||
|
|
||||||
`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` targets the shared
|
`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` targets the shared
|
||||||
wallet's private store. The trailing `store` arg left `undefined` targets the
|
wallet's private store. The trailing `store` arg left `undefined` targets the
|
||||||
private store (this is what `store-registry.ts`'s `createDoc()` does). So every
|
private store (this is what `shared-wallet/account-registry.ts`'s `createDoc()` does). So every
|
||||||
document the shim creates physically lives in one store (private), and the
|
document the shim creates physically lives in one store (private), and the
|
||||||
`public|protected|private` scope is a logical label tracked in RDF by the
|
`public|protected|private` scope is a logical label tracked in RDF by the
|
||||||
shim — not a NextGraph store. Therefore what a consumer application's "multi-store"
|
shim — not a NextGraph store. Therefore what a consumer application's "multi-store"
|
||||||
@@ -95,12 +95,12 @@ multi-store. Do not read `Scope` (`types.ts`) as a physical store — it is the
|
|||||||
logical label the registry attaches.
|
logical label the registry attaches.
|
||||||
|
|
||||||
> Why `undefined` and not a real store? Because `doc_create` **cannot target a
|
> Why `undefined` and not a real store? Because `doc_create` **cannot target a
|
||||||
> non-private native store** today: `StoreRepo` is not JS-constructible (verified
|
> non-private native store** today from the WEB build: `StoreRepo` is not constructible there (verified
|
||||||
> — see the parked `getNativeStore` note in
|
> — see the parked `getNativeStore` note in
|
||||||
> [`migration-guide.md`](./migration-guide.md)). The private store is reachable
|
> [`migration-guide.md`](./migration-guide.md)). The private store is reachable
|
||||||
> because it opens without `RepoNotFound`.
|
> because it opens without `RepoNotFound`.
|
||||||
|
|
||||||
## The shared-wallet shim (`store-registry.ts`)
|
## The shared-wallet shim (`shared-wallet/account-registry.ts`)
|
||||||
|
|
||||||
Emulates the target infrastructure — where each user owns their own
|
Emulates the target infrastructure — where each user owns their own
|
||||||
public/protected/private stores — on top of one shared wallet.
|
public/protected/private stores — on top of one shared wallet.
|
||||||
@@ -125,7 +125,7 @@ public/protected/private stores — on top of one shared wallet.
|
|||||||
account→document trust root, which is why every untrusted value that reaches its
|
account→document trust root, which is why every untrusted value that reaches its
|
||||||
SPARQL is escaped (see SPARQL hardening below). It makes identity resolution
|
SPARQL is escaped (see SPARQL hardening below). It makes identity resolution
|
||||||
cross-device: another device opening the same wallet reads the same pointer → the
|
cross-device: another device opening the same wallet reads the same pointer → the
|
||||||
same doc-shim → the same accounts.
|
same doc-shim → the same virtualUsers.
|
||||||
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
|
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
|
||||||
makes a dedicated document for one entity (mirrors the target, where each entity
|
makes a dedicated document for one entity (mirrors the target, where each entity
|
||||||
is its own document/repo with a future inbox) and appends its NURI to the
|
is its own document/repo with a future inbox) and appends its NURI to the
|
||||||
@@ -135,15 +135,15 @@ public/protected/private stores — on top of one shared wallet.
|
|||||||
user, and the only listing there is: the cross-account fan-out
|
user, and the only listing there is: the cross-account fan-out
|
||||||
(`listEntityDocs` / `resolveReadGraphs` / `allAccounts` / `loadShim`) was
|
(`listEntityDocs` / `resolveReadGraphs` / `allAccounts` / `loadShim`) was
|
||||||
**removed on 2026-07-30**, being cross-user enumeration by construction. The real read path is
|
**removed on 2026-07-30**, being cross-user enumeration by construction. The real read path is
|
||||||
`readModel.readUnion(docs)`, which reads the by-need doc set with one per-doc
|
`readUnion(docs)`, which reads the by-need doc set with one per-doc
|
||||||
anchored `sparql_query`, never an anchorless union-scan of the physical
|
anchored `sparql_query`, never an anchorless union-scan of the physical
|
||||||
wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves
|
wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves
|
||||||
the by-need doc set from the current wallet's own scope index and
|
the by-need doc set from the current wallet's own scope index and
|
||||||
`listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out).
|
`listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out).
|
||||||
- **Generic by construction.** The registry knows only the three native scopes,
|
- **Generic by construction.** The registry knows only the three native scopes,
|
||||||
zero application entity kind. The consumer application maps its entities to a scope
|
zero application entity kind. The consumer application maps its entities to a scope
|
||||||
and injects the session + identity-id normalization via `configureStoreRegistry({
|
and injects the session + identity-id normalization through `configure({
|
||||||
getSession, normalizeId })` (`polyfill.ts`).
|
getSession, normalizeId })` (`shared-wallet/bootstrap.ts`, published by the entry).
|
||||||
|
|
||||||
The `store≠document` two axes materialize here directly: the registry moves along
|
The `store≠document` two axes materialize here directly: the registry moves along
|
||||||
axis B (more documents = more isolation), never axis A (it always writes into the
|
axis B (more documents = more isolation), never axis A (it always writes into the
|
||||||
@@ -166,7 +166,7 @@ Virtual user (id)
|
|||||||
|
|
||||||
So the 3 native stores (public/protected/private) are present, but emulated: each
|
So the 3 native stores (public/protected/private) are present, but emulated: each
|
||||||
"store" is an index document
|
"store" is an index document
|
||||||
(`AccountRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the
|
(`VirtualUserRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the
|
||||||
per-entity documents in that scope. It is not a physical native store.
|
per-entity documents in that scope. It is not a physical native store.
|
||||||
|
|
||||||
Everything is physical in one place: the 3 index documents, every per-entity
|
Everything is physical in one place: the 3 index documents, every per-entity
|
||||||
@@ -187,7 +187,7 @@ virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-g
|
|||||||
### SDK-shaped scope resolvers — the consumer application holds no store-id
|
### SDK-shaped scope resolvers — the consumer application holds no store-id
|
||||||
|
|
||||||
The consumer application must never construct a `did:ng:${store_id}` NURI itself:
|
The consumer application must never construct a `did:ng:${store_id}` NURI itself:
|
||||||
physical placement is the lib's job (the whole point of the SDK boundary). Two
|
physical placement is the lib's job (the whole point of the polyfill boundary). Two
|
||||||
resolvers turn a logical scope into an opaque graph NURI without exposing any
|
resolvers turn a logical scope into an opaque graph NURI without exposing any
|
||||||
store-id:
|
store-id:
|
||||||
|
|
||||||
@@ -200,14 +200,14 @@ store-id:
|
|||||||
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
|
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
|
||||||
resolves to the user's real per-scope store — the change is in this function,
|
resolves to the user's real per-scope store — the change is in this function,
|
||||||
and the consumer application is unchanged.
|
and the consumer application is unchanged.
|
||||||
- **`walletInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
|
- **`userInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
|
||||||
first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for
|
first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for
|
||||||
one of its documents, opened on demand by its **owner only** (ownership read from the
|
one of its documents, opened on demand by its **owner only** (ownership read from the
|
||||||
Store branches — a received cap is not ownership, and a recipient must not be able to
|
Store branches — a received cap is not ownership, and a recipient must not be able to
|
||||||
redirect the owner's deposits to itself). Both are dedicated documents (real repo
|
redirect the owner's deposits to itself). Both are dedicated documents (real repo
|
||||||
NURIs from `docCreate`), never the private-store root: routing deposits into the shim
|
NURIs from `docCreate`), never the private-store root: routing deposits into the shim
|
||||||
graph would bloat the account→document trust root without bound. `myInboxes()`
|
graph would bloat the account→document trust root without bound. `myInboxes()`
|
||||||
enumerates both levels — what `connect.ts` drains at connection — and `isOwnInbox`
|
enumerates both levels — what `emulated-verifier/connect.ts` drains at connection — and `isOwnInbox`
|
||||||
answers from the same record. *(The former `resolveInboxAnchor`, a single inbox COMMON
|
answers from the same record. *(The former `resolveInboxAnchor`, a single inbox COMMON
|
||||||
to every user, was removed on 2026-07-30: nothing may be common but the mechanisms
|
to every user, was removed on 2026-07-30: nothing may be common but the mechanisms
|
||||||
that make the virtual users work.)*
|
that make the virtual users work.)*
|
||||||
@@ -220,7 +220,7 @@ store-id:
|
|||||||
A document that has an inbox carries its address on its emulated **Header branch** —
|
A document that has an inbox carries its address on its emulated **Header branch** —
|
||||||
a reserved subject inside the document, so any holder of the document reads it, and
|
a reserved subject inside the document, so any holder of the document reads it, and
|
||||||
`read-model` filters the whole `urn:ng-eventually:` namespace out of consumer data
|
`read-model` filters the whole `urn:ng-eventually:` namespace out of consumer data
|
||||||
(`src/machinery.ts`). This mirrors upstream's split: a depositor seals with the inbox
|
(`src/emulated-verifier/machinery.ts`). This mirrors upstream's split: a depositor seals with the inbox
|
||||||
PUBLIC key and needs nothing else, only the owner holds the private half.
|
PUBLIC key and needs nothing else, only the owner holds the private half.
|
||||||
**One inbox belongs to one document** — never several documents behind one inbox, a
|
**One inbox belongs to one document** — never several documents behind one inbox, a
|
||||||
relation upstream cannot express (the verifier routes by `inboxes: PubKey → RepoId`
|
relation upstream cannot express (the verifier routes by `inboxes: PubKey → RepoId`
|
||||||
@@ -238,7 +238,7 @@ store-id:
|
|||||||
Both resolve the native store ids from the injected session
|
Both resolve the native store ids from the injected session
|
||||||
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
|
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
|
||||||
`privateStoreId` anchor). The consumer application hands the whole session to the
|
`privateStoreId` anchor). The consumer application hands the whole session to the
|
||||||
lib at the one injection point (`configureStoreRegistry({ getSession })`) — that is
|
lib at the one injection point (`configure({ getSession })`) — that is
|
||||||
wiring, not placement logic; everything else in the consumer application speaks only
|
wiring, not placement logic; everything else in the consumer application speaks only
|
||||||
in scopes. If the session omits `protectedStoreId`, the non-private scopes fall back
|
in scopes. If the session omits `protectedStoreId`, the non-private scopes fall back
|
||||||
to the private store rather than emit a broken NURI.
|
to the private store rather than emit a broken NURI.
|
||||||
@@ -265,7 +265,7 @@ is preserved in [`decisions/private-store-nuri-scope.md`](./decisions/private-st
|
|||||||
|
|
||||||
A validated hard constraint, not a style choice: `docs.ts` calls the real
|
A validated hard constraint, not a style choice: `docs.ts` calls the real
|
||||||
injected `ng` (`getConfig().ng`) directly, never the public `ng` proxy
|
injected `ng` (`getConfig().ng`) directly, never the public `ng` proxy
|
||||||
(`makeNg` in `ng-proxy.ts`).
|
(`makeNg` in `surface/ng-proxy.ts`).
|
||||||
|
|
||||||
`@ng-org/web`'s `ng` is already an iframe-RPC proxy (postMessage marshaling,
|
`@ng-org/web`'s `ng` is already an iframe-RPC proxy (postMessage marshaling,
|
||||||
see [`nextgraph-current-state.md`](./nextgraph-current-state.md) § integration).
|
see [`nextgraph-current-state.md`](./nextgraph-current-state.md) § integration).
|
||||||
@@ -283,13 +283,13 @@ verified: routing the shim's `doc_create`/SPARQL through the public proxy turned
|
|||||||
`docs.ts` therefore imports **no** `@ng-org` package and must **not** import from
|
`docs.ts` therefore imports **no** `@ng-org` package and must **not** import from
|
||||||
`./ng-proxy`.
|
`./ng-proxy`.
|
||||||
|
|
||||||
## Emulated ReadCap — per document (`caps.ts` + `read-filter.ts`)
|
## Emulated ReadCap — per document (`emulated-verifier/caps.ts` + `emulated-verifier/read-filter.ts`)
|
||||||
|
|
||||||
In the target the broker only delivers documents the wallet holds a ReadCap
|
In the target the broker only delivers documents the wallet holds a ReadCap
|
||||||
for, so `useShape` already returns an authorized subset. Here (single shared
|
for, so `useShape` already returns an authorized subset. Here (single shared
|
||||||
wallet, everything readable) the lib reproduces that with a read-filtered view:
|
wallet, everything readable) the lib reproduces that with a read-filtered view:
|
||||||
|
|
||||||
- **`CapRegistry` (`caps.ts`)** models a ReadCap as what it is: **the document's
|
- **`CapRegistry` (`emulated-verifier/caps.ts`)** models a ReadCap as what it is: **the document's
|
||||||
key**. The access unit is the document = repo NURI (an item's `@graph`), never
|
key**. The access unit is the document = repo NURI (an item's `@graph`), never
|
||||||
the item — because in `nextgraph-rs` a store is just a container repo and holding
|
the item — because in `nextgraph-rs` a store is just a container repo and holding
|
||||||
its cap does not grant the repos it references (no store-level read inheritance;
|
its cap does not grant the repos it references (no store-level read inheritance;
|
||||||
@@ -304,14 +304,14 @@ wallet, everything readable) the lib reproduces that with a read-filtered view:
|
|||||||
type would be a concept NextGraph does not have. The stand-in key value is the
|
type would be a concept NextGraph does not have. The stand-in key value is the
|
||||||
constant `OK` (see the module header): the only question the emulation answers is
|
constant `OK` (see the module header): the only question the emulation answers is
|
||||||
*do I hold this cap or not*, so the value says exactly that and pretends nothing
|
*do I hold this cap or not*, so the value says exactly that and pretends nothing
|
||||||
more. P1b, not P1a, is the batch that turns the shape into a protection.
|
more. Cap-enforcement, not cap-surface, is the batch that turns the shape into a protection.
|
||||||
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
|
- **`emulated-verifier/read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
|
||||||
`Proxy`: iteration / `size` / `forEach` keep only items whose `@graph` the
|
`Proxy`: iteration / `size` / `forEach` keep only items whose `@graph` the
|
||||||
current holder holds; everything else (`add`, `delete`, `has`, `getById`…) forwards to
|
current holder holds; everything else (`add`, `delete`, `has`, `getById`…) forwards to
|
||||||
the target, preserving writes and reactivity. An item with no `@graph` is kept (it
|
the target, preserving writes and reactivity. An item with no `@graph` is kept (it
|
||||||
names no document, so there is no cap to hold). `filterReadable` is the pure
|
names no document, so there is no cap to hold). `filterReadable` is the pure
|
||||||
variant. Note the absence of a `user` parameter — that absence *is* the model.
|
variant. Note the absence of a `user` parameter — that absence *is* the model.
|
||||||
- **`useShape` (`use-shape.ts`)** applies the view only once a cap exists at all
|
- **`useShape` (`surface/use-shape.ts`)** applies the view only once a cap exists at all
|
||||||
(`caps.isEnforcing()`) — before that it passes the real set through unchanged (no
|
(`caps.isEnforcing()`) — before that it passes the real set through unchanged (no
|
||||||
regression for a consumer that never touches caps). Once ANY cap is issued the
|
regression for a consumer that never touches caps). Once ANY cap is issued the
|
||||||
regime is possession for **every** holder, including one who holds nothing:
|
regime is possession for **every** holder, including one who holds nothing:
|
||||||
@@ -340,12 +340,12 @@ Three ways a cap arrives, and there are no others:
|
|||||||
- **Creation.** `createEntityDoc(id, scope)` writes the cap on the store's emulated
|
- **Creation.** `createEntityDoc(id, scope)` writes the cap on the store's emulated
|
||||||
Store branch (`shim:readCap`) and the creator holds it. The consumer declares
|
Store branch (`shim:readCap`) and the creator holds it. The consumer declares
|
||||||
nothing, and the cap is minted exactly ONCE — the stored value is the held value,
|
nothing, and the cap is minted exactly ONCE — the stored value is the held value,
|
||||||
which is what keeps this correct when P1b makes the key real.
|
which is what keeps this correct when cap-enforcement makes the key real.
|
||||||
- **Re-listing.** `listMyEntityDocs(id, scope)` READS those records back. It does not
|
- **Re-listing.** `listMyEntityDocs(id, scope)` READS those records back. It does not
|
||||||
recompute anything: that is the whole reason for storing them, and it is what lets
|
recompute anything: that is the whole reason for storing them, and it is what lets
|
||||||
a **fresh session** read its own documents again with nothing re-declared — the
|
a **fresh session** read its own documents again with nothing re-declared — the
|
||||||
durability the old in-memory ACL faked and lost every reload.
|
durability the old in-memory ACL faked and lost every reload.
|
||||||
- **Delivery.** `shareCap(cap, toInbox)` deposits one document's cap into one
|
- **Delivery.** `inbox.share(doc, toUser)` deposits one document's cap into one
|
||||||
recipient's inbox; `inbox.read` applies it inline, exactly as the recipient's own
|
recipient's inbox; `inbox.read` applies it inline, exactly as the recipient's own
|
||||||
verifier applies queued messages upstream. **Receiving needs no operation** — a
|
verifier applies queued messages upstream. **Receiving needs no operation** — a
|
||||||
consumer already watching its inbox gets them, and the resulting change
|
consumer already watching its inbox gets them, and the resulting change
|
||||||
@@ -361,20 +361,22 @@ another name.
|
|||||||
|
|
||||||
### Sharing, publication, and the recipient
|
### Sharing, publication, and the recipient
|
||||||
|
|
||||||
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
|
- **`setCurrentUser(id)` (`shared-wallet/bootstrap.ts`, INTERNAL since 2026-08-07 — `ensureIdentity` is what an application calls)** — the SDK's "current identity" call.
|
||||||
It selects *whose* caps are consulted, lazily, so the delivered subset always
|
It selects *whose* caps are consulted, lazily, so the delivered subset always
|
||||||
reflects the identity in effect at read time.
|
reflects the identity in effect at read time.
|
||||||
- **`shareCap(cap, toInbox)`** — the one sharing act the lib exposes. Recipients
|
- **`inbox.share(doc, toUser)`** — the one sharing act the lib exposes. Recipients
|
||||||
are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
|
are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
|
||||||
there is no `PrincipalId` in this surface, because that notion exists nowhere
|
there is no `PrincipalId` in this surface, because that notion exists nowhere
|
||||||
upstream. Reaching several recipients means calling it once per inbox, which is
|
upstream. Reaching several recipients means calling it once per inbox, which is
|
||||||
what the real model does too (each delivery is sealed to one recipient).
|
what the real model does too (each delivery is sealed to one recipient).
|
||||||
- **`getCaps().publishRepoLink(doc)`** — upstream `RepoLinkV0`: a shareable link
|
- **A document created in the `public` scope** needs no sharing act at all. The store
|
||||||
**whoever receives it** can open. Put the *link* in what you make discoverable, not
|
serves its ReadCap to whoever asks (`emulated-verifier/public-store.ts`, emulating
|
||||||
the bare NURI, or no reader can open it. Publication is **not recursive**: a public
|
*"the latest ReadCap will be downloaded from the outerOverlay"* — `PublicRepoLinkV0`,
|
||||||
document may reference private ones, and the reference grants nothing on what it
|
`engine/net/src/types.rs:5098`), so what an application circulates is the **bare
|
||||||
references — which is what lets a public object point at a private identity without
|
reference**, exactly as it will after migration. Never recursive: a public document
|
||||||
disclosing it.
|
may reference private ones, and the reference grants nothing on what it references —
|
||||||
|
which is what lets a public object point at a private identity without disclosing it.
|
||||||
|
And never a write right: what the store serves is a read cap.
|
||||||
|
|
||||||
Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap`
|
Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap`
|
||||||
exists, but the message construction is `unimplemented!()`, its only caller passes
|
exists, but the message construction is `unimplemented!()`, its only caller passes
|
||||||
@@ -416,10 +418,10 @@ registry. And dynamically: the cap lands in Bob's inbox, his client processes it
|
|||||||
the read that was empty yields the content — the held-caps signal re-running it.
|
the read that was empty yields the content — the held-caps signal re-running it.
|
||||||
|
|
||||||
That is what real NextGraph does, and it holds **without a line of encryption** —
|
That is what real NextGraph does, and it holds **without a line of encryption** —
|
||||||
which is what makes the P1a (shape) / P1b (enforcement) split honest rather than
|
which is what makes the cap-surface (shape) / cap-enforcement (enforcement) split honest rather than
|
||||||
cosmetic. Proven in `test/cross-user-access.test.ts`.
|
cosmetic. Proven in `test/cross-user-access.test.ts`.
|
||||||
|
|
||||||
> **After P1a the shape is right and the isolation is still fake.** The stand-in key
|
> **After the cap-surface batch the shape is right and the isolation is still fake.** The stand-in key
|
||||||
> is a constant, and several read paths (`docs.sparqlQuery`/`sparqlUpdate`, the whole
|
> is a constant, and several read paths (`docs.sparqlQuery`/`sparqlUpdate`, the whole
|
||||||
> inbox, `store-registry`, `subscribe`, `open-repo`) consult no cap at all — worse,
|
> inbox, `store-registry`, `subscribe`, `open-repo`) consult no cap at all — worse,
|
||||||
> any wallet can reach any document. That is the subject of
|
> any wallet can reach any document. That is the subject of
|
||||||
@@ -428,7 +430,7 @@ cosmetic. Proven in `test/cross-user-access.test.ts`.
|
|||||||
|
|
||||||
### Write-guard coverage (honest scope)
|
### Write-guard coverage (honest scope)
|
||||||
|
|
||||||
The emulated write guard (`ng-proxy.ts`, `sparql_update` override) enforces the
|
The emulated write guard (`surface/ng-proxy.ts`, `sparql_update` override) enforces the
|
||||||
per-document write cap on the public `ng` proxy only. In practice the
|
per-document write cap on the public `ng` proxy only. In practice the
|
||||||
consumer application's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the real
|
consumer application's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the real
|
||||||
injected `ng` directly — never the public proxy — for the validated `DataCloneError`
|
injected `ng` directly — never the public proxy — for the validated `DataCloneError`
|
||||||
@@ -440,17 +442,17 @@ natively at migration); the read side is what makes isolation observably active.
|
|||||||
|
|
||||||
### The per-document ReadCap is the isolation path (item-level filter retired)
|
### The per-document ReadCap is the isolation path (item-level filter retired)
|
||||||
|
|
||||||
Isolation is enforced by the per-document ReadCap (`caps.ts` + `read-filter.ts`)
|
Isolation is enforced by the per-document ReadCap (`emulated-verifier/caps.ts` + `emulated-verifier/read-filter.ts`)
|
||||||
alone: the access unit is the document (`@graph` = repo), and the only acts are
|
alone: the access unit is the document (`@graph` = repo), and the only acts are
|
||||||
possession-shaped (`createEntityDoc` files a cap, `shareCap` delivers one,
|
possession-shaped (`createEntityDoc` files a cap, `inbox.share` delivers one, a public
|
||||||
`publishRepoLink` emits an openable link). Because the consumer application writes
|
store serves one to whoever asks). Because the consumer application writes
|
||||||
one document per entity, the per-document cap discriminates at entity granularity —
|
one document per entity, the per-document cap discriminates at entity granularity —
|
||||||
the target's behaviour.
|
the target's behaviour.
|
||||||
|
|
||||||
The old item-level application-visibility filter (the since-deleted `isolation.ts`
|
The old item-level application-visibility filter (the since-deleted `isolation.ts`
|
||||||
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is retired
|
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is retired
|
||||||
from the consumer path: the application carries no access logic — it declares its
|
from the consumer path: the application carries no access logic — it declares its
|
||||||
identity and shares caps, and trusts the SDK. Its matrix functions are
|
identity and shares caps, and trusts the polyfill. Its matrix functions are
|
||||||
dead scaffolding kept for reference and removed at migration. There is no longer a
|
dead scaffolding kept for reference and removed at migration. There is no longer a
|
||||||
second, coexisting app-layer filter to reconcile — the single axis is the
|
second, coexisting app-layer filter to reconcile — the single axis is the
|
||||||
per-document cap, exactly as in the target.
|
per-document cap, exactly as in the target.
|
||||||
@@ -489,7 +491,7 @@ emulates the inbox on the shared wallet:
|
|||||||
|
|
||||||
### An inbox BELONGS to a virtual user (2026-07-30)
|
### An inbox BELONGS to a virtual user (2026-07-30)
|
||||||
|
|
||||||
`storeRegistry.walletInbox(id)` resolves — creating on first sight — the inbox
|
`storeRegistry.userInbox(id)` resolves — creating on first sight — the inbox
|
||||||
document of one virtual user, recorded in the doc-shim under `shim:docInbox` and
|
document of one virtual user, recorded in the doc-shim under `shim:docInbox` and
|
||||||
read by its own query (so an account written before this existed still resolves).
|
read by its own query (so an account written before this existed still resolves).
|
||||||
The asymmetry that matters:
|
The asymmetry that matters:
|
||||||
@@ -499,7 +501,7 @@ The asymmetry that matters:
|
|||||||
the whole reachability graph. A deposit grants the depositor nothing in return —
|
the whole reachability graph. A deposit grants the depositor nothing in return —
|
||||||
upstream it is an anonymous sealed box.
|
upstream it is an anonymous sealed box.
|
||||||
- **Reading an inbox is confined to its owner** (`isOwnInbox`, enforced in `read` /
|
- **Reading an inbox is confined to its owner** (`isOwnInbox`, enforced in `read` /
|
||||||
`readSynced`, hence in `watch`). Since P1a routes ReadCaps through deposits, an
|
`readSynced`, hence in `watch`). Since the cap-surface batch routes ReadCaps through deposits, an
|
||||||
unguarded read let anyone who knew an inbox NURI collect the caps addressed to its
|
unguarded read let anyone who knew an inbox NURI collect the caps addressed to its
|
||||||
owner — defeating directed sharing. Anonymous owns no inbox and reads none.
|
owner — defeating directed sharing. Anonymous owns no inbox and reads none.
|
||||||
|
|
||||||
@@ -512,10 +514,10 @@ deposit — **whose JS name and signature are not known**, since none is exposed
|
|||||||
announced — and the read side is served by the recipient's own verifier unsealing
|
announced — and the read side is served by the recipient's own verifier unsealing
|
||||||
queued messages inline.
|
queued messages inline.
|
||||||
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
|
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
|
||||||
purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to
|
purposes — a registration/deposit, a cap delivery (`inbox.share`), a link handed to
|
||||||
someone — same `post` API, same watcher.
|
someone — same `post` API, same watcher.
|
||||||
|
|
||||||
## The virtual user boundary (`reach.ts` + `physical.ts`)
|
## The virtual user boundary (`emulated-verifier/reach.ts` + `shared-wallet/physical.ts`)
|
||||||
|
|
||||||
Every access function is confined to the user currently connected: no cross-user
|
Every access function is confined to the user currently connected: no cross-user
|
||||||
access, so the consumer is coded against a reach that will actually exist.
|
access, so the consumer is coded against a reach that will actually exist.
|
||||||
@@ -536,18 +538,18 @@ redundant so a lapse in either is caught by the other:
|
|||||||
legitimately holds a bare NURI while possessing its cap elsewhere — references travel
|
legitimately holds a bare NURI while possessing its cap elsewhere — references travel
|
||||||
bare through content and stores, the cap sits in what the user holds.
|
bare through content and stores, the cap sits in what the user holds.
|
||||||
|
|
||||||
The exception is **depositing** into another user's inbox (`docs.depositInto`): a
|
The exception is **depositing** into another user's inbox (`register-write.depositInto`, internal): a
|
||||||
named primitive rather than a flag, because it is a different act — you hold no cap,
|
named primitive rather than a flag, because it is a different act — you hold no cap,
|
||||||
you cannot read back, and you get nothing in return. It is the only channel by which
|
you cannot read back, and you get nothing in return. It is the only channel by which
|
||||||
a link crosses between users, hence the bootstrap of the whole reachability graph.
|
a link crosses between users, hence the bootstrap of the whole reachability graph.
|
||||||
|
|
||||||
The machinery lives in `physical.ts` (see *Physical user vs virtual user* above):
|
The machinery lives in `shared-wallet/physical.ts` (see *Physical user vs virtual user* above):
|
||||||
unguarded primitives, never exported from the package, used only for the index of
|
unguarded primitives, never exported from the package, used only for the index of
|
||||||
virtual users. Separating the FUNCTIONS is what replaced an earlier exemption list —
|
virtual users. Separating the FUNCTIONS is what replaced an earlier exemption list —
|
||||||
the machinery does not get waved through the guard, it calls something the guard
|
the machinery does not get waved through the guard, it calls something the guard
|
||||||
never saw.
|
never saw.
|
||||||
|
|
||||||
## Connecting a user (`connect.ts`)
|
## Connecting a user (`emulated-verifier/connect.ts`)
|
||||||
|
|
||||||
Processing inboxes is the **library's** job, not the app's: a consumer must never
|
Processing inboxes is the **library's** job, not the app's: a consumer must never
|
||||||
have to remember to drain a queue for documents shared with it to become readable —
|
have to remember to drain a queue for documents shared with it to become readable —
|
||||||
@@ -587,7 +589,7 @@ first.
|
|||||||
|
|
||||||
The ADR that specified it ([`decisions/discovery-model.md`](./decisions/discovery-model.md)) is marked superseded, and keeps the part that survives: the `discovery → synchronization → query` frame still holds, with stage 1 re-read as *"a link reached you"* rather than *"you consulted an index"*. Which makes the **inbox** the bootstrap of the whole reachability graph — see [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
|
The ADR that specified it ([`decisions/discovery-model.md`](./decisions/discovery-model.md)) is marked superseded, and keeps the part that survives: the `discovery → synchronization → query` frame still holds, with stage 1 re-read as *"a link reached you"* rather than *"you consulted an index"*. Which makes the **inbox** the bootstrap of the whole reachability graph — see [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||||
|
|
||||||
## Emulated write guard (`ng-proxy.ts`)
|
## Emulated write guard (`surface/ng-proxy.ts`)
|
||||||
|
|
||||||
The public `ng` proxy overrides `sparql_update` to enforce an emulated write
|
The public `ng` proxy overrides `sparql_update` to enforce an emulated write
|
||||||
cap: a write is refused unless the current user holds the target document's
|
cap: a write is refused unless the current user holds the target document's
|
||||||
@@ -596,7 +598,7 @@ specific document (the `anchor` arg) is governed by it — ungoverned docs (the
|
|||||||
mono-store default, no cap declared) flow through unchanged. This mirrors the target
|
mono-store default, no cap declared) flow through unchanged. This mirrors the target
|
||||||
broker/verifier, which refuses a write without the document's write cap.
|
broker/verifier, which refuses a write without the document's write cap.
|
||||||
|
|
||||||
## Identity store (`accounts.ts`)
|
## Identity store (`shared-wallet/virtualUsers.ts`)
|
||||||
|
|
||||||
The real NextGraph login (redirect to the broker, opening the single shared
|
The real NextGraph login (redirect to the broker, opening the single shared
|
||||||
wallet) is perceived as a technical access barrier (see the login
|
wallet) is perceived as a technical access barrier (see the login
|
||||||
@@ -614,7 +616,7 @@ identity id the consumer application relays to it:
|
|||||||
open underneath. The real logout lives elsewhere (hidden in the consumer
|
open underneath. The real logout lives elsewhere (hidden in the consumer
|
||||||
application's settings/debug), because it forces a new redirect.
|
application's settings/debug), because it forces a new redirect.
|
||||||
- Framework-agnostic: no React, no DOM beyond an optional injected
|
- Framework-agnostic: no React, no DOM beyond an optional injected
|
||||||
`AccountStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The
|
`VirtualUserStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The
|
||||||
React `Context`/`Provider` stays in the consumer application. `normalizeId`
|
React `Context`/`Provider` stays in the consumer application. `normalizeId`
|
||||||
(case-insensitive, optional leading `@` stripped, trimmed) is the pure
|
(case-insensitive, optional leading `@` stripped, trimmed) is the pure
|
||||||
normalizer, reusable as the shim key normalizer.
|
normalizer, reusable as the shim key normalizer.
|
||||||
@@ -637,5 +639,5 @@ document trust root):
|
|||||||
(`did:ng:...`): validates and throws on IRI-breaking chars rather than emitting
|
(`did:ng:...`): validates and throws on IRI-breaking chars rather than emitting
|
||||||
a malformed/injected query.
|
a malformed/injected query.
|
||||||
|
|
||||||
These are re-exported from `@ng-eventually/client` so the consumer application
|
These are re-exported from `@ng-eventually/polyfill` so the consumer application
|
||||||
reuses the same escaping when it builds SPARQL.
|
reuses the same escaping when it builds SPARQL.
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Source layout by migration fate — analysis and recommendation
|
||||||
|
|
||||||
|
**Status: ADOPTED.** The layout this document recommends is the one in the tree — `model/`, `surface/`, `emulated-verifier/`, `shared-wallet/`. Read the rest as the reasoning that produced it, not as a proposal. *(Two things have moved under it since: the package became `packages/polyfill` / `@ng-eventually/polyfill` on 2026-08-10 — it was `packages/client` / `@ng-eventually/client`, kept here as a search keyword — and `emulated-verifier/public-store.ts` was added on 2026-08-06. Paths below point at where the files are now.)*
|
||||||
|
|
||||||
|
Written 2026-08-04, when nothing had yet been moved, from the source of `src/` (25 modules), the two contracts (`docs/api-contract.md`, `docs/internal-contract.md`) used as the export-level inventory, and the read-only `nextgraph-rs` clone (HEAD `213338f6`); the upstream facts this analysis leans on were re-verified at the source and are cited with layer numbers per `README.md` § *The three references* (1 = engine, 2 = wasm binding, 3 = JS ORM).
|
||||||
|
|
||||||
|
**The question.** Today all 25 modules sit flat in `src/`, named mechanically. Three different fates coexist undistinguished: modules whose *shape* the consumer keeps (the surface the real SDK replaces), modules standing in for what the engine/verifier will do natively, and modules that exist only because the emulation runs on one shared wallet. The bet under evaluation: if the folder structure mirrors the target's own structure, divergence gets harder to commit and easier to spot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Is layout-by-future-layer the right axis?
|
||||||
|
|
||||||
|
**Yes on the axis, no on the literal reading — and with a bounded claim about what it buys.**
|
||||||
|
|
||||||
|
### 1a. The bet as literally stated is a category error
|
||||||
|
|
||||||
|
"Mirror the target's own structure" cannot mean mirroring `nextgraph-rs`'s tree (`engine/`, `sdk/js/lib-wasm`, `sdk/js/orm`). Every line this library ships lives in the polyfill; the three references are layers we *align on*, never places we write (`README.md` § *The three references*: "REFERENCES, not places we write code"). A folder named `engine/` or `verifier/` inside `src/` would claim residency in a layer we only read — the exact conflation the README warns produces false certainty. The right axis is the target's **stack as seen from the polyfill** — what each piece of our code stands in for, and therefore what happens to it at migration — not the target's repo tree.
|
||||||
|
|
||||||
|
### 1b. The three fates are real, but they are not three piles of equal nature
|
||||||
|
|
||||||
|
The two that evaporate at migration differ in the only way that matters for divergence:
|
||||||
|
|
||||||
|
- **Stand-ins for native behaviour** (cap possession and filing, the reach boundary, read filtering, inbox processing, branch registers, repo opening) have a **model to diverge from** — the engine's, level 1. This is where the dangerous failure lives: an emulation that drifts from the model teaches the consumer something to unlearn. Both incidents in `README.md` § *Design principle* happened here.
|
||||||
|
- **Shared-wallet compensation** (the account directory, the physical user, the identity relay, the diagnostics) has **nothing to align on** — NO COUNTERPART at any layer. It cannot diverge from a model; its only law is invisibility from the surface. Its failure mode is *leaking*, not drifting.
|
||||||
|
|
||||||
|
And a fourth group the three-fate framing misses: the **target's model vocabulary** (the NURI grammar, the type guards, the branded types) — level-1-verified transcription that every layer consumes and that survives migration as knowledge rather than as code to delete.
|
||||||
|
|
||||||
|
### 1c. Alternatives, honestly
|
||||||
|
|
||||||
|
- **By feature** (`inbox/`, `caps/`, `read/`…): optimises "find everything about X", which the api-contract's by-subject sections already do better — and it actively hides the fate axis: `inbox.ts` would stay one folder while its sender half is target-shaped surface and its reader half is emulation detail a consumer must not code against (`docs/api-contract.md` § 9). Rejected: it organises along the axis that is already served and flattens the one that is not.
|
||||||
|
- **By dependency direction** (layered, low → high): the import graph refuses it. `docs.ts` (surface) calls `getCaps().mint` (emulation) because upstream `doc_create` itself commits `AddRepo` (level 1, `engine/verifier/src/request_processor.rs:698`, re-verified); `emulated-verifier/connect.ts` (verifier stand-in) calls `resolveAccount` (shim) because the emulation runs on the shim. These cross-fate imports are *target-faithful*, not accidents — a layout that forbids them would force artificial inversions or be violated on day one. Rejected: it encodes a property the semantics do not have.
|
||||||
|
- **Flat with naming conventions only** (`surface-docs.ts`, `shim-physical.ts`…): carries the same information at the same churn — a rename churns every importer exactly as a move does — with weaker affordances: no per-folder contract note, no one-glance grouping, and no folder-granular entry rule to grep or lint. Prefixes also rot silently in a way a misplaced file in a four-folder tree does not. Rejected as strictly dominated: same cost, less structure.
|
||||||
|
- **Do nothing — rely on the contracts:** the serious alternative. The contracts are finer-grained than any layout (per-claim epistemic labels, not per-module), and they were verified at the source. But they are read *after* the fact; the incident that motivates this analysis happened in a module whose own header states the right doctrine. A layout is confronted *during* the edit: a new module must be placed, and placing it forces the "which fate?" question at the moment the docs.ts-style mistake is made. Layout and contracts are complementary instruments — the layout is the cheap always-on prompt, the contracts remain the enforcement.
|
||||||
|
|
||||||
|
### 1d. What the layout actually buys — bounded claims
|
||||||
|
|
||||||
|
1. **The placement question fires at creation time.** A new module cannot be added without answering "surface, native stand-in, shared-wallet, or model?" — the question whose non-asking is the root of the flat layout's failure.
|
||||||
|
2. **The entry rule becomes folder-granular and mechanical.** Today `index.ts`'s purity is maintained per-symbol (the hand-built `surface/placement.ts` slice). With folders the rule is "`index.ts` re-exports only from `surface/` and `model/`" — one grep, lintable in CI, reviewable at a glance.
|
||||||
|
3. **Cross-fate imports become visible seams.** `import { … } from "../emulated-verifier/…"` inside `surface/docs.ts` is a reviewable event in a diff; the same call inside a flat sibling import is invisible. The seam does not *prevent* the docs.ts incident class — it makes it show up in review.
|
||||||
|
4. **The contracts map onto the tree.** `docs/api-contract.md` ≈ `surface/` + `model/`; `docs/internal-contract.md` ≈ `emulated-verifier/` + `shared-wallet/`. Drift between doc and code becomes a folder-membership diff instead of an inventory audit.
|
||||||
|
5. **It generalises a pattern this repo already proved.** `surface/placement.ts` (a hand-maintained surface slice), `shared-wallet/physical.ts` (privilege as *separate functions*, not exemption flags), and the 2026-08-03 entry-header fix are all the same idea implemented piecemeal at module granularity. The layout is the same discipline promoted to the tree.
|
||||||
|
|
||||||
|
**Verdict on the bet:** "easier to spot" — substantially yes (points 2–4). "Harder to commit" — only mildly: nothing in a folder tree stops a determined or oblivious edit, and the decisive question (*is this behaviour the target's?*) is answered by reading `nextgraph-rs`, not by any layout (§ 6). Worth doing, with the expectations of § 1d and the costs of § 5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The recommended layout
|
||||||
|
|
||||||
|
Both entry files stay at `src/` root, so `package.json`'s `exports` map is untouched and the consumer application sees no change. *(The two doors were merged into one on 2026-08-07 — the `exports` map is now just `.`, and the polyfill-era symbols sit in a marked block of `index.ts`.)*
|
||||||
|
|
||||||
|
| Folder | What the name asserts | Alignment reference | Fate at migration |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/` root (`index.ts`, `polyfill.ts`) | The two published doors, nothing else. `index.ts` may re-export only from `surface/` and `model/`; `polyfill.ts` may re-export by name from anywhere — it is the polyfill-era door and its imports *are* the list of what dies. *(Merged 2026-08-07: one door, `index.ts`, with the polyfill-era symbols in a marked block. The rule survives as a block boundary instead of a file boundary.)* | — | The SDK-shaped half is replaced by the real SDK via the build alias; the polyfill-era block is deleted. |
|
||||||
|
| `model/` | The target's addressing model, transcribed: pure vocabulary (types, NURI grammar, guards). No I/O, no state, no minting. Importable by every layer. | Level 1, verified (`NuriV0`, `readcap_nuri` — `engine/repo/src/types.rs:518-521`) | Survives as knowledge; the guards stay useful against the real SDK (which takes plain strings). |
|
||||||
|
| `surface/` | App-facing, and every symbol has a target counterpart — verified or a documented bet — in `docs/api-contract.md`. A consumer coding against this folder learns nothing to unlearn. | Levels 3/2 where they answer, level-1 shape where they do not (per subject, in the contract) | Deleted when the alias flips; the consumer's code is unchanged. |
|
||||||
|
| `emulated-verifier/` | Stand-ins for what the engine/verifier/broker do natively: possession, filing, boundary, non-delivery, inbox processing, branch registers, repo opening. Aligned on the level-1 model; each module names its native counterpart mechanism. **This is the folder where divergence from the model is possible, and its main risk.** | Level 1 (the model is the specification) | Deleted — the native side takes over. |
|
||||||
|
| `shared-wallet/` | Exists only because one wallet hosts every identity. NO COUNTERPART at any layer — nothing to align on; the only law is invisibility from `surface/` and from the consumer. | None (nothing upstream has an image of this) | Evaporates entirely, replaced by nothing. |
|
||||||
|
|
||||||
|
On the name `emulated-verifier/`: every module in it has its native counterpart running *in the verifier* (cap state and Store/User-branch replay, `Verifier::load_repo_from_read_cap` `engine/verifier/src/verifier.rs:2237`, level 1; non-delivery, `resolve_target_for_sparql` → `RepoNotFound` `engine/verifier/src/request_processor.rs:264,269`, level 1, re-verified; inbox processing, `Verifier::inbox` `verifier.rs:1674-1690`, level 1; session repos, `self.repos`). The `emulated-` prefix is load-bearing: it says *stands in for*, never *is* — the residency confusion § 1a rules out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Module-by-module assignment
|
||||||
|
|
||||||
|
The 25 current modules, with the two splits' offspring shown where a module divides (§ 4 gives the criterion and the rulings). "Stays whole" means the file moves as-is.
|
||||||
|
|
||||||
|
| Current module | Destination | What the placement asserts / notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `index.ts` | `src/index.ts` (unchanged path) | The SDK-entry manifest. New rule made checkable: imports only from `surface/` and `model/`. |
|
||||||
|
| `polyfill.ts` | `src/polyfill.ts` (path unchanged) + **new** `shared-wallet/bootstrap.ts` | Split: the entry keeps the re-exports; the config store (`configure`, `getConfig`, `registryDeps`, the current-user relay, the `CapRegistry` singleton wiring) moves to `shared-wallet/bootstrap.ts` — it is the injection machinery with NO COUNTERPART by design (`docs/api-contract.md` § 1). Side effect: removes the current entry↔internal import cycles (`polyfill.ts:16` ↔ `connect.ts:37`; `polyfill.ts:228` ↔ `inbox.ts:32`). |
|
||||||
|
| `types.ts` | `model/types.ts`, minus `NgLike` / `UseShapeLike` → `shared-wallet/bootstrap.ts` | `Nuri`/`ReadCap`/`Scope` are level-1-verified vocabulary; `PrincipalId` stays with a note (target: the wallet user; polyfill: a relayed id). `NgLike`/`UseShapeLike` describe the *injection*, not the target — they belong to the bootstrap. See § 5 for the published-type wrinkle this creates. |
|
||||||
|
| `nuri.ts` | `model/nuri.ts`, minus `mintCap` → `emulated-verifier/` | The guards and `targetOf`/`parseNuri` are the model transcription. `mintCap` is the emulation's minting point — upstream only the engine mints, at repo creation (level 1, `BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`) — and its presence in the model module contradicts the module's own header ("nothing on the surface turns a bare reference into a cap"). Cap-enforcement swaps its constant; migration deletes it. |
|
||||||
|
| `sparql.ts` | `surface/sparql.ts` | Published, generic injection-safety utilities with NO COUNTERPART and none expected (`docs/api-contract.md` § 11) — the one surface family that survives *any* migration unchanged. Placed with the surface because it is published and documented there; the folder note must carry this exception. |
|
||||||
|
| `docs.ts` | `surface/docs.ts` — stays whole | Level-2 passthroughs whose in-body cap filing and reach guard *mirror the target's own composition* (§ 4 ruling). The mint and the guard become named imports from `emulated-verifier/` — the visible seam. |
|
||||||
|
| `lifecycle.ts` | `surface/lifecycle.ts` | Pure forwarding to the injected level-2/3 calls (`docs/api-contract.md` § 2). |
|
||||||
|
| `surface/ng-proxy.ts` | `surface/ng-proxy.ts` — stays whole | Builds the published `ng`; its two overrides compose `emulated-verifier/` predicates (the write guard stands in for `verify_perm` inside `Commit::verify`, level 1, `engine/repo/src/commit.rs:892-899`, re-verified — noting `verify` has no runtime caller today, which says nothing about the target). The `login` arm is finding F1 of the internal contract: an unprovenanced fabricated member — its fix is deletion, not relocation. |
|
||||||
|
| `surface/use-shape.ts` | `surface/use-shape.ts` | Level-3 passthrough + the read-filter view imported from `emulated-verifier/` — mixture-by-import, already in the right shape. |
|
||||||
|
| `surface/watch-shape.ts` | `surface/watch-shape.ts` | Surface composition over `emulated-verifier/` and the placement calls; its "planned `useShape` upgrade" header claim remains an ASSUMPTION with no provenance (`docs/api-contract.md` § 5) — a layout cannot fix that (§ 6). |
|
||||||
|
| `subscribe.ts` | `surface/subscribe.ts`, minus `subscribePhysicalDoc` → `shared-wallet/physical.ts` | The guarded `subscribeDoc`/`subscribeDocs` and `docChangeType` are surface (level 2, `doc_subscribe`, `sdk/js/lib-wasm/src/lib.rs:1908`). The physical door moves to the machinery module (§ 4); the unguarded core is exported under its `Unguarded` name for that one importer. |
|
||||||
|
| `surface/read-model.ts` | `surface/read-model.ts` — stays whole | The anchored-read mechanics are level-1-verified and survive as composition (`docs/api-contract.md` § 6). Its possession gate and machinery filter mirror native behaviour (§ 4 ruling) and arrive via named `emulated-verifier/` imports. |
|
||||||
|
| `inbox.ts` | **split**: `surface/inbox.ts` (post, `postToDocument`, `shareCap`) + `emulated-verifier/inbox-processing.ts` (`read`/`materialize`/`readSynced`/`processInbox`/`watch`, `assertOwnInbox`, the deposit RDF vocabulary) | § 4 ruling. `surface/inbox.ts` re-exports the processing functions with a header saying exactly what `docs/api-contract.md` § 9 says — that enumerating deposits is emulation detail — so the published `inbox.*` namespace is unchanged and the warning sits at the one place the two halves meet. |
|
||||||
|
| `surface/placement.ts` | dissolved into `surface/placement.ts` | The hand-built slice becomes a real module: the app-facing placement/addressing calls (`createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`), composed from the two lower layers. Its existence today is the strongest in-repo evidence for the whole reorganisation: it does by hand what the folder boundary does structurally. |
|
||||||
|
| `shared-wallet/account-registry.ts` | **split three ways**: `surface/placement.ts` + `emulated-verifier/branch-registers.ts` + `shared-wallet/account-registry.ts` | The sharpest case — 1377 lines spanning all three fates. `branch-registers.ts` takes the durable compartments: the Store-branch cap register (`holdOwnCap`/`readStoreCaps` — emulating `AddRepo { read_cap }`, level 1, `engine/repo/src/types.rs:1890-1899`), the User-branch Link register (`addLink`/`readLinks` — `AddLink { read_cap }`, `types.rs:1939-1948`), the inbox-cap records (`AddInboxCapV0`, `types.rs:1969-1981`) and the Header-branch address records. `account-registry.ts` takes the pointer→doc-shim indirection, `resolveAccount`/`ensureAccount`, `reservedAccount`, the cache, `VirtualUserRecord`/`RegistrySession` — NO COUNTERPART, the shim proper. `placement.ts` (surface) keeps the app-facing calls listed above plus `userStoreDoc`/`isOwnInbox`/`myInboxes` staying internal on the register side per their contract entries. |
|
||||||
|
| `emulated-verifier/caps.ts` | `emulated-verifier/caps.ts` — stays whole (+ receives `mintCap`) | The in-memory record of what the connected holder holds — the verifier's cap state. The per-holder `heldByHolder` keying is its one shared-wallet dimension (one wallet, many holders); it stays, isolated behind the injected `holder()` and noted: at migration there is exactly one holder and the map collapses. |
|
||||||
|
| `emulated-verifier/reach.ts` | `emulated-verifier/reach.ts` | The emulated wallet boundary: stands in for "a repo you hold no cap for cannot even be addressed" (level 1, `resolve_target_for_sparql` → `RepoNotFound`, `request_processor.rs:264,269`, re-verified). The two-rules redundancy is a lib choice, documented. |
|
||||||
|
| `emulated-verifier/read-filter.ts` | `emulated-verifier/read-filter.ts` | Stands in for cryptographic non-delivery (same level-1 citations); deleted at migration with nothing to migrate to. |
|
||||||
|
| `emulated-verifier/connect.ts` | `emulated-verifier/connect.ts` — stays whole | The recipient-verifier moment (`Verifier::inbox` → `process_inbox`, level 1, `verifier.rs:1674-1690`). Its import of `resolveAccount` (shared-wallet) is a deliberate cross-fate *check* — connecting must not provision — and stays visible at the import line. |
|
||||||
|
| `emulated-verifier/open-repo.ts` | `emulated-verifier/open-repo.ts`, minus `ensurePhysicalRepoOpen` → `shared-wallet/physical.ts` | Stands in for the verifier bringing repos into `self.repos` (native at migration: open by cap at bootstrap). Flag kept from the internal contract: this module is a *current-state heal*, not a model emulation — its barrier ("TabInfo then first State") is empirical, pinned only by the e2e CONTRACT-3 probe, and its header's mechanism claim is finding F2 (contradicted at the source). The folder cannot fix either (§ 6). |
|
||||||
|
| `emulated-verifier/machinery.ts` | `emulated-verifier/machinery.ts` | The subject filter fabricates what is structurally impossible upstream: a content read cannot see Store/User/Header compartments because they are separate branches with no triples (level 1, `BranchCrdt::None`, `engine/repo/src/types.rs:1420`, re-verified). It sits beside `branch-registers.ts`, whose compartments it hides; note that `MACHINERY_NS` also covers the account-shim's vocabulary. |
|
||||||
|
| `shared-wallet/physical.ts` | `shared-wallet/physical.ts` — grows into the complete privileged door | The quintessential shared-wallet module. It absorbs `subscribePhysicalDoc` and `ensurePhysicalRepoOpen`, so **one module is the machinery's entire unguarded API** — completing its own doctrine ("separate functions, never exemptions", `physical.ts:19-27`) at the tree level. Cost: the two unguarded cores get exported (under `Unguarded` names) from their mechanism modules; acceptable because neither entry ever re-exports them. |
|
||||||
|
| `shared-wallet/virtualUsers.ts` | `shared-wallet/virtualUsers.ts` | Identity persistence for the shared wallet; NO COUNTERPART (`docs/api-contract.md` § 13); already correctly published via `/polyfill` only. |
|
||||||
|
| `shared-wallet/access-log.ts` | `shared-wallet/access-log.ts` | Diagnoses the shared-wallet isolation leak; the identity it prefixes is the relayed virtual id. Deleted at migration. |
|
||||||
|
| `shared-wallet/outbox-log.ts` | `shared-wallet/outbox-log.ts` | Polyfill-era trace probe over the injected SDK's private persistence (level-2 facts verified in the internal contract § 11). Deleted at migration. |
|
||||||
|
|
||||||
|
Resulting tree: 2 entry files + `model/` (2) + `surface/` (10) + `emulated-verifier/` (8) + `shared-wallet/` (6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The mixed modules — mechanical detection, one criterion, and rulings
|
||||||
|
|
||||||
|
### Detection method
|
||||||
|
|
||||||
|
Fate labels exist per *export* in the two contracts; a module is mixed when its exports (or its internal effects) span fates. Three code signals find the internal effects mechanically, without trusting headers:
|
||||||
|
|
||||||
|
- **Signal A — wire call × emulation-state write:** the module calls the injected `ng` (`getConfig().ng`) *and* mutates emulation state (`getCaps().mint/learn/open`, `addLink`, `declareInfrastructure`). Grep hits: `docs.ts:73` (mint), `inbox.ts:385` (learn), `store-registry.ts:875,893-895,903,1169,1371` (learn/open), `connect.ts:67` (learn).
|
||||||
|
- **Signal B — guarded/unguarded twin exports:** the `*Physical*` / `*Unguarded` pairs. Hits: `subscribe.ts:104/118`, `open-repo.ts:167/184`.
|
||||||
|
- **Signal C — act vs stand-in-processing under one namespace:** exports of the same module carrying different fate labels in the contracts. Hits: `inbox.ts` (§ 9: sender acts are target-shaped; deposit enumeration is emulation detail), `shared-wallet/account-registry.ts` (§ 12: labels range from level-2 VERIFIED to NO COUNTERPART), `types.ts` (model types vs injection types, § 1 vs § 10), `nuri.ts` (guards vs `mintCap`, internal contract § 2), `polyfill.ts` (entry vs config store).
|
||||||
|
|
||||||
|
Full mixed list: `docs.ts`, `surface/read-model.ts`, `inbox.ts`, `subscribe.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/account-registry.ts`, `nuri.ts`, `types.ts`, `polyfill.ts`, `surface/ng-proxy.ts`, `emulated-verifier/caps.ts`, `emulated-verifier/connect.ts`. (`surface/use-shape.ts` and `surface/watch-shape.ts` cross fates only through imports — already the desired end state.)
|
||||||
|
|
||||||
|
### The criterion
|
||||||
|
|
||||||
|
**Split when the halves have different fates AND different callers. Keep whole when the mixture reproduces a composition the target itself performs atomically — and then express the emulated half as a named import from the other folder, so the seam is on the import line.** Corollary: an unguarded twin of a guarded operation always lives with the machinery that calls it, never beside its guarded sibling.
|
||||||
|
|
||||||
|
### Rulings
|
||||||
|
|
||||||
|
- **`docs.ts` — KEEP WHOLE.** The cap filing inside `docCreate` mirrors the target's own `doc_create`, which commits `AddRepo` to the Store branch and `ldp:contains` to the Main branch *in the same native call* (level 1, `engine/verifier/src/request_processor.rs:697-710`, re-verified). Splitting the mint into a separate caller-visible step would create a two-step creation surface the target does not have — the split itself would be the divergence. Same for the reach guard: the refusal is native (`RepoNotFound`). The fix is visibility, not surgery: both effects become named imports from `emulated-verifier/`.
|
||||||
|
- **`surface/read-model.ts` — KEEP WHOLE.** Its possession gate mirrors native non-delivery, and its machinery-subject drop mirrors the structural invisibility of non-content branches (`BranchCrdt::None`, `types.rs:1420`, level 1). Both are the target's own composition of "read a document".
|
||||||
|
- **`inbox.ts` — SPLIT.** Different fates (api-contract § 9: the acts are target-shaped level-1 inventions; the deposit-list surface "may never have this shape") *and* different callers (apps post/share/watch; `emulated-verifier/connect.ts` processes). The deposit RDF vocabulary — pure emulation transport (upstream a deposit is a sealed message, `InboxMsgBody`, `engine/net/src/types.rs:4265`, level 1, carrying no target document) — lives once, on the emulated side.
|
||||||
|
- **`subscribe.ts` / `emulated-verifier/open-repo.ts` — SPLIT the physical doors out** (criterion's corollary): different caller (machinery only), different fate (the guarded/unguarded pair collapses to one call when the wallet is the boundary). They regroup in `shared-wallet/physical.ts`.
|
||||||
|
- **`shared-wallet/account-registry.ts` — SPLIT three ways** (§ 3). It is the module the flat layout hides most: signal C fires on nearly every export group, and the repo already voted for the split by hand-building `surface/placement.ts`.
|
||||||
|
- **`nuri.ts` — SPLIT `mintCap` out.** Different fate (model vocabulary survives; the minting point is deleted when the engine mints) and the module's own stated invariant argues for it.
|
||||||
|
- **`types.ts` — SPLIT the injection types out** (small; see § 5 for the published-type consequence, which must be decided, not slipped).
|
||||||
|
- **`polyfill.ts` — SPLIT entry from config store.** Different fates (a published door vs internal state) and it removes real import cycles.
|
||||||
|
- **`surface/ng-proxy.ts` — KEEP WHOLE.** 59 lines; the overrides *are* "what the native side takes over" and already compose `emulated-verifier/` predicates; the proxy artifact itself is the published surface. F1 (the fabricated `login` member) is fixed by deletion wherever the file lives.
|
||||||
|
- **`emulated-verifier/caps.ts` — KEEP WHOLE.** The per-holder keying is shared-wallet-flavoured, but splitting holder-resolution from the possession model would fragment one coherent level-1 model for no boundary gain; the injected `holder()` already isolates the dimension that collapses at migration.
|
||||||
|
- **`emulated-verifier/connect.ts` — KEEP WHOLE.** Its shim import is a deliberate cross-fate check (must-not-provision), which is exactly what the seam should show.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Cost and risk
|
||||||
|
|
||||||
|
**What does not change: the published surface.** Both entries keep their `src/` paths *(they were merged into one on 2026-08-07, and `package.json`'s `exports` map now maps only `.`)*; the `inbox.*` and `storeRegistry.*` namespaces are re-assembled at the entry with identical contents. A consumer application importing the entry sees nothing — with one deliberate exception below.
|
||||||
|
|
||||||
|
**Import churn — the inventory:**
|
||||||
|
|
||||||
|
- All 23 non-entry `src/` modules import each other relatively; every moved file churns its importers' paths (mechanical, type-checked).
|
||||||
|
- 19 of 19 unit-test files deep-import `../src/*` — 66 static import lines (heaviest: `store-registry` ×16, `polyfill` ×14) **plus 3 dynamic `await import("../src/…")` sites** (`test/isolation-active.test.ts:359`, `test/reach.test.ts:201-202`) that a naive static-import codemod will miss and that fail only at runtime.
|
||||||
|
- The e2e harness deep-imports twice (`e2e/polyfill-entry.ts:41-42`: `../src/store-registry`, `../src/accounts`); its package-name imports resolve through the exports map and are immune. `e2e/tsconfig.json` includes `"."` only — path-agnostic.
|
||||||
|
- Decision to make alongside: whether `test/` mirrors the new folders (keeps the module↔spec correspondence at more churn) or stays flat.
|
||||||
|
|
||||||
|
**Silent-breakage candidates — the ones tests may not catch:**
|
||||||
|
|
||||||
|
1. **Module-evaluation order.** `polyfill.ts` is today both entry and config store and sits inside import cycles (`polyfill ↔ connect`, `polyfill ↔ inbox`) that work through ES-module hoisting; the `CapRegistry` singleton is constructed at module scope (`polyfill.ts:97`). Re-cutting the graph changes which module evaluates first; a cycle that works today can break — or, worse, *change initialization order without breaking*. Mitigation: extract `shared-wallet/bootstrap.ts` as its own first step with the full unit + e2e suite run before any other move (baseline discipline), since the e2e suite is what actually exercises load order against a real broker.
|
||||||
|
2. **`export * from "./types"`.** After the types split, `NgLike`/`UseShapeLike` silently vanish from the `.` entry's type surface — erased types, so nothing in this repo's runtime tests notices; only the consumer's typecheck would. This is a real (if arguably desirable) published-surface change and must be an explicit decision: either re-export them deliberately from `/polyfill` (where `EventuallyConfig`, which references them, already lives) with a documented deprecation on `.`, or accept the narrowing and record it in the api-contract.
|
||||||
|
3. **The contracts' citations — the largest single cost.** `docs/api-contract.md` and `docs/internal-contract.md` (plus several briefs) carry hundreds of `file:line` references into `src/`; every moved or split module stales them wholesale. These two documents are the library's enforcement instrument — letting their citations rot would undercut the very discipline the reorganisation serves. A citation-refresh pass over both contracts is part of the change, not a follow-up.
|
||||||
|
4. **`git blame` archaeology.** Moves (and especially the three-way `store-registry` split) break naive blame; `--follow` works per-file but split hunks lose lineage. One-time tax; worth staging the splits as move-then-edit commits so content moves stay detectable.
|
||||||
|
|
||||||
|
**Staging that contains the risk:** (1) extract `shared-wallet/bootstrap.ts`, full suite green; (2) pure moves into the four folders, no content edits, full suite green; (3) the splits (`inbox`, `store-registry`, `nuri`, `types`), one per commit, each behind its baseline; (4) the citation-refresh pass on both contracts. Each stage leaves the published surface byte-identical (stage-3 exception 2 above being the one flagged decision).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. What the layout will NOT fix
|
||||||
|
|
||||||
|
Being specific, because overclaiming here would recreate the false-certainty problem the layout is meant to reduce:
|
||||||
|
|
||||||
|
- **It cannot decide whether a behaviour is the target's.** The decisive act remains reading `nextgraph-rs`. Both README § *Design principle* incidents would have type-checked and folder-checked: "every document has a native inbox" was a *belief* error, and the owner-inbox pointer was a *model* error inside code that belongs exactly where it was. A correctly named folder holds wrong code without complaint.
|
||||||
|
- **It cannot fix wrong claims inside correctly placed modules.** Finding F2 (`emulated-verifier/open-repo.ts`'s header asserts a silent-0-rows mechanism the source contradicts — upstream errors `RepoNotFound`, `request_processor.rs:264,269`, level 1) and `surface/watch-shape.ts`'s unprovenanced "planned upgrade" survive any tree untouched. Header claims are policed by source-verification passes, not placement.
|
||||||
|
- **It is coarser than the epistemic labels.** PASSTHROUGH vs LEVEL-1 SHAPE vs ASSUMPTION vary per *claim* within one module (`inbox.post`'s act is level-1-shaped, its transport is pure emulation, arbitrary payloads are an ASSUMPTION — all in one function's orbit). A folder carries one label; the contracts remain the finer instrument and the layout must not be read as replacing them.
|
||||||
|
- **It cannot stop in-module shape drift.** `Deposit` growing a target-document field — the exact divergence class of the reverted owner-inbox episode, since upstream a message carries no document (`InboxMsgBody`, `engine/net/src/types.rs:4265`, level 1) — is one line in a correctly placed file.
|
||||||
|
- **It does not police the empirical bets.** The sync barrier's push ordering and "a held subscription keeps the repo open" are pinned by the e2e CONTRACT-3 probe alone; no layout substitutes for that tripwire.
|
||||||
|
- **It does not remove dead or decorative surface** (`inbox.watch`'s ignored `intervalMs`, the decorative write caps) — inventory work, already tracked in the contracts.
|
||||||
|
- **A wrong placement is worse than no placement.** Folders assert; a mis-filed module borrows the folder's authority (a shim-flavoured helper landing in `surface/` would *look* migration-safe). The contracts' per-subject verification remains the check on the layout — never the reverse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Cross-references: `README.md` § Design principle and § The three references (the doctrine this layout serializes into the tree); `docs/api-contract.md` (the would-be `surface/`+`model/` inventory); `docs/internal-contract.md` (the would-be `emulated-verifier/`+`shared-wallet/` inventory, findings F1–F5).*
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# Vision & principles of the `@ng-eventually/client` polyfill
|
# Vision & principles of `@ng-eventually/polyfill`
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ Without a minimum of crypto simulation, damaging shortcuts get taken (reading th
|
|||||||
|
|
||||||
Concretely, **in the target**: a document's data is **stored encrypted** (per-doc symmetric encryption, however lightweight); the **ReadCap = the key**; without it, **decrypting/reading is impossible**. No ACL, no plaintext accessible "on the side". Obtaining read access = **holding the key**, exactly as in the target model.
|
Concretely, **in the target**: a document's data is **stored encrypted** (per-doc symmetric encryption, however lightweight); the **ReadCap = the key**; without it, **decrypting/reading is impossible**. No ACL, no plaintext accessible "on the side". Obtaining read access = **holding the key**, exactly as in the target model.
|
||||||
|
|
||||||
> **Not yet true here, and saying so matters.** The shape is in place — possession decides, every access is confined to the connected virtual user, caps are stored and read back — but the cap value is the constant `OK` and nothing is encrypted. Per-document encryption is **P1b**, and it is one function (`nuri.ts` `mintCap`). Until it lands, nothing this library does may be described as anonymous or private.
|
> **Not yet true here, and saying so matters.** The shape is in place — possession decides, every access is confined to the connected virtual user, caps are stored and read back — but the cap value is the constant `OK` and nothing is encrypted. Per-document encryption is the **cap-enforcement** work, and it is one function (`mintCap`, in `emulated-verifier/caps.ts` — this said `nuri.ts` until 2026-08-10). Until it lands, nothing this library does may be described as anonymous or private.
|
||||||
|
|
||||||
## Shape consequences (to respect everywhere)
|
## Shape consequences (to respect everywhere)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Notebook — the library's example application
|
||||||
|
|
||||||
|
A minimal application written against `@ng-eventually/polyfill`, in plain DOM.
|
||||||
|
|
||||||
|
It exists for two reasons, and the second is the one that matters.
|
||||||
|
|
||||||
|
**It shows how to use the library.** Every call in `app.ts` is what a real consumer writes. There is no test scaffolding, no privileged import, no reaching into the library's internals — it resolves `@ng-eventually/polyfill` as an external consumer does. If something reads awkwardly here, it reads awkwardly for everyone.
|
||||||
|
|
||||||
|
**It is what the applicative e2e suite drives** (`packages/polyfill/e2e/notebook.ts`, `bun run test:e2e:app`). The other suite talks to a bag of methods on `window.__sdk`, which proves the functions run but never that an application can be written with them — and that gap shipped a real defect once: a document's inbox was green in tests and unusable in practice, because the harness handed an address across an identity boundary through a variable, something no application can do. Here each identity is its own browser page, and the only values that cross between them are the ones that cross in life: a note's reference, copied off one screen, and an identifier typed into a field.
|
||||||
|
|
||||||
|
It has already paid for itself: writing it surfaced that `UnionSubject` returned `string` where the values are always document references (so a consumer had to cast whatever it had just read before passing it back), and that the access gate normalized what a user typed but not what the URL carried.
|
||||||
|
|
||||||
|
## What it exercises
|
||||||
|
|
||||||
|
Signing in, writing notes by scope, listing one's own, reading a note from its bare reference, handing a reader the key to a protected note, opening a note for messages, leaving a message on someone else's note, and reacting to changes.
|
||||||
|
|
||||||
|
The four journeys the suite runs, and what each proves:
|
||||||
|
|
||||||
|
| Journey | What it proves |
|
||||||
|
|---|---|
|
||||||
|
| Bob reads Alice's PUBLIC note from its reference alone | A public store serves its notes' keys — a bare reference is enough, and no key ever crosses |
|
||||||
|
| Alice's PROTECTED note stays shut until she shares it | The same gesture, the opposite outcome, decided by where the note sits and not by what was sent |
|
||||||
|
| Bob leaves a message on Alice's note, and only Alice reads it | A depositor FINDS the address from the note itself; depositing grants no reading |
|
||||||
|
| Each actor's list holds their own notes | The boundary, seen from the only place that matters: the screen |
|
||||||
|
|
||||||
|
It has also found three defects of its own, each one the harness could not see. The first turned out to be a LIBRARY defect rather than an application one: the connection work had to be awaited at sign-in, or a note just shared with you read as unreadable — so `ensureIdentity` now awaits it, and `connectedUser` left the published surface. The other two were the application's: a stale answer stayed on screen beside a fresh question, and changing the scope did not refresh the list.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
`cd packages/polyfill && bun run test:e2e:app` builds it, serves it, and drives it against the real broker. To open it by hand you need a wallet: serve the folder with a bundled `app.js` and a `/shared-wallet.ngw`, and set `__NOTEBOOK_WALLET_PASSWORD__`.
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Notebook — a minimal application written against `@ng-eventually/polyfill`.
|
||||||
|
*
|
||||||
|
* It exists for two reasons, and the second is the one that matters:
|
||||||
|
*
|
||||||
|
* 1. **It shows how to use the library.** Every call here is what a real consumer
|
||||||
|
* writes; there is no test scaffolding, no privileged import, no reaching into the
|
||||||
|
* library's internals. If something is awkward here, it is awkward for everyone.
|
||||||
|
*
|
||||||
|
* 2. **It is what the applicative e2e suite drives** (`packages/polyfill/e2e/notebook.ts`).
|
||||||
|
* The other suite talks to a bag of methods on `window.__sdk`, which proves the
|
||||||
|
* functions run but never that an application could be written with them — and that
|
||||||
|
* gap shipped a real defect: a document's inbox was green in tests and unusable in
|
||||||
|
* practice, because the harness handed the address across an identity boundary
|
||||||
|
* through a variable. No application can do that. This app can only do what an
|
||||||
|
* application can do, so a test that passes here means the surface is usable, not
|
||||||
|
* merely callable.
|
||||||
|
*
|
||||||
|
* ── The domain is deliberately thin ───────────────────────────────────────
|
||||||
|
* Notes. Each user writes their own, may publish one, may hand a reader the key to a
|
||||||
|
* private one, and may leave a message on someone else's note. That is enough to
|
||||||
|
* exercise placement by scope, capability possession, directed sharing, per-document
|
||||||
|
* inboxes and reactive reads — without inventing a product.
|
||||||
|
*
|
||||||
|
* ── Plain DOM, on purpose ─────────────────────────────────────────────────
|
||||||
|
* The library imposes no framework, so its example must not adopt one: a consumer
|
||||||
|
* reading this should see the SDK calls, not a component tree. The UI here is the
|
||||||
|
* shortest thing that makes each act reachable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
// SDK-shaped — these survive migration, the real SDK replaces them in place.
|
||||||
|
docs,
|
||||||
|
ensureIdentity,
|
||||||
|
inbox,
|
||||||
|
init,
|
||||||
|
readUnion,
|
||||||
|
storeRegistry,
|
||||||
|
subscribeDoc,
|
||||||
|
type Nuri,
|
||||||
|
type Scope,
|
||||||
|
// Polyfill-era — ONE call, and it is the whole of what goes away.
|
||||||
|
configure,
|
||||||
|
} from "@ng-eventually/polyfill";
|
||||||
|
import { ng as realNg, init as realInit } from "@ng-org/web";
|
||||||
|
|
||||||
|
// --- the domain, such as it is ---------------------------------------------
|
||||||
|
|
||||||
|
const TITLE = "urn:notebook:title";
|
||||||
|
const BODY = "urn:notebook:body";
|
||||||
|
|
||||||
|
interface Note {
|
||||||
|
doc: Nuri;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bootstrap: ONE polyfill-era call --------------------------------------
|
||||||
|
//
|
||||||
|
// Everything else an application calls is SDK surface, preserved at migration. This one
|
||||||
|
// is the scaffolding, and at migration it goes: the app imports the real SDK, and the
|
||||||
|
// identity comes from the wallet instead of a barrier.
|
||||||
|
|
||||||
|
configure({
|
||||||
|
ng: realNg,
|
||||||
|
useShape: (() => {}) as never, // this example reads through `readUnion`, not the ORM
|
||||||
|
init: realInit,
|
||||||
|
sharedWallet: {
|
||||||
|
fileUrl: "/shared-wallet.ngw",
|
||||||
|
password: (globalThis as { __NOTEBOOK_WALLET_PASSWORD__?: string }).__NOTEBOOK_WALLET_PASSWORD__ ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The library's `init`, not the injected one — and this line is SDK-shaped, kept at
|
||||||
|
// migration. It settles the identity before handing the page to the broker, so the
|
||||||
|
// round-trip leaves with the identifier in the URL it carries. `realInit` called here
|
||||||
|
// would navigate away first, and the barrier would never show.
|
||||||
|
//
|
||||||
|
// The callback is this application's own business, and only its own: it keeps the session
|
||||||
|
// because the SPARQL primitives below take a `session_id`, exactly as the real SDK's do.
|
||||||
|
// It used to ALSO hand the library a thunk unwrapping this event into a session shape —
|
||||||
|
// plumbing every consumer wrote identically, and this one wrote wrong twice. The library
|
||||||
|
// catches the same event on its way through `init` now (2026-08-12), so what is left here
|
||||||
|
// is only what this app itself reads.
|
||||||
|
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
|
||||||
|
init((event: { status: string; session?: { session_id: string } }) => {
|
||||||
|
if (event.status === "loggedin" && event.session) resolve(event.session);
|
||||||
|
}, true, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- the acts ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Write a new note in `scope`. The document is created, then filled. */
|
||||||
|
async function writeNote(scope: Scope, title: string, body: string): Promise<Nuri> {
|
||||||
|
const doc = await storeRegistry.createEntityDoc(scope);
|
||||||
|
const s = await sessionReady;
|
||||||
|
await docs.sparqlUpdate(
|
||||||
|
s.session_id,
|
||||||
|
`INSERT DATA { <${doc}> <${TITLE}> "${escape(title)}" ; <${BODY}> "${escape(body)}" }`,
|
||||||
|
doc,
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** My notes in `scope`, read the way the library intends: list, then read. */
|
||||||
|
async function myNotes(scope: Scope): Promise<Note[]> {
|
||||||
|
const docsOfScope = await storeRegistry.listMyEntityDocs(scope);
|
||||||
|
const subjects = await readUnion(docsOfScope);
|
||||||
|
return subjects.map((s) => ({
|
||||||
|
doc: s.graph,
|
||||||
|
title: s.props[TITLE]?.[0] ?? "(sans titre)",
|
||||||
|
body: s.props[BODY]?.[0] ?? "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read someone else's note from its REFERENCE.
|
||||||
|
*
|
||||||
|
* A reference is what circulates — you do not discover a note, someone gives you its
|
||||||
|
* reference (a message, a URL, a QR code). It carries no key, and that is the point:
|
||||||
|
* if the note is in a PUBLIC store the store hands its key to whoever asks, so the
|
||||||
|
* reference is enough; if it is protected, the reference names the note and opens
|
||||||
|
* nothing, until its owner shares it (see `shareNote`).
|
||||||
|
*
|
||||||
|
* It arrives as a plain string, from a field or a URL, and goes straight in: the
|
||||||
|
* library validates it. Nothing to narrow, nothing to cast, and nothing that will have
|
||||||
|
* to change when the real SDK takes that same string.
|
||||||
|
*/
|
||||||
|
async function readSharedNote(reference: string): Promise<Note | null> {
|
||||||
|
const [note] = await readUnion([reference]);
|
||||||
|
if (!note) return null;
|
||||||
|
return {
|
||||||
|
doc: note.graph,
|
||||||
|
title: note.props[TITLE]?.[0] ?? "(sans titre)",
|
||||||
|
body: note.props[BODY]?.[0] ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand a reader access to one of my notes.
|
||||||
|
*
|
||||||
|
* 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<void> {
|
||||||
|
await inbox.share(doc, withUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a note for messages — only its owner can, and only they will read them. */
|
||||||
|
async function openNoteForMessages(doc: Nuri): Promise<void> {
|
||||||
|
await storeRegistry.openDocumentInbox(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave a message on someone else's note. One call, naming the NOTE. */
|
||||||
|
async function leaveMessage(doc: Nuri, text: string): Promise<void> {
|
||||||
|
await inbox.postToDocument(doc, { payload: { text } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The messages left on one of my notes — named by the note, like leaving one. */
|
||||||
|
async function messagesOn(doc: Nuri): Promise<string[]> {
|
||||||
|
const deposits = await inbox.readForDocument(doc);
|
||||||
|
return deposits.map((d) => String((d.payload as { text?: string })?.text ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-render whenever a note changes — locally or from a peer. */
|
||||||
|
function watchNote(doc: Nuri, onChange: () => void): () => void {
|
||||||
|
return subscribeDoc(doc, onChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- identity ---------------------------------------------------------------
|
||||||
|
|
||||||
|
let identity = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in, and learn who you are.
|
||||||
|
*
|
||||||
|
* One await, and it covers everything: the library settles the identity, waits for the
|
||||||
|
* connection work it fires (restoring what others shared with you, draining your
|
||||||
|
* inboxes), and **returns the identity**. The application keeps it only to display it —
|
||||||
|
* no call takes it, because a session belongs to one user and the target's own
|
||||||
|
* `doc_create` carries no user at all.
|
||||||
|
*
|
||||||
|
* This used to read the library's private storage key to find out who it was, which is a
|
||||||
|
* boundary no consumer should be able to see. Writing this application is what made that
|
||||||
|
* visible.
|
||||||
|
*/
|
||||||
|
async function signIn(): Promise<void> {
|
||||||
|
identity = await ensureIdentity();
|
||||||
|
await sessionReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape(s: string): string {
|
||||||
|
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the thinnest UI that makes each act reachable --------------------------
|
||||||
|
|
||||||
|
const el = (id: string): HTMLElement => document.getElementById(id)!;
|
||||||
|
const val = (id: string): string => (el(id) as HTMLInputElement).value.trim();
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
const scope = (el("scope") as HTMLSelectElement).value as Scope;
|
||||||
|
const notes = await myNotes(scope);
|
||||||
|
el("notes").innerHTML = notes
|
||||||
|
.map(
|
||||||
|
(n) => `<li data-doc="${n.doc}">
|
||||||
|
<b class="t">${n.title}</b> — <span class="b">${n.body}</span>
|
||||||
|
<button class="share" data-doc="${n.doc}">partager</button>
|
||||||
|
<button class="open" data-doc="${n.doc}">ouvrir aux messages</button>
|
||||||
|
<button class="msgs" data-doc="${n.doc}">messages</button>
|
||||||
|
<div><code class="ref" data-testid="ref">${n.doc}</code></div>
|
||||||
|
</li>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
// What `ensureIdentity()` returns is an OPAQUE identifier, not a display name: the SDK
|
||||||
|
// publishes none. So it is shown verbatim and marked as an identifier — never parsed,
|
||||||
|
// never split, never dressed up as a person's name.
|
||||||
|
const idTag = document.createElement("code");
|
||||||
|
idTag.textContent = identity;
|
||||||
|
el("who").replaceChildren("id ", idTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(): void {
|
||||||
|
// Changing the scope changes which notes are listed — without this the list keeps
|
||||||
|
// showing the previous scope's notes, which reads as "my note disappeared".
|
||||||
|
el("scope").addEventListener("change", () => void refresh());
|
||||||
|
el("write").addEventListener("click", async () => {
|
||||||
|
await writeNote((el("scope") as HTMLSelectElement).value as Scope, val("title"), val("body"));
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
el("openRef").addEventListener("click", async () => {
|
||||||
|
el("shared").textContent = ""; // never show a previous answer beside a new question
|
||||||
|
const note = await readSharedNote(val("reference"));
|
||||||
|
el("shared").textContent = note ? `${note.title} — ${note.body}` : "(illisible)";
|
||||||
|
});
|
||||||
|
el("leave").addEventListener("click", async () => {
|
||||||
|
el("left").textContent = "";
|
||||||
|
await leaveMessage(val("onNote") as Nuri, val("message"));
|
||||||
|
el("left").textContent = "déposé";
|
||||||
|
});
|
||||||
|
el("notes").addEventListener("click", async (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const doc = target.dataset.doc as Nuri | undefined;
|
||||||
|
if (!doc) return;
|
||||||
|
el("shareResult").textContent = "";
|
||||||
|
el("messages").textContent = "";
|
||||||
|
if (target.classList.contains("share")) {
|
||||||
|
await shareNote(doc, val("shareWith"));
|
||||||
|
el("shareResult").textContent = "partagé";
|
||||||
|
} else if (target.classList.contains("open")) {
|
||||||
|
await openNoteForMessages(doc);
|
||||||
|
el("shareResult").textContent = "ouverte aux messages";
|
||||||
|
} else if (target.classList.contains("msgs")) {
|
||||||
|
el("messages").textContent = (await messagesOn(doc)).join(" | ") || "(aucun)";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
wire();
|
||||||
|
await signIn();
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
|
|
||||||
|
// The e2e suite drives this app through the DOM. It exposes nothing else: a test that
|
||||||
|
// needed a back door would be testing something an application cannot do. `watchNote`
|
||||||
|
// is here because reactivity has no visible surface in this UI yet — not as an escape
|
||||||
|
// hatch, and it takes no identity: switching user means reloading with another `?ng-id=`,
|
||||||
|
// exactly as switching upstream means opening another wallet.
|
||||||
|
(globalThis as { __notebook?: unknown }).__notebook = { watchNote };
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Notebook — exemple ng-eventually</title>
|
||||||
|
<style>
|
||||||
|
body { font: 15px/1.5 system-ui, sans-serif; max-width: 640px; margin: 24px auto; padding: 0 16px; color: #222; }
|
||||||
|
fieldset { border: 1px solid #ddd; border-radius: 8px; margin: 0 0 16px; }
|
||||||
|
legend { padding: 0 6px; color: #666; font-size: 13px; }
|
||||||
|
input, select, button { font: inherit; padding: 6px 8px; }
|
||||||
|
input { border: 1px solid #bbb; border-radius: 5px; }
|
||||||
|
button { cursor: pointer; border: 1px solid #bbb; border-radius: 5px; background: #f6f6f6; }
|
||||||
|
ul { list-style: none; padding: 0; }
|
||||||
|
li { padding: 6px 0; border-bottom: 1px solid #eee; }
|
||||||
|
code.ref { font-size: 11px; color: #888; user-select: all; }
|
||||||
|
.out { color: #555; font-size: 13px; min-height: 1.2em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Connecté : <b id="who" data-testid="who"></b></p>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Écrire une note</legend>
|
||||||
|
<input id="title" data-testid="title" placeholder="titre" />
|
||||||
|
<input id="body" data-testid="body" placeholder="contenu" />
|
||||||
|
<select id="scope" data-testid="scope">
|
||||||
|
<option value="protected">protégée</option>
|
||||||
|
<option value="public">publique</option>
|
||||||
|
<option value="private">privée</option>
|
||||||
|
</select>
|
||||||
|
<button id="write" data-testid="write">écrire</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Mes notes</legend>
|
||||||
|
<input id="shareWith" data-testid="share-with" placeholder="partager avec (identifiant)" />
|
||||||
|
<ul id="notes" data-testid="notes"></ul>
|
||||||
|
<p class="out" id="shareResult" data-testid="share-result"></p>
|
||||||
|
<p class="out" id="messages" data-testid="messages"></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Ouvrir une note reçue</legend>
|
||||||
|
<input id="reference" data-testid="reference" placeholder="référence de la note" size="46" />
|
||||||
|
<button id="openRef" data-testid="open-reference">ouvrir</button>
|
||||||
|
<p class="out" id="shared" data-testid="shared"></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Laisser un message sur la note d'un autre</legend>
|
||||||
|
<input id="onNote" data-testid="on-note" placeholder="note visée" size="46" />
|
||||||
|
<input id="message" data-testid="message" placeholder="message" />
|
||||||
|
<button id="leave" data-testid="leave">déposer</button>
|
||||||
|
<p class="out" id="left" data-testid="left"></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "@ng-eventually/example-notebook",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "A minimal application written against @ng-eventually/polyfill \u2014 the library's usage example, and what the e2e suite drives.",
|
||||||
|
"dependencies": {
|
||||||
|
"@ng-eventually/polyfill": "workspace:*",
|
||||||
|
"@ng-org/web": "0.1.2-alpha.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": [
|
||||||
|
"bun"
|
||||||
|
],
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": [
|
||||||
|
"ESNext",
|
||||||
|
"DOM"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@ng-eventually/polyfill": [
|
||||||
|
"../../packages/polyfill/src/index.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"."
|
||||||
|
]
|
||||||
|
}
|
||||||
+5
-2
@@ -3,10 +3,13 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Generic polyfill layer over the NextGraph JS SDK — makes a single broker behave like the (not-yet-shipped) multi-user NextGraph: emulated capabilities and inbox. Drop-in SDK-shaped client; remove at migration. (A global-index curator package is deferred — see README.)",
|
"description": "Generic polyfill layer over the NextGraph JS SDK — makes a single broker behave like the (not-yet-shipped) multi-user NextGraph: emulated capabilities and inbox. Drop-in SDK-shaped client; remove at migration. (A global-index curator package is deferred — see README.)",
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": [
|
||||||
|
"packages/*",
|
||||||
|
"examples/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"typecheck": "bunx tsc --noEmit -p packages/client/tsconfig.json"
|
"typecheck": "bunx tsc --noEmit -p packages/polyfill/tsconfig.json"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
# @ng-eventually/client
|
|
||||||
|
|
||||||
Two entry points — the data-plane is SDK-identical, the polyfill bootstrap is
|
|
||||||
separate:
|
|
||||||
|
|
||||||
| Import | Surface |
|
|
||||||
|---|---|
|
|
||||||
| `@ng-eventually/client` | The same signature as the SDK — `ng`, `useShape`, `inbox` (+ types). A drop-in for `@ng-org/web` / `@ng-org/orm`; as NextGraph matures it resolves to the real SDK (build alias removed) with no code change. |
|
|
||||||
| `@ng-eventually/client/polyfill` | The only non-SDK surface — `configure`, `setCurrentUser`, and the capability surface (`capFor`, `shareCap`, `getCaps`). It falls away as NextGraph matures. |
|
|
||||||
|
|
||||||
> **Reading is key possession, and the isolation here is still fake.** The cap
|
|
||||||
> surface has the shape of the real model — you hold a document's `ReadCap` or you
|
|
||||||
> do not read it, and there is no authorization list anywhere — but nothing is
|
|
||||||
> encrypted yet and several read paths bypass the guard entirely. Nothing this
|
|
||||||
> library does may be described as "anonymous" or "private" until per-document
|
|
||||||
> encryption lands (P1b).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// bootstrap (the only non-SDK call) — inject the real SDK
|
|
||||||
import { configure } from "@ng-eventually/client/polyfill";
|
|
||||||
configure({ ng: realNg, useShape: realUseShape, sharedWallet, currentUser });
|
|
||||||
|
|
||||||
// from here on, a pure SDK surface:
|
|
||||||
import { ng, useShape, inbox } from "@ng-eventually/client";
|
|
||||||
await ng.doc_create(/* … */);
|
|
||||||
const set = useShape(MyShape, scope); // filtered to what the identity may read
|
|
||||||
await inbox.post(targetInbox, ref); // deposit (anticipated SDK API)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Principle — the polyfill compensates, it never extends
|
|
||||||
|
|
||||||
**The polyfill's ONLY reason to exist is to bridge a NextGraph implementation gap
|
|
||||||
or a bug.** Every non-SDK surface must map to a capability NextGraph will provide
|
|
||||||
natively, and must fall away at that point. The polyfill MUST NOT add functionality
|
|
||||||
of its own — no bespoke features, no observability/tooling, no convenience API that
|
|
||||||
isn't strictly "NextGraph will do this natively later." The test for any proposed
|
|
||||||
addition: *does it compensate a real, exhibited NextGraph gap or bug?* If not, it
|
|
||||||
does not belong here — build it in the consumer application, not in the polyfill.
|
|
||||||
Corollary: a compensation whose gap is not actually exhibited on the target broker
|
|
||||||
is dead weight, not defensive code — it should be removed, not kept "just in case."
|
|
||||||
|
|
||||||
What the polyfill adds on top of the real SDK (each emulated for now, native as
|
|
||||||
NextGraph matures):
|
|
||||||
- Shared-wallet identity (one wallet for everyone; the current identity id is
|
|
||||||
relayed to the SDK).
|
|
||||||
- Capability emulation — per-identity **cap possession** (`capFor`) and a read filter
|
|
||||||
over it: you read the documents whose cap you hold. Creating a document files its
|
|
||||||
cap; receiving one is an inbox deposit. There is no authorization list.
|
|
||||||
- Anticipated methods (inbox `post`, `shareCap`) with their future-SDK shapes,
|
|
||||||
emulated for now.
|
|
||||||
|
|
||||||
Generic: no application domain. The consumer application injects its shapes and
|
|
||||||
performs the acts of sharing. The relationship concept ("who is connected to whom")
|
|
||||||
is the consumer application's own — the client exposes only "share this one
|
|
||||||
document's cap to that inbox".
|
|
||||||
|
|
||||||
### The cap surface in three calls
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { capFor, shareCap, getCaps } from "@ng-eventually/client/polyfill";
|
|
||||||
import { storeRegistry } from "@ng-eventually/client";
|
|
||||||
|
|
||||||
// Creating a document records its cap and you hold it — nothing to declare.
|
|
||||||
const doc = await storeRegistry.createEntityDoc(myId, "protected");
|
|
||||||
capFor(doc); // → `${doc}:r:…` — you hold it
|
|
||||||
|
|
||||||
// Share it with one recipient, addressed by their inbox. They need no "receive"
|
|
||||||
// operation: their existing inbox.watch absorbs it.
|
|
||||||
await shareCap(capFor(doc)!, theirInbox);
|
|
||||||
|
|
||||||
// Publishing is TWO acts: place the data in your public store, and circulate its
|
|
||||||
// LINK. There is no discovery — you cannot be found, you can only be reached — so
|
|
||||||
// the link has to travel: into an inbox, or into a document the reader already
|
|
||||||
// holds. The bare NURI would name the document without opening it.
|
|
||||||
const link = getCaps().publishRepoLink(publicDoc);
|
|
||||||
await shareCap(link, theirInbox);
|
|
||||||
```
|
|
||||||
|
|
||||||
The one invariant to keep in mind: **you never derive a cap from a bare reference.**
|
|
||||||
You look it up in what you hold, or you were given it. A `did:ng:o:…` without `:r:`
|
|
||||||
names a document and grants nothing.
|
|
||||||
|
|
||||||
### The types carry that invariant
|
|
||||||
|
|
||||||
`Nuri` and `ReadCap` are **template literal types**, not `string` aliases:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type Nuri = `did:ng:${string}`
|
|
||||||
type ReadCap = `did:ng:${string}:r:${string}`
|
|
||||||
```
|
|
||||||
|
|
||||||
They are still strings — assignable to `string`, JSON-serializable, no wrapper — but
|
|
||||||
the distinction is checked. A `ReadCap` goes wherever a `Nuri` is expected (a cap
|
|
||||||
*is* a NURI with the key inside); the reverse does not compile:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
await shareCap(doc, theirInbox); // ✗ Argument of type '`did:ng:${string}`' is not
|
|
||||||
// assignable to '`did:ng:${string}:r:${string}`'
|
|
||||||
```
|
|
||||||
|
|
||||||
A string that comes from outside your code — storage, a URL, JSON, a form — is a
|
|
||||||
plain `string`. **Narrow it, do not cast it**: a cast re-opens exactly the confusion
|
|
||||||
the types close.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { isNuri, hasReadCap } from "@ng-eventually/client";
|
|
||||||
|
|
||||||
const saved = localStorage.getItem("cap");
|
|
||||||
if (saved && hasReadCap(saved)) await shareCap(saved, theirInbox); // ✓ narrowed
|
|
||||||
```
|
|
||||||
|
|
||||||
The runtime guards remain regardless — a JavaScript caller never meets the compiler,
|
|
||||||
and a cast bypasses it — so passing a bare reference where a cap belongs throws with
|
|
||||||
a message that says so.
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
/**
|
|
||||||
* Real-broker plumbing for the SDK e2e harness — a DEDICATED test wallet for
|
|
||||||
* `@ng-eventually/client`, fully separate from any consumer app's profile.
|
|
||||||
*
|
|
||||||
* Adapted from the Festipod app's `src/shared/support/hooks.ts` (the reference
|
|
||||||
* real-broker Playwright flow): headless wallet CREATION on nextgraph.eu, broker
|
|
||||||
* redirect via nextgraph.net, iframe handling. Here it authenticates a wallet
|
|
||||||
* created FOR THIS LIB (distinct name + distinct profile dir), and loads the
|
|
||||||
* minimal SDK page (sdk-entry.ts) inside the broker iframe.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium, type BrowserContext, type Page, type Frame } from "playwright";
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import * as http from "node:http";
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as os from "node:os";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
// ── Dedicated, gitignored profile + wallet (NOT the app's .playwright-profile) ──
|
|
||||||
export const PROFILE_DIR = path.resolve(__dirname, ".playwright-profile-lib");
|
|
||||||
const WALLET_READY_MARKER = path.join(PROFILE_DIR, ".wallet-ready");
|
|
||||||
export const WALLET_NAME = "ng-eventually-e2e";
|
|
||||||
export const WALLET_PASSWORD = "ng-eventually-e2e";
|
|
||||||
|
|
||||||
const ENTRY = path.resolve(__dirname, "sdk-entry.ts");
|
|
||||||
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "sdk-entry.js");
|
|
||||||
|
|
||||||
const LAUNCH_ARGS = [
|
|
||||||
"--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations",
|
|
||||||
"--allow-insecure-localhost",
|
|
||||||
"--disable-web-security",
|
|
||||||
];
|
|
||||||
|
|
||||||
function resolveChromePath(): string | undefined {
|
|
||||||
const p = chromium
|
|
||||||
.executablePath()
|
|
||||||
.replace("chrome-headless-shell", "chrome")
|
|
||||||
.replace("chromium_headless_shell", "chromium");
|
|
||||||
return p.includes("headless") ? undefined : p;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildBundle(): void {
|
|
||||||
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
|
|
||||||
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
|
|
||||||
stdio: "pipe",
|
|
||||||
cwd: path.resolve(__dirname, ".."),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serveHarness(): Promise<{ url: string; close: () => void }> {
|
|
||||||
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
|
|
||||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually sdk e2e</title></head><body><div id="root"></div><script type="module" src="/sdk-entry.js"></script></body></html>`;
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
if (req.url === "/sdk-entry.js") {
|
|
||||||
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
|
||||||
res.end(bundle);
|
|
||||||
} else {
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
||||||
res.end(html);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const port = (server.address() as { port: number }).port;
|
|
||||||
resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the dedicated lib wallet once (headless UI flow on nextgraph.eu),
|
|
||||||
* persisted in PROFILE_DIR. Mirrors Festipod's ensureAuth but with the lib's own
|
|
||||||
* wallet name + profile. Idempotent via the ready marker.
|
|
||||||
*/
|
|
||||||
export async function ensureWallet(): Promise<void> {
|
|
||||||
if (fs.existsSync(WALLET_READY_MARKER)) {
|
|
||||||
console.log("[e2e] dedicated lib wallet present — skipping creation");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("[e2e] creating dedicated lib wallet on nextgraph.eu...");
|
|
||||||
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
|
||||||
const ctx = await chromium.launchPersistentContext(PROFILE_DIR, {
|
|
||||||
headless: true,
|
|
||||||
executablePath: resolveChromePath(),
|
|
||||||
args: LAUNCH_ARGS,
|
|
||||||
});
|
|
||||||
const page = ctx.pages()[0] || (await ctx.newPage());
|
|
||||||
page.on("pageerror", () => {});
|
|
||||||
try {
|
|
||||||
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
||||||
const createButton = page.getByText("Create Wallet", { exact: true });
|
|
||||||
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
|
||||||
await createButton.click();
|
|
||||||
|
|
||||||
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
|
|
||||||
const acceptButton = page.getByText("I accept", { exact: true });
|
|
||||||
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
|
||||||
await acceptButton.click();
|
|
||||||
|
|
||||||
const usernameInput = page.locator("#username-input");
|
|
||||||
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
|
||||||
await usernameInput.fill(WALLET_NAME);
|
|
||||||
const passwordInput = page.locator("#password-input");
|
|
||||||
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
|
||||||
await passwordInput.fill(WALLET_PASSWORD);
|
|
||||||
|
|
||||||
const submitButton = page.getByText("create my wallet", { exact: false });
|
|
||||||
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
|
||||||
await submitButton.click();
|
|
||||||
|
|
||||||
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// First login → bootstrap the verifier repos from the broker.
|
|
||||||
const walletLink = page.getByText("Click here to login with your wallet");
|
|
||||||
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
||||||
await walletLink.click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
}
|
|
||||||
const loginPassword = page.locator('input[type="password"]');
|
|
||||||
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
|
||||||
await loginPassword.fill(WALLET_PASSWORD);
|
|
||||||
await loginPassword.press("Enter");
|
|
||||||
await page.waitForTimeout(10000);
|
|
||||||
console.log("[e2e] dedicated lib wallet created + bootstrapped");
|
|
||||||
} finally {
|
|
||||||
await ctx.close();
|
|
||||||
}
|
|
||||||
fs.writeFileSync(WALLET_READY_MARKER, new Date().toISOString());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function launchWalletContext(): Promise<BrowserContext> {
|
|
||||||
return chromium.launchPersistentContext(PROFILE_DIR, {
|
|
||||||
headless: true,
|
|
||||||
executablePath: resolveChromePath(),
|
|
||||||
args: LAUNCH_ARGS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched
|
|
||||||
* context, without a `.wallet-ready` marker and WITHOUT tearing the context down.
|
|
||||||
* Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across
|
|
||||||
* runs — so it is always "hot"), this mints a genuinely FRESH wallet each call so
|
|
||||||
* the cold-start (private-store repo not yet in `self.repos`) can be exercised.
|
|
||||||
*
|
|
||||||
* Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet,
|
|
||||||
* but the context stays OPEN and is returned (with its dir) so the caller can then
|
|
||||||
* open the SDK page in the SAME profile — i.e. the very first app session over a
|
|
||||||
* wallet that has never run the app. Caller cleans up ctx + dir.
|
|
||||||
*/
|
|
||||||
export async function createFreshWalletContext(): Promise<{
|
|
||||||
ctx: BrowserContext;
|
|
||||||
dir: string;
|
|
||||||
name: string;
|
|
||||||
}> {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-"));
|
|
||||||
const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
||||||
const ctx = await chromium.launchPersistentContext(dir, {
|
|
||||||
headless: true,
|
|
||||||
executablePath: resolveChromePath(),
|
|
||||||
args: LAUNCH_ARGS,
|
|
||||||
});
|
|
||||||
const page = ctx.pages()[0] || (await ctx.newPage());
|
|
||||||
page.on("pageerror", () => {});
|
|
||||||
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
||||||
const createButton = page.getByText("Create Wallet", { exact: true });
|
|
||||||
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
|
||||||
await createButton.click();
|
|
||||||
|
|
||||||
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
|
|
||||||
const acceptButton = page.getByText("I accept", { exact: true });
|
|
||||||
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
|
||||||
await acceptButton.click();
|
|
||||||
|
|
||||||
const usernameInput = page.locator("#username-input");
|
|
||||||
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
|
||||||
await usernameInput.fill(name);
|
|
||||||
const passwordInput = page.locator("#password-input");
|
|
||||||
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
|
||||||
await passwordInput.fill(WALLET_PASSWORD);
|
|
||||||
|
|
||||||
const submitButton = page.getByText("create my wallet", { exact: false });
|
|
||||||
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
|
||||||
await submitButton.click();
|
|
||||||
|
|
||||||
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// First login → bootstrap the verifier repos from the broker (this is what a
|
|
||||||
// brand-new wallet does on its very first unlock).
|
|
||||||
const walletLink = page.getByText("Click here to login with your wallet");
|
|
||||||
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
||||||
await walletLink.click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
}
|
|
||||||
const loginPassword = page.locator('input[type="password"]');
|
|
||||||
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
|
||||||
await loginPassword.fill(WALLET_PASSWORD);
|
|
||||||
await loginPassword.press("Enter");
|
|
||||||
await page.waitForTimeout(10000);
|
|
||||||
await page.close().catch(() => {});
|
|
||||||
return { ctx, dir, name };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
|
|
||||||
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start:
|
|
||||||
* the same wallet's repos are on the broker but NOT in this profile's IndexedDB, so
|
|
||||||
* a session over it starts with an empty `self.repos`. Caller must import the wallet
|
|
||||||
* (see {@link importWalletViaFile}) before opening the SDK page. Returns the context
|
|
||||||
* and the dir so the caller can clean it up.
|
|
||||||
*/
|
|
||||||
export async function launchCleanProfileContext(): Promise<{ ctx: BrowserContext; dir: string }> {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-clean-"));
|
|
||||||
const ctx = await chromium.launchPersistentContext(dir, {
|
|
||||||
headless: true,
|
|
||||||
executablePath: resolveChromePath(),
|
|
||||||
args: LAUNCH_ARGS,
|
|
||||||
});
|
|
||||||
return { ctx, dir };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import a `.ngw` wallet FILE into the current (clean) profile via the standalone
|
|
||||||
* nextgraph.eu "Import a Wallet File" flow, then unlock it with the password. After
|
|
||||||
* this the profile holds the wallet — but NOT the repos' local cache — so the next
|
|
||||||
* SDK session over it hits the broker-only cold-start. Adapted from the Festipod
|
|
||||||
* app's `importWalletViaFile` (the proven real-broker wallet-file import).
|
|
||||||
*/
|
|
||||||
export async function importWalletViaFile(page: Page, ngwPath: string): Promise<void> {
|
|
||||||
await page.goto("https://nextgraph.eu/#/wallet/login", { waitUntil: "domcontentloaded" });
|
|
||||||
// Let the SPA render + attach the file input (uploading too early → EncryptionError).
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.locator('input[type=file]').waitFor({ state: "attached", timeout: 15000 });
|
|
||||||
await page.setInputFiles('input[type=file]', ngwPath);
|
|
||||||
const passwordInput = page.locator('input[type=password]').first();
|
|
||||||
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
|
|
||||||
await passwordInput.fill(WALLET_PASSWORD);
|
|
||||||
await passwordInput.press("Enter");
|
|
||||||
const confirm = page.getByRole("button", { name: /Confirm/i });
|
|
||||||
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
|
|
||||||
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate through the broker (nextgraph.net redirect) to load `appUrl` in the
|
|
||||||
* broker iframe; unlock the dedicated wallet if a login is shown; return the app
|
|
||||||
* iframe Frame. Adapted from Festipod setupBrokerPage + completeBrokerLogin.
|
|
||||||
*/
|
|
||||||
export async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
|
|
||||||
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
|
|
||||||
await page.goto(brokerRedirect, { waitUntil: "domcontentloaded" });
|
|
||||||
|
|
||||||
const loginButton = page.getByText("Login", { exact: true });
|
|
||||||
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
||||||
await loginButton.click();
|
|
||||||
await page.waitForURL("**/wallet/login", { timeout: 5000 }).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAppFrame = () => page.frames().some((f) => f.url().includes("127.0.0.1"));
|
|
||||||
const walletLink = page.getByText("Click here to login with your wallet", { exact: false });
|
|
||||||
const loginDeadline = Date.now() + 25000;
|
|
||||||
while (Date.now() < loginDeadline && !hasAppFrame() && !(await walletLink.isVisible().catch(() => false))) {
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAppFrame() && (await walletLink.isVisible().catch(() => false))) {
|
|
||||||
await walletLink.click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
const passwordInput = page.locator('input[type="password"]');
|
|
||||||
if (await passwordInput.isVisible({ timeout: 8000 }).catch(() => false)) {
|
|
||||||
await passwordInput.fill(WALLET_PASSWORD);
|
|
||||||
await passwordInput.press("Enter");
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let appFrame: Frame | null = null;
|
|
||||||
const deadline = Date.now() + 30000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
for (const f of page.frames()) {
|
|
||||||
if (f.url().startsWith(appUrl) || f.url().includes("127.0.0.1")) {
|
|
||||||
appFrame = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (appFrame) break;
|
|
||||||
for (const iframe of await page.locator("iframe").all()) {
|
|
||||||
const src = await iframe.getAttribute("src");
|
|
||||||
if (src && src.includes("127.0.0.1")) {
|
|
||||||
const el = await iframe.elementHandle();
|
|
||||||
appFrame = (await el?.contentFrame()) ?? null;
|
|
||||||
if (appFrame) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (appFrame) break;
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!appFrame) {
|
|
||||||
const frames = page.frames().map((f) => f.url());
|
|
||||||
throw new Error(`SDK iframe not found after 30s. Frames: ${JSON.stringify(frames)}`);
|
|
||||||
}
|
|
||||||
return appFrame;
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
/**
|
|
||||||
* Capability emulation — key POSSESSION, not an authorization list.
|
|
||||||
*
|
|
||||||
* In NextGraph a ReadCap **is** the document's read key: whoever holds it reads,
|
|
||||||
* and there is no read-ACL anywhere. This module emulates that shape (see
|
|
||||||
* `docs/briefs/2026-07-27-p1a-cap-surface.md`), which means it answers exactly one
|
|
||||||
* question — *do I hold this document's cap?* — and cannot answer "may principal P
|
|
||||||
* read document D", because the real model cannot either.
|
|
||||||
*
|
|
||||||
* ── Where caps come from — and why this is NOT "a keyring" ────────────────
|
|
||||||
* There is no keyring object in NextGraph, and calling this one invited a wrong
|
|
||||||
* mental model: that some single place holds every key. It does not. Upstream the
|
|
||||||
* caps of a user are in **two** places, by origin (see
|
|
||||||
* `docs/readcap-and-nuri-model.md` §4quater/§4quinquies):
|
|
||||||
*
|
|
||||||
* - documents the user CREATED → `AddRepo { read_cap }` on the **Store branch**
|
|
||||||
* of the store they live in — one such branch per store;
|
|
||||||
* - caps RECEIVED for someone else's documents → `AddLink { read_cap }` on the
|
|
||||||
* **User branch** of the private store.
|
|
||||||
*
|
|
||||||
* The wallet itself holds exactly one key per user: the private store's read cap,
|
|
||||||
* from which everything else is reached. Hence the invariant:
|
|
||||||
*
|
|
||||||
* > You do not derive a cap from a bare reference. You look it up in what you
|
|
||||||
* > hold — or you were given it.
|
|
||||||
*
|
|
||||||
* This class is the in-memory record of what the connected holder currently holds:
|
|
||||||
* upstream's local user storage, not a durable register. The durable ones are
|
|
||||||
* emulated in `store-registry.ts` — for created documents, `holdOwnCap` writes and
|
|
||||||
* `readStoreCaps` reads the Store branch back; for received ones, `addLink` /
|
|
||||||
* `readLinks` on the User branch. `connect.ts` restores the Links at connection;
|
|
||||||
* the own-document caps come back through `listMyEntityDocs`.
|
|
||||||
*
|
|
||||||
* One record PER holder, since one shared wallet hosts every identity. Switching
|
|
||||||
* identity therefore SWITCHES records; it never wipes one (a wipe would make
|
|
||||||
* durability a lie and bring per-session re-declaration back under another name).
|
|
||||||
*
|
|
||||||
* ── Sharing ───────────────────────────────────────────────────────────────
|
|
||||||
* Not here: the unit of sharing is the document and the recipient is an INBOX, so
|
|
||||||
* sharing is `inbox.shareCap(cap, toInbox)` — a **Link** deposit — and receiving is
|
|
||||||
* the recipient processing their inbox. Handing over a store's cap is NOT the
|
|
||||||
* gesture: it would give away everything that store contains, present and future.
|
|
||||||
*
|
|
||||||
* ── What this module does NOT do ──────────────────────────────────────────
|
|
||||||
* Enforce. The shape is right after P1a; the isolation is still fake. Per-document
|
|
||||||
* encryption and closing the read paths that bypass the guard (`docs.sparqlQuery`,
|
|
||||||
* the inbox, `store-registry`, `subscribe`, `open-repo`) are P1b. Nothing may be
|
|
||||||
* claimed "anonymous" or "private" until then. The write caps below are likewise
|
|
||||||
* decorative — the guard they feed (`ng-proxy`) is bypassed by every internal
|
|
||||||
* writer; they are left as-is and belong to P1b.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { hasReadCap, mintCap, targetOf } from "./nuri";
|
|
||||||
import type { Nuri, PrincipalId, ReadCap, Scope } from "./types";
|
|
||||||
|
|
||||||
/** The map key of the anonymous holder (no identity established yet). */
|
|
||||||
const ANONYMOUS = "";
|
|
||||||
|
|
||||||
export class CapRegistry {
|
|
||||||
/** holder → the caps they hold, indexed by the cap-less NURI. */
|
|
||||||
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
|
|
||||||
/**
|
|
||||||
* Documents published as a shareable repo link (`RepoLinkV0`) — the emulated
|
|
||||||
* public store. This is NOT a read grant: a published document is read by
|
|
||||||
* whoever HOLDS the link, exactly like §5 of the brief says ("whoever has the
|
|
||||||
* URL reads the content"), and holding it means having received it. The set
|
|
||||||
* exists so the library can refuse to surface a document its holder never
|
|
||||||
* published. *(This fed `discovery.submitToIndex`, removed 2026-07-30; the flag is kept because publishing is still what turns a document into a shareable link.)*
|
|
||||||
*/
|
|
||||||
private published = new Set<Nuri>();
|
|
||||||
/** doc NURI → principals holding its WRITE cap. Decorative until P1b. */
|
|
||||||
private writers = new Map<Nuri, Set<PrincipalId>>();
|
|
||||||
/** Fired whenever a holder gains a cap — a cap delivered asynchronously must
|
|
||||||
* re-trigger the reads that were empty for want of it. */
|
|
||||||
private listeners = new Set<() => void>();
|
|
||||||
/** Has any cap been issued at all? Gates the whole emulation (see {@link isEnforcing}). */
|
|
||||||
private issued = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param holder resolves WHO is holding — the current identity. Looked up through it on every
|
|
||||||
* call, so an identity switch switches records with nothing to reset. Defaults to the anonymous holder.
|
|
||||||
*/
|
|
||||||
constructor(private readonly holder: () => PrincipalId | null = () => null) {}
|
|
||||||
|
|
||||||
// --- what the holder holds ----------------------------------------------
|
|
||||||
|
|
||||||
/** What the current holder holds, created on first use. */
|
|
||||||
private heldCaps(): Map<Nuri, ReadCap> {
|
|
||||||
const key = this.holder() ?? ANONYMOUS;
|
|
||||||
let ring = this.heldByHolder.get(key);
|
|
||||||
if (!ring) this.heldByHolder.set(key, (ring = new Map()));
|
|
||||||
return ring;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File `cap` among what the current holder holds — the ONE door in, so
|
|
||||||
* the invariant is carried here rather than by each caller remembering it.
|
|
||||||
*
|
|
||||||
* A reference with no `:r:` is REFUSED. `Nuri` and `ReadCap` are both `string`
|
|
||||||
* (deliberately — the real SDK takes `nuri: String`), so the compiler cannot
|
|
||||||
* catch a caller passing the naming form where the reading form is meant. Left
|
|
||||||
* unchecked, that mistake files a bare reference under its own name, `capFor`
|
|
||||||
* then returns it, and the document reads — turning "naming is not reading" into
|
|
||||||
* "naming is reading", which is the exact inversion this batch exists to remove.
|
|
||||||
* The check is cheap and it is the only thing standing between the two.
|
|
||||||
*
|
|
||||||
* Returns whether the cap was new.
|
|
||||||
*/
|
|
||||||
private file(cap: ReadCap): boolean {
|
|
||||||
if (!hasReadCap(cap)) {
|
|
||||||
throw new Error(
|
|
||||||
"[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " +
|
|
||||||
`reference — naming is not reading, and no cap derives from one: ${JSON.stringify(cap)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const target = targetOf(cap);
|
|
||||||
const ring = this.heldCaps();
|
|
||||||
if (ring.get(target) === cap) return false;
|
|
||||||
ring.set(target, cap);
|
|
||||||
this.issued = true;
|
|
||||||
this.notify();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The cap of a document I just CREATED, filed among what I hold — the emulated
|
|
||||||
* `AddRepo { read_cap }`. Idempotent. Returns the cap.
|
|
||||||
*/
|
|
||||||
mint(nuri: Nuri): ReadCap {
|
|
||||||
const cap = mintCap(nuri);
|
|
||||||
this.file(cap);
|
|
||||||
return cap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File a cap I was GIVEN — an inbox deposit of kind `cap`, or a repo link found
|
|
||||||
* in world-readable content. This is the ONLY way a cap arrives from
|
|
||||||
* outside: nothing turns a bare reference into a cap.
|
|
||||||
*
|
|
||||||
* @throws if `cap` carries no `:r:` — see {@link file}. Passing a bare `Nuri`
|
|
||||||
* here is the one type confusion that would silently invert the model, and both
|
|
||||||
* forms are `string`, so it is rejected at runtime instead.
|
|
||||||
*/
|
|
||||||
learn(cap: ReadCap): void {
|
|
||||||
this.file(cap);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold
|
|
||||||
* none — which is the whole answer the model can give. Absorbs the former
|
|
||||||
* `canRead(doc, principal)`: there is no principal parameter, because there is
|
|
||||||
* no list to look a principal up in.
|
|
||||||
*/
|
|
||||||
capFor(nuri: Nuri): ReadCap | undefined {
|
|
||||||
return this.heldCaps().get(targetOf(nuri));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- publication (the public store) -------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish `nuri` as a shareable repo link and return it — the upstream
|
|
||||||
* `RepoLinkV0 { read_cap }`, which whoever receives it can open. The consumer
|
|
||||||
* puts this link (not the bare NURI) in what it makes discoverable.
|
|
||||||
*
|
|
||||||
* NOT recursive: the published document may REFERENCE private documents, and the
|
|
||||||
* reference grants nothing on what it references — that non-recursiveness is
|
|
||||||
* what lets a public object point at a private identity without disclosing it.
|
|
||||||
*/
|
|
||||||
publishRepoLink(nuri: Nuri): ReadCap {
|
|
||||||
const target = targetOf(nuri);
|
|
||||||
this.published.add(target);
|
|
||||||
return this.mint(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Was `nuri` published as a repo link? (An emitter-side guard, not a right.) */
|
|
||||||
isPublished(nuri: Nuri): boolean {
|
|
||||||
return this.published.has(targetOf(nuri));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a document the current holder owns in `scope`: its cap lands in their
|
|
||||||
* what they hold, and a `public` one is additionally published as a repo link. Returns
|
|
||||||
* the cap (the shareable link when public). Idempotent — the store-registry calls
|
|
||||||
* it both when creating a document and when listing the holder's own documents
|
|
||||||
* back, which is how a holder's caps are rebuilt on a fresh session.
|
|
||||||
*
|
|
||||||
* Deliberately does NOT touch write caps: those are decorative until P1b, and
|
|
||||||
* arming their guard here would be enforcement this batch does not do.
|
|
||||||
*/
|
|
||||||
open(nuri: Nuri, scope: Scope): ReadCap {
|
|
||||||
return scope === "public" ? this.publishRepoLink(nuri) : this.mint(nuri);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- enforcement gate ---------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the cap emulation in force? False until the first cap is issued, so a
|
|
||||||
* consumer that never touches caps keeps reading everything (no regression).
|
|
||||||
* Once ANY cap exists the regime is possession for EVERY holder — including one
|
|
||||||
* who holds nothing, which is exactly the isolation being emulated.
|
|
||||||
*/
|
|
||||||
isEnforcing(): boolean {
|
|
||||||
return this.issued;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- change signal ------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to changes in what the holder holds. A cap that arrives asynchronously (an inbox
|
|
||||||
* deposit) must make the views that were empty for want of it re-read; without
|
|
||||||
* this signal they stay stale until an unrelated change happens to fire.
|
|
||||||
*/
|
|
||||||
onChange(listener: () => void): () => void {
|
|
||||||
this.listeners.add(listener);
|
|
||||||
return () => {
|
|
||||||
this.listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private notify(): void {
|
|
||||||
for (const l of this.listeners) {
|
|
||||||
try {
|
|
||||||
l();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[caps] change listener threw", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- write caps (decorative until P1b) ----------------------------------
|
|
||||||
|
|
||||||
/** Grant `principal` the WRITE cap of document `doc`. */
|
|
||||||
grantWrite(doc: Nuri, principal: PrincipalId): void {
|
|
||||||
const target = targetOf(doc);
|
|
||||||
let s = this.writers.get(target);
|
|
||||||
if (!s) this.writers.set(target, (s = new Set()));
|
|
||||||
s.add(principal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is `doc` under any WRITE-cap policy? */
|
|
||||||
governsWrite(doc: Nuri): boolean {
|
|
||||||
return this.writers.has(targetOf(doc));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Does `principal` hold a WRITE cap for `doc`? */
|
|
||||||
canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
|
|
||||||
if (principal === null) return false;
|
|
||||||
return this.writers.get(targetOf(doc))?.has(principal) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** No WRITE policy declared → the write guard stays inert (passthrough). */
|
|
||||||
hasWritePolicy(): boolean {
|
|
||||||
return this.writers.size > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop every holder's caps and every publication. Tests / a fresh wallet only —
|
|
||||||
* NOT what an identity change does (that switches heldByHolder, see the header). */
|
|
||||||
clear(): void {
|
|
||||||
this.heldByHolder.clear();
|
|
||||||
this.published.clear();
|
|
||||||
this.writers.clear();
|
|
||||||
this.issued = false;
|
|
||||||
this.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* connect — what the polyfill does when the app connects a virtual user.
|
|
||||||
*
|
|
||||||
* ── Processing inboxes is the LIBRARY's job, not the app's ────────────────
|
|
||||||
* Stated by the PO, 2026-07-30. A consumer must not have to remember to drain its
|
|
||||||
* inbox for documents shared with it to become readable; forgetting would look
|
|
||||||
* like "the share did not work" rather than "nobody processed the queue". So the
|
|
||||||
* moment an identity is connected ({@link setCurrentUser}), this runs.
|
|
||||||
*
|
|
||||||
* Two steps, in order, and the order matters:
|
|
||||||
*
|
|
||||||
* 1. **Restore** — read the Links already applied (`storeRegistry.readLinks`, the
|
|
||||||
* emulated `AddLink` records on the User branch of the private store) back into
|
|
||||||
* what this user holds. This is durable state; it costs one read and needs no inbox.
|
|
||||||
* 2. **Process** — drain the user's inbox (`inbox.processInbox`), which files any
|
|
||||||
* new Link durably and puts it among what the user holds.
|
|
||||||
*
|
|
||||||
* Restoring first means a reconnecting user can read its shared documents
|
|
||||||
* immediately, without waiting on the inbox round-trip.
|
|
||||||
*
|
|
||||||
* ── Fire-and-forget, on purpose ───────────────────────────────────────────
|
|
||||||
* `setCurrentUser` is synchronous and every consumer calls it from synchronous
|
|
||||||
* code. Making it async would push the wait onto the app, which is exactly the
|
|
||||||
* obligation this removes. So the work runs in the background and announces itself
|
|
||||||
* through the registry's change signal (`CapRegistry.onChange`), which is what
|
|
||||||
* `watchShape` already listens to — a view that was empty for want of a cap
|
|
||||||
* re-reads when the cap lands. {@link connectedUser} is there for a caller that
|
|
||||||
* genuinely needs to await it (tests, an app that wants a deterministic start).
|
|
||||||
*
|
|
||||||
* ── Every inbox, at both levels ───────────────────────────────────────────
|
|
||||||
* The user's own inbox AND the inbox of every document it opened one on. Upstream
|
|
||||||
* both are answered by the same place — `AddInboxCap` records on the User branch
|
|
||||||
* (`engine/repo/src/types.rs:1969`) — so `storeRegistry.myInboxes()` enumerates
|
|
||||||
* them and this drains each in turn.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getCaps, getCurrentUser } from "./polyfill";
|
|
||||||
import { myInboxes, readLinks, resolveAccount } from "./store-registry";
|
|
||||||
import { processInbox } from "./inbox";
|
|
||||||
|
|
||||||
/** The in-flight connection work, per user key — so two calls do not race. */
|
|
||||||
const inFlight = new Map<string, Promise<void>>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore and drain for the connected user. Idempotent per user while in flight.
|
|
||||||
*
|
|
||||||
* Tolerant by construction: it runs on every `setCurrentUser`, including in
|
|
||||||
* contexts where the store registry was never configured (unit tests, an app
|
|
||||||
* setting the identity before the session resolves). Those simply have nothing to
|
|
||||||
* restore, and a failure here must never break connecting.
|
|
||||||
*/
|
|
||||||
export async function connectedUser(): Promise<void> {
|
|
||||||
const holder = getCurrentUser();
|
|
||||||
if (holder === null) return;
|
|
||||||
const pending = inFlight.get(holder);
|
|
||||||
if (pending) return pending;
|
|
||||||
|
|
||||||
const run = (async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Connecting must not PROVISION. `ensureAccount` would create the user on
|
|
||||||
// first sight, so connecting an identity that does not exist yet would
|
|
||||||
// silently mint its stores and their caps — arming the whole emulation as a
|
|
||||||
// background side effect, at a moment nothing controls. An account that does
|
|
||||||
// not exist has nothing to restore and no inbox to drain.
|
|
||||||
if ((await resolveAccount(holder)) === null) return;
|
|
||||||
// 1. Durable first: what this user has already applied.
|
|
||||||
for (const cap of await readLinks()) getCaps().learn(cap);
|
|
||||||
// 2. Then the queues: ALL of them — the user's own inbox, plus one per
|
|
||||||
// document it opened an inbox on. Both levels, as the PO specified, and
|
|
||||||
// both are answered by the same User-branch record (`AddInboxCap`).
|
|
||||||
// Sequential rather than parallel: each `processInbox` writes what it
|
|
||||||
// applies to the SAME private store, and interleaving those writes buys
|
|
||||||
// nothing on a queue that is nearly always empty.
|
|
||||||
for (const inbox of await myInboxes()) await processInbox(inbox);
|
|
||||||
} catch {
|
|
||||||
// Not configured yet, or offline. Nothing to restore, and connecting must
|
|
||||||
// not fail because a queue could not be reached — the next connection, or
|
|
||||||
// an explicit `connectedUser()`, picks it up.
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
inFlight.set(holder, run);
|
|
||||||
try {
|
|
||||||
await run;
|
|
||||||
} finally {
|
|
||||||
inFlight.delete(holder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fire the connection work without awaiting it. Called by `setCurrentUser`. */
|
|
||||||
export function startConnect(): void {
|
|
||||||
void connectedUser();
|
|
||||||
}
|
|
||||||
@@ -1,538 +0,0 @@
|
|||||||
/**
|
|
||||||
* Inbox — a generic deposit + read/materialize mechanism the consumer reuses for
|
|
||||||
* its own purposes (same `inbox.post` API, same watcher — see the discovery-model
|
|
||||||
* decision). The mechanism itself knows no application domain: the consumer
|
|
||||||
* supplies the inbox document NURI and interprets the `payload`. (An example
|
|
||||||
* consumer mapping, purely illustrative: a consumer might use one inbox for a
|
|
||||||
* registration deposit and another for submitting a reference to an index.)
|
|
||||||
*
|
|
||||||
* ── Real target vs this emulation ─────────────────────────────────────────
|
|
||||||
* In real NextGraph, a message is sealed to the recipient's key and queued into
|
|
||||||
* their inbox; the recipient's own verifier unseals each queued message and
|
|
||||||
* applies it inline as it processes the inbox — there is no separate curator
|
|
||||||
* process. There is NO sender-side JS call for this today: the verifier has no
|
|
||||||
* `InboxPost` arm and `@ng-org/web` exposes no inbox method at all. (`inbox_post_link`,
|
|
||||||
* named elsewhere in these docs, is OUR proposal from `docs/fork-inbox-fallback.md` —
|
|
||||||
* no such symbol exists in `nextgraph-rs`. Do not cite it as a planned API.)
|
|
||||||
*
|
|
||||||
* Here, on one shared wallet where everything is readable, both sides run in-lib:
|
|
||||||
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
|
|
||||||
* document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
|
|
||||||
* - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose
|
|
||||||
* them. This in-lib read stands in for the recipient's own inbox processing
|
|
||||||
* until a sealed-inbox path is exposed to JS.
|
|
||||||
*
|
|
||||||
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
|
|
||||||
* never `makeNg`), so this module imports no `@ng-org` package.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { depositInto, sparqlQuery } from "./docs";
|
|
||||||
import { subscribeDoc } from "./subscribe";
|
|
||||||
import { ensureRepoOpen } from "./open-repo";
|
|
||||||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
|
||||||
import { addLink, documentInboxAddress, isOwnInbox } from "./store-registry";
|
|
||||||
import { escapeLiteral } from "./sparql";
|
|
||||||
import { hasReadCap } from "./nuri";
|
|
||||||
import {
|
|
||||||
accessLogPrefix,
|
|
||||||
enabled as accessLogEnabled,
|
|
||||||
logAccess,
|
|
||||||
logStage,
|
|
||||||
shortNuri,
|
|
||||||
} from "./access-log";
|
|
||||||
import type { Nuri, PrincipalId, ReadCap } from "./types";
|
|
||||||
|
|
||||||
// --- deposit model --------------------------------------------------------
|
|
||||||
|
|
||||||
/** One deposit as materialized from an inbox document. */
|
|
||||||
export interface Deposit {
|
|
||||||
/** The sender, if identified; `null` when the deposit was anonymous. */
|
|
||||||
from: PrincipalId | null;
|
|
||||||
/** The consumer-defined payload (opaque here — JSON-serialized in storage). */
|
|
||||||
payload: unknown;
|
|
||||||
/** Deposit timestamp (ms epoch). Caller may pass one for determinism. */
|
|
||||||
ts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options for {@link post}. `from` and `ts` are both optional. */
|
|
||||||
export interface PostOptions {
|
|
||||||
/**
|
|
||||||
* Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a
|
|
||||||
* principal id to identify the sender. Defaults to the current polyfill user
|
|
||||||
* ({@link getCurrentUser}) when the property is entirely absent, so callers
|
|
||||||
* that want anonymity must pass `from: null` explicitly.
|
|
||||||
*/
|
|
||||||
from?: PrincipalId | null;
|
|
||||||
/** The payload to deposit (interpreted only by the consumer). */
|
|
||||||
payload: unknown;
|
|
||||||
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
|
|
||||||
* keeps tests deterministic. */
|
|
||||||
ts?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHIM = "urn:ng-eventually:inbox";
|
|
||||||
const P = {
|
|
||||||
type: `${SHIM}:Deposit`,
|
|
||||||
from: `${SHIM}:from`,
|
|
||||||
payload: `${SHIM}:payload`,
|
|
||||||
ts: `${SHIM}:ts`,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// --- session access (shared with the storeRegistry) -----------------------
|
|
||||||
|
|
||||||
/** 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<string> {
|
|
||||||
return (await getStoreRegistryDeps().getSession()).sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- diagnostic logging helper ---------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort, length-capped JSON rendering of a deposit payload for the
|
|
||||||
* inbox diagnostic log (see {@link enabled}/{@link logAccess}). This module
|
|
||||||
* stays domain-agnostic (see module header) — it never interprets payload
|
|
||||||
* fields, it only dumps them verbatim so the consumer's own shape (e.g. a
|
|
||||||
* Festipod participation: `{ participantId, eventId, … }`) is visible in the
|
|
||||||
* log without this module knowing that shape. Capped so one oversized payload
|
|
||||||
* can't blow up a log line; a payload that fails to stringify (e.g. a
|
|
||||||
* circular structure a caller mistakenly passed) falls back to `String()`.
|
|
||||||
*/
|
|
||||||
function summarizePayload(payload: unknown): string {
|
|
||||||
let s: string;
|
|
||||||
try {
|
|
||||||
s = JSON.stringify(payload) ?? String(payload);
|
|
||||||
} catch {
|
|
||||||
s = String(payload);
|
|
||||||
}
|
|
||||||
return s.length > 200 ? s.slice(0, 200) + "…" : s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SPARQL result helpers ------------------------------------------------
|
|
||||||
|
|
||||||
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
|
||||||
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
|
|
||||||
if (!result) return [];
|
|
||||||
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
|
|
||||||
const anyRes = result as {
|
|
||||||
results?: { bindings?: Array<Record<string, { value: string }>> };
|
|
||||||
};
|
|
||||||
return anyRes.results?.bindings ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- deposit (client side) ------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deposit a payload into `targetInbox`.
|
|
||||||
*
|
|
||||||
* Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate`
|
|
||||||
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
|
|
||||||
* graph, so concurrent deposits don't collide.
|
|
||||||
*
|
|
||||||
* `from` is bound to the current identity — it is authenticated, not
|
|
||||||
* caller-supplied. Omit it to stamp the current identity; pass `null` to deposit
|
|
||||||
* anonymously (a legitimate choice — identified if known, anonymous otherwise).
|
|
||||||
* A `from` naming another identity is rejected as a spoof: in the target the
|
|
||||||
* broker seals the sender from the wallet's own key, so a client cannot forge
|
|
||||||
* another's identity. This check is redundant once the seal enforces it, but
|
|
||||||
* until then it closes the spoof the shared wallet would otherwise allow.
|
|
||||||
*/
|
|
||||||
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void> {
|
|
||||||
const current = getCurrentUser();
|
|
||||||
let from: PrincipalId | null;
|
|
||||||
if (opts.from === undefined) {
|
|
||||||
from = current; // default: stamp the current identity
|
|
||||||
} else if (opts.from === null) {
|
|
||||||
from = null; // explicit anonymous deposit
|
|
||||||
} else if (opts.from === current) {
|
|
||||||
from = opts.from; // identifying as self — allowed
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"[ng-eventually] inbox.post: `from` must be the current identity or null " +
|
|
||||||
"(anonymous) — depositing as another principal is a spoof.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const ts = opts.ts ?? Date.now();
|
|
||||||
const sid = await sessionId();
|
|
||||||
|
|
||||||
// A unique subject per deposit (in the inbox graph) — no collisions.
|
|
||||||
const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`;
|
|
||||||
const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null));
|
|
||||||
const fromTriple =
|
|
||||||
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
|
|
||||||
|
|
||||||
// NO explicit `GRAPH <…>` wrapper — write the anchored DEFAULT graph:
|
|
||||||
// `sparqlUpdate(sid, update, targetInbox)` scopes the write to that repo's
|
|
||||||
// default graph (same shape as read-model.ts readDoc/readUnion). This is the
|
|
||||||
// CANONICAL, always-safe shape and the one the anchored default-graph read
|
|
||||||
// queries. (Not a round-trip necessity on the current broker: the e2e harness
|
|
||||||
// `packages/client/e2e/` verified that an anchored `GRAPH <plainNuri>` write
|
|
||||||
// ALSO round-trips here — it resolves to the same repo graph, no phantom graph.
|
|
||||||
// The no-GRAPH form is kept as a simplicity/safety convention; re-verify with
|
|
||||||
// that harness if the broker version changes.)
|
|
||||||
const update = `
|
|
||||||
INSERT DATA {
|
|
||||||
<${subject}> a <${P.type}> ;
|
|
||||||
<${P.payload}> "${payloadLiteral}" ;
|
|
||||||
<${P.ts}> "${ts}"${fromTriple} .
|
|
||||||
}`;
|
|
||||||
// A deposit crosses the boundary on purpose — see docs.depositInto.
|
|
||||||
await depositInto(sid, update, targetInbox, "deposit");
|
|
||||||
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):
|
|
||||||
// who deposited WHAT into which inbox — the decoded payload, not just the
|
|
||||||
// triple-write. Gated by the same access-log flag; skip the JSON work when off.
|
|
||||||
if (accessLogEnabled()) {
|
|
||||||
logAccess(
|
|
||||||
"WRITE",
|
|
||||||
targetInbox,
|
|
||||||
"inbox deposit",
|
|
||||||
" from=" + (from ?? "anonymous") + " payload=" + summarizePayload(opts.payload ?? null),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deposit into the inbox of a DOCUMENT — resolve where, then deposit there.
|
|
||||||
*
|
|
||||||
* The call an app makes to reach a document's owner: it needs the document (which it
|
|
||||||
* must be able to read) and nothing else. Where the inbox is, and whether the owner
|
|
||||||
* ever opened one, are the library's business.
|
|
||||||
*
|
|
||||||
* **No target-document field on the deposit, deliberately.** Upstream an inbox belongs
|
|
||||||
* to exactly one repo — the verifier routes by `inboxes: PubKey → RepoId` and unseals
|
|
||||||
* with that repo's key (`engine/verifier/src/verifier.rs:1677`) — and `InboxMsgBody`
|
|
||||||
* carries no document (`engine/net/src/types.rs:4265`), because the address already
|
|
||||||
* identifies it. Tagging deposits with their document would be an invention consumers
|
|
||||||
* would have to unlearn at migration, so this resolves the address and stops there.
|
|
||||||
*
|
|
||||||
* @throws if the document has no inbox — its owner never opened one, so there is
|
|
||||||
* nowhere for this to go. Throwing rather than returning quietly is the whole lesson of
|
|
||||||
* this path: a deposit that vanishes without an error is worse than a refusal, and it
|
|
||||||
* is exactly the bug per-document inboxes shipped with
|
|
||||||
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). Call
|
|
||||||
* `storeRegistry.documentInboxAddress(doc)` first when "no inbox" is an expected case.
|
|
||||||
*/
|
|
||||||
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void> {
|
|
||||||
const target = await documentInboxAddress(doc);
|
|
||||||
if (target === undefined) {
|
|
||||||
throw new Error(
|
|
||||||
"[ng-eventually] inbox.postToDocument: this document has no inbox — either its owner " +
|
|
||||||
"never opened one, or you cannot read the document (the address rides on it): " +
|
|
||||||
JSON.stringify(doc),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return post(target, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- cap delivery ---------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it
|
|
||||||
* is the same one at all three stages: `InboxMsgContent::Link` is the message
|
|
||||||
* (`engine/net/src/types.rs:4249-4261`, declared but payload-less so far),
|
|
||||||
* `AddLink { read_cap }` is where the recipient files it (`repo/types.rs:1934-1950`),
|
|
||||||
* `RemoveLink` withdraws it. So giving access is: deposit a Link, and on connection
|
|
||||||
* the recipient processes their inbox and files it.
|
|
||||||
*
|
|
||||||
* It travels the SAME channel as any other deposit, which is why key ROTATION needs
|
|
||||||
* no special case on the surface — a re-delivered cap is just another Link.
|
|
||||||
*/
|
|
||||||
const LINK_KIND = "urn:ng-eventually:inbox:link";
|
|
||||||
|
|
||||||
/** Links observed during the last read of an inbox, awaiting durable filing. */
|
|
||||||
const seenByInbox = new Map<Nuri, ReadCap[]>();
|
|
||||||
function capsSeenIn(inbox: Nuri): ReadCap[] {
|
|
||||||
return seenByInbox.get(inbox) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/** The cap a deposit carries, if it is a Link rather than consumer data. */
|
|
||||||
function capOfPayload(payload: unknown): ReadCap | null {
|
|
||||||
const p = payload as { kind?: unknown; cap?: unknown } | null;
|
|
||||||
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
|
|
||||||
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Share ONE document's read cap with ONE recipient, addressed by their inbox.
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* no dedicated operation to receive it — the cap arrives as a deposit that their
|
|
||||||
* existing {@link watch} absorbs into what they hold (see {@link read}).
|
|
||||||
*
|
|
||||||
* Reaching several recipients means calling this once per inbox, which is what the
|
|
||||||
* real model does too: each delivery is sealed to one recipient.
|
|
||||||
*
|
|
||||||
* Upstream this path is a GAP, not a disagreement — verified at both ends:
|
|
||||||
* - the field exists, `ContactDetails.read_cap: Option<ReadCap>`
|
|
||||||
* (`engine/net/src/types.rs:4233`), but building a message that carries one is
|
|
||||||
* `read_cap: if with_readcap { unimplemented!() }` (`types.rs:3786`);
|
|
||||||
* - and the receiver ignores it: `InboxMsgContent::ContactDetails` writes only
|
|
||||||
* `ng:site`/`ng:protected` + `ng:*_inbox` into a fresh contact document
|
|
||||||
* (`engine/verifier/src/inbox_processor.rs:778-830`), never `details.read_cap`.
|
|
||||||
*
|
|
||||||
* Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit
|
|
||||||
* variant carrying nothing** (`engine/net/src/types.rs:4251`).
|
|
||||||
*
|
|
||||||
* The shape is right; the implementation is absent at both ends, so we emulate it
|
|
||||||
* meanwhile.
|
|
||||||
*/
|
|
||||||
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void> {
|
|
||||||
if (!hasReadCap(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)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await post(toInbox, { payload: { kind: LINK_KIND, cap } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- the read guard ------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refuse to READ an inbox that is not the current wallet's.
|
|
||||||
*
|
|
||||||
* Depositing into someone else's inbox is the one legitimate cross-wallet act (it
|
|
||||||
* is how a link reaches another wallet at all — see {@link post} / {@link shareCap});
|
|
||||||
* READING one is not, and it is not symmetric with it. Since caps travel as
|
|
||||||
* deposits, an unguarded read let anyone who knew an inbox NURI collect the caps
|
|
||||||
* addressed to its owner, which defeats directed sharing entirely.
|
|
||||||
*
|
|
||||||
* Anonymous owns no inbox, so it can read none — an identity has to be established
|
|
||||||
* first. At migration this disappears: an inbox is sealed to its owner's key, and
|
|
||||||
* the guard is the cryptography.
|
|
||||||
*/
|
|
||||||
async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
|
|
||||||
if (getCurrentUser() === null) {
|
|
||||||
throw new Error(
|
|
||||||
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` +
|
|
||||||
"session — call setCurrentUser() first. Depositing (post/shareCap) stays open.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!(await isOwnInbox(targetInbox))) {
|
|
||||||
throw new Error(
|
|
||||||
`[ng-eventually] inbox.${op}: refusing to read an inbox that does not belong to ` +
|
|
||||||
"the connected wallet. You may DEPOSIT into anyone's inbox; you may only READ " +
|
|
||||||
"your own — otherwise the caps addressed to its owner would be collectable by " +
|
|
||||||
`whoever knows its NURI: ${JSON.stringify(targetInbox)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- read --------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read every deposit currently in `targetInbox`, sorted by `ts` ascending. In
|
|
||||||
* real NextGraph the recipient's own verifier applies queued messages inline as
|
|
||||||
* it processes the inbox; here this read stands in for that until the
|
|
||||||
* sealed-inbox path is available. The consumer interprets each deposit's
|
|
||||||
* `payload`.
|
|
||||||
*
|
|
||||||
* Cap deliveries ({@link shareCap}) are applied inline and NOT returned: they land
|
|
||||||
* in what the current holder holds, like the verifier applying a queued message.
|
|
||||||
* That is why receiving a cap needs no dedicated operation — a consumer already
|
|
||||||
* watching its inbox gets them, and the resulting change re-triggers the
|
|
||||||
* reads that were empty for want of that cap.
|
|
||||||
*/
|
|
||||||
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
|
||||||
await assertOwnInbox(targetInbox, "read");
|
|
||||||
const sid = await sessionId();
|
|
||||||
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
|
|
||||||
// (a cold reader that opens the repo before reading), NOT here — `inbox.watch`
|
|
||||||
// already holds the repo open via its own `subscribeDoc`, so opening a second
|
|
||||||
// bootstrap subscription from inside a watch's re-read would be redundant and can
|
|
||||||
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
|
|
||||||
// read leaves both callers correct: the watch path stays event-driven, and the
|
|
||||||
// cold direct-read path opens the repo explicitly before calling `read`.
|
|
||||||
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
|
|
||||||
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
|
|
||||||
// default graph, exactly where `post` writes.
|
|
||||||
const query = `
|
|
||||||
SELECT ?payload ?ts ?from WHERE {
|
|
||||||
?d a <${P.type}> ;
|
|
||||||
<${P.payload}> ?payload ;
|
|
||||||
<${P.ts}> ?ts .
|
|
||||||
OPTIONAL { ?d <${P.from}> ?from }
|
|
||||||
}`;
|
|
||||||
const result = await sparqlQuery(sid, query, undefined, targetInbox, "inboxRead");
|
|
||||||
const deposits: Deposit[] = [];
|
|
||||||
for (const row of readBindings(result)) {
|
|
||||||
const rawPayload = row.payload?.value ?? "null";
|
|
||||||
let payload: unknown;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(rawPayload);
|
|
||||||
} catch {
|
|
||||||
payload = rawPayload; // tolerate a non-JSON literal
|
|
||||||
}
|
|
||||||
const tsRaw = row.ts?.value ?? "0";
|
|
||||||
const ts = Number.parseInt(tsRaw, 10) || 0;
|
|
||||||
const fromValue = row.from?.value;
|
|
||||||
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
|
|
||||||
}
|
|
||||||
deposits.sort((a, b) => a.ts - b.ts);
|
|
||||||
// Links are infrastructure, not consumer data: they never reach the caller. They
|
|
||||||
// are only KEPT here (in memory, for this session) — FILING them durably is
|
|
||||||
// `processInbox`'s job, because reading an inbox must not quietly write to a
|
|
||||||
// user's store. Filing fires the registry's change signal, which is what makes a
|
|
||||||
// view that was empty for want of that cap re-read instead of staying stale.
|
|
||||||
const delivered: Deposit[] = [];
|
|
||||||
const links: ReadCap[] = [];
|
|
||||||
for (const d of deposits) {
|
|
||||||
const cap = capOfPayload(d.payload);
|
|
||||||
if (cap) {
|
|
||||||
getCaps().learn(cap);
|
|
||||||
links.push(cap);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
delivered.push(d);
|
|
||||||
}
|
|
||||||
if (links.length > 0) seenByInbox.set(targetInbox, links);
|
|
||||||
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log
|
|
||||||
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of
|
|
||||||
// each — the exact visibility needed to trace materialization at the owner
|
|
||||||
// side. Gated by the same access-log flag; skip the JSON work when off.
|
|
||||||
if (accessLogEnabled()) {
|
|
||||||
logAccess(
|
|
||||||
"READ",
|
|
||||||
targetInbox,
|
|
||||||
"inbox materialize",
|
|
||||||
" → " + delivered.length + " message(s)" +
|
|
||||||
(deposits.length !== delivered.length
|
|
||||||
? " (+" + (deposits.length - delivered.length) + " cap deliver(y/ies) absorbed)"
|
|
||||||
: ""),
|
|
||||||
);
|
|
||||||
for (const d of delivered) {
|
|
||||||
logAccess(
|
|
||||||
"READ",
|
|
||||||
targetInbox,
|
|
||||||
"inbox message",
|
|
||||||
" ts=" + d.ts + " from=" + (d.from ?? "anonymous") + " payload=" + summarizePayload(d.payload),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return delivered;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Alias for {@link read} — the name that reads as "process the inbox now". */
|
|
||||||
export const materialize = read;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at
|
|
||||||
* (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first
|
|
||||||
* `State` (the deterministic sync barrier — after it, presence is guaranteed and
|
|
||||||
* absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}.
|
|
||||||
*
|
|
||||||
* Why this over a plain {@link read}: on a FRESH session over the persistent
|
|
||||||
* wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's
|
|
||||||
* `self.repos`, so a plain anchored `read` resolves an unopened repo and silently
|
|
||||||
* returns 0 deposits — even for a deposit a remote session already synced to the
|
|
||||||
* broker. Gating on the sync barrier makes the read see the synced deposits. This
|
|
||||||
* is the same cold-read heal any cold direct reader needs.
|
|
||||||
*
|
|
||||||
* NOT for the `watch` path: {@link watch} already holds the repo open via its own
|
|
||||||
* `subscribeDoc`, so opening a second bootstrap subscription from inside a watch
|
|
||||||
* re-read would be redundant and could race the watch's own initial-`State`
|
|
||||||
* delivery. Use this from a COLD reader (materialize-at-connection), like
|
|
||||||
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
|
|
||||||
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
|
|
||||||
*/
|
|
||||||
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
|
|
||||||
// Marks the cold, connection-triggered entry point in the trace — the BARRIER
|
|
||||||
// line (open-repo.ts) and the "inbox materialize"/"inbox message" lines below
|
|
||||||
// (from the read() this wraps) follow right after, so a live session shows
|
|
||||||
// the whole owner-reconnect sequence together.
|
|
||||||
logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)");
|
|
||||||
await assertOwnInbox(targetInbox, "readSynced");
|
|
||||||
await ensureRepoOpen(targetInbox);
|
|
||||||
return read(targetInbox);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PROCESS an inbox: read it, and **apply** what it contains.
|
|
||||||
*
|
|
||||||
* Applying a {@link shareCap} Link means filing it durably — `storeRegistry.addLink`,
|
|
||||||
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
|
|
||||||
* the cap survives the session. Upstream this is what a verifier does when it
|
|
||||||
* processes queued messages: an inbox is a **queue you consume**, not a store you
|
|
||||||
* re-read. Re-reading an inbox every session to recover caps is using a queue as a
|
|
||||||
* database, and it is the thing this replaces.
|
|
||||||
*
|
|
||||||
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
|
|
||||||
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
|
|
||||||
* {@link read} does — Links are never surfaced.
|
|
||||||
*/
|
|
||||||
export async function processInbox(targetInbox: Nuri): Promise<Deposit[]> {
|
|
||||||
const deposits = await readSynced(targetInbox);
|
|
||||||
// `readSynced` already put every Link in memory for this session; now make
|
|
||||||
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
|
|
||||||
// are taken from what the read just observed.
|
|
||||||
for (const cap of capsSeenIn(targetInbox)) await addLink(cap);
|
|
||||||
seenByInbox.delete(targetInbox);
|
|
||||||
return deposits;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
|
|
||||||
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
|
|
||||||
* `onDeposits` fires once on the initial state push and again on every subsequent
|
|
||||||
* change to the inbox document — a local deposit OR a broker-synced remote one.
|
|
||||||
* Returns an unsubscribe function.
|
|
||||||
*
|
|
||||||
* On each push it re-reads the full deposit list ({@link read}) and invokes
|
|
||||||
* `onDeposits` only when the deposit count changed (grew), keeping the same
|
|
||||||
* "fires on change" contract the polling watcher had — same callback signature
|
|
||||||
* and same behaviour, just event-driven instead of `setInterval`.
|
|
||||||
*
|
|
||||||
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
|
|
||||||
* there is no polling. (The inbox document is a single doc, so this is immune to
|
|
||||||
* the ORM fan-out hang — see {@link subscribeDoc}.)
|
|
||||||
*/
|
|
||||||
export function watch(
|
|
||||||
targetInbox: Nuri,
|
|
||||||
onDeposits: (deposits: Deposit[]) => void,
|
|
||||||
_opts?: { intervalMs?: number },
|
|
||||||
): () => void {
|
|
||||||
let stopped = false;
|
|
||||||
let lastCount = -1;
|
|
||||||
|
|
||||||
// Re-read on every push; fire onDeposits only when the set changed (grew).
|
|
||||||
const refresh = async (): Promise<void> => {
|
|
||||||
if (stopped) return;
|
|
||||||
try {
|
|
||||||
const deposits = await read(targetInbox);
|
|
||||||
const changed = deposits.length !== lastCount;
|
|
||||||
// Owner-side processing decision: did this push actually grow the
|
|
||||||
// deposit set (→ onDeposits fires, the polyfill's stand-in for
|
|
||||||
// materialization) or was it a no-op push (→ skipped)? This is the
|
|
||||||
// exact line to check for the "must reconnect an extra time" symptom:
|
|
||||||
// a push whose read still sees the OLD count means the barrier/read
|
|
||||||
// raced the write, not that watch itself failed to fire.
|
|
||||||
if (accessLogEnabled()) {
|
|
||||||
logAccess(
|
|
||||||
"READ",
|
|
||||||
targetInbox,
|
|
||||||
"inbox watch",
|
|
||||||
" → " + deposits.length + " message(s)" + (changed ? " (materializing)" : " (unchanged, skip)"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!stopped && changed) {
|
|
||||||
lastCount = deposits.length;
|
|
||||||
onDeposits(deposits);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(accessLogPrefix() + " watch read failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Subscribe to the inbox document: the initial State push fires the first read
|
|
||||||
// (so onDeposits fires once immediately, as before), each later Patch a re-read.
|
|
||||||
// The ownership guard runs inside `read`, so a watch on someone else's inbox
|
|
||||||
// yields nothing but logged refusals rather than their deposits.
|
|
||||||
const unsubscribe = subscribeDoc(targetInbox, () => void refresh());
|
|
||||||
return () => {
|
|
||||||
stopped = true;
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* @ng-eventually/client — the surface a consumer application codes against.
|
|
||||||
*
|
|
||||||
* Everything here has a target-SDK counterpart, verified or assumed, listed in
|
|
||||||
* `docs/api-contract.md`. Import `ng` / `useShape` from here rather than from the
|
|
||||||
* SDK during the polyfill period; at migration the build alias is removed and
|
|
||||||
* these resolve to the real SDK.
|
|
||||||
*
|
|
||||||
* **This entry carries no machinery.** The earlier header claimed it exposed "ONLY
|
|
||||||
* what `@ng-org/web` / `@ng-org/orm` expose", which was false as written: it also
|
|
||||||
* shipped the whole `store-registry` module (account resolution, cap registers,
|
|
||||||
* cache resets) and `accounts` (browser identity persistence, polyfill-era with no
|
|
||||||
* SDK counterpart). Both leaked machinery onto the entry whose promise is that it
|
|
||||||
* survives migration. `storeRegistry` is now the app-facing slice only
|
|
||||||
* (`store-registry-api.ts`); `accounts` moved to `/polyfill`.
|
|
||||||
*
|
|
||||||
* The polyfill bootstrap — `configure`, the capability helpers, the current user,
|
|
||||||
* identity persistence — lives at `@ng-eventually/client/polyfill`: everything an
|
|
||||||
* application needs TODAY that will not exist tomorrow, kept apart so what goes
|
|
||||||
* away is visible at the import line.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./types";
|
|
||||||
export { useShape } from "./use-shape";
|
|
||||||
export { watchShape } from "./watch-shape";
|
|
||||||
export type { ShapeQuery, ShapeObservable } from "./watch-shape";
|
|
||||||
export { init, initNg } from "./lifecycle";
|
|
||||||
export * as inbox from "./inbox";
|
|
||||||
export * as docs from "./docs";
|
|
||||||
export { subscribeDoc, subscribeDocs, docChangeType } from "./subscribe";
|
|
||||||
export type { DocChange, DocChangeType, Unsubscribe } from "./subscribe";
|
|
||||||
export * as readModel from "./read-model";
|
|
||||||
export type { UnionSubject } from "./read-model";
|
|
||||||
export * as storeRegistry from "./store-registry-api";
|
|
||||||
|
|
||||||
// SPARQL injection-safety helpers — so the app can reuse the same escaping /
|
|
||||||
// validation when it builds SPARQL by interpolation. `escapeLiteral` for string
|
|
||||||
// literals, `escapeIri` to embed untrusted values in an IRI, `assertNuri` to
|
|
||||||
// validate trusted-shaped NURIs before embedding them in an IRI.
|
|
||||||
export { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
|
||||||
|
|
||||||
// NURI type guards — the doors through which an app's own `string` (read back
|
|
||||||
// from storage, a URL, JSON, a form) becomes a typed `Nuri` or `ReadCap`. `Nuri`
|
|
||||||
// and `ReadCap` are template literal types, so an app that narrows with these
|
|
||||||
// gets the same compile-time distinction the library uses internally — in
|
|
||||||
// particular, it cannot pass a bare reference where a cap is required. Narrow
|
|
||||||
// with these rather than casting: a cast re-opens exactly the confusion the
|
|
||||||
// types exist to close.
|
|
||||||
export { isNuri, hasReadCap } from "./nuri";
|
|
||||||
|
|
||||||
// SDK type re-exports — so the app imports these from @ng-eventually/client too,
|
|
||||||
// not from @ng-org. `export type` is ERASED at build, so this adds NO runtime
|
|
||||||
// @ng-org import to the lib (no risk of a duplicate SDK copy in the bundle).
|
|
||||||
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
|
|
||||||
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
|
|
||||||
export type { NG } from "@ng-org/web";
|
|
||||||
|
|
||||||
import { makeNg } from "./ng-proxy";
|
|
||||||
|
|
||||||
/** SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`. */
|
|
||||||
export const ng: Record<string, any> = makeNg();
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Lifecycle re-exports — SDK-shaped forwarders so the app imports `init` /
|
|
||||||
* `initNg` from `@ng-eventually/client` rather than from `@ng-org/*`. They
|
|
||||||
* delegate to the REAL functions injected at `configure()`. Passthrough today;
|
|
||||||
* a hook point later (e.g. opening the shared wallet on `init`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getConfig } from "./polyfill";
|
|
||||||
|
|
||||||
/** Forwards to the real `@ng-org/web` `init`. */
|
|
||||||
export function init(...args: any[]): any {
|
|
||||||
const f = getConfig().init;
|
|
||||||
if (!f) throw new Error("[ng-eventually] init() not injected — pass it to configure()");
|
|
||||||
return f(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */
|
|
||||||
export function initNg(...args: any[]): any {
|
|
||||||
const f = getConfig().initNg;
|
|
||||||
if (!f) throw new Error("[ng-eventually] initNg() not injected — pass it to configure()");
|
|
||||||
return f(...args);
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* The wrapped `ng`: a Proxy that forwards every method to the real SDK and
|
|
||||||
* overrides only what the broker/verifier will do natively at migration. The
|
|
||||||
* surface stays identical to `@ng-org/web`'s `ng`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getConfig, getCaps, getCurrentUser } from "./polyfill";
|
|
||||||
import type { Nuri } from "./types";
|
|
||||||
|
|
||||||
export function makeNg(): Record<string, any> {
|
|
||||||
return new Proxy({} as Record<string, any>, {
|
|
||||||
get(_target, prop: string) {
|
|
||||||
const { ng } = getConfig();
|
|
||||||
|
|
||||||
// login / session_start → open the SHARED wallet invisibly.
|
|
||||||
if (prop === "login" || 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.
|
|
||||||
|
|
||||||
// Everything else: passthrough to the real SDK, unchanged.
|
|
||||||
const real = ng[prop];
|
|
||||||
return typeof real === "function" ? real.bind(ng) : real;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
/**
|
|
||||||
* The polyfill bootstrap — the ONLY non-SDK surface of the client.
|
|
||||||
*
|
|
||||||
* It injects the REAL SDK and the polyfill settings; afterwards the SDK-shaped
|
|
||||||
* exports (`ng`, `useShape`, `inbox`) behave as drop-ins. This is exposed at the
|
|
||||||
* subpath `@ng-eventually/client/polyfill` so the main entry
|
|
||||||
* (`@ng-eventually/client`) stays a **pure, SDK-identical** surface. Everything
|
|
||||||
* here is removed at migration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { NgLike, UseShapeLike, Nuri, PrincipalId, ReadCap } from "./types";
|
|
||||||
import type { RegistrySession } from "./store-registry";
|
|
||||||
import { CapRegistry } from "./caps";
|
|
||||||
import { setAccessLog } from "./access-log";
|
|
||||||
import { inspectOutbox } from "./outbox-log";
|
|
||||||
import { startConnect } from "./connect";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
|
|
||||||
* registry itself is generic (it knows only native scopes); the consumer wires
|
|
||||||
* up how to reach the shared-wallet session and how to normalize an identity id
|
|
||||||
* used as the shim key. Removed at migration along with the whole shim.
|
|
||||||
*/
|
|
||||||
export interface StoreRegistryDeps {
|
|
||||||
/** Resolve the current shared-wallet session (id + private-store anchor). */
|
|
||||||
getSession: () => Promise<RegistrySession>;
|
|
||||||
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
|
|
||||||
normalizeId?: (id: string) => string;
|
|
||||||
/**
|
|
||||||
* POINTER micro-guard budget. The account records now live in a subscribable
|
|
||||||
* doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple
|
|
||||||
* in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts
|
|
||||||
* need NO retry (this replaces the deleted account-level `provisionRetry`). The
|
|
||||||
* ONLY residual sync-lag window is the store-root pointer read itself — one
|
|
||||||
* write-once triple. This bounded guard re-reads JUST that pointer a few times if a
|
|
||||||
* fresh cold read misses it; it can never provision or fork an account (worst case:
|
|
||||||
* a couple extra reads before an existing pointer is seen). Enable it where the REAL
|
|
||||||
* broker is used (app + e2e). Left UNSET (the default) → `attempts: 1` = single
|
|
||||||
* read, keeping the synchronous unit fakes fast and unchanged.
|
|
||||||
*/
|
|
||||||
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventuallyConfig {
|
|
||||||
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
|
|
||||||
ng: NgLike;
|
|
||||||
/** The REAL `@ng-org/orm` `useShape`. */
|
|
||||||
useShape: UseShapeLike;
|
|
||||||
/** Shared-wallet credentials — polyfill only (one wallet for everyone). */
|
|
||||||
sharedWallet?: { name: string; secret: string };
|
|
||||||
/** Initial current user; may also be set later via {@link setCurrentUser}. */
|
|
||||||
currentUser?: PrincipalId;
|
|
||||||
/**
|
|
||||||
* Turn on the OFF-by-default document access log (see {@link ./access-log}):
|
|
||||||
* every real read/write is printed, prefixed by the active identity, to
|
|
||||||
* diagnose the shared-wallet isolation leak. Also enablable without a code
|
|
||||||
* change via the env var `NG_EVENTUALLY_ACCESS_LOG=1`. Default: false.
|
|
||||||
*/
|
|
||||||
debugAccessLog?: boolean;
|
|
||||||
/** REAL `@ng-org/web` `init` (lifecycle) — forwarded by the lib's `init()`. */
|
|
||||||
init?: (...args: any[]) => any;
|
|
||||||
/** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */
|
|
||||||
initNg?: (...args: any[]) => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cfg: EventuallyConfig | null = null;
|
|
||||||
let currentUser: PrincipalId | null = null;
|
|
||||||
/** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard`
|
|
||||||
* defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */
|
|
||||||
type ResolvedRegistryDeps = Required<
|
|
||||||
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
|
|
||||||
>;
|
|
||||||
let registryDeps: ResolvedRegistryDeps | null = null;
|
|
||||||
/**
|
|
||||||
* The map key of the current identity — deliberately NOT the raw id.
|
|
||||||
*
|
|
||||||
* A virtual user IS a shim account, and the shim keys accounts by the
|
|
||||||
* consumer-injected `normalizeId` ("@Alice" and "alice" are ONE account, with one
|
|
||||||
* set of scope documents). This record must key the same way, or a consumer that
|
|
||||||
* spells its own id differently between two calls gets a SECOND record and stops
|
|
||||||
* reading its own documents — the caps are filed under one spelling and looked up
|
|
||||||
* under the other. Falls back to the raw id while the registry deps are not yet
|
|
||||||
* configured (nothing can be filed before that anyway).
|
|
||||||
*/
|
|
||||||
function capsHolder(): PrincipalId | null {
|
|
||||||
if (currentUser === null) return null;
|
|
||||||
return registryDeps ? registryDeps.normalizeId(currentUser) : currentUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The emulated cap registry — one record PER identity (per virtual user),
|
|
||||||
* resolved through {@link capsHolder} on every call. So switching identity
|
|
||||||
* SWITCHES heldByHolder (nothing to reset, nothing wiped); see `caps.ts`. Empty until
|
|
||||||
* the first cap is issued, and while it is empty the read filter passes through
|
|
||||||
* (no regression).
|
|
||||||
*/
|
|
||||||
let caps = new CapRegistry(capsHolder);
|
|
||||||
|
|
||||||
export function configure(c: EventuallyConfig): void {
|
|
||||||
cfg = c;
|
|
||||||
currentUser = c.currentUser ?? null;
|
|
||||||
setAccessLog(c.debugAccessLog ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
|
|
||||||
export function getConfig(): EventuallyConfig {
|
|
||||||
if (!cfg) throw new Error("[ng-eventually] configure() must be called before use");
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset the injected config back to un-configured (mainly for tests, so a
|
|
||||||
* suite that calls configure() can restore the not-configured guard state). */
|
|
||||||
export function resetConfig(): void {
|
|
||||||
cfg = null;
|
|
||||||
currentUser = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wire the storeRegistry's consumer-injected dependencies (session + identity-id
|
|
||||||
* normalization). Must be called before any storeRegistry.* use. Separate from
|
|
||||||
* {@link configure} because it's storeRegistry-specific and, like the shim,
|
|
||||||
* disappears at migration.
|
|
||||||
*/
|
|
||||||
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
|
||||||
// Fire the outbox inspection (Volet 3 of the low-level data-path trace) once,
|
|
||||||
// on the FIRST successful `getSession()` resolution — the most reliable
|
|
||||||
// "a session is established" signal available: every low-level reader/writer
|
|
||||||
// (store-registry, open-repo, read-model, subscribe, inbox) reaches its
|
|
||||||
// session through this SAME injected `getSession`, so wrapping it HERE catches
|
|
||||||
// the first success from whichever caller happens to run first, instead of
|
|
||||||
// tying the probe to one particular call site. Only on SUCCESS (an error
|
|
||||||
// propagates untouched, exactly as before) and only ONCE per
|
|
||||||
// `configureStoreRegistry()` call (a fresh session config → a fresh check).
|
|
||||||
let outboxInspected = false;
|
|
||||||
const getSession = async (): Promise<RegistrySession> => {
|
|
||||||
const session = await deps.getSession();
|
|
||||||
if (!outboxInspected) {
|
|
||||||
outboxInspected = true;
|
|
||||||
inspectOutbox();
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
};
|
|
||||||
registryDeps = {
|
|
||||||
getSession,
|
|
||||||
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
|
|
||||||
// Default: single read (no re-read). Only the real-broker consumers (app + e2e)
|
|
||||||
// opt into the bounded pointer micro-guard; unit fakes stay synchronous.
|
|
||||||
pointerGuard: deps.pointerGuard ?? { attempts: 1 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal — used by the storeRegistry to reach its injected dependencies. */
|
|
||||||
export function getStoreRegistryDeps(): ResolvedRegistryDeps {
|
|
||||||
if (!registryDeps) {
|
|
||||||
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
|
|
||||||
}
|
|
||||||
return registryDeps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset storeRegistry deps (mainly for tests). */
|
|
||||||
export function resetStoreRegistry(): void {
|
|
||||||
registryDeps = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the current identity id — who the SDK is reading/writing as. In the target
|
|
||||||
* this is the wallet user established at wallet-import time; here the consumer
|
|
||||||
* relays that id through this call so the read filter and the inbox `from` know
|
|
||||||
* who is acting. Passing `null` clears it (no identity yet, e.g. during startup).
|
|
||||||
*/
|
|
||||||
export function setCurrentUser(id: PrincipalId | null): void {
|
|
||||||
const changed = currentUser !== id;
|
|
||||||
currentUser = id;
|
|
||||||
// Connecting a user is what triggers inbox processing — the library's job, not
|
|
||||||
// the app's. Fire-and-forget: this setter is synchronous and every consumer calls
|
|
||||||
// it from synchronous code, so the work announces itself through the cap
|
|
||||||
// registry's change signal instead of making callers await. See `connect.ts`.
|
|
||||||
//
|
|
||||||
// Gated on the registry being configured, and that is not a test convenience: an
|
|
||||||
// identity set before the session resolves has nothing to restore and no inbox to
|
|
||||||
// reach, so firing would be I/O that can only fail. The consumer's real sequence
|
|
||||||
// is `configureStoreRegistry` then `setCurrentUser`; anything else can call
|
|
||||||
// `connectedUser()` explicitly.
|
|
||||||
if (changed && id !== null && registryDeps !== null) startConnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCurrentUser(): PrincipalId | null {
|
|
||||||
return currentUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The emulated cap registry — what the current identity holds, plus the emulated
|
|
||||||
* public store. The read filter and the read-model consult it. */
|
|
||||||
export function getCaps(): CapRegistry {
|
|
||||||
return caps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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?").
|
|
||||||
*
|
|
||||||
* Shorthand for `getCaps().capFor(nuri)`, exposed because it is the surface the
|
|
||||||
* consumer actually uses.
|
|
||||||
*/
|
|
||||||
export function capFor(nuri: Nuri): ReadCap | undefined {
|
|
||||||
return caps.capFor(nuri);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop EVERY holder's caps (tests / a fresh wallet). This is **not** what an identity
|
|
||||||
* change does: switching identity switches heldByHolder, it never wipes one — if it
|
|
||||||
* wiped, durability would be a lie and per-session re-declaration would come back
|
|
||||||
* under another name. Nothing in the library calls this on `setCurrentUser`.
|
|
||||||
*/
|
|
||||||
export function resetCaps(): void {
|
|
||||||
// Clear IN PLACE rather than rebuilding: whoever subscribed to the registry's
|
|
||||||
// change signal (`watchShape`) stays subscribed to the live instance instead of
|
|
||||||
// silently holding a listener on an orphaned one.
|
|
||||||
caps.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap surface — polyfill-era (caps are emulated now; native at migration).
|
|
||||||
// Re-exported here so the whole polyfill API lives under /polyfill. `shareCap`
|
|
||||||
// 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 { CapRegistry } from "./caps";
|
|
||||||
export { shareCap } from "./inbox";
|
|
||||||
export { connectedUser } from "./connect";
|
|
||||||
|
|
||||||
// --- identity persistence (polyfill-era, no SDK counterpart) ----------------
|
|
||||||
//
|
|
||||||
// Moved here from the SDK-identical entry on 2026-08-03. `accounts` persists WHICH
|
|
||||||
// virtual user is connected, in browser storage — a notion that exists only because
|
|
||||||
// one shared wallet hosts several identities. The real SDK has no counterpart: there
|
|
||||||
// each user opens their own wallet, and "who am I" is the session. Shipping it from
|
|
||||||
// the SDK entry advertised as durable something that disappears at migration.
|
|
||||||
export * as accounts from "./accounts";
|
|
||||||
export type { AccountStorage } from "./accounts";
|
|
||||||
// Config-shaped types the bootstrap needs; both describe the shim, not the SDK.
|
|
||||||
export type { AccountRecord, RegistrySession } from "./store-registry";
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* reach — may the CONNECTED virtual user touch this document at all?
|
|
||||||
*
|
|
||||||
* The one predicate every path to `ng` consults, so the boundary is decided in a
|
|
||||||
* single place instead of being re-argued at each call site.
|
|
||||||
*
|
|
||||||
* ── The boundary ──────────────────────────────────────────────────────────
|
|
||||||
* A virtual user must simulate the boundary of the future single-user wallet:
|
|
||||||
* every access function is confined to the user currently connected
|
|
||||||
* (`setCurrentUser`), and no cross-user access is permitted. Otherwise the
|
|
||||||
* consumer is coded against a reach that will never exist — the same failure mode
|
|
||||||
* as an ACL where the real model is key possession, one level down.
|
|
||||||
*
|
|
||||||
* Two ways a document is legitimately reachable, and no others:
|
|
||||||
*
|
|
||||||
* 1. **You hold its cap.** Either because you created it (the store refiles the
|
|
||||||
* cap) or because someone delivered it to you. This is the whole of the
|
|
||||||
* access model, so it is the whole of the predicate.
|
|
||||||
* 2. **It is declared INFRASTRUCTURE.** A short, explicitly-registered list —
|
|
||||||
* never inferred from the shape of a NURI, because an inferred exemption is
|
|
||||||
* a hole. See {@link declareInfrastructure}.
|
|
||||||
*
|
|
||||||
* ── What may be exempt, and why so little ─────────────────────────────────
|
|
||||||
* > The only reads/writes not confined to a virtual user are those that make
|
|
||||||
* > multi-user operation possible at all. Nothing common — only the indexing
|
|
||||||
* > mechanisms that make the virtual users work.
|
|
||||||
*
|
|
||||||
* The test an exemption must pass: *does removing it stop the virtual users from
|
|
||||||
* functioning, or does it merely stop users from seeing each other's content?*
|
|
||||||
* Only the first qualifies. The shim passes (remove it and no user is resolvable
|
|
||||||
* at all); a shared index of user content does not (remove it and every user still
|
|
||||||
* works — you simply have to be given links).
|
|
||||||
*
|
|
||||||
* Depositing into another user's inbox is NOT handled here: it is a write to a
|
|
||||||
* document you do not hold, and it is legitimate — the only channel by which a
|
|
||||||
* link crosses from one user to another, hence the bootstrap of the whole
|
|
||||||
* reachability graph. It is allowed at the inbox surface, which is where the
|
|
||||||
* asymmetry (deposit yes, read no) is expressed.
|
|
||||||
*
|
|
||||||
* At migration this module disappears: the boundary becomes the wallet itself.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getCaps } from "./polyfill";
|
|
||||||
import { targetOf } from "./nuri";
|
|
||||||
import type { Nuri } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NURIs of the polyfill's own scaffolding, registered as they are resolved.
|
|
||||||
*
|
|
||||||
* Explicit registration rather than pattern-matching: the store-root and the
|
|
||||||
* doc-shim are exempt because they ARE the index of virtual users, not because
|
|
||||||
* they look a certain way. A NURI is in here because some code path put it here,
|
|
||||||
* knowing what it was.
|
|
||||||
*/
|
|
||||||
const infrastructure = new Set<Nuri>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register `nuri` as scaffolding that the boundary does not apply to. Called by
|
|
||||||
* the store-registry as it resolves the store-root pointer and the doc-shim —
|
|
||||||
* the only two documents that qualify, because without them no virtual user can
|
|
||||||
* be resolved at all.
|
|
||||||
*
|
|
||||||
* Deliberately NOT exported from the package: nothing outside the library may
|
|
||||||
* widen the exemption list.
|
|
||||||
*/
|
|
||||||
export function declareInfrastructure(nuri: Nuri): void {
|
|
||||||
infrastructure.add(nuri);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is `nuri` registered scaffolding? */
|
|
||||||
export function isInfrastructure(nuri: Nuri): boolean {
|
|
||||||
return infrastructure.has(nuri);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forget every declared exemption (tests / a fresh wallet). */
|
|
||||||
export function resetInfrastructure(): void {
|
|
||||||
infrastructure.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do we POSSESS the cap of `nuri`? Not "does this string carry one" — a caller may
|
|
||||||
* legitimately be holding the bare form and possess the cap elsewhere, which is the
|
|
||||||
* normal case: NURIs travel bare through content and indexes, while the cap sits in
|
|
||||||
* what the user holds. Possession is what decides; the shape of the reference the
|
|
||||||
* caller happens to have in hand decides nothing.
|
|
||||||
*
|
|
||||||
* `targetOf` first, so a cap-bearing reference and its bare form answer alike.
|
|
||||||
*
|
|
||||||
* Inert until the first cap exists (`caps.isEnforcing()`), so a consumer that never
|
|
||||||
* touches caps keeps working. Once ANY cap has been issued the boundary applies to
|
|
||||||
* every user, including one holding nothing: that is the isolation.
|
|
||||||
*/
|
|
||||||
export function mayReach(nuri: Nuri): boolean {
|
|
||||||
const caps = getCaps();
|
|
||||||
if (!caps.isEnforcing()) return true;
|
|
||||||
const target = targetOf(nuri);
|
|
||||||
return isInfrastructure(target) || caps.capFor(target) !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **Rule 1 — authorization**, at the PASSAGE POINTS (`docs.*`, `subscribe`).
|
|
||||||
*
|
|
||||||
* Nothing reaches `ng` unless the connected user possesses the document's cap. This
|
|
||||||
* is the guard: it fires on a request that should never have been made, and its job
|
|
||||||
* is to make sure the attempt fails rather than succeeds quietly.
|
|
||||||
*
|
|
||||||
* Deliberately duplicated with rule 2 below — see {@link mustNotAttempt}. Two rules,
|
|
||||||
* two places, one criterion: a lapse in either is caught by the other.
|
|
||||||
*/
|
|
||||||
export function assertMayReach(nuri: Nuri, op: string): void {
|
|
||||||
if (mayReach(nuri)) return;
|
|
||||||
throw new Error(
|
|
||||||
`[ng-eventually] ${op}: refused — the connected user does not hold this document's ` +
|
|
||||||
"cap. Naming a document does not grant access to it: a cap is looked up in what " +
|
|
||||||
`you hold, or it was delivered to you. ${JSON.stringify(nuri)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **Rule 2 — do not even attempt**, at the CALLERS (`read-model`, `open-repo`,
|
|
||||||
* `subscribe`'s callers…).
|
|
||||||
*
|
|
||||||
* A reader that does not hold a document's cap must not issue the operation at all.
|
|
||||||
* Not attempting and being refused are different things: the first is a caller that
|
|
||||||
* knows what it holds, the second is one that hoped and got caught. Only the first
|
|
||||||
* is the model — upstream you cannot even address a repo you have no cap for.
|
|
||||||
*
|
|
||||||
* Practically it also stops the library from asking the broker for documents it has
|
|
||||||
* no business asking about, which is work, noise, and a leak of intent.
|
|
||||||
*/
|
|
||||||
export function mustNotAttempt(nuri: Nuri): boolean {
|
|
||||||
return !mayReach(nuri);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* Read filter — the polyfill of capability-based read access.
|
|
||||||
*
|
|
||||||
* In the target, the broker only delivers documents the holder has the **ReadCap**
|
|
||||||
* of, so `useShape` already returns an authorized subset. Here (single shared
|
|
||||||
* wallet, everything readable) we reproduce that with a read-filtered VIEW over
|
|
||||||
* the reactive set: it keeps only items whose **document** (its `@graph` = the
|
|
||||||
* repo it lives in) is in what the current holder holds, per the
|
|
||||||
* {@link CapRegistry}.
|
|
||||||
*
|
|
||||||
* Faithful to NextGraph: the access unit is the DOCUMENT, not the item. In a
|
|
||||||
* mono-store layout (every item in one repo) the filter is therefore all-or-
|
|
||||||
* nothing on that document — which is exactly the native behavior, and why
|
|
||||||
* fine-grained isolation requires one document per entity. Removed at migration.
|
|
||||||
*
|
|
||||||
* Note there is no `user` parameter anywhere below, and that is the point: reading
|
|
||||||
* is key possession, so the only question askable is "do I hold this document's
|
|
||||||
* cap?". "May principal P read document D?" is an ACL question the real model
|
|
||||||
* cannot answer either. Which holder's caps are consulted follows the identity the
|
|
||||||
* registry resolves, so the view reflects the holder in effect at read time.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CapRegistry } from "./caps";
|
|
||||||
import { isNuri } from "./nuri";
|
|
||||||
import type { Nuri } from "./types";
|
|
||||||
|
|
||||||
/** The document (repo NURI) an item lives in — its `@graph`. The ORM boundary:
|
|
||||||
* `@graph` is an untyped value on a property bag, so it is narrowed here rather
|
|
||||||
* than cast. Anything that is not a NextGraph reference names no document. */
|
|
||||||
function docOf(item: unknown): Nuri | null {
|
|
||||||
const g = (item as Record<string, unknown> | null)?.["@graph"];
|
|
||||||
return typeof g === "string" && isNuri(g) ? g : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do I hold this item's document? An item with no `@graph` is KEPT (it names no
|
|
||||||
* document, so there is no cap to hold). Everything else needs the cap: a bare
|
|
||||||
* reference names without reading.
|
|
||||||
*/
|
|
||||||
function readable(item: unknown, caps: CapRegistry): boolean {
|
|
||||||
const doc = docOf(item);
|
|
||||||
if (doc === null) return true;
|
|
||||||
return caps.capFor(doc) !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pure: keep only the items whose document the current holder holds. */
|
|
||||||
export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
|
|
||||||
const out: T[] = [];
|
|
||||||
for (const item of items) if (readable(item, caps)) out.push(item);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like).
|
|
||||||
* Iteration / `size` / `forEach` yield only readable items; everything else
|
|
||||||
* (`add`, `delete`, `has`, `getById`, …) forwards to the target, so writes and
|
|
||||||
* the underlying reactivity are preserved. What the holder holds is consulted lazily, so the
|
|
||||||
* view reflects the holder in effect at read time.
|
|
||||||
*/
|
|
||||||
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S {
|
|
||||||
const keep = (item: unknown): boolean => readable(item, caps);
|
|
||||||
return new Proxy(set, {
|
|
||||||
get(target, prop, receiver) {
|
|
||||||
if (prop === Symbol.iterator) {
|
|
||||||
return function* () {
|
|
||||||
for (const item of target as Iterable<unknown>) if (keep(item)) yield item;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (prop === "size") {
|
|
||||||
let n = 0;
|
|
||||||
for (const item of target as Iterable<unknown>) if (keep(item)) n++;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
if (prop === "forEach") {
|
|
||||||
return (cb: (v: unknown, v2: unknown, s: unknown) => void) => {
|
|
||||||
for (const item of target as Iterable<unknown>) if (keep(item)) cb(item, item, receiver);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const v = Reflect.get(target, prop, target);
|
|
||||||
return typeof v === "function" ? v.bind(target) : v;
|
|
||||||
},
|
|
||||||
}) as S;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* The app-facing slice of `store-registry` — and the reason it exists as a file.
|
|
||||||
*
|
|
||||||
* `store-registry.ts` holds two things that must not be exported together: the
|
|
||||||
* placement/addressing calls a consumer application legitimately makes, and the
|
|
||||||
* shim machinery that makes virtual users work at all (account resolution, the
|
|
||||||
* durable cap registers, the inbox-ownership predicate, cache resets). Until now
|
|
||||||
* `index.ts` did `export * as storeRegistry from "./store-registry"` and shipped
|
|
||||||
* both, so an application could reach `ensureAccount`, `addLink` or
|
|
||||||
* `resetRegistryCache` from the SDK-identical entry — machinery it must never call,
|
|
||||||
* on the entry whose whole promise is "this survives migration unchanged".
|
|
||||||
*
|
|
||||||
* What is re-exported here is only what an application needs to do its own work,
|
|
||||||
* and each has a target-SDK counterpart (see `docs/api-contract.md`). Everything
|
|
||||||
* else stays reachable at `./store-registry` for the library's own modules, the
|
|
||||||
* unit tests and the e2e harness — an internal path, not a published one.
|
|
||||||
*
|
|
||||||
* At migration this file disappears: placement becomes the user's real per-scope
|
|
||||||
* stores and the calls below become native SDK ones.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export {
|
|
||||||
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
|
|
||||||
createEntityDoc,
|
|
||||||
/** The entity documents this user owns in `scope` — with their caps recovered. */
|
|
||||||
listMyEntityDocs,
|
|
||||||
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
|
|
||||||
resolveScopeGraph,
|
|
||||||
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
|
|
||||||
resolveWriteGraph,
|
|
||||||
/** A user's own inbox — where caps and messages addressed to THEM arrive. */
|
|
||||||
walletInbox,
|
|
||||||
/** Open an inbox on a document you OWN, so others can deposit into it. */
|
|
||||||
openDocumentInbox,
|
|
||||||
/** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */
|
|
||||||
documentInboxAddress,
|
|
||||||
} from "./store-registry";
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* Reactive single-document subscription — the polyfill's typed wrapper over the
|
|
||||||
* platform's `doc_subscribe` primitive. This is the canonical NextGraph reactive
|
|
||||||
* read at the document granularity: subscribe once, get the initial state pushed,
|
|
||||||
* then a push on every subsequent commit to that document — whether the write was
|
|
||||||
* local (this session) or a broker-synced remote change. NO POLLING.
|
|
||||||
*
|
|
||||||
* ── Why call the REAL injected `ng` directly (never `makeNg`) ──────────────
|
|
||||||
* Same hard constraint as `docs.ts`: the public `ng` is a JS `Proxy` over
|
|
||||||
* `@ng-org/web`'s iframe-RPC proxy. `doc_subscribe` is a STREAMED method — the
|
|
||||||
* `@ng-org/web` RPC strips the callback (by positional index) BEFORE it posts to
|
|
||||||
* the iframe and drives it locally via a `MessageChannel` port (the function is
|
|
||||||
* never structured-cloned, so no `DataCloneError`). Layering our own Proxy on top
|
|
||||||
* risks re-wrapping that surface; reaching the real `ng` held in the config avoids
|
|
||||||
* the double-proxy exactly as the raw `docs` primitives do. Do not import from
|
|
||||||
* `./ng-proxy`.
|
|
||||||
*
|
|
||||||
* ── The primitive shape (verified against nextgraph-rs) ────────────────────
|
|
||||||
* `ng.doc_subscribe(repo_o: string, session_id, callback)`
|
|
||||||
* (`sdk/js/lib-wasm/src/lib.rs:1907`) is **per-document** — one repo NURI, one
|
|
||||||
* callback. It is `async`, resolving to a JS **unsubscribe function**. The
|
|
||||||
* callback is invoked `callback(appResponse)` with a serialized `AppResponse`:
|
|
||||||
* `{ V0: { State | Patch | TabInfo | ... } }`. It pushes an initial `State`
|
|
||||||
* (plus a `TabInfo`) on subscribe, then a `Patch` per verified commit on the
|
|
||||||
* branch. Returning `true` from the callback also cancels; we cancel by calling
|
|
||||||
* the returned unsubscribe fn.
|
|
||||||
*
|
|
||||||
* ── Why per-document, never `orm_start_graph(graphs:[…])` ──────────────────
|
|
||||||
* A single not-yet-synced repo in an ORM graph fan-out makes `RepoNotFound` abort
|
|
||||||
* the WHOLE subscription (`initialize.rs:125-128`), so the readyPromise never
|
|
||||||
* resolves → the ~75s hang. `doc_subscribe` is per-branch/per-doc and has no
|
|
||||||
* fan-out: an absent doc breaks only its own subscription. {@link subscribeDocs}
|
|
||||||
* builds a set of these with per-doc error isolation to preserve that property.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getConfig, getStoreRegistryDeps } from "./polyfill";
|
|
||||||
import { assertMayReach } from "./reach";
|
|
||||||
import type { Nuri } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A push from the platform to a document subscriber. Loosely typed: the raw
|
|
||||||
* serialized `AppResponse` (`{ V0: { State | Patch | TabInfo | ... } }`). The
|
|
||||||
* consumer typically ignores the payload and uses the push purely as a
|
|
||||||
* change SIGNAL (re-query on change — the read-model pattern), so this stays
|
|
||||||
* permissive rather than modelling every AppResponse variant.
|
|
||||||
*/
|
|
||||||
export type DocChange = unknown;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The discriminant of a {@link DocChange} — the single variant key of the raw
|
|
||||||
* `AppResponse` payload (`{ V0: { State | Patch | TabInfo | … } }`). It is NOT a
|
|
||||||
* closed enum: the platform may push other variants, so this is a bare `string`
|
|
||||||
* (e.g. `"State"`, `"Patch"`, `"TabInfo"`), or `undefined` when the shape can't
|
|
||||||
* be read. Verified against the CONTRACT-3 e2e probe (`e2e/sdk-entry.ts`): the
|
|
||||||
* variant is `Object.keys(resp.V0)[0]`. Exposed so a caller that needs the SYNC
|
|
||||||
* BARRIER (the first `State`, per CONTRACT 3) can distinguish it from the earlier
|
|
||||||
* `TabInfo`/`Patch` pushes — see `open-repo.ts`. Most callers ignore it and use
|
|
||||||
* any push as a plain change signal.
|
|
||||||
*/
|
|
||||||
export type DocChangeType = string | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the variant key from a raw {@link DocChange}. Reads `resp.V0` (case-
|
|
||||||
* tolerant to `v0`) and returns its first key — the AppResponse variant name
|
|
||||||
* (`"State"` / `"Patch"` / `"TabInfo"` / …). Returns `undefined` if the payload
|
|
||||||
* is not a recognisable `{ V0: { <Variant>: … } }` object. Inspects the variant
|
|
||||||
* proplerly (no `any`-cast to force it) so a `State` push is identifiable.
|
|
||||||
*/
|
|
||||||
export function docChangeType(resp: DocChange): DocChangeType {
|
|
||||||
if (!resp || typeof resp !== "object") return undefined;
|
|
||||||
const outer = resp as { V0?: unknown; v0?: unknown };
|
|
||||||
const v0 = outer.V0 ?? outer.v0;
|
|
||||||
if (!v0 || typeof v0 !== "object") return undefined;
|
|
||||||
const keys = Object.keys(v0 as Record<string, unknown>);
|
|
||||||
return keys.length > 0 ? keys[0] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An unsubscribe function — idempotent (calling it twice is a no-op). */
|
|
||||||
export type Unsubscribe = () => void;
|
|
||||||
|
|
||||||
async function sessionId(): Promise<string> {
|
|
||||||
return (await getStoreRegistryDeps().getSession()).sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to ONE document. `onChange` fires on the initial state push and on
|
|
||||||
* every subsequent change to that doc (local write OR broker-synced remote
|
|
||||||
* change). Returns an unsubscribe function.
|
|
||||||
*
|
|
||||||
* The wrapper is synchronous-returning (an unsubscribe fn) even though the
|
|
||||||
* underlying `ng.doc_subscribe` is async: the real unsubscribe is captured when
|
|
||||||
* the promise resolves; if the caller unsubscribes before setup completes, the
|
|
||||||
* cancellation is honoured as soon as the real unsubscribe is available (and no
|
|
||||||
* further `onChange` fires after unsubscribe).
|
|
||||||
*
|
|
||||||
* `onChange` receives the raw payload AND its variant type ({@link docChangeType},
|
|
||||||
* e.g. `"State"`). The type is a NON-BREAKING second argument: existing callers
|
|
||||||
* that ignore it (the change-signal pattern — `discovery.ts`, `inbox.ts`) are
|
|
||||||
* unaffected; a caller that needs the sync barrier (`open-repo.ts`) reads it to
|
|
||||||
* act only on the first `State`.
|
|
||||||
*
|
|
||||||
* Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`).
|
|
||||||
*/
|
|
||||||
export function subscribeDoc(
|
|
||||||
nuri: Nuri,
|
|
||||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
|
||||||
): Unsubscribe {
|
|
||||||
// RULE 1 — a subscription IS an access: the push carries the document's state.
|
|
||||||
// Guarding the read paths while leaving this open would be a door beside the gate.
|
|
||||||
assertMayReach(nuri, "subscribeDoc");
|
|
||||||
return subscribeDocUnguarded(nuri, onChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe as the PHYSICAL user — the shim's own documents. The machinery's
|
|
||||||
* counterpart to {@link subscribeDoc}; never exported from the package.
|
|
||||||
*/
|
|
||||||
export function subscribePhysicalDoc(
|
|
||||||
nuri: Nuri,
|
|
||||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
|
||||||
): Unsubscribe {
|
|
||||||
return subscribeDocUnguarded(nuri, onChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribeDocUnguarded(
|
|
||||||
nuri: Nuri,
|
|
||||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
|
||||||
): Unsubscribe {
|
|
||||||
const { ng } = getConfig();
|
|
||||||
let stopped = false;
|
|
||||||
let realUnsub: (() => void) | null = null;
|
|
||||||
|
|
||||||
const cb = (resp: DocChange): void => {
|
|
||||||
if (stopped) return;
|
|
||||||
try {
|
|
||||||
onChange(resp, docChangeType(resp));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[subscribe] onChange handler threw for", nuri, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Kick off the async subscription. Errors are isolated to this doc (they never
|
|
||||||
// reject a shared batch — see subscribeDocs). If setup fails, this doc simply
|
|
||||||
// never fires; the caller's unsubscribe stays a safe no-op.
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const sid = await sessionId();
|
|
||||||
const unsub = (await ng.doc_subscribe(nuri, sid, cb)) as (() => void) | undefined;
|
|
||||||
if (stopped) {
|
|
||||||
// Unsubscribed before setup resolved — cancel immediately.
|
|
||||||
if (typeof unsub === "function") unsub();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
realUnsub = typeof unsub === "function" ? unsub : null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[subscribe] doc_subscribe failed for", nuri, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (stopped) return;
|
|
||||||
stopped = true;
|
|
||||||
if (realUnsub) {
|
|
||||||
try {
|
|
||||||
realUnsub();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[subscribe] unsubscribe failed for", nuri, error);
|
|
||||||
}
|
|
||||||
realUnsub = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to a SET of documents, one {@link subscribeDoc} per NURI, with
|
|
||||||
* PER-DOC error isolation. `onChange(nuri, r)` fires for whichever doc changed.
|
|
||||||
* Returns a single unsubscribe that tears down all of them.
|
|
||||||
*
|
|
||||||
* The per-doc isolation is the point: a bad / not-yet-synced doc breaks only its
|
|
||||||
* own subscription and NEVER aborts the others (this is precisely what avoids the
|
|
||||||
* ORM fan-out hang — do NOT replace this with `orm_start_graph(graphs:[…])`). The
|
|
||||||
* set is deduplicated; an empty set returns a no-op unsubscribe.
|
|
||||||
*/
|
|
||||||
export function subscribeDocs(
|
|
||||||
nuris: Nuri[],
|
|
||||||
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
|
|
||||||
): Unsubscribe {
|
|
||||||
const unique = [...new Set(nuris.filter(Boolean))];
|
|
||||||
const unsubs = unique.map((nuri) => {
|
|
||||||
// Each subscription is independent: subscribeDoc already isolates its own
|
|
||||||
// async setup failure (logged, never thrown), so one bad doc cannot abort the
|
|
||||||
// construction of the others here.
|
|
||||||
try {
|
|
||||||
return subscribeDoc(nuri, (r, type) => onChange(nuri, r, type));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[subscribe] subscribeDocs: failed to subscribe", nuri, error);
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
for (const u of unsubs) {
|
|
||||||
try {
|
|
||||||
u();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[subscribe] subscribeDocs: unsubscribe failed", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generic, NextGraph-shaped types. ZERO application domain.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A NextGraph URI (document / store / inbox) in its **cap-less** form — it NAMES
|
|
||||||
* and locates, it does not grant the right to read: `did:ng:o:{doc}:v:{overlay}`.
|
|
||||||
* `did:ng:` is the URI scheme prefix, not a "without cap" marker; the discriminant
|
|
||||||
* is the `:r:` segment (see {@link ReadCap}).
|
|
||||||
*/
|
|
||||||
export type Nuri = `did:ng:${string}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A NextGraph URI that carries the document's read cap — `…:r:{cap}`. It NAMES
|
|
||||||
* *and* READS: reading is key possession, never an authorization list. This is the
|
|
||||||
* upstream name (`ReadCap`).
|
|
||||||
*
|
|
||||||
* ── Why a template literal type, and not a branded one ────────────────────
|
|
||||||
* Both this and {@link Nuri} are **still strings** — assignable to `string`,
|
|
||||||
* JSON-serializable, no wrapper object — so nothing has to be *un*-typed when the
|
|
||||||
* real SDK arrives and takes `nuri: String`. What the template buys is the one
|
|
||||||
* direction that matters: a `ReadCap` is freely usable wherever a `Nuri` is
|
|
||||||
* expected (a cap IS a NURI with the key inside — upstream's single `NuriV0`),
|
|
||||||
* while a bare `Nuri` passed where a `ReadCap` is required is a **compile error**.
|
|
||||||
* That confusion, left to runtime, silently turns "naming is not reading" into
|
|
||||||
* "naming is reading" — the exact inversion this model exists to remove.
|
|
||||||
*
|
|
||||||
* It constrains the consumer's code the same way, which is the point: an app that
|
|
||||||
* reads a cap back from storage, a URL or JSON gets a `string` and must pass it
|
|
||||||
* through {@link isNuri} / {@link hasReadCap} (exported from the SDK entry) to use
|
|
||||||
* it — a validation it should be doing anyway. The runtime guards stay regardless:
|
|
||||||
* a JavaScript consumer bypasses the compiler entirely.
|
|
||||||
*/
|
|
||||||
export type ReadCap = `did:ng:${string}:r:${string}`;
|
|
||||||
|
|
||||||
/** NextGraph-native store scopes. The *mapping* of entities to scopes is the
|
|
||||||
* consumer's concern; this layer only knows the three scopes exist. */
|
|
||||||
export type Scope = "public" | "protected" | "private";
|
|
||||||
|
|
||||||
/** The current identity id. Target: the wallet user (`session.user`). Polyfill:
|
|
||||||
* a chosen id, because everyone shares one wallet. */
|
|
||||||
export type PrincipalId = string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loose shape of the real `@ng-org/web` `ng` object that we wrap. Injected by
|
|
||||||
* the consumer at {@link configure} — we never hard-import the SDK, which keeps
|
|
||||||
* the build-alias safe (the app's `@ng-org/web` import can resolve to us) and
|
|
||||||
* makes the wrapper testable with a fake. Permissive on purpose: the real `ng`
|
|
||||||
* carries non-function members too, so we accept any property bag.
|
|
||||||
*/
|
|
||||||
export type NgLike = Record<string, any>;
|
|
||||||
|
|
||||||
/** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */
|
|
||||||
export type UseShapeLike = (...args: any[]) => any;
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { test, expect } from "bun:test";
|
|
||||||
import { filterReadable, makeReadFilteredView } from "../src/read-filter";
|
|
||||||
import { CapRegistry } from "../src/caps";
|
|
||||||
|
|
||||||
// The access unit is the DOCUMENT (an item's `@graph` = the repo it lives in),
|
|
||||||
// not the item. Items here carry `@graph`; each holder holds caps per document.
|
|
||||||
interface Item { id: string; "@graph"?: string }
|
|
||||||
|
|
||||||
const MINE: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc
|
|
||||||
const LINKED: Item = { id: "p", "@graph": "did:ng:o:public" }; // a published doc
|
|
||||||
const FOREIGN: Item = { id: "n", "@graph": "did:ng:o:other" }; // no cap held
|
|
||||||
const NOGRAPH: Item = { id: "x" }; // names no document
|
|
||||||
|
|
||||||
/** A registry whose holder the test drives; alice created one doc and published one. */
|
|
||||||
function setup(initial: string | null = "alice") {
|
|
||||||
let holder = initial;
|
|
||||||
const caps = new CapRegistry(() => holder);
|
|
||||||
const before = holder;
|
|
||||||
holder = "alice";
|
|
||||||
caps.mint("did:ng:o:alice");
|
|
||||||
const link = caps.publishRepoLink("did:ng:o:public");
|
|
||||||
holder = before;
|
|
||||||
return { caps, link, become: (id: string | null) => (holder = id) };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("filterReadable keeps only documents whose cap is held; a graphless item names none", () => {
|
|
||||||
const items = [MINE, LINKED, FOREIGN, NOGRAPH];
|
|
||||||
const { caps, become } = setup("alice");
|
|
||||||
expect(filterReadable(items, caps).map((i) => i.id)).toEqual(["a", "p", "x"]);
|
|
||||||
|
|
||||||
// bob holds nothing — including the published doc, until he receives its link.
|
|
||||||
become("bob");
|
|
||||||
expect(filterReadable(items, caps).map((i) => i.id)).toEqual(["x"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a bare reference yields nothing — naming is not reading", () => {
|
|
||||||
const { caps } = setup("alice");
|
|
||||||
// `did:ng:o:other` is perfectly well-formed and perfectly unreadable.
|
|
||||||
expect(filterReadable([FOREIGN], caps)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("receiving the repo link is what opens a published document", () => {
|
|
||||||
const { caps, link, become } = setup("alice");
|
|
||||||
become("bob");
|
|
||||||
expect(filterReadable([LINKED], caps)).toEqual([]);
|
|
||||||
caps.learn(link);
|
|
||||||
expect(filterReadable([LINKED], caps).map((i) => i.id)).toEqual(["p"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("makeReadFilteredView filters iteration/size, and follows the holder in effect", () => {
|
|
||||||
const set = new Set<Item>([MINE, LINKED, FOREIGN, NOGRAPH]);
|
|
||||||
const { caps, become } = setup("bob");
|
|
||||||
const view = makeReadFilteredView(set, caps);
|
|
||||||
|
|
||||||
expect([...view].map((i) => i.id)).toEqual(["x"]);
|
|
||||||
expect(view.size).toBe(1);
|
|
||||||
|
|
||||||
become("alice"); // the held caps are read lazily → the view updates without rewrapping
|
|
||||||
expect([...view].map((i) => i.id)).toEqual(["a", "p", "x"]);
|
|
||||||
expect(view.size).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("makeReadFilteredView forwards mutations and membership to the target", () => {
|
|
||||||
const set = new Set<Item>([LINKED]);
|
|
||||||
const { caps } = setup("alice");
|
|
||||||
const view = makeReadFilteredView(set, caps);
|
|
||||||
const C: Item = { id: "c", "@graph": "did:ng:o:public" };
|
|
||||||
|
|
||||||
view.add(C);
|
|
||||||
expect(set.has(C)).toBe(true); // mutation reached the real set
|
|
||||||
expect([...view].map((i) => i.id)).toEqual(["p", "c"]);
|
|
||||||
|
|
||||||
view.delete(C);
|
|
||||||
expect(set.has(C)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("forEach is filtered too", () => {
|
|
||||||
const set = new Set<Item>([MINE, LINKED]);
|
|
||||||
const seen: string[] = [];
|
|
||||||
const { caps, become } = setup("alice");
|
|
||||||
become("bob");
|
|
||||||
makeReadFilteredView(set, caps).forEach((i) => seen.push((i as Item).id));
|
|
||||||
expect(seen).toEqual([]);
|
|
||||||
});
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { test, expect, mock, afterAll } from "bun:test";
|
|
||||||
import { readUnion } from "../src/read-model";
|
|
||||||
import type { Nuri } from "../src/types";
|
|
||||||
import {
|
|
||||||
configure,
|
|
||||||
configureStoreRegistry,
|
|
||||||
getCaps,
|
|
||||||
resetCaps,
|
|
||||||
setCurrentUser,
|
|
||||||
} from "../src/polyfill";
|
|
||||||
|
|
||||||
// The cap registry is process-wide, so each inject() starts from an empty one:
|
|
||||||
// once ANY cap exists the possession gate is in force for every reader, and a
|
|
||||||
// suite that never declares caps must not inherit another suite's.
|
|
||||||
afterAll(() => {
|
|
||||||
resetCaps();
|
|
||||||
setCurrentUser(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o
|
|
||||||
// WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is
|
|
||||||
// NO anchorless union scan: each doc is read independently by its own anchor. Each
|
|
||||||
// entity subject IRI IS its own document NURI (writeEntity convention), so the
|
|
||||||
// fixture keys triples by the doc NURI and returns them for the matching anchor.
|
|
||||||
function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
|
|
||||||
return {
|
|
||||||
doc_create: mock(async () => "did:ng:o:new"),
|
|
||||||
sparql_update: mock(async () => undefined),
|
|
||||||
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
|
|
||||||
// Every read is ANCHORED to one doc NURI — never anchorless.
|
|
||||||
if (anchor === undefined) {
|
|
||||||
throw new Error("read-model must NEVER run an anchorless (union) query");
|
|
||||||
}
|
|
||||||
const doc = anchor as string;
|
|
||||||
const triples = triplesByDoc[doc];
|
|
||||||
if (!triples) return { results: { bindings: [] } };
|
|
||||||
const bindings = triples.map(([p, o]) => ({
|
|
||||||
s: { value: doc },
|
|
||||||
p: { value: p },
|
|
||||||
o: { value: o },
|
|
||||||
}));
|
|
||||||
return { results: { bindings } };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
|
|
||||||
const ng = fakeNgWith(triplesByDoc);
|
|
||||||
resetCaps();
|
|
||||||
setCurrentUser(null);
|
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
||||||
configureStoreRegistry({
|
|
||||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
|
||||||
normalizeId: (u: string) => u,
|
|
||||||
});
|
|
||||||
return ng;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
||||||
const FP = "http://festipod.org/";
|
|
||||||
|
|
||||||
test("readUnion reads each doc with its OWN anchored query (never anchorless)", async () => {
|
|
||||||
const ng = inject({
|
|
||||||
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "A"]],
|
|
||||||
"did:ng:o:b": [[TYPE, `${FP}Event`], [`${FP}title`, "B"]],
|
|
||||||
});
|
|
||||||
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
|
|
||||||
|
|
||||||
// One anchored query per doc = 2 sparql_query calls, each anchored (c[3] set).
|
|
||||||
expect(ng.sparql_query).toHaveBeenCalledTimes(2);
|
|
||||||
const anchored = ng.sparql_query.mock.calls.filter((c: unknown[]) => c[3] !== undefined);
|
|
||||||
expect(anchored.length).toBe(2);
|
|
||||||
// The anchors are exactly the requested doc NURIs.
|
|
||||||
expect(new Set(anchored.map((c: unknown[]) => c[3]))).toEqual(
|
|
||||||
new Set(["did:ng:o:a", "did:ng:o:b"]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(subjects.length).toBe(2);
|
|
||||||
const a = subjects.find((s) => s.subject === "did:ng:o:a")!;
|
|
||||||
expect(a.props[`${FP}title`]).toEqual(["A"]);
|
|
||||||
expect(a.graph).toBe("did:ng:o:a");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("readUnion groups predicates per subject", async () => {
|
|
||||||
inject({
|
|
||||||
"did:ng:o:p": [
|
|
||||||
[TYPE, `${FP}Participation`],
|
|
||||||
[`${FP}event`, "did:ng:o:e"],
|
|
||||||
[`${FP}user`, "urn:festipod:user:x"],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const s = (await readUnion(["did:ng:o:p"]))[0]!;
|
|
||||||
expect(s.subject).toBe("did:ng:o:p");
|
|
||||||
expect(s.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
|
|
||||||
expect(s.props[`${FP}user`]).toEqual(["urn:festipod:user:x"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("readUnion returns [] for an empty doc set (no query)", async () => {
|
|
||||||
const ng = inject({});
|
|
||||||
const subjects = await readUnion([]);
|
|
||||||
expect(subjects).toEqual([]);
|
|
||||||
expect(ng.sparql_query).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a doc that fails to read is skipped, not aborting the batch", async () => {
|
|
||||||
const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] });
|
|
||||||
const orig = ng.sparql_query;
|
|
||||||
// Make the anchored read throw for the bad doc only.
|
|
||||||
ng.sparql_query = mock(async (sid: string, query: string, base: unknown, anchor: unknown) => {
|
|
||||||
if (anchor === "did:ng:o:bad") throw new Error("RepoNotFound");
|
|
||||||
return orig(sid, query, base, anchor);
|
|
||||||
}) as any;
|
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
||||||
configureStoreRegistry({
|
|
||||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
|
||||||
normalizeId: (u: string) => u,
|
|
||||||
});
|
|
||||||
|
|
||||||
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
|
|
||||||
// The bad doc failed its read but the good one still lists.
|
|
||||||
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The possession gate, at the read-model's own level: once ANY cap exists, a doc
|
|
||||||
// whose cap is not in what the current holder holds is dropped — however well its
|
|
||||||
// NURI resolves. Before the first cap the gate is inert (no regression).
|
|
||||||
test("readUnion drops a doc whose cap the holder does not hold", async () => {
|
|
||||||
inject({
|
|
||||||
"did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]],
|
|
||||||
"did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]],
|
|
||||||
});
|
|
||||||
const both: Nuri[] = ["did:ng:o:mine", "did:ng:o:theirs"];
|
|
||||||
|
|
||||||
// Inert: no cap issued yet → everything flows through.
|
|
||||||
expect((await readUnion(both)).map((s) => s.subject).sort()).toEqual(both);
|
|
||||||
|
|
||||||
// One cap issued → possession is now the rule for every document.
|
|
||||||
setCurrentUser("alice");
|
|
||||||
getCaps().mint("did:ng:o:mine");
|
|
||||||
expect((await readUnion(both)).map((s) => s.subject)).toEqual(["did:ng:o:mine"]);
|
|
||||||
|
|
||||||
// …and for every holder: bob holds nothing, so bob reads nothing.
|
|
||||||
setCurrentUser("bob");
|
|
||||||
expect(await readUnion(both)).toEqual([]);
|
|
||||||
});
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { test, expect, mock, afterAll } from "bun:test";
|
|
||||||
import { subscribeDoc, subscribeDocs } from "../src/subscribe";
|
|
||||||
import {
|
|
||||||
configure,
|
|
||||||
configureStoreRegistry,
|
|
||||||
resetConfig,
|
|
||||||
resetStoreRegistry,
|
|
||||||
} from "../src/polyfill";
|
|
||||||
import type { RegistrySession } from "../src/store-registry";
|
|
||||||
|
|
||||||
// subscribeDoc/subscribeDocs wrap the REAL injected `ng.doc_subscribe`. This
|
|
||||||
// suite injects a fake `ng` whose `doc_subscribe` records the callback per doc
|
|
||||||
// and hands back an unsubscribe, so we can assert routing + isolation without a
|
|
||||||
// broker. Restore the un-configured state at the end.
|
|
||||||
afterAll(() => {
|
|
||||||
resetConfig();
|
|
||||||
resetStoreRegistry();
|
|
||||||
});
|
|
||||||
|
|
||||||
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fake reactive `ng`: `doc_subscribe(nuri, sid, cb)` registers `cb` for `nuri`,
|
|
||||||
* fires it once (initial State push), and returns an unsubscribe. `push(nuri)`
|
|
||||||
* drives a later change to that doc's subscribers. A per-doc `failFor` set makes
|
|
||||||
* `doc_subscribe` reject for chosen NURIs (a not-yet-synced doc).
|
|
||||||
*/
|
|
||||||
function makeFakeNg(failFor: Set<string> = new Set()) {
|
|
||||||
const subs = new Map<string, Set<(r: unknown) => void>>();
|
|
||||||
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
|
|
||||||
if (failFor.has(nuri)) throw new Error(`RepoNotFound: ${nuri}`);
|
|
||||||
let set = subs.get(nuri);
|
|
||||||
if (!set) {
|
|
||||||
set = new Set();
|
|
||||||
subs.set(nuri, set);
|
|
||||||
}
|
|
||||||
set.add(cb);
|
|
||||||
// Initial State push, delivered async (as the real RPC does).
|
|
||||||
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } }));
|
|
||||||
return () => set!.delete(cb);
|
|
||||||
});
|
|
||||||
const push = (nuri: string): void => {
|
|
||||||
for (const cb of subs.get(nuri) ?? []) cb({ V0: { Patch: { doc: nuri } } });
|
|
||||||
};
|
|
||||||
const isSubscribed = (nuri: string): boolean => (subs.get(nuri)?.size ?? 0) > 0;
|
|
||||||
return { doc_subscribe, push, isSubscribed, _subs: subs };
|
|
||||||
}
|
|
||||||
|
|
||||||
function inject(failFor?: Set<string>) {
|
|
||||||
const ng = makeFakeNg(failFor);
|
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
||||||
configureStoreRegistry({ getSession: async () => SESSION });
|
|
||||||
return ng;
|
|
||||||
}
|
|
||||||
|
|
||||||
const A = "did:ng:o:docA";
|
|
||||||
const B = "did:ng:o:docB";
|
|
||||||
|
|
||||||
const tick = () => new Promise((r) => setTimeout(r, 5));
|
|
||||||
|
|
||||||
test("subscribeDoc calls ng.doc_subscribe with (nuri, sessionId, callback)", async () => {
|
|
||||||
const ng = inject();
|
|
||||||
const onChange = mock(() => {});
|
|
||||||
subscribeDoc(A, onChange);
|
|
||||||
await tick();
|
|
||||||
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
|
|
||||||
const call = ng.doc_subscribe.mock.calls[0]!;
|
|
||||||
expect(call[0]).toBe(A);
|
|
||||||
expect(call[1]).toBe("sid-1"); // sessionId from the injected session
|
|
||||||
expect(typeof call[2]).toBe("function"); // the callback
|
|
||||||
});
|
|
||||||
|
|
||||||
test("subscribeDoc routes the initial State push and every later change", async () => {
|
|
||||||
const ng = inject();
|
|
||||||
const seen: unknown[] = [];
|
|
||||||
subscribeDoc(A, (r) => seen.push(r));
|
|
||||||
await tick();
|
|
||||||
expect(seen).toHaveLength(1); // initial State push
|
|
||||||
ng.push(A);
|
|
||||||
ng.push(A);
|
|
||||||
expect(seen).toHaveLength(3); // + 2 patches
|
|
||||||
});
|
|
||||||
|
|
||||||
test("subscribeDoc unsubscribe stops further callbacks", async () => {
|
|
||||||
const ng = inject();
|
|
||||||
const seen: unknown[] = [];
|
|
||||||
const stop = subscribeDoc(A, (r) => seen.push(r));
|
|
||||||
await tick();
|
|
||||||
expect(seen).toHaveLength(1);
|
|
||||||
stop();
|
|
||||||
expect(ng.isSubscribed(A)).toBe(false); // real unsubscribe was invoked
|
|
||||||
ng.push(A); // ignored — no subscriber
|
|
||||||
expect(seen).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("subscribeDoc unsubscribe BEFORE async setup resolves cancels cleanly", async () => {
|
|
||||||
const ng = inject();
|
|
||||||
const seen: unknown[] = [];
|
|
||||||
const stop = subscribeDoc(A, (r) => seen.push(r));
|
|
||||||
stop(); // before the microtask/promise setup resolved
|
|
||||||
await tick();
|
|
||||||
// The subscription was cancelled the moment setup resolved: no callbacks, and
|
|
||||||
// no lingering subscriber.
|
|
||||||
expect(seen).toHaveLength(0);
|
|
||||||
expect(ng.isSubscribed(A)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("subscribeDocs fans out one subscription per doc and reports the source nuri", async () => {
|
|
||||||
const ng = inject();
|
|
||||||
const seen: Array<[string, unknown]> = [];
|
|
||||||
subscribeDocs([A, B], (nuri, r) => seen.push([nuri, r]));
|
|
||||||
await tick();
|
|
||||||
// Two initial pushes, one per doc.
|
|
||||||
expect(seen.map((s) => s[0]).sort()).toEqual([A, B]);
|
|
||||||
ng.push(B);
|
|
||||||
expect(seen.filter((s) => s[0] === B)).toHaveLength(2); // initial + patch
|
|
||||||
expect(seen.filter((s) => s[0] === A)).toHaveLength(1); // isolated: A didn't fire
|
|
||||||
});
|
|
||||||
|
|
||||||
test("subscribeDocs isolates a failing doc — the others still fire", async () => {
|
|
||||||
const ng = inject(new Set([A])); // A's subscription throws (RepoNotFound)
|
|
||||||
const seen: Array<[string, unknown]> = [];
|
|
||||||
subscribeDocs([A, B], (nuri, r) => seen.push([nuri, r]));
|
|
||||||
await tick();
|
|
||||||
// A failed to subscribe (logged, not thrown); B is unaffected and fired.
|
|
||||||
expect(seen.map((s) => s[0])).toEqual([B]);
|
|
||||||
ng.push(B);
|
|
||||||
expect(seen.filter((s) => s[0] === B)).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("subscribeDocs unsubscribe tears down all subscriptions", async () => {
|
|
||||||
const ng = inject();
|
|
||||||
const stop = subscribeDocs([A, B], () => {});
|
|
||||||
await tick();
|
|
||||||
expect(ng.isSubscribed(A)).toBe(true);
|
|
||||||
expect(ng.isSubscribed(B)).toBe(true);
|
|
||||||
stop();
|
|
||||||
expect(ng.isSubscribed(A)).toBe(false);
|
|
||||||
expect(ng.isSubscribed(B)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("subscribeDocs deduplicates repeated NURIs", async () => {
|
|
||||||
const ng = inject();
|
|
||||||
subscribeDocs([A, A, A], () => {});
|
|
||||||
await tick();
|
|
||||||
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
Executable
+106
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Mint a NextGraph wallet and write it as a `.ngw` — the one-off a person runs to provision a
|
||||||
|
* deployment.
|
||||||
|
*
|
||||||
|
* ── Why this exists next to the library function ─────────────────────────────
|
||||||
|
* An application that hands a wallet out serves a `.ngw` at the URL it passes to
|
||||||
|
* `configure({ sharedWallet: { fileUrl, password } })`, and nothing produces that file: minting
|
||||||
|
* one means driving the wallet application in a browser, which is exactly what
|
||||||
|
* `mintWalletBytes` already does for the suites. So this is not a second implementation — it is
|
||||||
|
* that call, a `writeFileSync`, and the two lines a human needs to fill the configuration in.
|
||||||
|
*
|
||||||
|
* ── What it does NOT do ──────────────────────────────────────────────────────
|
||||||
|
* It does not invent a password. The password is what opens the wallet for everyone the
|
||||||
|
* deployment lets in; one chosen here would be a secret the tool knows and the operator does
|
||||||
|
* not, printed to a terminal and never chosen by anybody. It is a required argument.
|
||||||
|
*
|
||||||
|
* It also refuses to overwrite an existing file unless told to. A `.ngw` is an identity, and
|
||||||
|
* the identities it holds exist nowhere else — a clobbered one is not recoverable from the
|
||||||
|
* broker or anywhere else.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run packages/ng-e2e-helpers/bin/mint-wallet.ts --password <password> [--out <path.ngw>] [--name <wallet name>] [--force]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { DEFAULT_WALLET_NAME, mintWalletBytes } from "../src/wallet";
|
||||||
|
|
||||||
|
const USAGE =
|
||||||
|
"usage: mint-wallet --password <password> [--out <path.ngw>] [--name <wallet name>] [--force]";
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
readonly password: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly out: string;
|
||||||
|
readonly force: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `--k v` and `--k=v` both, because a person types whichever one they learnt first. */
|
||||||
|
function parseArguments(argv: readonly string[]): Options {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
let force = false;
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i]!;
|
||||||
|
if (arg === "--force") {
|
||||||
|
force = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}\n${USAGE}`);
|
||||||
|
const equals = arg.indexOf("=");
|
||||||
|
const key = equals === -1 ? arg.slice(2) : arg.slice(2, equals);
|
||||||
|
let value: string | undefined;
|
||||||
|
if (equals !== -1) {
|
||||||
|
value = arg.slice(equals + 1);
|
||||||
|
} else {
|
||||||
|
value = argv[++i];
|
||||||
|
}
|
||||||
|
if (value === undefined) throw new Error(`--${key} needs a value\n${USAGE}`);
|
||||||
|
if (!["password", "out", "name"].includes(key)) {
|
||||||
|
throw new Error(`unknown option --${key}\n${USAGE}`);
|
||||||
|
}
|
||||||
|
values.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = values.get("password");
|
||||||
|
if (password === undefined || password === "") {
|
||||||
|
throw new Error(`--password is required — this tool does not invent one\n${USAGE}`);
|
||||||
|
}
|
||||||
|
const name = values.get("name") ?? DEFAULT_WALLET_NAME;
|
||||||
|
// Relative to where the person is standing, which is the only path they can predict. Any
|
||||||
|
// default landing inside a checkout is covered by the repository-wide `*.ngw` ignore.
|
||||||
|
const out = path.resolve(process.cwd(), values.get("out") ?? `${name}.ngw`);
|
||||||
|
return { password, name, out, force };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const options = parseArguments(process.argv.slice(2));
|
||||||
|
if (!options.force && fs.existsSync(options.out)) {
|
||||||
|
throw new Error(
|
||||||
|
`${options.out} already exists — a .ngw is an identity, so this refuses to overwrite one.\n` +
|
||||||
|
"Pass --force if that file is genuinely disposable.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[mint-wallet] minting the wallet "${options.name}" (this drives a real browser)...`);
|
||||||
|
const bytes = await mintWalletBytes(options.password, options.name);
|
||||||
|
fs.mkdirSync(path.dirname(options.out), { recursive: true });
|
||||||
|
fs.writeFileSync(options.out, bytes);
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log(`[mint-wallet] wrote ${bytes.length} bytes`);
|
||||||
|
console.log(` file: ${options.out}`);
|
||||||
|
console.log(` password: ${options.password}`);
|
||||||
|
console.log("");
|
||||||
|
console.log("Serve that file, and give the application its URL and this password:");
|
||||||
|
console.log(" configure({ sharedWallet: { fileUrl: <where you serve it>, password: <above> } })");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().then(
|
||||||
|
() => process.exit(0),
|
||||||
|
(e: unknown) => {
|
||||||
|
console.error(`[mint-wallet] ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
process.exit(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "ng-e2e-helpers",
|
||||||
|
"version": "1.0.0-dev.2",
|
||||||
|
"type": "module",
|
||||||
|
"description": "End-to-end testing machinery for a NextGraph application: mint and carry a wallet, cross the broker, per-run browser profiles, bounded waits that name what they were waiting for, and a run report whose size does not depend on what failed.",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ng-mint-wallet": "./bin/mint-wallet.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"playwright": ">=1.40.0",
|
||||||
|
"@ng-org/web": ">=0.1.2-alpha.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ng-org/web": "0.1.2-alpha.13",
|
||||||
|
"playwright": "^1.61.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "bunx tsc --noEmit -p tsconfig.json",
|
||||||
|
"mint-wallet": "bun run bin/mint-wallet.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
/**
|
||||||
|
* The broker crossing — `nextgraph.net/redir` → the broker's auth page → the wallet list →
|
||||||
|
* (sometimes) the password → nested iframes → the application's own frame.
|
||||||
|
*
|
||||||
|
* ── The defect this replaces, and why it looked like the network ─────────────
|
||||||
|
* An earlier version decided where it was in the flow by TIMING: a 2-second probe for the
|
||||||
|
* "Login" button, a 500 ms poll loop, a 1-second settle, an 8-second window for the password
|
||||||
|
* prompt, a 3-second hope after submitting. Under it sat a predicate that was simply wrong —
|
||||||
|
* an "application frame" was any frame whose URL CONTAINED `127.0.0.1`.
|
||||||
|
*
|
||||||
|
* VERIFIED 2026-08-14 by driving the real pages: the broker's own auth page carries the
|
||||||
|
* application's address in its query string, so its MAIN frame's URL is
|
||||||
|
* `https://nextgraph.eu/auth/#/?o=http%3A%2F%2F127.0.0.1%3A39975` — which contains
|
||||||
|
* `127.0.0.1`. The predicate therefore matched the AUTH PAGE ITSELF, from the first instant,
|
||||||
|
* before any login had happened. Everything downstream then followed: the poll loop exited at
|
||||||
|
* once, the wallet click and the password were SKIPPED as "already logged in", and the
|
||||||
|
* function returned `page.mainFrame()` — the broker's login screen — as the application's
|
||||||
|
* frame. The caller then waited its full minute for an element that page does not have, and
|
||||||
|
* reported a timeout naming nothing.
|
||||||
|
*
|
||||||
|
* The only thing that ever saved a run was the very first branch: clicking "Login" moves the
|
||||||
|
* URL to `#/wallet/login`, which carries no `o=` parameter and so no `127.0.0.1` — after which
|
||||||
|
* the broken predicate happens to behave. That click was guarded by a 2-second visibility
|
||||||
|
* probe, and the button paints at 1.0–1.6 s (VERIFIED, three consecutive sign-ins). A 2-second
|
||||||
|
* bound on a 1.0–1.6-second event is a coin toss, and which side it lands on is decided by how
|
||||||
|
* loaded the machine is — which is exactly why this read as "the broker" or "the host
|
||||||
|
* network", and why it hit the SECOND actor most: it signs in while the first one's tab is
|
||||||
|
* busy with its own broker traffic.
|
||||||
|
*
|
||||||
|
* So nothing here waits for a DURATION. It waits for whichever screen appears, dispatches on
|
||||||
|
* it, and stops when a frame is on the application's ORIGIN — an origin the broker's pages can
|
||||||
|
* never be on, whatever they carry in their query string.
|
||||||
|
*
|
||||||
|
* The screens themselves, their signatures and their answers are DESCRIPTION and live in
|
||||||
|
* `nextgraph-ui.ts`; this file is the driving.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Frame, Locator, Page } from "playwright";
|
||||||
|
import { CONTEXT_NAVIGATION_MS, within } from "./deadline";
|
||||||
|
import { browserTrouble } from "./known-failures";
|
||||||
|
import {
|
||||||
|
BROKER_SCREENS,
|
||||||
|
brokerRedirectFor,
|
||||||
|
type BrokerScreen,
|
||||||
|
type BrokerScreenSpec,
|
||||||
|
} from "./nextgraph-ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bound for the whole ceremony — the screens, the clicks, and the application's frame
|
||||||
|
* attaching. Measured 1.3–2.8 s for an actor and 1.7 s on a cold profile's barrier passage
|
||||||
|
* (2026-08-16, `E2E_TIMINGS=1`). Bounded at 45 s ≈ 16x the slowest measured: enough that a
|
||||||
|
* busy host does not manufacture a false diagnosis, little enough that the rich failure below
|
||||||
|
* — the screen, the trail, the frames, the page's own text — arrives in under a minute instead
|
||||||
|
* of after two.
|
||||||
|
*
|
||||||
|
* Exported for the same reason as `NEW_PAGE_MS`: this function's failure message is the most
|
||||||
|
* informative one in the harness, and an enclosing bound set below it would replace that
|
||||||
|
* message with "the round-trip timed out" and lose every fact in it.
|
||||||
|
*/
|
||||||
|
export const BROKER_LOGIN_MS = 45_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What {@link setupBrokerPage} costs at worst: its navigation plus the ceremony. A caller that
|
||||||
|
* wants to MEASURE the round-trip should hand this to `measured` rather than invent a bound of
|
||||||
|
* its own — an enclosure below this number fires before the ceremony can explain itself.
|
||||||
|
*/
|
||||||
|
export const BROKER_ROUND_TRIP_MS = CONTEXT_NAVIGATION_MS + BROKER_LOGIN_MS;
|
||||||
|
|
||||||
|
/** How often the browser re-reads the screen. Not a sleep: it is the interval of a condition
|
||||||
|
* check that runs INSIDE the page, the same mechanism `isVisible` uses. */
|
||||||
|
const SCREEN_POLL_MS = 200;
|
||||||
|
/** A screen answered this many times without the flow moving on is a livelock, not a slow
|
||||||
|
* page — say so instead of clicking for ever. */
|
||||||
|
const SCREEN_REVISITS_ALLOWED = 3;
|
||||||
|
/** A click or a fill that has not landed in 15 s is not going to; the ceremony's own bound is
|
||||||
|
* eight times that, so failing here leaves room to say so rather than to hang. */
|
||||||
|
const CLICK_MS = 15_000;
|
||||||
|
/** Reading the text of a page for a failure message is a round-trip like any other, and
|
||||||
|
* `evaluate` carries no bound of its own — a diagnosis must not become the new hang. */
|
||||||
|
const TEXT_MS = 15_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate through the broker's redirect to load `appUrl` in the broker iframe, unlock the
|
||||||
|
* wallet if a login is shown, and return the application's frame.
|
||||||
|
*/
|
||||||
|
export async function setupBrokerPage(page: Page, appUrl: string, walletPassword: string): Promise<Frame> {
|
||||||
|
await page.goto(brokerRedirectFor(appUrl), { waitUntil: "domcontentloaded" });
|
||||||
|
return completeBrokerLogin(page, appUrl, walletPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which screen the page is on — evaluated INSIDE the page, returning `false` while it is still
|
||||||
|
* the one the caller already knows about, so it doubles as the change detector.
|
||||||
|
*
|
||||||
|
* Self-contained on purpose: Playwright ships this function's source into the browser, so it
|
||||||
|
* may close over nothing at all. The screen inventory is therefore an ARGUMENT, not an import
|
||||||
|
* — which is also what lets the description live in one file and the driving in another. It is
|
||||||
|
* passed both to `waitForFunction` (wait for a DIFFERENT screen) and to `evaluate` (read the
|
||||||
|
* current one): one definition, so the name in a failure message is always the name the
|
||||||
|
* machine dispatched on.
|
||||||
|
*/
|
||||||
|
function readBrokerScreen(input: {
|
||||||
|
previous: string | null;
|
||||||
|
screens: readonly BrokerScreenSpec[];
|
||||||
|
}): string | false {
|
||||||
|
const shown = (el: Element | null): boolean => {
|
||||||
|
if (el === null) return false;
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
return box.width > 0 && box.height > 0;
|
||||||
|
};
|
||||||
|
let kind: string | null = null;
|
||||||
|
for (const spec of input.screens) {
|
||||||
|
const signature = spec.signature;
|
||||||
|
if (signature.kind === "rendered") {
|
||||||
|
if (signature.selectors.some((selector) => shown(document.querySelector(selector)))) {
|
||||||
|
kind = spec.screen;
|
||||||
|
}
|
||||||
|
} else if (signature.kind === "rendered-control") {
|
||||||
|
const pattern = new RegExp(signature.matches.source, signature.matches.flags);
|
||||||
|
const control = Array.from(document.querySelectorAll("button, a")).find((el) =>
|
||||||
|
pattern.test((el.textContent ?? "").trim()),
|
||||||
|
);
|
||||||
|
if (shown(control ?? null)) kind = spec.screen;
|
||||||
|
} else if (signature.kind === "page-text") {
|
||||||
|
// `innerText`, not `textContent`, and only here: it is the one test that has to read
|
||||||
|
// prose rather than a selector, and only what is RENDERED counts — the hidden login UI
|
||||||
|
// would otherwise answer for a page that is not showing it.
|
||||||
|
const pattern = new RegExp(signature.matches.source, signature.matches.flags);
|
||||||
|
const visible = document.body === null ? "" : document.body.innerText;
|
||||||
|
if (pattern.test(visible)) kind = spec.screen;
|
||||||
|
} else {
|
||||||
|
kind = spec.screen;
|
||||||
|
}
|
||||||
|
if (kind !== null) break;
|
||||||
|
}
|
||||||
|
if (kind === null) return false;
|
||||||
|
return kind === input.previous ? false : kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application's frame: a SUB-frame whose URL is on the application's own origin.
|
||||||
|
*
|
||||||
|
* ── Both halves of that sentence are load-bearing ────────────────────────────
|
||||||
|
* `startsWith(origin)` rather than `includes(host)` is what stops the broker's own pages from
|
||||||
|
* answering. THE BROKER'S AUTH PAGE CARRIES THE APPLICATION'S ADDRESS IN ITS QUERY STRING, so
|
||||||
|
* a substring match returns the LOGIN PAGE and the whole crossing then fails silently: the
|
||||||
|
* wallet click and the password are skipped as "already logged in", and the caller is handed a
|
||||||
|
* frame that will never render the application. That bug cost days, and it was readable in one
|
||||||
|
* line the whole time. Never match a host, a port or a path here — match the origin, from the
|
||||||
|
* start of the URL.
|
||||||
|
*
|
||||||
|
* Excluding the MAIN frame is the second half, and it is not redundant: a journey that lets the
|
||||||
|
* application hand the page over ITSELF calls this while the page is still ON the application,
|
||||||
|
* top-level, and a main-frame match there would hand back a frame that is about to navigate
|
||||||
|
* away.
|
||||||
|
*
|
||||||
|
* Event-driven rather than polled — `frameattached`/`framenavigated` is exactly the signal, so
|
||||||
|
* there is nothing to sleep between.
|
||||||
|
*/
|
||||||
|
interface AppFrameWatcher {
|
||||||
|
/** The frame now, or null. */
|
||||||
|
found(): Frame | null;
|
||||||
|
/** Resolves once one appears. The same promise every time, so racing it costs no listener. */
|
||||||
|
whenFound(): Promise<Frame>;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchForAppFrame(page: Page, appOrigin: string): AppFrameWatcher {
|
||||||
|
const pick = (): Frame | null => {
|
||||||
|
for (const f of page.frames()) {
|
||||||
|
if (f === page.mainFrame()) continue;
|
||||||
|
if (f.url().startsWith(appOrigin)) return f;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
let settle: ((f: Frame) => void) | null = null;
|
||||||
|
const appeared = new Promise<Frame>((resolve) => {
|
||||||
|
settle = resolve;
|
||||||
|
});
|
||||||
|
const check = (): void => {
|
||||||
|
const f = pick();
|
||||||
|
if (f !== null && settle !== null) {
|
||||||
|
settle(f);
|
||||||
|
settle = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
page.on("frameattached", check);
|
||||||
|
page.on("framenavigated", check);
|
||||||
|
check();
|
||||||
|
return {
|
||||||
|
found: pick,
|
||||||
|
whenFound: () => appeared,
|
||||||
|
stop: () => {
|
||||||
|
page.off("frameattached", check);
|
||||||
|
page.off("framenavigated", check);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrokerEvent =
|
||||||
|
| { kind: "frame"; frame: Frame }
|
||||||
|
| { kind: "screen"; screen: BrokerScreen }
|
||||||
|
/** Nothing happened before the bound. `because` is set when the watch itself failed (a
|
||||||
|
* closed browser, say) rather than simply running out of time — reporting the two the same
|
||||||
|
* way is how "the screen never changed" gets blamed for a dead browser. */
|
||||||
|
| { kind: "stalled"; because: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whichever comes first: a screen that is not `previous`, or the application's frame.
|
||||||
|
*
|
||||||
|
* The race is not an optimisation. The successful end of this flow leaves the page on a screen
|
||||||
|
* that never changes again ("Wallet opened for …", which reads as `working`), so a wait for a
|
||||||
|
* screen CHANGE alone would sit there until its deadline with the frame it wanted already
|
||||||
|
* attached.
|
||||||
|
*/
|
||||||
|
async function nextBrokerEvent(
|
||||||
|
page: Page,
|
||||||
|
watcher: AppFrameWatcher,
|
||||||
|
previous: BrokerScreen | null,
|
||||||
|
ms: number,
|
||||||
|
): Promise<BrokerEvent> {
|
||||||
|
const onScreen = page
|
||||||
|
.waitForFunction(readBrokerScreen, { previous, screens: BROKER_SCREENS }, { timeout: ms, polling: SCREEN_POLL_MS })
|
||||||
|
.then(async (handle): Promise<BrokerEvent> => {
|
||||||
|
const value = await handle.jsonValue();
|
||||||
|
return typeof value === "string"
|
||||||
|
? { kind: "screen", screen: value as BrokerScreen }
|
||||||
|
: { kind: "stalled", because: null };
|
||||||
|
});
|
||||||
|
// Absorbed, so the loser of the race cannot surface as an unhandled rejection minutes after
|
||||||
|
// the winner has already been acted on — the same hazard `deadline.ts` documents. A plain
|
||||||
|
// expiry is NOT an error worth quoting; anything else is, and is quoted.
|
||||||
|
const settled = onScreen.catch((e: unknown): BrokerEvent => {
|
||||||
|
const message = String((e as Error)?.message ?? e).split("\n")[0] ?? "";
|
||||||
|
const expired = (e as Error)?.name === "TimeoutError" || /Timeout .* exceeded/i.test(message);
|
||||||
|
return { kind: "stalled", because: expired ? null : message };
|
||||||
|
});
|
||||||
|
const onFrame = watcher.whenFound().then((frame): BrokerEvent => ({ kind: "frame", frame }));
|
||||||
|
return Promise.race([settled, onFrame]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer a screen, as its description says to. Returns what it did, for the failure message,
|
||||||
|
* or null if there was nothing to do but wait.
|
||||||
|
*
|
||||||
|
* A click that cannot land is REPORTED, not thrown: the loop sees the screen again and the
|
||||||
|
* revisit cap turns a stuck click into a named failure — which says far more than the click's
|
||||||
|
* own timeout would.
|
||||||
|
*/
|
||||||
|
async function answerBrokerScreen(
|
||||||
|
page: Page,
|
||||||
|
spec: BrokerScreenSpec,
|
||||||
|
walletPassword: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const click = async (what: string, locator: Locator): Promise<string> => {
|
||||||
|
try {
|
||||||
|
await locator.first().click({ timeout: CLICK_MS });
|
||||||
|
return `clicked ${what}`;
|
||||||
|
} catch (e) {
|
||||||
|
return `could NOT click ${what}: ${String((e as Error)?.message ?? e).split("\n")[0]}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const answer = spec.answer;
|
||||||
|
switch (answer.kind) {
|
||||||
|
case "click":
|
||||||
|
return click(answer.what, page.locator(answer.selector));
|
||||||
|
case "click-text":
|
||||||
|
return click(answer.what, page.getByText(answer.text, { exact: true }));
|
||||||
|
case "submit-password": {
|
||||||
|
const field = page.locator(answer.selector).first();
|
||||||
|
try {
|
||||||
|
await field.fill(walletPassword, { timeout: CLICK_MS });
|
||||||
|
await field.press("Enter", { timeout: CLICK_MS });
|
||||||
|
return `filled ${answer.what} and submitted it`;
|
||||||
|
} catch (e) {
|
||||||
|
return `could NOT submit ${answer.what}: ${String((e as Error)?.message ?? e).split("\n")[0]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "wait":
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the crossing did not get where it was going — with enough on it to skip the guessing.
|
||||||
|
*
|
||||||
|
* An earlier version said "SDK iframe not found after 30s" plus a list of frame URLs, and a
|
||||||
|
* whole day went into attributing that to the network. So this names the screen the machine
|
||||||
|
* last recognised, the screens it walked through and what it did on each, the origin it was
|
||||||
|
* waiting for, every frame, and the text the page was actually showing — which is the one
|
||||||
|
* thing that distinguishes a broker error page from a page that is simply still working.
|
||||||
|
*
|
||||||
|
* And, when it applies, the browser's own condition FIRST: a crossing that failed because the
|
||||||
|
* browser stopped answering must not be reported as a broker problem.
|
||||||
|
*/
|
||||||
|
async function brokerLoginFailure(
|
||||||
|
page: Page,
|
||||||
|
appOrigin: string,
|
||||||
|
screen: BrokerScreen | null,
|
||||||
|
trail: string[],
|
||||||
|
startedAt: number,
|
||||||
|
why: string,
|
||||||
|
): Promise<Error> {
|
||||||
|
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||||
|
const trouble = await browserTrouble("crossing", page.context());
|
||||||
|
let shown: string;
|
||||||
|
try {
|
||||||
|
const text = await within("the failed broker page's own text", TEXT_MS, () =>
|
||||||
|
page.evaluate(() => (document.body === null ? "" : document.body.innerText)),
|
||||||
|
);
|
||||||
|
const compact = text.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim();
|
||||||
|
shown = compact === "" ? "(the page showed nothing at all)" : compact.slice(0, 800);
|
||||||
|
} catch (e) {
|
||||||
|
shown = `(could not be read: ${String((e as Error)?.message ?? e).split("\n")[0]})`;
|
||||||
|
}
|
||||||
|
const frames = page
|
||||||
|
.frames()
|
||||||
|
.map((f) => ` ${f === page.mainFrame() ? "top" : "sub"} ${f.url() === "" ? "(blank)" : f.url()}`);
|
||||||
|
return new Error(
|
||||||
|
`[e2e broker login] gave up after ${elapsed}s — ${why}.\n` +
|
||||||
|
(trouble === null ? "" : ` BUT FIRST: ${trouble}\n`) +
|
||||||
|
` last screen it recognised: ${screen ?? "(none)"}\n` +
|
||||||
|
` it was waiting for: a sub-frame whose URL starts with ${appOrigin}\n` +
|
||||||
|
` how it got here:\n${trail.length === 0 ? " (nothing happened)" : trail.map((s) => ` ${s}`).join("\n")}\n` +
|
||||||
|
` frames on the page (${frames.length}):\n${frames.join("\n")}\n` +
|
||||||
|
` what the page was showing:\n${shown
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => ` | ${l}`)
|
||||||
|
.join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The half of {@link setupBrokerPage} that does NOT navigate: walk the crossing from whatever
|
||||||
|
* screen the page is on, and return the application's frame.
|
||||||
|
*
|
||||||
|
* Split out because there are two ways to arrive at the broker, and only one of them is the
|
||||||
|
* suite's. A test signs the round-trip off itself (`setupBrokerPage`); an APPLICATION hands the
|
||||||
|
* page over on its own, once the identity is settled — and a journey that walks a first-time
|
||||||
|
* user through an access barrier has to LET it, because that hand-over IS what it is checking.
|
||||||
|
* Calling `setupBrokerPage` there would re-navigate and throw away the URL the application had
|
||||||
|
* just built, its query parameters included — i.e. it would quietly substitute the suite's path
|
||||||
|
* for the one under test.
|
||||||
|
*
|
||||||
|
* ── A password may simply never be asked for ─────────────────────────────────
|
||||||
|
* The second actor is a BRANCH, not a fallthrough: the wallet is broadcast between the broker
|
||||||
|
* origin's tabs, so a later actor's selection logs straight in. The machine below neither
|
||||||
|
* expects nor requires the password screen — it answers what is on screen. An earlier version
|
||||||
|
* treated the password as the normal case and gave the no-password path an 8-second window to
|
||||||
|
* prove itself innocent.
|
||||||
|
*/
|
||||||
|
export async function completeBrokerLogin(page: Page, appUrl: string, walletPassword: string): Promise<Frame> {
|
||||||
|
const appOrigin = new URL(appUrl).origin;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const deadline = startedAt + BROKER_LOGIN_MS;
|
||||||
|
const trail: string[] = [];
|
||||||
|
const actedOn = new Map<BrokerScreen, number>();
|
||||||
|
let screen: BrokerScreen | null = null;
|
||||||
|
|
||||||
|
const watcher = watchForAppFrame(page, appOrigin);
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const already = watcher.found();
|
||||||
|
if (already !== null) return already;
|
||||||
|
|
||||||
|
const left = deadline - Date.now();
|
||||||
|
if (left <= 0) {
|
||||||
|
throw await brokerLoginFailure(page, appOrigin, screen, trail, startedAt, "its overall deadline expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await nextBrokerEvent(page, watcher, screen, left);
|
||||||
|
if (next.kind === "frame") return next.frame;
|
||||||
|
if (next.kind === "stalled") {
|
||||||
|
throw await brokerLoginFailure(
|
||||||
|
page,
|
||||||
|
appOrigin,
|
||||||
|
screen,
|
||||||
|
trail,
|
||||||
|
startedAt,
|
||||||
|
next.because !== null
|
||||||
|
? `watching the page stopped working: ${next.because}`
|
||||||
|
: screen === null
|
||||||
|
? "no screen it recognises ever appeared"
|
||||||
|
: `the ${screen} screen never changed and no application frame ever appeared`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
screen = next.screen;
|
||||||
|
trail.push(`+${((Date.now() - startedAt) / 1000).toFixed(1)}s ${screen}`);
|
||||||
|
const spec = BROKER_SCREENS.find((s) => s.screen === screen);
|
||||||
|
if (spec === undefined) {
|
||||||
|
throw await brokerLoginFailure(
|
||||||
|
page,
|
||||||
|
appOrigin,
|
||||||
|
screen,
|
||||||
|
trail,
|
||||||
|
startedAt,
|
||||||
|
`the page reported a screen the inventory does not describe (${screen})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (spec.terminal === true) {
|
||||||
|
throw await brokerLoginFailure(page, appOrigin, screen, trail, startedAt, "the broker showed an error page");
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenBefore = (actedOn.get(screen) ?? 0) + 1;
|
||||||
|
actedOn.set(screen, seenBefore);
|
||||||
|
if (seenBefore > SCREEN_REVISITS_ALLOWED) {
|
||||||
|
throw await brokerLoginFailure(
|
||||||
|
page,
|
||||||
|
appOrigin,
|
||||||
|
screen,
|
||||||
|
trail,
|
||||||
|
startedAt,
|
||||||
|
`the ${screen} screen came back ${seenBefore} times — the click it answers is not moving the flow on`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const did = await answerBrokerScreen(page, spec, walletPassword);
|
||||||
|
if (did !== null) trail.push(` ${did}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
watcher.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Every browser this harness opens, launched and watched the same way.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium, type BrowserContext, type Page } from "playwright";
|
||||||
|
import {
|
||||||
|
CONTEXT_ACTION_MS,
|
||||||
|
CONTEXT_NAVIGATION_MS,
|
||||||
|
browserLost,
|
||||||
|
closeQuietly,
|
||||||
|
within,
|
||||||
|
} from "./deadline";
|
||||||
|
|
||||||
|
/** Launching a browser is local — 30 s is Playwright's own default, doubled. */
|
||||||
|
export const LAUNCH_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opening a page in a live browser is instant — measured 0.0–0.1 s over a run. Bounded at
|
||||||
|
* 10 s, which is a hundred times the measurement and still fails while a reader is watching.
|
||||||
|
* Exported because a caller that wraps `newPage` in a TIGHTER bound of its own would fire
|
||||||
|
* first and report its own name instead of this one.
|
||||||
|
*/
|
||||||
|
export const NEW_PAGE_MS = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this harness needs Chromium to allow: the application under test is served from
|
||||||
|
* `127.0.0.1` and loaded inside a broker iframe on a public origin, which is a private-network
|
||||||
|
* request and a cross-origin one at once.
|
||||||
|
*/
|
||||||
|
const LAUNCH_ARGS = [
|
||||||
|
"--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations",
|
||||||
|
"--allow-insecure-localhost",
|
||||||
|
"--disable-web-security",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real Chromium rather than the headless shell: the shell has no support for the extensions
|
||||||
|
* of a full browser, and the wallet application's flows have been observed only on the full
|
||||||
|
* build. Falls back to Playwright's own choice when no full build is installed beside it.
|
||||||
|
*/
|
||||||
|
function resolveChromePath(): string | undefined {
|
||||||
|
const p = chromium
|
||||||
|
.executablePath()
|
||||||
|
.replace("chrome-headless-shell", "chrome")
|
||||||
|
.replace("chromium_headless_shell", "chromium");
|
||||||
|
return p.includes("headless") ? undefined : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contexts we are closing ON PURPOSE — so their `close` event is not read as a loss. */
|
||||||
|
const closingOnPurpose = new WeakSet<BrowserContext>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a persistent context on `dir`, bounded, with the harness's own timeouts applied and
|
||||||
|
* its disappearance turned into an immediate, named failure.
|
||||||
|
*
|
||||||
|
* The watch is the load-bearing part. VERIFIED 2026-08-11: a browser can exit mid-run — the
|
||||||
|
* devtools pipe between the runner and Chromium is terminated and Chromium shuts down
|
||||||
|
* (exitCode=0) — and Playwright does NOT reject the calls already waiting on it. A bounded
|
||||||
|
* wait then burns its whole timeout; an unbounded one (`newPage`, `evaluate`, and
|
||||||
|
* `context.close()` in a `finally`) waits for ever. That is how a 60-second failure became
|
||||||
|
* three runs killed at 50 and 68 minutes having printed nothing.
|
||||||
|
*
|
||||||
|
* So the context's own `close` event is listened to, and anything it was not asked to do is
|
||||||
|
* declared a loss once, loudly, for every wait at once.
|
||||||
|
*/
|
||||||
|
export async function launchWatchedContext(label: string, dir: string): Promise<BrowserContext> {
|
||||||
|
const ctx = await within(`the ${label} browser to launch`, LAUNCH_MS, () =>
|
||||||
|
chromium.launchPersistentContext(dir, {
|
||||||
|
headless: true,
|
||||||
|
executablePath: resolveChromePath(),
|
||||||
|
args: LAUNCH_ARGS,
|
||||||
|
timeout: LAUNCH_MS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ctx.setDefaultTimeout(CONTEXT_ACTION_MS);
|
||||||
|
ctx.setDefaultNavigationTimeout(CONTEXT_NAVIGATION_MS);
|
||||||
|
const gone = (how: string): void => {
|
||||||
|
if (closingOnPurpose.has(ctx)) return;
|
||||||
|
browserLost(
|
||||||
|
`the ${label} browser went away mid-run — ${how}. Every wait on it is now ` +
|
||||||
|
"unanswerable, so the run stops here instead of waiting on a browser that " +
|
||||||
|
"no longer exists",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// Both signals, and NEITHER of them covers the loss that hurts most — which is the whole
|
||||||
|
// reason the deadlines are not optional.
|
||||||
|
//
|
||||||
|
// VERIFIED 2026-08-11: on a normal teardown both `close` and `disconnected` fire. On the
|
||||||
|
// failure this harness actually suffers — Chromium logging "Connection terminated while
|
||||||
|
// reading from pipe" and exiting — Playwright fires NEITHER, four times out of four. Its
|
||||||
|
// client never learns the pipe is gone, so every call already in flight simply waits, and
|
||||||
|
// every call after it waits too. That is why a browser dying used to cost an hour of
|
||||||
|
// silence, and why no event-based guard can be the protection here: only a deadline can
|
||||||
|
// (and `known-failures.ts` is what turns the deadline back into the right name).
|
||||||
|
//
|
||||||
|
// They are wired anyway because they DO catch the losses they can see (a context closed by
|
||||||
|
// something nobody asked), and those are free to catch immediately rather than at the end
|
||||||
|
// of a bound.
|
||||||
|
ctx.on("close", () => gone("its context closed and nobody asked it to"));
|
||||||
|
ctx.browser()?.on("disconnected", () => gone("its devtools connection dropped"));
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close a context we own, bounded, without its `close` event being read as a loss. */
|
||||||
|
export async function closeContext(label: string, ctx: BrowserContext): Promise<void> {
|
||||||
|
closingOnPurpose.add(ctx);
|
||||||
|
await closeQuietly(`the ${label} context`, () => ctx.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a page under a bound: `context.newPage()` carries no timeout of its own. */
|
||||||
|
export function newPage(label: string, ctx: BrowserContext): Promise<Page> {
|
||||||
|
return within(`a new page for ${label}`, NEW_PAGE_MS, () => ctx.newPage());
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Deadlines — so a wait that cannot end FAILS, named, instead of hanging.
|
||||||
|
*
|
||||||
|
* ── Why this module exists ───────────────────────────────────────────────────
|
||||||
|
* A harness that hangs is worse than one that fails. A failure names a suspect and costs a
|
||||||
|
* minute; a hang costs an hour and leaves every measurement of the session undecidable —
|
||||||
|
* was the suite slow, was the broker slow, or was it stuck? Two of the waits these suites
|
||||||
|
* lean on have NO bound at all: `frame.evaluate()` (which is what every bridge call into a
|
||||||
|
* page is) and `context.newPage()`. Playwright applies no timeout to either.
|
||||||
|
*
|
||||||
|
* And the worst one is in the teardown. VERIFIED 2026-08-11: when the browser goes away
|
||||||
|
* mid-run, `BrowserContext.close()` in a `finally` never resolves — so the suite dies
|
||||||
|
* INSIDE its own cleanup, after its last journey, without ever printing its summary or its
|
||||||
|
* failures. That is the "prints the setup lines, then nothing for 68 minutes" the harness
|
||||||
|
* was killed for, three times.
|
||||||
|
*
|
||||||
|
* So: every wait that can block gets a deadline, and on expiry an error that says WHAT it
|
||||||
|
* was waiting for and WHERE — the chain of journeys and steps it sits inside (see
|
||||||
|
* {@link enclosing}) — because a bound whose message is "Timeout" only moves the guessing
|
||||||
|
* from "which wait" to "which of these thirty-two".
|
||||||
|
*
|
||||||
|
* ── Bounds are generous on purpose ───────────────────────────────────────────
|
||||||
|
* The numbers are sized from OBSERVED healthy timings with a wide margin (see each
|
||||||
|
* caller). The goal is to catch a hang, never to make a healthy-but-slow run flaky: a
|
||||||
|
* bound that fires on a slow broker manufactures exactly the false diagnosis it exists to
|
||||||
|
* prevent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Thrown when a bounded wait outlives its deadline. */
|
||||||
|
export class DeadlineExceeded extends Error {
|
||||||
|
constructor(what: string, ms: number, where: string) {
|
||||||
|
super(`[e2e deadline] gave up after ${fmtMs(ms)} waiting for: ${what}\n ${where}`);
|
||||||
|
this.name = "DeadlineExceeded";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown at every wait in flight when the browser they all depend on has gone away.
|
||||||
|
*
|
||||||
|
* Without it, a dead browser is discovered one 60-second timeout at a time — or never, on
|
||||||
|
* the waits Playwright does not bound. The suite has nothing left to measure at that
|
||||||
|
* point, so the useful thing is to say so once, immediately, and name the loss.
|
||||||
|
*/
|
||||||
|
export class BrowserGone extends Error {
|
||||||
|
constructor(reason: string, what: string, where: string) {
|
||||||
|
super(`[e2e] ${reason}\n it was waiting for: ${what}\n ${where}`);
|
||||||
|
this.name = "BrowserGone";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMs(ms: number): string {
|
||||||
|
return ms >= 60000 ? `${(ms / 60000).toFixed(1)} min` : `${Math.round(ms / 1000)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
what: string;
|
||||||
|
where: string;
|
||||||
|
ms: number;
|
||||||
|
startedAt: number;
|
||||||
|
abandon: (e: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything currently being waited on, so a loss can name every casualty at once. */
|
||||||
|
const pending = new Set<Pending>();
|
||||||
|
|
||||||
|
/** Set once the run has lost the thing every wait depends on. */
|
||||||
|
let lost: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a wait sits, as the chain of waits enclosing it — `journey X › alice to sign in`.
|
||||||
|
*
|
||||||
|
* Deliberately NOT a file:line read off a stack. The runner is Bun, and Bun elides frames
|
||||||
|
* across `await` boundaries: measured 2026-08-11, a `within` called from an async function
|
||||||
|
* reports `moduleEvaluation (native:1:11)` and nothing else, so a stack-derived call site
|
||||||
|
* is silently wrong exactly when it is needed. The enclosing chain is better anyway — a
|
||||||
|
* reader wants "which journey, which step" far more than a line number, and journeys and
|
||||||
|
* steps are themselves bounded waits, so the chain is already there to be read.
|
||||||
|
*
|
||||||
|
* These suites are strictly sequential, which is what makes "everything else in flight" the
|
||||||
|
* same thing as "everything enclosing this". A concurrent harness would need real context
|
||||||
|
* propagation.
|
||||||
|
*/
|
||||||
|
function enclosing(): string {
|
||||||
|
const chain = [...pending].map((p) => p.what);
|
||||||
|
return chain.length === 0 ? "(the suite's top level)" : `while: ${chain.join(" › ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `task` under a deadline. On expiry — or the moment {@link browserLost} is declared —
|
||||||
|
* reject with an error naming what was being waited for and where.
|
||||||
|
*
|
||||||
|
* The losing task is NOT cancelled; nothing here can cancel a browser round-trip. Its
|
||||||
|
* eventual rejection is absorbed instead, because a race loser surfacing as an unhandled
|
||||||
|
* rejection would crash the process minutes after the real failure was already reported.
|
||||||
|
*/
|
||||||
|
export function within<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
|
||||||
|
if (lost !== null) return Promise.reject(new BrowserGone(lost, what, enclosing()));
|
||||||
|
return bounded(what, ms, enclosing(), task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The race itself, shared by {@link within} and the teardown path that outlives a loss. */
|
||||||
|
function bounded<T>(what: string, ms: number, where: string, task: () => Promise<T>): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let entry!: Pending;
|
||||||
|
const interrupted = new Promise<never>((_, reject) => {
|
||||||
|
entry = { what, where, ms, startedAt: Date.now(), abandon: reject };
|
||||||
|
timer = setTimeout(() => reject(new DeadlineExceeded(what, ms, where)), ms);
|
||||||
|
});
|
||||||
|
pending.add(entry);
|
||||||
|
const running = task();
|
||||||
|
running.catch(() => {}); // absorbed: the race's loser must not become an unhandled rejection
|
||||||
|
return Promise.race([running, interrupted]).finally(() => {
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
pending.delete(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare that the browser every wait depends on has gone, and abandon them all now.
|
||||||
|
*
|
||||||
|
* Idempotent, and one-way: once a run has lost its browser there is nothing further to
|
||||||
|
* measure, so later waits are refused rather than left to time out one by one.
|
||||||
|
*/
|
||||||
|
export function browserLost(reason: string): void {
|
||||||
|
if (lost !== null) return;
|
||||||
|
lost = reason;
|
||||||
|
console.error(`\n[e2e] ${reason}`);
|
||||||
|
if (pending.size > 0) {
|
||||||
|
console.error(` ${pending.size} wait(s) were in flight and are abandoned:`);
|
||||||
|
for (const p of pending) console.error(` - ${p.what} [${p.where}]`);
|
||||||
|
}
|
||||||
|
for (const p of [...pending]) p.abandon(new BrowserGone(reason, p.what, p.where));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loss already declared, or `null` while the run still has a browser.
|
||||||
|
*
|
||||||
|
* Read by the failure-mode recognition (`known-failures.ts`) so that a diagnosis asked for
|
||||||
|
* AFTER a loss answers instantly with the loss, instead of spending a probe's bound
|
||||||
|
* re-discovering what is already known.
|
||||||
|
*/
|
||||||
|
export function lossDeclared(): string | null {
|
||||||
|
return lost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ENCLOSING bound, computed from the bounds it encloses rather than picked.
|
||||||
|
*
|
||||||
|
* ── Why this is an addition and not a comment ────────────────────────────────
|
||||||
|
* An enclosing deadline shorter than its own steps can only ever fire FIRST, so every
|
||||||
|
* failure underneath it is reported as "the enclosure timed out" and the step that actually
|
||||||
|
* hung is never named. Observed at length: a sign-in bounded at 3 min sat over steps
|
||||||
|
* totalling 4.5 min, and for days every sign-in failure said the same four words while the
|
||||||
|
* real step stayed anonymous. Days went into looking for a cause the harness was
|
||||||
|
* structurally incapable of reporting.
|
||||||
|
*
|
||||||
|
* A comment saying "keep this above the sum" is a discipline; an addition is a mechanism,
|
||||||
|
* and the mechanism is what survives the next edit — shrink a step's bound and the
|
||||||
|
* enclosure shrinks with it. That is the lever: the steps, never the enclosure.
|
||||||
|
*
|
||||||
|
* `margin` is for the enclosure's OWN overhead (the code between the steps), not for
|
||||||
|
* comfort — an enclosure sized "generously" above its steps just delays every report.
|
||||||
|
*/
|
||||||
|
export function enclosingBound(steps: readonly number[], margin: number): number {
|
||||||
|
return steps.reduce((sum, ms) => sum + ms, 0) + margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teardown bound: a close that has not returned in 30s is not going to. */
|
||||||
|
export const CLOSE_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a page/context/server under a deadline, reporting rather than throwing.
|
||||||
|
*
|
||||||
|
* Teardown is where a bound matters most and an exception matters least: the verdict is
|
||||||
|
* already decided, so a close that never returns must not be what the run dies of. This is
|
||||||
|
* the exact shape of the observed hang — `BrowserContext.close()` on a browser that had
|
||||||
|
* already exited, inside a `finally`, swallowing the summary that was on its way out.
|
||||||
|
*
|
||||||
|
* Deliberately NOT refused after a loss, unlike {@link within}: a lost browser is when
|
||||||
|
* closing matters most. Skipping it there would leave the Chromium processes of a failed
|
||||||
|
* run alive, and the next run would inherit them.
|
||||||
|
*/
|
||||||
|
export async function closeQuietly(what: string, close: () => Promise<unknown>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await bounded(`${what} to close`, CLOSE_MS, enclosing(), async () => {
|
||||||
|
await close();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(` [warn] ${what} did not close cleanly: ${String((e as Error)?.message ?? e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the suite's own wall clock. On expiry, name every wait still in flight and exit.
|
||||||
|
*
|
||||||
|
* The last resort behind the per-wait deadlines: it catches the wait nobody wrapped. It
|
||||||
|
* reports before it dies, because "the run was killed" is the uninformative message that
|
||||||
|
* cost the hours this module exists to stop spending.
|
||||||
|
*
|
||||||
|
* `unref`ed, so a healthy run is never held open by its own watchdog.
|
||||||
|
*
|
||||||
|
* `thenReport` lets a suite print its own summary before the process goes — without it a run
|
||||||
|
* that trips this watchdog reports its waits and then vanishes, so its check count is zero
|
||||||
|
* and comparable with nothing. (A suite built on `report.ts` passes its `finish`.)
|
||||||
|
*/
|
||||||
|
export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.error(
|
||||||
|
`\n[e2e deadline] ${suite} exceeded its wall clock of ${fmtMs(ms)} — aborting.\n` +
|
||||||
|
" This is a HANG, not a verdict.",
|
||||||
|
);
|
||||||
|
if (pending.size === 0) {
|
||||||
|
console.error(
|
||||||
|
" Nothing was inside a bounded wait, so the block is in unbounded code: " +
|
||||||
|
"wrap the step it stopped at with `within(...)`.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(` Waits still in flight (${pending.size}):`);
|
||||||
|
for (const p of pending) {
|
||||||
|
console.error(
|
||||||
|
` - ${p.what} — ${fmtMs(Date.now() - p.startedAt)} of ${fmtMs(p.ms)}\n at ${p.where}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(` Total elapsed: ${fmtMs(Date.now() - startedAt)}`);
|
||||||
|
// A suite that can still say what it did and did not verify must be allowed to say it —
|
||||||
|
// otherwise the watchdog, whose whole purpose is to replace a silent kill with a report,
|
||||||
|
// produces its own silent kill. `thenReport` is expected to exit; the line below is the
|
||||||
|
// fallback for a caller that has nothing to report.
|
||||||
|
if (thenReport !== undefined) thenReport();
|
||||||
|
process.exit(1);
|
||||||
|
}, ms);
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright's per-context defaults, set explicitly so the bound on every locator action and
|
||||||
|
* navigation is a decision in this file rather than a library default nobody looked up.
|
||||||
|
*
|
||||||
|
* The value is Playwright's own 30s, deliberately: raising it to 120s was tried on
|
||||||
|
* 2026-08-11 and made things WORSE, because a bound is not only a hang-catcher — it is also
|
||||||
|
* how fast a genuine failure is reported. The wallet-creation flow on nextgraph.eu can
|
||||||
|
* re-render under a click ("element was detached from the DOM, retrying"), and at 120s that
|
||||||
|
* flake took two minutes to surface instead of thirty seconds. Every action and navigation
|
||||||
|
* here already had a bound; the waits that had NONE are the ones this module wraps
|
||||||
|
* (`evaluate`, `newPage`, `close`), and the slow broker calls pass their own timeout.
|
||||||
|
*/
|
||||||
|
export const CONTEXT_ACTION_MS = 30_000;
|
||||||
|
export const CONTEXT_NAVIGATION_MS = 30_000;
|
||||||
|
|
||||||
|
/** The first line of whatever was thrown — the form a report carries. */
|
||||||
|
export function firstLine(e: unknown): string {
|
||||||
|
return String((e as Error)?.message ?? e).split("\n")[0] ?? "(no message)";
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* `ng-e2e-helpers` — what any NextGraph application needs to test itself end to end, against
|
||||||
|
* the real broker and the real wallet application.
|
||||||
|
*
|
||||||
|
* ── What it is for ───────────────────────────────────────────────────────────
|
||||||
|
* Testing a NextGraph application end to end means getting a real person into it: minting a
|
||||||
|
* wallet by driving the wallet application, crossing the broker, and coming back inside the
|
||||||
|
* iframe the application actually runs in. None of that is about any one application, and all
|
||||||
|
* of it is expensive to get right — the crossing alone has cost days of misdiagnosis, twice,
|
||||||
|
* for reasons recorded in `broker.ts` and `known-failures.ts`.
|
||||||
|
*
|
||||||
|
* It knows nothing about any compatibility layer and never will: an application that uses the
|
||||||
|
* NextGraph SDK directly is its intended consumer.
|
||||||
|
*
|
||||||
|
* ── The five things it gives you ─────────────────────────────────────────────
|
||||||
|
* - a WALLET: minted for this run, exported as bytes an application can serve, imported into
|
||||||
|
* a profile (`wallet.ts`) — and, for the one-off that provisions a deployment rather than a
|
||||||
|
* run, the same minting behind an executable (`bin/mint-wallet.ts`);
|
||||||
|
* - the BROKER CROSSING, which dispatches on the screen it can see and identifies the
|
||||||
|
* application by ORIGIN (`broker.ts`, `nextgraph-ui.ts`);
|
||||||
|
* - PROFILES that belong to one run and are cleaned up after it (`profiles.ts`, `browser.ts`);
|
||||||
|
* - BOUNDS that turn a hang into a named failure (`deadline.ts`, `measure.ts`);
|
||||||
|
* - a REPORT whose size does not depend on what failed (`report.ts`), and the recognition of
|
||||||
|
* the failure modes that are not the application's fault (`known-failures.ts`).
|
||||||
|
*
|
||||||
|
* Playwright and `@ng-org/web` are peer dependencies: the consumer owns both versions — the
|
||||||
|
* first because browser binaries have to match the driver, the second because the SDK the
|
||||||
|
* export page opens a session with must be the one the application and the broker agree on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
BrowserGone,
|
||||||
|
CLOSE_MS,
|
||||||
|
CONTEXT_ACTION_MS,
|
||||||
|
CONTEXT_NAVIGATION_MS,
|
||||||
|
DeadlineExceeded,
|
||||||
|
armSuiteDeadline,
|
||||||
|
browserLost,
|
||||||
|
closeQuietly,
|
||||||
|
enclosingBound,
|
||||||
|
firstLine,
|
||||||
|
lossDeclared,
|
||||||
|
within,
|
||||||
|
} from "./deadline";
|
||||||
|
|
||||||
|
export { measured, printTimings, record, timingsWanted } from "./measure";
|
||||||
|
|
||||||
|
export { LAUNCH_MS, NEW_PAGE_MS, closeContext, launchWatchedContext, newPage } from "./browser";
|
||||||
|
|
||||||
|
export { isAlive, newRunProfile, type RunProfile } from "./profiles";
|
||||||
|
|
||||||
|
export { serveOnEphemeralPort } from "./serve";
|
||||||
|
|
||||||
|
export { BROKER_LOGIN_MS, BROKER_ROUND_TRIP_MS, completeBrokerLogin, setupBrokerPage } from "./broker";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DEFAULT_WALLET_NAME,
|
||||||
|
createWalletInContext,
|
||||||
|
emptyProfileContext,
|
||||||
|
exportWalletBytes,
|
||||||
|
exportWalletFile,
|
||||||
|
importWalletFile,
|
||||||
|
mintWalletBytes,
|
||||||
|
mintWalletProfile,
|
||||||
|
mintWalletProfileKeepingContext,
|
||||||
|
type WalletCredentials,
|
||||||
|
} from "./wallet";
|
||||||
|
|
||||||
|
export {
|
||||||
|
BROWSER_PROBE_MS,
|
||||||
|
FRAME_PROBE_MS,
|
||||||
|
browserTrouble,
|
||||||
|
frameTrouble,
|
||||||
|
} from "./known-failures";
|
||||||
|
|
||||||
|
export {
|
||||||
|
declareSuite,
|
||||||
|
type JourneyDeclaration,
|
||||||
|
type JourneySpec,
|
||||||
|
type Prerequisite,
|
||||||
|
type SuiteOptions,
|
||||||
|
type SuiteReport,
|
||||||
|
} from "./report";
|
||||||
|
|
||||||
|
export {
|
||||||
|
BROKER_SCREENS,
|
||||||
|
WALLET_APP,
|
||||||
|
WALLET_CREATION,
|
||||||
|
WALLET_IMPORT,
|
||||||
|
brokerRedirectFor,
|
||||||
|
type BrokerScreen,
|
||||||
|
type BrokerScreenSpec,
|
||||||
|
type ScreenResponse,
|
||||||
|
type ScreenSignature,
|
||||||
|
type TextPattern,
|
||||||
|
} from "./nextgraph-ui";
|
||||||
|
|
||||||
|
// Re-exported so a consumer can type its own helpers against our signatures without a second,
|
||||||
|
// independently-versioned import of `playwright` — exactly the set our own exported functions
|
||||||
|
// mention (`launchWatchedContext`, `setupBrokerPage`, `frameTrouble`, and their neighbors).
|
||||||
|
export type { BrowserContext, Frame, Page } from "playwright";
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* The failure modes this harness cannot fix, and must therefore NAME.
|
||||||
|
*
|
||||||
|
* ── Why naming is the whole of the job ───────────────────────────────────────
|
||||||
|
* Both modes below present as a bounded wait expiring on whatever operation happened to be in
|
||||||
|
* flight — a `fill`, a `selectOption`, a `click`. Reported that way they read as product
|
||||||
|
* defects, and they have been diagnosed as such more than once: a run whose browser had
|
||||||
|
* stopped answering reported three timeouts on three different innocent selectors, none of
|
||||||
|
* them naming the browser. A whole day went into one of those.
|
||||||
|
*
|
||||||
|
* So when a wait fails, the honest question is asked before the verdict is written: does the
|
||||||
|
* browser still answer AT ALL? A trivial round-trip settles it in milliseconds when things are
|
||||||
|
* healthy, so asking costs a healthy run nothing.
|
||||||
|
*
|
||||||
|
* ── The two modes ────────────────────────────────────────────────────────────
|
||||||
|
* **A dropped devtools pipe.** Chromium's control pipe drops mid-run: it logs a terminated-pipe
|
||||||
|
* message and exits cleanly, and Playwright emits NEITHER `close` NOR `disconnected` — observed
|
||||||
|
* four times out of four. From the client's side the browser simply stops answering, so every
|
||||||
|
* wait on it burns its bound and the unbounded ones wait for ever. It is not caused by how the
|
||||||
|
* child process is spawned, nor by a leftover holding the profile, nor by overlapping launches
|
||||||
|
* — all three were probed and ruled out. It looks like Playwright losing its file descriptors
|
||||||
|
* without telling its client.
|
||||||
|
*
|
||||||
|
* **A context that stops answering.** The same shape at frame level: a frame that is attached,
|
||||||
|
* on the right URL, and holds NOTHING — what a RELOADED iframe looks like from the outside.
|
||||||
|
* VERIFIED 2026-08-16, one actor's frame reached it mid-run and the next three journeys each
|
||||||
|
* reported a 30 s timeout on a different innocent selector.
|
||||||
|
*
|
||||||
|
* ── What a named deadline does NOT prove ─────────────────────────────────────
|
||||||
|
* That the transport is at fault. A deadline says only that something did not happen in time;
|
||||||
|
* reaching for the environment is the comfortable answer because it absolves the code. The
|
||||||
|
* worst instance of that reflex here was a one-line harness bug — an application frame matched
|
||||||
|
* by SUBSTRING — blamed on the broker and on the host network for a day. Read your own harness
|
||||||
|
* first, and call it transport only once you can name the mechanism.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BrowserContext, Frame, Page } from "playwright";
|
||||||
|
import { DeadlineExceeded, firstLine, lossDeclared, within } from "./deadline";
|
||||||
|
|
||||||
|
/** Asking a live browser something trivial: it answers in milliseconds, or it is gone. */
|
||||||
|
export const BROWSER_PROBE_MS = 5_000;
|
||||||
|
/**
|
||||||
|
* Asking a live frame whether it still holds the application. A `count()` is one round-trip
|
||||||
|
* and does not wait for the element, so it answers in milliseconds or the frame is gone — the
|
||||||
|
* probe cannot itself become the hang it exists to name.
|
||||||
|
*/
|
||||||
|
export const FRAME_PROBE_MS = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the browser has stopped answering — as a sentence naming the mode, or `null` when it
|
||||||
|
* answers normally and the operation that failed is the real suspect.
|
||||||
|
*
|
||||||
|
* Consult this on a failure path, never in the success path: it exists to REPLACE a misleading
|
||||||
|
* verdict, not to add a check.
|
||||||
|
*/
|
||||||
|
export async function browserTrouble(label: string, ctx: BrowserContext): Promise<string | null> {
|
||||||
|
// Already established, by the `close`/`disconnected` listeners that can see their losses.
|
||||||
|
// Answering from it costs nothing and says the same thing.
|
||||||
|
const declared = lossDeclared();
|
||||||
|
if (declared !== null) return declared;
|
||||||
|
|
||||||
|
const live = ctx.pages().filter((p) => !p.isClosed());
|
||||||
|
if (live.length === 0) return null; // nothing to ask — no verdict, rather than a wrong one
|
||||||
|
const page = live[0]!;
|
||||||
|
try {
|
||||||
|
await within(`the ${label} browser to answer a trivial question`, BROWSER_PROBE_MS, () =>
|
||||||
|
page.evaluate(() => 1),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DeadlineExceeded) {
|
||||||
|
return (
|
||||||
|
`the ${label} browser STOPPED ANSWERING — a trivial round-trip did not come back in ` +
|
||||||
|
`${BROWSER_PROBE_MS / 1000}s. This is the dropped devtools pipe (Chromium exits and ` +
|
||||||
|
"Playwright emits neither `close` nor `disconnected`), so whatever operation was in " +
|
||||||
|
"flight is a casualty and not the cause. It is not ours to fix — re-run, and do not " +
|
||||||
|
"read this as a verdict on the code under test"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `the ${label} browser refused a trivial question: ${firstLine(e)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why `frame` cannot be driven, or `null` when it can.
|
||||||
|
*
|
||||||
|
* `marker` is the selector that proves the application is still in the frame — the caller's,
|
||||||
|
* because only the application knows what its own presence looks like. The THIRD state is the
|
||||||
|
* one that actually happens and the one no naive check catches: attached, on the right URL,
|
||||||
|
* and empty.
|
||||||
|
*/
|
||||||
|
export async function frameTrouble(
|
||||||
|
id: string,
|
||||||
|
page: Page,
|
||||||
|
frame: Frame,
|
||||||
|
marker: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (page.isClosed()) return `${id}'s page has been closed`;
|
||||||
|
if (frame.isDetached()) return `${id}'s application frame is detached`;
|
||||||
|
const shell = await within(`${id}'s frame to answer`, FRAME_PROBE_MS, () =>
|
||||||
|
frame.locator(marker).count(),
|
||||||
|
).catch((e: unknown) => firstLine(e));
|
||||||
|
if (typeof shell === "string") return `${id}'s frame did not answer (${shell})`;
|
||||||
|
if (shell === 0) return `${id}'s frame no longer holds the application — it reloaded`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* What each bounded operation ACTUALLY takes — the measurement every bound is sized from.
|
||||||
|
*
|
||||||
|
* ── Why a bound needs its measurement kept beside it ─────────────────────────
|
||||||
|
* A bare number teaches nothing and rots in silence. "180 seconds" cannot be judged: is it
|
||||||
|
* ten times the normal duration, or a hundred? Only one of those is a bound; the other is a
|
||||||
|
* hang dressed up as one. The suite this module serves had a sign-in bounded at 3 minutes
|
||||||
|
* for an operation that measures 1.4s, so its single job — turning a hang into a named
|
||||||
|
* failure fast — was done fifty times too slowly to be worth anything.
|
||||||
|
*
|
||||||
|
* So every bound in these harnesses is written as `measured normal → bound → margin`, and
|
||||||
|
* this module is how the "measured normal" half is obtained and re-obtained. Run any suite
|
||||||
|
* with `E2E_TIMINGS=1` and it prints, at the end, what each named operation took and how
|
||||||
|
* much headroom its bound still has. A future reader who suspects a number has gone stale
|
||||||
|
* does not have to believe this file's comments: they can re-run the measurement.
|
||||||
|
*
|
||||||
|
* Passive by default — a `Date.now()` per wait, and nothing printed unless asked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One observation of one named operation. */
|
||||||
|
interface Sample {
|
||||||
|
readonly ms: number;
|
||||||
|
/** Sizing a bound from a FAILED attempt would size it from the bound itself. */
|
||||||
|
readonly ok: boolean;
|
||||||
|
/** The bound in force, so the report can show the headroom rather than make one guess it. */
|
||||||
|
readonly bound: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const samples = new Map<string, Sample[]>();
|
||||||
|
|
||||||
|
export function record(what: string, ms: number, ok: boolean, bound: number): void {
|
||||||
|
const seen = samples.get(what);
|
||||||
|
if (seen === undefined) samples.set(what, [{ ms, ok, bound }]);
|
||||||
|
else seen.push({ ms, ok, bound });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a wait under `bound`, recording what it took under the stable name `what`.
|
||||||
|
*
|
||||||
|
* The bound is handed TO the task rather than raced against it, deliberately: Playwright's
|
||||||
|
* own timeout reports the call log ("waiting for locator(…)"), and a race would replace that
|
||||||
|
* with a message naming only the enclosure. What this adds is the measurement and a stable
|
||||||
|
* name — not a second, competing deadline.
|
||||||
|
*
|
||||||
|
* The name must be stable across runs (no identifiers, no ports) or the table fragments into
|
||||||
|
* one row per run and measures nothing.
|
||||||
|
*/
|
||||||
|
export async function measured<T>(what: string, bound: number, task: (ms: number) => Promise<T>): Promise<T> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
const out = await task(bound);
|
||||||
|
ok = true;
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
record(what, Date.now() - startedAt, ok, bound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the caller asked for the table. */
|
||||||
|
export function timingsWanted(): boolean {
|
||||||
|
return (process.env.E2E_TIMINGS ?? "") !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(ms: number): string {
|
||||||
|
return ms >= 10_000 ? `${(ms / 1000).toFixed(0)}s` : `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print what was measured: per operation, the healthy observations and the headroom its
|
||||||
|
* bound has over the SLOWEST of them.
|
||||||
|
*
|
||||||
|
* Failed attempts are counted but excluded from the statistics, because an operation that
|
||||||
|
* hit its bound measures the bound and not the operation — feeding that back into the sizing
|
||||||
|
* is how a bound ratchets upward for ever, one bad run at a time.
|
||||||
|
*/
|
||||||
|
export function printTimings(): void {
|
||||||
|
if (samples.size === 0) {
|
||||||
|
console.log("\n── measured durations ── nothing was recorded.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = [...samples.entries()].map(([what, all]) => {
|
||||||
|
const good = all.filter((s) => s.ok).map((s) => s.ms).sort((a, b) => a - b);
|
||||||
|
const bound = all[all.length - 1]!.bound;
|
||||||
|
const failed = all.length - good.length;
|
||||||
|
return {
|
||||||
|
what,
|
||||||
|
n: good.length,
|
||||||
|
min: good.length === 0 ? null : good[0]!,
|
||||||
|
max: good.length === 0 ? null : good[good.length - 1]!,
|
||||||
|
bound,
|
||||||
|
failed,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const width = Math.max(...rows.map((r) => r.what.length), 9);
|
||||||
|
console.log("\n── measured durations (E2E_TIMINGS) ─────────────────────────────────────────");
|
||||||
|
console.log(
|
||||||
|
` ${"operation".padEnd(width)} ${"n".padStart(3)} ${"min".padStart(6)} ${"max".padStart(6)}` +
|
||||||
|
` ${"bound".padStart(6)} headroom failed`,
|
||||||
|
);
|
||||||
|
for (const r of rows) {
|
||||||
|
const headroom = r.max === null || r.max === 0 ? "—" : `${(r.bound / r.max).toFixed(0)}x`;
|
||||||
|
console.log(
|
||||||
|
` ${r.what.padEnd(width)} ${String(r.n).padStart(3)} ` +
|
||||||
|
`${(r.min === null ? "—" : fmt(r.min)).padStart(6)} ${(r.max === null ? "—" : fmt(r.max)).padStart(6)} ` +
|
||||||
|
`${fmt(r.bound).padStart(6)} ${headroom.padStart(8)} ${r.failed === 0 ? "" : String(r.failed)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
" (statistics are over SUCCESSFUL attempts only: a wait that hit its bound measures\n" +
|
||||||
|
" the bound, and sizing the next bound from it ratchets upward for ever.)",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* What NextGraph's own pages LOOK like — addresses, selectors, and the inventory of screens
|
||||||
|
* the sign-in walks through. Description only: nothing here drives a browser.
|
||||||
|
*
|
||||||
|
* ── Why it is a separate file, and why it is data ────────────────────────────
|
||||||
|
* Two kinds of knowledge live in this package and they age at completely different rates.
|
||||||
|
* How to cross a broker — dispatch on the screen you can see, never on elapsed time; identify
|
||||||
|
* the application by its origin, never by a substring — is a *method*, and it has survived
|
||||||
|
* every change upstream. WHICH selector shows a wallet list is a *fact about today's markup*,
|
||||||
|
* and it changes whenever the wallet application is restyled.
|
||||||
|
*
|
||||||
|
* Keeping the second kind as plain data has two consequences worth the split. Upstream
|
||||||
|
* changes a selector: you edit a string in this file and no control flow moves. And the
|
||||||
|
* driving code below (`broker.ts`, `wallet.ts`) reads this inventory rather than embedding
|
||||||
|
* it, so a harness built on some other browser driver would reuse this file whole and
|
||||||
|
* rewrite only the driving. That adapter is NOT built here — the point is only that
|
||||||
|
* building it would not be a rewrite.
|
||||||
|
*
|
||||||
|
* The screen inventory is deliberately SERIALIZABLE: it is handed to the browser as an
|
||||||
|
* argument (see `readBrokerScreen` in `broker.ts`), so the same description that names a
|
||||||
|
* screen in a failure message is the one the recognition dispatched on. That rules out
|
||||||
|
* regular expressions as values, hence {@link TextPattern}.
|
||||||
|
*
|
||||||
|
* VERIFIED 2026-08-14 against the live pages unless noted; the upstream source is
|
||||||
|
* `nextgraph-rs` (`infra/ngnet/redir`, `engine/broker/auth`, `app/ui-common`), read but
|
||||||
|
* never modified.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── the wallet application (nextgraph.eu) ───────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a wallet is created and where one is imported. The wallet application is a real
|
||||||
|
* application like any other — this harness drives its actual interface rather than
|
||||||
|
* reaching behind it, because a wallet obtained any other way is not the one a person has.
|
||||||
|
*/
|
||||||
|
export const WALLET_APP = {
|
||||||
|
home: "https://nextgraph.eu/",
|
||||||
|
/** The standalone import/unlock route, reachable without going through the broker. */
|
||||||
|
login: "https://nextgraph.eu/#/wallet/login",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The creation flow, screen by screen, as labels and selectors. */
|
||||||
|
export const WALLET_CREATION = {
|
||||||
|
/** Step 1 — the home page's entry point. */
|
||||||
|
createWallet: "Create Wallet",
|
||||||
|
/** Step 2 — the terms screen, reached on the `/account` route. */
|
||||||
|
acceptTerms: "I accept",
|
||||||
|
/** The URL glob that route is awaited by. */
|
||||||
|
termsRoute: "**/account*",
|
||||||
|
/** Step 3 — the credentials form. */
|
||||||
|
username: "#username-input",
|
||||||
|
password: "#password-input",
|
||||||
|
/** Matched loosely: the button's caption is not stable in case. */
|
||||||
|
submit: "create my wallet",
|
||||||
|
/** Step 4 — creation lands here, and the first unlock happens from it. */
|
||||||
|
landsOn: "**/#/wallet/login",
|
||||||
|
/** Offered on the login route when a wallet is already on the device. */
|
||||||
|
loginWithThisWallet: "Click here to login with your wallet",
|
||||||
|
passwordField: 'input[type="password"]',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The import-a-wallet-file flow on the same login route. */
|
||||||
|
export const WALLET_IMPORT = {
|
||||||
|
fileInput: "input[type=file]",
|
||||||
|
passwordField: "input[type=password]",
|
||||||
|
/** Shown by some builds after the password; absent in others, so it is probed, not awaited. */
|
||||||
|
confirm: /Confirm/i,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ── the broker crossing (nextgraph.net/redir → the broker's auth page) ──────
|
||||||
|
|
||||||
|
/** The redirect that hands an application's address to the broker. */
|
||||||
|
export function brokerRedirectFor(appUrl: string): string {
|
||||||
|
return `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinct screens the crossing can be on.
|
||||||
|
*
|
||||||
|
* - `choose-broker` — the redirect page with MORE than one broker to pick from. Not observed
|
||||||
|
* on hosts that resolve to a single broker (which auto-selects), so it is described from
|
||||||
|
* the upstream source rather than from observation.
|
||||||
|
* - `login-offered` — "We could not find a wallet on this device… Login". The entry screen of
|
||||||
|
* every sign-in observed, first actor and later ones alike.
|
||||||
|
* - `wallet-list` — "Select a wallet to login with", one box per wallet.
|
||||||
|
* - `password` — "Enter your password". Reached by the FIRST actor only: the wallet is
|
||||||
|
* broadcast between the broker origin's tabs over a `BroadcastChannel` named `ng_wallet`,
|
||||||
|
* so a later actor's wallet is already in `opened_wallets` and selecting it logs straight
|
||||||
|
* in (`ui-common/src/routes/WalletLogin.svelte`, the `$opened_wallets[selected]` path).
|
||||||
|
* VERIFIED 2026-08-14, three consecutive sign-ins in one browser context.
|
||||||
|
* - `working` — a splash, "Opening your wallet…", "Wallet opened for …". Nothing to do but
|
||||||
|
* wait for it to become something else. Note that SUCCESS is one of these: the final screen
|
||||||
|
* never stops being `working`, which is why the application's frame is watched separately
|
||||||
|
* rather than inferred from the screen.
|
||||||
|
* - `error` — the broker said no ("An error occurred", "Invalid request"). Terminal.
|
||||||
|
*/
|
||||||
|
export type BrokerScreen =
|
||||||
|
| "choose-broker"
|
||||||
|
| "login-offered"
|
||||||
|
| "wallet-list"
|
||||||
|
| "password"
|
||||||
|
| "working"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A regular expression as data, because the inventory crosses into the browser and a
|
||||||
|
* `RegExp` does not survive that trip. Rebuilt on the far side with `new RegExp(...)`.
|
||||||
|
*/
|
||||||
|
export interface TextPattern {
|
||||||
|
readonly source: string;
|
||||||
|
readonly flags: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a screen is told apart from the ones described BEFORE it. */
|
||||||
|
export type ScreenSignature =
|
||||||
|
/** Any of these selectors matches an element with a non-zero box. */
|
||||||
|
| { readonly kind: "rendered"; readonly selectors: readonly string[] }
|
||||||
|
/** A rendered `<button>`/`<a>` whose trimmed text matches. */
|
||||||
|
| { readonly kind: "rendered-control"; readonly matches: TextPattern }
|
||||||
|
/** The page's RENDERED prose matches — the one test that has to read words. */
|
||||||
|
| { readonly kind: "page-text"; readonly matches: TextPattern }
|
||||||
|
/** Whatever is left. Must be the last entry, and there must be one. */
|
||||||
|
| { readonly kind: "otherwise" };
|
||||||
|
|
||||||
|
/** What moves the flow on from a screen. */
|
||||||
|
export type ScreenResponse =
|
||||||
|
| { readonly kind: "click"; readonly what: string; readonly selector: string }
|
||||||
|
| { readonly kind: "click-text"; readonly what: string; readonly text: string }
|
||||||
|
/** Fill the run's wallet password and submit it. The password is never described here —
|
||||||
|
* it belongs to the run, not to the pages. */
|
||||||
|
| { readonly kind: "submit-password"; readonly what: string; readonly selector: string }
|
||||||
|
/** Nothing to do but let it become something else. */
|
||||||
|
| { readonly kind: "wait" };
|
||||||
|
|
||||||
|
export interface BrokerScreenSpec {
|
||||||
|
readonly screen: BrokerScreen;
|
||||||
|
readonly signature: ScreenSignature;
|
||||||
|
readonly answer: ScreenResponse;
|
||||||
|
/** Terminal: reaching it ends the crossing with a failure rather than an action. */
|
||||||
|
readonly terminal?: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inventory, IN THE ORDER IT IS TESTED — and the order is load-bearing, not cosmetic.
|
||||||
|
*
|
||||||
|
* Each screen is identified by the signature that the screens BEFORE it do not have.
|
||||||
|
* Visibility is checked by measured box rather than by presence, because the auth
|
||||||
|
* application HIDES its whole login UI (`#app` gets `display:none`) instead of removing it
|
||||||
|
* once the wallet is open — a presence test would keep reporting `wallet-list` on a page
|
||||||
|
* that has already logged in.
|
||||||
|
*/
|
||||||
|
export const BROKER_SCREENS: readonly BrokerScreenSpec[] = [
|
||||||
|
{
|
||||||
|
screen: "password",
|
||||||
|
signature: { kind: "rendered", selectors: ["#password-input", 'input[type="password"]'] },
|
||||||
|
answer: { kind: "submit-password", what: "the password", selector: "#password-input, input[type='password']" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
screen: "wallet-list",
|
||||||
|
signature: { kind: "rendered", selectors: [".wallet-box"] },
|
||||||
|
// The BOX, not its caption: the caption only renders for a wallet that carries a
|
||||||
|
// password, and the box is the thing with `role="button"` either way.
|
||||||
|
answer: { kind: "click", what: "this run's wallet", selector: ".wallet-box" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
screen: "choose-broker",
|
||||||
|
signature: { kind: "rendered", selectors: ['[role="menuitem"]'] },
|
||||||
|
answer: { kind: "click", what: "the first broker in the list", selector: '[role="menuitem"]' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
screen: "login-offered",
|
||||||
|
signature: { kind: "rendered-control", matches: { source: "^(login|anmelden)$", flags: "i" } },
|
||||||
|
answer: { kind: "click-text", what: 'the "Login" button', text: "Login" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
screen: "error",
|
||||||
|
signature: { kind: "page-text", matches: { source: "An error occurred|Invalid request", flags: "i" } },
|
||||||
|
answer: { kind: "wait" },
|
||||||
|
terminal: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
screen: "working",
|
||||||
|
signature: { kind: "otherwise" },
|
||||||
|
answer: { kind: "wait" },
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Browser profiles — one per run, never shared, never inherited.
|
||||||
|
*
|
||||||
|
* ── Why a run owns its profile instead of borrowing a shared one ─────────────
|
||||||
|
* A run mints its own physical NextGraph user and must not inherit the previous run's. That
|
||||||
|
* discipline is not an optimisation: a wallet reused across runs ACCUMULATES — every run
|
||||||
|
* leaves behind the identities and documents it created, nothing removes them, and a cold
|
||||||
|
* resynchronisation is O(the user's size). A wallet kept for a month took 286 s on a single
|
||||||
|
* sync step against 250 s a week earlier, and the drift was invisible because it was never
|
||||||
|
* measured against a stable baseline. A fresh user per run makes that duration comparable
|
||||||
|
* from one run to the next instead of a number that only ever grows.
|
||||||
|
*
|
||||||
|
* The obvious way to get a fresh user is to WIPE a profile at a fixed path — which is what
|
||||||
|
* this harness used to do, and it is why it needed a lock. A wipe destroys a profile that
|
||||||
|
* another run may be using, so runs had to be serialised, and a suite belonging to a
|
||||||
|
* consuming application — run from its own checkout, against the same broker — collided with
|
||||||
|
* ours exactly as two of ours would, invisibly to both. The lock could never have fixed that:
|
||||||
|
* it guarded one repository's idea of a path.
|
||||||
|
*
|
||||||
|
* A directory of its own removes the problem rather than exporting it. There is nothing to
|
||||||
|
* serialise, concurrent runs are independent by construction, and "one physical user per run,
|
||||||
|
* never reused" stops being a rule anyone can forget — a directory that did not exist a
|
||||||
|
* moment ago cannot hold a previous run's user.
|
||||||
|
*
|
||||||
|
* What the profile still IS, and must remain: persistent for the WHOLE run. A run opens
|
||||||
|
* several browser contexts over it in sequence (a reconnection is exactly that), and the
|
||||||
|
* contracts about reconnecting faithfully and not forking an account are checks on that
|
||||||
|
* persistence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
/** A profile directory this run owns and will remove. */
|
||||||
|
export interface RunProfile {
|
||||||
|
/** The directory to launch a persistent context on. */
|
||||||
|
readonly dir: string;
|
||||||
|
/** What it is for, as it appears in logs. */
|
||||||
|
readonly purpose: string;
|
||||||
|
/**
|
||||||
|
* Kill whatever still holds it, then remove it. Idempotent, and also run automatically when
|
||||||
|
* the process leaves (see below), so a killed run cleans up after itself.
|
||||||
|
*/
|
||||||
|
discard(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = new Set<RunProfile>();
|
||||||
|
let leavingHandlersInstalled = false;
|
||||||
|
|
||||||
|
/** Is that process still alive? Signal 0 tests for existence without touching it. */
|
||||||
|
export function isAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
// EPERM means it exists and is someone else's — still alive, still holding it.
|
||||||
|
return (e as NodeJS.ErrnoException).code === "EPERM";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Chromium still holding `dir`, if any. Chromium names the holder itself: `SingletonLock`
|
||||||
|
* is a symlink to `<host>-<pid>`.
|
||||||
|
*/
|
||||||
|
function holderOf(dir: string): number | null {
|
||||||
|
let target: string;
|
||||||
|
try {
|
||||||
|
target = fs.readlinkSync(path.join(dir, "SingletonLock"));
|
||||||
|
} catch {
|
||||||
|
return null; // no lock, nothing holding it
|
||||||
|
}
|
||||||
|
const pid = Number(target.slice(target.lastIndexOf("-") + 1));
|
||||||
|
return Number.isInteger(pid) && pid > 0 && isAlive(pid) ? pid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A profile directory of this run's own, under the system temp dir.
|
||||||
|
*
|
||||||
|
* Under the TEMP dir and not the repository, deliberately: a profile holds a wallet, a wallet
|
||||||
|
* is an identity, and an identity must never end up committed. It also means two checkouts of
|
||||||
|
* the same suite cannot land on the same path.
|
||||||
|
*/
|
||||||
|
export function newRunProfile(purpose: string): RunProfile {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-profile-"));
|
||||||
|
let discarded = false;
|
||||||
|
const profile: RunProfile = {
|
||||||
|
dir,
|
||||||
|
purpose,
|
||||||
|
discard: () => {
|
||||||
|
if (discarded) return;
|
||||||
|
discarded = true;
|
||||||
|
live.delete(profile);
|
||||||
|
// A run that fails or is killed leaves its Chromium ALIVE: `BrowserContext.close()` in
|
||||||
|
// the teardown gives up after its bound, and a `kill -9` on the runner never gets there
|
||||||
|
// at all. Nothing else will ever want this directory, so the orphan cannot poison a
|
||||||
|
// later run the way it used to — but it would sit on the host's memory for ever, and a
|
||||||
|
// loaded host is how this suite manufactures its own flakiness. Each run therefore
|
||||||
|
// clears its OWN leftovers, which is the one moment where it is certainly safe.
|
||||||
|
const holder = holderOf(dir);
|
||||||
|
if (holder !== null) {
|
||||||
|
try {
|
||||||
|
process.kill(holder, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
/* gone between the check and the signal */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* a temp dir the OS will collect anyway */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
live.add(profile);
|
||||||
|
installLeavingHandlers();
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard every profile still live when the process leaves — including the ways out nobody
|
||||||
|
* plans for.
|
||||||
|
*
|
||||||
|
* `exit` covers the normal end and `process.exit()`, which is how these suites finish; the
|
||||||
|
* signal handlers cover Ctrl-C and `kill`, which is how a hung run ends. Everything here is
|
||||||
|
* synchronous, because an `exit` handler is the only thing that runs at that point.
|
||||||
|
*/
|
||||||
|
function installLeavingHandlers(): void {
|
||||||
|
if (leavingHandlersInstalled) return;
|
||||||
|
leavingHandlersInstalled = true;
|
||||||
|
process.on("exit", () => {
|
||||||
|
for (const profile of [...live]) profile.discard();
|
||||||
|
});
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
for (const profile of [...live]) profile.discard();
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* What a run REPORTS — a constant number of rows, whatever fails.
|
||||||
|
*
|
||||||
|
* ── The arithmetic is the point ──────────────────────────────────────────────
|
||||||
|
* A suite whose check TOTAL is a function of how far it got cannot be compared with itself. A
|
||||||
|
* journey that dies halfway takes its unreported checks with it and simply never mentions
|
||||||
|
* them: three runs of the same suite reported 24, 26 and 27 checks (VERIFIED 2026-08-16), and
|
||||||
|
* a moving total compares nothing. Worse, the checks that vanish are the ones nobody looks
|
||||||
|
* for — silence reads as absence, not as failure. A shrinking total even reads like a
|
||||||
|
* SMALLER problem instead of a bigger one.
|
||||||
|
*
|
||||||
|
* So every check is DECLARED before anything can fail. Read off the declaration, the
|
||||||
|
* arithmetic survives any death: every journey contributes exactly `checks.length + 1` rows
|
||||||
|
* whatever happens to it, including journeys that never ran because the setup died first. A
|
||||||
|
* difference between two runs is then always a real difference.
|
||||||
|
*
|
||||||
|
* The declaration doubles as the suite's table of contents, which is the other reason to keep
|
||||||
|
* it whole and in execution order.
|
||||||
|
*
|
||||||
|
* The related trap that made this self-perpetuating once: the checks were declared inside each
|
||||||
|
* journey, so a run that died in the SETUP — before any journey — printed `fatal:` and left,
|
||||||
|
* with no summary and nothing a previous run could be compared to.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { firstLine, within } from "./deadline";
|
||||||
|
import { printTimings, timingsWanted } from "./measure";
|
||||||
|
|
||||||
|
/** One journey and every check it reports. Declared up front; never assembled at run time. */
|
||||||
|
export interface JourneyDeclaration {
|
||||||
|
readonly name: string;
|
||||||
|
readonly checks: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a journey cannot start, or `null` when it can. */
|
||||||
|
export type Prerequisite = () => Promise<string | null> | (string | null);
|
||||||
|
|
||||||
|
export interface JourneySpec {
|
||||||
|
/** Must name a declared journey, which is where its checks are declared. */
|
||||||
|
readonly name: string;
|
||||||
|
/**
|
||||||
|
* What this journey needs from the ones before it. A prerequisite that is provably dead is
|
||||||
|
* reported as such INSTEAD of being driven — not to spare the journey, but because driving
|
||||||
|
* a closed page answers with "Target page, context or browser has been closed", a verdict
|
||||||
|
* that names the innocent operation and hides the journey that actually broke.
|
||||||
|
*/
|
||||||
|
readonly needs?: readonly Prerequisite[];
|
||||||
|
readonly run: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuiteOptions {
|
||||||
|
/** Names the suite in its summary line, e.g. "Application e2e". */
|
||||||
|
readonly label: string;
|
||||||
|
/** Every journey, in execution order, with its checks. */
|
||||||
|
readonly journeys: readonly JourneyDeclaration[];
|
||||||
|
/** The bound on ONE journey — what catches a journey that never returns. */
|
||||||
|
readonly journeyBound: number;
|
||||||
|
/**
|
||||||
|
* Asked on a journey's failure: is there a KNOWN failure mode to name instead of the
|
||||||
|
* operation that happened to be in flight? Typically `() => browserTrouble(label, ctx)`.
|
||||||
|
* Its answer is put in front of the journey's reason, never in place of it.
|
||||||
|
*/
|
||||||
|
readonly diagnose?: () => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuiteReport {
|
||||||
|
/** Report a declared check. Throws if the name is not one the journey declared. */
|
||||||
|
check(name: string, ok: boolean, detail?: string): void;
|
||||||
|
/** Run one journey: bounded, isolated, unable to change the shape of the report. */
|
||||||
|
journey(spec: JourneySpec): Promise<void>;
|
||||||
|
/** Report everything this run did not get to, print the summary, and leave. */
|
||||||
|
finish(fatal: string | null): never;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Check {
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the reporting for a suite from its declaration.
|
||||||
|
*
|
||||||
|
* The returned functions do not use `this`, so a caller may destructure them
|
||||||
|
* (`const { check, journey, finish } = declareSuite(...)`) and read like a test file.
|
||||||
|
*/
|
||||||
|
export function declareSuite(options: SuiteOptions): SuiteReport {
|
||||||
|
const results: Check[] = [];
|
||||||
|
/** The journeys already reported, so `finish` knows what is missing. */
|
||||||
|
const reported = new Set<string>();
|
||||||
|
/** The checks the journey in flight has DECLARED and not yet reported — `null` between
|
||||||
|
* journeys, which is what makes a stray report detectable. */
|
||||||
|
let outstanding: Set<string> | null = null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
const record = (name: string, ok: boolean, detail?: string): void => {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail === undefined ? "" : " — " + detail}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both rules — the name must be declared, and each may be reported once — are enforced by
|
||||||
|
* throwing rather than by tolerating, because either violation silently breaks the
|
||||||
|
* arithmetic the declaration exists to fix. An undeclared name adds a row no other run has;
|
||||||
|
* a repeated one consumes a row that then reads as "not reached". A throw here fails the
|
||||||
|
* journey it happens in and says exactly what is wrong with it, which is a harness bug
|
||||||
|
* reported the same way as any other failure.
|
||||||
|
*/
|
||||||
|
const check = (name: string, ok: boolean, detail?: string): void => {
|
||||||
|
if (outstanding === null) {
|
||||||
|
throw new Error(`[${options.label}] the check ${JSON.stringify(name)} was reported outside any journey`);
|
||||||
|
}
|
||||||
|
if (!outstanding.delete(name)) {
|
||||||
|
throw new Error(
|
||||||
|
`[${options.label}] the check ${JSON.stringify(name)} was reported but its journey does not ` +
|
||||||
|
"declare it (or declares it once and reports it twice) — fix the journey's `checks` list",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
record(name, ok, detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ── What "isolated" buys, and what it does not ──────────────────────────────
|
||||||
|
* It does NOT mean a failure is absorbed — a contained failure is still a failure and is
|
||||||
|
* still counted, here as every one of the journey's declared checks plus the "ran to the
|
||||||
|
* end" row. What it means is that the journey's failure cannot take the FOLLOWING journeys'
|
||||||
|
* checks off the report, cannot leave them reporting a timeout that names the wrong suspect,
|
||||||
|
* and cannot end the run before its summary.
|
||||||
|
*
|
||||||
|
* The bound is what makes the catch honest: catching everything and recording a FAIL is
|
||||||
|
* right for a journey that fails, but a journey that never RETURNS is caught by nothing —
|
||||||
|
* and that is what three killed runs looked like from the outside.
|
||||||
|
*
|
||||||
|
* The last row, `ran to the end`, is not decoration either. Without it a journey that throws
|
||||||
|
* AFTER reporting its last check would report no failure at all, since there would be no
|
||||||
|
* unreached check left to carry the reason.
|
||||||
|
*/
|
||||||
|
const journey = async (spec: JourneySpec): Promise<void> => {
|
||||||
|
console.log(`\n── ${spec.name} ──`);
|
||||||
|
const planned = options.journeys.find((j) => j.name === spec.name);
|
||||||
|
if (planned === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`[${options.label}] the journey ${JSON.stringify(spec.name)} is not declared — add it, or fix the name`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const declared = new Set(planned.checks);
|
||||||
|
if (declared.size !== planned.checks.length) {
|
||||||
|
throw new Error(`[${options.label}] the same check is declared twice under "${spec.name}"`);
|
||||||
|
}
|
||||||
|
reported.add(spec.name);
|
||||||
|
const journeyStartedAt = Date.now();
|
||||||
|
let why: string | null = null;
|
||||||
|
|
||||||
|
const blocked = (await Promise.all((spec.needs ?? []).map(async (needed) => needed()))).filter(
|
||||||
|
(r): r is string => r !== null,
|
||||||
|
);
|
||||||
|
if (blocked.length > 0) {
|
||||||
|
why = `it could not start: ${blocked.join("; ")}`;
|
||||||
|
console.error(` [blocked] ${why}`);
|
||||||
|
} else {
|
||||||
|
outstanding = declared;
|
||||||
|
try {
|
||||||
|
await within(`the journey "${spec.name}"`, options.journeyBound, spec.run);
|
||||||
|
} catch (e) {
|
||||||
|
why = firstLine(e);
|
||||||
|
// In full, and to stderr: the one-liner above is what the report carries, and it is
|
||||||
|
// never the whole of a driver's call log or a broker crossing's trail.
|
||||||
|
console.error(` [threw] ${String((e as Error)?.stack ?? e)}`);
|
||||||
|
// A known failure mode goes IN FRONT of the reason, never in place of it: the
|
||||||
|
// operation in flight is still worth having, it is just not the cause.
|
||||||
|
if (options.diagnose !== undefined) {
|
||||||
|
const known = await options.diagnose().catch(() => null);
|
||||||
|
if (known !== null) why = `${known} — the operation it died on: ${why}`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
outstanding = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of declared) {
|
||||||
|
record(name, false, why === null ? "the journey ended without reporting it" : `not reached — ${why}`);
|
||||||
|
}
|
||||||
|
record(
|
||||||
|
`the journey "${spec.name}" ran to the end`,
|
||||||
|
why === null,
|
||||||
|
why ?? `${((Date.now() - journeyStartedAt) / 1000).toFixed(1)}s`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journeys that never ran are read off the declaration, so a run that died in its setup
|
||||||
|
* reports exactly the same number of checks as one that finished — all of them failed, and
|
||||||
|
* each saying why. "24 checks" and "27 checks" are not two results of the same suite; they
|
||||||
|
* are two different suites, and comparing them quietly compares nothing.
|
||||||
|
*/
|
||||||
|
const finish = (fatal: string | null): never => {
|
||||||
|
for (const planned of options.journeys) {
|
||||||
|
if (reported.has(planned.name)) continue;
|
||||||
|
const why = fatal === null ? "the suite ended before this journey ran" : `the suite died first: ${fatal}`;
|
||||||
|
for (const name of planned.checks) record(name, false, `not reached — ${why}`);
|
||||||
|
record(`the journey "${planned.name}" ran to the end`, false, why);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The measurement every bound is sized from, on request. Printed BEFORE the summary so the
|
||||||
|
// summary stays the last line — which is what a reader and a `tail` look at.
|
||||||
|
if (timingsWanted()) printTimings();
|
||||||
|
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.log("\n── what failed ──");
|
||||||
|
for (const r of failed) console.log(` ${r.name}${r.detail === undefined ? "" : " — " + r.detail}`);
|
||||||
|
}
|
||||||
|
const minutes = ((Date.now() - startedAt) / 60000).toFixed(1);
|
||||||
|
console.log(
|
||||||
|
`\n══ ${options.label} summary: ${results.length - failed.length} passed, ${failed.length} failed, ` +
|
||||||
|
`${results.length} total — ${minutes} min ══`,
|
||||||
|
);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { check, journey, finish };
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Serving an application (or a fixture page) to the browser under test, the way a deployment
|
||||||
|
* would.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as http from "node:http";
|
||||||
|
import type { Socket } from "node:net";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve `handler` on an ephemeral port, and hand back a close that CLOSES.
|
||||||
|
*
|
||||||
|
* `server.close()` alone stops the listener and then waits for every keep-alive connection
|
||||||
|
* to drain on its own — a browser that is still attached keeps the server half-alive long
|
||||||
|
* after the harness believes it gone. These suites close a server while a browser is still
|
||||||
|
* pointed at it (the wallet export does exactly that), so the sockets are tracked and
|
||||||
|
* destroyed: "closed" has to mean closed, or the next thing to go wrong gets blamed on the
|
||||||
|
* suite instead of on the connection nobody hung up.
|
||||||
|
*/
|
||||||
|
export function serveOnEphemeralPort(
|
||||||
|
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
||||||
|
): Promise<{ url: string; close: () => void }> {
|
||||||
|
const server = http.createServer(handler);
|
||||||
|
const open = new Set<Socket>();
|
||||||
|
server.on("connection", (socket) => {
|
||||||
|
open.add(socket);
|
||||||
|
socket.on("close", () => open.delete(socket));
|
||||||
|
});
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const port = (server.address() as { port: number }).port;
|
||||||
|
resolve({
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
close: () => {
|
||||||
|
server.close();
|
||||||
|
for (const socket of open) socket.destroy();
|
||||||
|
open.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* The page that fetches a wallet's bytes — bundled and served by `exportWalletBytes`.
|
||||||
|
*
|
||||||
|
* ── Why a page has to do this at all ─────────────────────────────────────────
|
||||||
|
* A wallet's bytes exist only inside the broker iframe: `wallet_get_file()` is an RPC to the
|
||||||
|
* wallet the broker holds, so nothing in Node can produce one. This page is the smallest thing
|
||||||
|
* that can ask — it opens a NextGraph session the way any application does, and exposes one
|
||||||
|
* function.
|
||||||
|
*
|
||||||
|
* It talks to `@ng-org/web` and to nothing else, deliberately: the machinery around it must
|
||||||
|
* stay usable by an application that has never heard of any particular compatibility layer.
|
||||||
|
* `init(callback, true, [])` is the shape an application writes; the broker (which loaded this
|
||||||
|
* page in its iframe) drives the connection and calls back with the session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ng, init } from "@ng-org/web";
|
||||||
|
|
||||||
|
/** What crosses back to Node: base64, because a `Uint8Array` does not survive `evaluate`. */
|
||||||
|
export interface ExportedWallet {
|
||||||
|
readonly walletName: string;
|
||||||
|
readonly b64: string;
|
||||||
|
readonly len: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two calls this page needs, named. A narrow local shape rather than the SDK's own types:
|
||||||
|
* the wallet functions are not in its published surface at this version, and asserting the two
|
||||||
|
* signatures we actually use says more than widening everything.
|
||||||
|
*/
|
||||||
|
interface WalletFunctions {
|
||||||
|
get_wallets(): Promise<Record<string, unknown> | null | undefined>;
|
||||||
|
wallet_get_file(name: string): Promise<Uint8Array | ArrayLike<number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallet = ng as unknown as WalletFunctions;
|
||||||
|
|
||||||
|
const state: { status: string } = { status: "connecting" };
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await init(() => {
|
||||||
|
state.status = "connected";
|
||||||
|
}, true, []);
|
||||||
|
} catch (e) {
|
||||||
|
state.status = `error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
(globalThis as unknown as { __ngWalletExport: unknown }).__ngWalletExport = {
|
||||||
|
status: (): string => state.status,
|
||||||
|
async file(): Promise<ExportedWallet> {
|
||||||
|
const wallets = await wallet.get_wallets();
|
||||||
|
const walletName = Object.keys(wallets ?? {})[0];
|
||||||
|
if (walletName === undefined) throw new Error("no wallet is open in this session");
|
||||||
|
const file = await wallet.wallet_get_file(walletName);
|
||||||
|
const bytes = file instanceof Uint8Array ? file : new Uint8Array(Array.from(file));
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
|
||||||
|
return { walletName, b64: btoa(binary), len: bytes.length };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* The wallet lifecycle: mint one by driving the wallet application's real interface, get its
|
||||||
|
* bytes out (`.ngw` contents, file optional), and put a `.ngw` file into a browser profile.
|
||||||
|
*
|
||||||
|
* ── Why the real interface and not a shortcut ────────────────────────────────
|
||||||
|
* A wallet obtained any other way is not the one a person has. The wallet application is an
|
||||||
|
* application like any other, so this drives it: click for click, field for field. That is
|
||||||
|
* also what makes the harness notice when the flow upstream changes, instead of testing
|
||||||
|
* against a fixture that quietly stopped resembling it.
|
||||||
|
*
|
||||||
|
* The addresses and selectors are DESCRIPTION and live in `nextgraph-ui.ts`.
|
||||||
|
*
|
||||||
|
* ── On the fixed waits in these flows ────────────────────────────────────────
|
||||||
|
* The creation and import flows below contain a handful of `waitForTimeout` calls, each on a
|
||||||
|
* step where the wallet application offers NO observable signal that the work is finished
|
||||||
|
* (unlocking a wallet bootstraps the verifier's repos from the broker and paints nothing).
|
||||||
|
* They are inherited as-is, with their measured durations, and they are the only fixed waits
|
||||||
|
* in this package — everything else waits for a condition. They are the first thing to replace
|
||||||
|
* if the wallet application ever grows a marker to wait on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BrowserContext, Page } from "playwright";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { closeQuietly, within } from "./deadline";
|
||||||
|
import { closeContext, launchWatchedContext, newPage } from "./browser";
|
||||||
|
import { newRunProfile, type RunProfile } from "./profiles";
|
||||||
|
import { serveOnEphemeralPort } from "./serve";
|
||||||
|
import { setupBrokerPage } from "./broker";
|
||||||
|
import { WALLET_APP, WALLET_CREATION, WALLET_IMPORT } from "./nextgraph-ui";
|
||||||
|
import type { ExportedWallet } from "./wallet-export-page";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
|
||||||
|
const BUILD_MS = 60_000;
|
||||||
|
/**
|
||||||
|
* The whole wallet export measures ~7 s against a real broker. Bounded at 60 s ≈ 8x.
|
||||||
|
*
|
||||||
|
* It was two minutes once, and that cost a run twice over: the export hung, and the suite
|
||||||
|
* spent two full minutes reaching a verdict it could have reached in one — before dying
|
||||||
|
* without a summary, because an export runs in the SETUP, ahead of every journey.
|
||||||
|
*/
|
||||||
|
const EXPORT_MS = 60_000;
|
||||||
|
/** The export page appearing, then its session connecting. Both against a live broker. */
|
||||||
|
const EXPORT_PAGE_MS = 30_000;
|
||||||
|
const EXPORT_CONNECT_MS = 60_000;
|
||||||
|
|
||||||
|
/** A wallet's name and the password that opens it. */
|
||||||
|
export interface WalletCredentials {
|
||||||
|
readonly name: string;
|
||||||
|
readonly password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name a wallet gets when the caller has no opinion about it.
|
||||||
|
*
|
||||||
|
* A wallet's name is what a person types to pick one among several, and nothing in NextGraph
|
||||||
|
* keys off it — so a caller minting the only wallet it will ever hold has nothing to decide
|
||||||
|
* here. The PASSWORD never gets a default: it is the only thing between the file and whoever
|
||||||
|
* finds it, and a defaulted one would be a published secret.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_WALLET_NAME = "ng-wallet";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a wallet in `ctx`'s profile by walking the wallet application, then unlock it once.
|
||||||
|
*
|
||||||
|
* The first unlock is not decoration: it is what bootstraps the verifier's repos from the
|
||||||
|
* broker, and a wallet that has never been unlocked is not usable by an application.
|
||||||
|
*/
|
||||||
|
export async function createWalletInContext(ctx: BrowserContext, credentials: WalletCredentials): Promise<void> {
|
||||||
|
const page = ctx.pages()[0] ?? (await newPage("the wallet creation flow", ctx));
|
||||||
|
page.on("pageerror", () => {});
|
||||||
|
|
||||||
|
await page.goto(WALLET_APP.home, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||||
|
const createButton = page.getByText(WALLET_CREATION.createWallet, { exact: true });
|
||||||
|
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
await page.waitForURL(WALLET_CREATION.termsRoute, { timeout: 15000 }).catch(() => {});
|
||||||
|
const acceptButton = page.getByText(WALLET_CREATION.acceptTerms, { exact: true });
|
||||||
|
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
||||||
|
await acceptButton.click();
|
||||||
|
|
||||||
|
const usernameInput = page.locator(WALLET_CREATION.username);
|
||||||
|
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
||||||
|
await usernameInput.fill(credentials.name);
|
||||||
|
const passwordInput = page.locator(WALLET_CREATION.password);
|
||||||
|
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
||||||
|
await passwordInput.fill(credentials.password);
|
||||||
|
|
||||||
|
const submitButton = page.getByText(WALLET_CREATION.submit, { exact: false });
|
||||||
|
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
await page.waitForURL(WALLET_CREATION.landsOn, { timeout: 30000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// First login → bootstrap the verifier repos from the broker. This is what a brand-new
|
||||||
|
// wallet does on its very first unlock.
|
||||||
|
const walletLink = page.getByText(WALLET_CREATION.loginWithThisWallet);
|
||||||
|
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||||
|
await walletLink.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
const loginPassword = page.locator(WALLET_CREATION.passwordField);
|
||||||
|
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await loginPassword.fill(credentials.password);
|
||||||
|
await loginPassword.press("Enter");
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This run's physical user: a profile of its own, and a wallet minted into it. The creation
|
||||||
|
* context is closed — the caller opens its own contexts over `profile.dir`, one at a time.
|
||||||
|
*
|
||||||
|
* One per run, never inherited from a previous one: see `profiles.ts` for why that is a
|
||||||
|
* property of the directory rather than a rule anyone has to remember.
|
||||||
|
*/
|
||||||
|
export async function mintWalletProfile(purpose: string, credentials: WalletCredentials): Promise<RunProfile> {
|
||||||
|
const profile = newRunProfile(purpose);
|
||||||
|
const ctx = await launchWatchedContext("wallet-creation", profile.dir);
|
||||||
|
try {
|
||||||
|
await createWalletInContext(ctx, credentials);
|
||||||
|
} finally {
|
||||||
|
const { closeContext } = await import("./browser");
|
||||||
|
await closeContext("wallet-creation", ctx);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same, with the context left OPEN.
|
||||||
|
*
|
||||||
|
* For the cold-start case: the caller then opens its application in the SAME profile, i.e. the
|
||||||
|
* very first application session over a wallet that has never run one. Closing and relaunching
|
||||||
|
* would not be the same thing.
|
||||||
|
*/
|
||||||
|
export async function mintWalletProfileKeepingContext(
|
||||||
|
purpose: string,
|
||||||
|
credentials: WalletCredentials,
|
||||||
|
): Promise<{ ctx: BrowserContext; profile: RunProfile }> {
|
||||||
|
const profile = newRunProfile(purpose);
|
||||||
|
const ctx = await launchWatchedContext("fresh-wallet", profile.dir);
|
||||||
|
await createWalletInContext(ctx, credentials);
|
||||||
|
const first = ctx.pages()[0];
|
||||||
|
if (first !== undefined) await first.close().catch(() => {});
|
||||||
|
return { ctx, profile };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A context on an EMPTY profile: no wallet, no local repo cache.
|
||||||
|
*
|
||||||
|
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start: a wallet's
|
||||||
|
* repos are on the broker but NOT in this profile, so a session over it starts with nothing
|
||||||
|
* local. The caller imports a wallet (see {@link importWalletFile}) before opening the
|
||||||
|
* application.
|
||||||
|
*/
|
||||||
|
export async function emptyProfileContext(
|
||||||
|
purpose: string,
|
||||||
|
): Promise<{ ctx: BrowserContext; profile: RunProfile }> {
|
||||||
|
const profile = newRunProfile(purpose);
|
||||||
|
const ctx = await launchWatchedContext("clean-profile", profile.dir);
|
||||||
|
return { ctx, profile };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a `.ngw` wallet FILE into the profile `page` belongs to, then unlock it.
|
||||||
|
*
|
||||||
|
* After this the profile holds the wallet — but NOT the repos' local cache — so the next
|
||||||
|
* application session over it hits the broker-only cold-start.
|
||||||
|
*
|
||||||
|
* The password is a PARAMETER and has no default. An access barrier that DISPLAYS a password
|
||||||
|
* can then be tested by reading it off its own screen and passing it here, which is the only
|
||||||
|
* way to tell that what the barrier shows is what actually opens the file. A default would
|
||||||
|
* make that step untestable: the import would succeed on a barrier showing anything at all,
|
||||||
|
* including nothing.
|
||||||
|
*/
|
||||||
|
export async function importWalletFile(page: Page, ngwPath: string, password: string): Promise<void> {
|
||||||
|
await page.goto(WALLET_APP.login, { waitUntil: "domcontentloaded" });
|
||||||
|
// Let the application render and attach the file input (uploading too early → EncryptionError).
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await page.locator(WALLET_IMPORT.fileInput).waitFor({ state: "attached", timeout: 15000 });
|
||||||
|
await page.setInputFiles(WALLET_IMPORT.fileInput, ngwPath);
|
||||||
|
const passwordInput = page.locator(WALLET_IMPORT.passwordField).first();
|
||||||
|
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
|
||||||
|
await passwordInput.fill(password);
|
||||||
|
await passwordInput.press("Enter");
|
||||||
|
const confirm = page.getByRole("button", { name: WALLET_IMPORT.confirm });
|
||||||
|
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
|
||||||
|
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The export page, bundled once per run. */
|
||||||
|
let exportBundle: string | null = null;
|
||||||
|
|
||||||
|
function buildExportBundle(): string {
|
||||||
|
if (exportBundle !== null) return exportBundle;
|
||||||
|
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-export-")), "wallet-export-page.js");
|
||||||
|
const entry = path.join(__dirname, "wallet-export-page.ts");
|
||||||
|
execSync(`bun build ${entry} --outfile ${out} --bundle --format=esm`, {
|
||||||
|
stdio: "pipe",
|
||||||
|
cwd: __dirname,
|
||||||
|
timeout: BUILD_MS,
|
||||||
|
});
|
||||||
|
exportBundle = fs.readFileSync(out, "utf-8");
|
||||||
|
return exportBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bytes of the wallet held by `ctx`'s profile — the contents of a `.ngw` file, without a
|
||||||
|
* `.ngw` file.
|
||||||
|
*
|
||||||
|
* Why an application's own suite needs this: a deployment that hands a wallet out — an access
|
||||||
|
* barrier with a download link, say — must be tested against a REAL wallet. Serving a
|
||||||
|
* placeholder there makes the download step a decoration: importing it cannot let anybody in,
|
||||||
|
* so the check that the link works cannot fail for the right reason.
|
||||||
|
*
|
||||||
|
* ── Why BYTES are the primitive and the file the convenience ─────────────────
|
||||||
|
* Every caller here already has the bytes in hand — they arrive from the broker iframe — and
|
||||||
|
* only some of them want a file. This used to write one unconditionally, so a suite that
|
||||||
|
* serves a wallet from memory had to name a temp path, write it, read it straight back and
|
||||||
|
* remember to remove it: three steps and a cleanup to get back what the function had. A caller
|
||||||
|
* that genuinely wants a file writes these bytes (or calls {@link exportWalletFile}), which is
|
||||||
|
* one step in the direction nobody has to undo.
|
||||||
|
*/
|
||||||
|
export async function exportWalletBytes(
|
||||||
|
ctx: BrowserContext,
|
||||||
|
walletPassword: string,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const bundle = buildExportBundle();
|
||||||
|
const html =
|
||||||
|
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>wallet export</title></head>' +
|
||||||
|
'<body><script type="module" src="/wallet-export-page.js"></script></body></html>';
|
||||||
|
const { url, close } = await serveOnEphemeralPort((req, res) => {
|
||||||
|
if (req.url === "/wallet-export-page.js") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
||||||
|
res.end(bundle);
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||||
|
res.end(html);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const page = await newPage("the wallet export", ctx);
|
||||||
|
page.on("pageerror", () => {});
|
||||||
|
try {
|
||||||
|
const frame = await setupBrokerPage(page, url, walletPassword);
|
||||||
|
await frame.waitForFunction(
|
||||||
|
() => (window as unknown as { __ngWalletExport?: unknown }).__ngWalletExport !== undefined,
|
||||||
|
{ timeout: EXPORT_PAGE_MS },
|
||||||
|
);
|
||||||
|
await frame.waitForFunction(
|
||||||
|
() => (window as unknown as { __ngWalletExport: { status(): string } }).__ngWalletExport.status() === "connected",
|
||||||
|
{ timeout: EXPORT_CONNECT_MS },
|
||||||
|
);
|
||||||
|
// `frame.evaluate` has NO timeout of its own — a bridge call that never settles is one of
|
||||||
|
// the two ways this harness used to hang for ever.
|
||||||
|
const exported = await within("the wallet bytes from the broker iframe", EXPORT_MS, () =>
|
||||||
|
frame.evaluate(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as unknown as { __ngWalletExport: { file(): Promise<ExportedWallet> } }
|
||||||
|
).__ngWalletExport.file(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return new Uint8Array(Buffer.from(exported.b64, "base64"));
|
||||||
|
} finally {
|
||||||
|
await closeQuietly("the wallet export page", () => page.close());
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same wallet, written to `ngwPath`, returning its size in bytes.
|
||||||
|
*
|
||||||
|
* For the callers that want a FILE — a browser's file input takes a path, and a person
|
||||||
|
* provisioning a deployment has to put the wallet somewhere. Everyone else takes the bytes.
|
||||||
|
*/
|
||||||
|
export async function exportWalletFile(
|
||||||
|
ctx: BrowserContext,
|
||||||
|
ngwPath: string,
|
||||||
|
walletPassword: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const bytes = await exportWalletBytes(ctx, walletPassword);
|
||||||
|
fs.writeFileSync(ngwPath, bytes);
|
||||||
|
return bytes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wallet that did not exist a moment ago, as the bytes of a `.ngw`: mint one by walking the
|
||||||
|
* wallet application, take its bytes out, and drop everything else.
|
||||||
|
*
|
||||||
|
* The one call behind all three callers who need a fresh wallet — this package's own suite, a
|
||||||
|
* consuming application's suite, and a person provisioning a deployment (see `bin/mint-wallet.ts`,
|
||||||
|
* which is this function plus a `writeFileSync` and two `console.log`s).
|
||||||
|
*
|
||||||
|
* ── What it deliberately does NOT keep ───────────────────────────────────────
|
||||||
|
* The browser profile the wallet was minted in is discarded here. That is not a shortcut: the
|
||||||
|
* wallet's repos live on the broker and the bytes returned are what opens them, so the profile
|
||||||
|
* is scaffolding in every one of the three cases — including the durable one, where what is
|
||||||
|
* kept is the FILE the caller writes, never a browser directory.
|
||||||
|
*
|
||||||
|
* A suite that needs the profile to survive — because its actors sign in THROUGH it rather than
|
||||||
|
* importing the file — composes the two halves itself: {@link mintWalletProfile} for a profile
|
||||||
|
* that lasts the run, then {@link exportWalletBytes} over it. Same minting, same export,
|
||||||
|
* different lifetime, which is the only thing the two paths disagree about.
|
||||||
|
*/
|
||||||
|
export async function mintWalletBytes(
|
||||||
|
password: string,
|
||||||
|
name: string = DEFAULT_WALLET_NAME,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const profile = await mintWalletProfile(`minting the wallet "${name}"`, { name, password });
|
||||||
|
try {
|
||||||
|
// A context of its own over the profile, opened after the creation one closed — the order
|
||||||
|
// `mintWalletProfile` already imposes, and the one every export in this repository uses.
|
||||||
|
const ctx = await launchWatchedContext("wallet-export", profile.dir);
|
||||||
|
try {
|
||||||
|
return await exportWalletBytes(ctx, password);
|
||||||
|
} finally {
|
||||||
|
await closeContext("wallet-export", ctx);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
profile.discard();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["bun"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "bin"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# @ng-eventually/polyfill
|
||||||
|
|
||||||
|
One entry point. Most of what it publishes has the same signature as the future SDK —
|
||||||
|
`ng`, `useShape`, `watchShape`, `docs`, `inbox`, `storeRegistry`, `readUnion` (+ types) —
|
||||||
|
and is a drop-in for `@ng-org/web` / `@ng-org/orm`: as NextGraph matures it resolves to
|
||||||
|
the real SDK (build alias removed) with no code change.
|
||||||
|
|
||||||
|
**One call does not, and it is the whole of what you will delete:** `configure`. It
|
||||||
|
exists because one shared wallet hosts every user; upstream, an application imports the
|
||||||
|
SDK and each user opens their own wallet. `src/index.ts` groups it under a heading that
|
||||||
|
says so. (`ensureIdentity` is a second in substance — the shared-wallet gate — but its
|
||||||
|
call site survives: an application still awaits a session before it renders.)
|
||||||
|
|
||||||
|
*(There were two entry points until 2026-08-07, `.` and `./polyfill`, and the second one
|
||||||
|
WAS that list. One door is easier to import from and says less — hence the grouping, and
|
||||||
|
hence `docs/api-contract.md`, whose export inventory a test keeps honest.)*
|
||||||
|
|
||||||
|
Per-symbol, with the target signature and an epistemic label on every claim:
|
||||||
|
[`docs/api-contract.md`](../../docs/api-contract.md).
|
||||||
|
|
||||||
|
> **Reading is key possession, and the isolation here is still fake.** The cap surface
|
||||||
|
> has the shape of the real model — you hold a document's `ReadCap` or you do not read
|
||||||
|
> it, and there is no authorization list anywhere — but nothing is encrypted yet and
|
||||||
|
> the stand-in key is a constant. Nothing this library does may be described as
|
||||||
|
> "anonymous" or "private" until cap-enforcement lands per-document encryption.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
// SDK-shaped — the real SDK replaces these in place.
|
||||||
|
ensureIdentity, storeRegistry, inbox, readUnion, docs,
|
||||||
|
// Polyfill-era — one call, and it is the whole of what goes away.
|
||||||
|
configure,
|
||||||
|
} from "@ng-eventually/polyfill";
|
||||||
|
|
||||||
|
configure({ ng: realNg, useShape: realUseShape, getSession, sharedWallet });
|
||||||
|
await ensureIdentity(); // who I am (returned), connection work awaited
|
||||||
|
const doc = await storeRegistry.createEntityDoc("protected");
|
||||||
|
await docs.sparqlUpdate(sid, `INSERT DATA { … }`, doc);
|
||||||
|
const subjects = await readUnion(await storeRegistry.listMyEntityDocs("protected"));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Principle — the polyfill compensates, it never extends
|
||||||
|
|
||||||
|
**Its only reason to exist is to bridge a NextGraph implementation gap.** Every
|
||||||
|
non-SDK surface must map to something NextGraph will provide natively, and must fall
|
||||||
|
away at that point — no bespoke features, no observability, no convenience API that
|
||||||
|
isn't strictly *"NextGraph will do this later"*. The test for any proposed addition:
|
||||||
|
*does it compensate a real, exhibited gap?* If not, it belongs in the consumer
|
||||||
|
application. And a compensation whose gap is not actually exhibited on the target
|
||||||
|
broker is dead weight, not defensive code.
|
||||||
|
|
||||||
|
Both halves are binding — **the surface AND the implementation** stay as close as
|
||||||
|
possible to what NextGraph plans. The question to ask at every choice: *would this
|
||||||
|
make a caller learn something it has to UNLEARN at migration?* If yes, it is a
|
||||||
|
deviation, whatever it buys.
|
||||||
|
|
||||||
|
What the polyfill adds, each emulated now and native later:
|
||||||
|
|
||||||
|
- **Shared-wallet identity** — one wallet hosts every user, so the library fabricates
|
||||||
|
*virtual users* and confines every access to the connected one
|
||||||
|
(`emulated-verifier/reach.ts`). Upstream, each user opens their own wallet.
|
||||||
|
- **Capability emulation** — per-identity cap possession plus a read filter over it:
|
||||||
|
you read the documents whose cap you hold. There is no authorization list, because
|
||||||
|
the real model has none.
|
||||||
|
- **Inbox** — `post`, `postToDocument`, `share`, and the recipient's processing.
|
||||||
|
The model is verified (an inbox is a keypair on one repo); no JS surface exists yet.
|
||||||
|
|
||||||
|
Generic by construction: no application domain here. See
|
||||||
|
[`examples/notebook`](../../examples/notebook) for an application written against it,
|
||||||
|
which the e2e suite drives.
|
||||||
|
|
||||||
|
## How a document is reached — the three acts, and no others
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { storeRegistry, inbox, readUnion } from "@ng-eventually/polyfill";
|
||||||
|
|
||||||
|
// 1. CREATE — you hold its cap, with nothing to declare. No identity parameter: a
|
||||||
|
// session belongs to one user, exactly as the target's own `doc_create` assumes.
|
||||||
|
const doc = await storeRegistry.createEntityDoc("protected");
|
||||||
|
|
||||||
|
// 2. GIVE TO READ — name the document and the person. The key is looked up and
|
||||||
|
// sealed into a deposit; the recipient applies it by connecting, with nothing
|
||||||
|
// to call. Irreversible: there is no revoking a key already handed out.
|
||||||
|
await inbox.share(doc, "bob");
|
||||||
|
|
||||||
|
// 3. CIRCULATE THE REFERENCE — no call at all. Every reference this surface returns
|
||||||
|
// is BARE: it names the document and grants nothing. If the document sits in a
|
||||||
|
// PUBLIC store, the store serves its read cap to whoever asks, so the bare
|
||||||
|
// reference is enough to read it — and if it does not, the reference still names
|
||||||
|
// it and opens nothing.
|
||||||
|
const publicDoc = await storeRegistry.createEntityDoc("public");
|
||||||
|
// …put `publicDoc` in a QR code, a message, another document. Nothing else to do.
|
||||||
|
await readUnion([publicDoc]); // a stranger holding only this reads it
|
||||||
|
```
|
||||||
|
|
||||||
|
**The invariant behind all three: you never derive a cap from a bare reference.** You
|
||||||
|
look it up in what you hold, you were given it, or a public store served it. A
|
||||||
|
`did:ng:o:…` without `:r:` names a document and opens nothing — which is what makes
|
||||||
|
confidentiality composable: a widely circulated document may point at a restricted
|
||||||
|
one, and following the reference gets you a name, not a key. See
|
||||||
|
[`docs/readcap-and-nuri-model.md`](../../docs/readcap-and-nuri-model.md) § 0.
|
||||||
|
|
||||||
|
## The types carry that invariant
|
||||||
|
|
||||||
|
`Nuri` and `ReadCap` are **template literal types**, not `string` aliases:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Nuri = `did:ng:${string}`
|
||||||
|
type ReadCap = `did:ng:${string}:r:${string}`
|
||||||
|
```
|
||||||
|
|
||||||
|
They are still strings — assignable to `string`, JSON-serializable, no wrapper — but
|
||||||
|
the distinction is checked. A `ReadCap` goes wherever a `Nuri` is expected (a cap *is*
|
||||||
|
a NURI with the key inside); the reverse does not compile.
|
||||||
|
|
||||||
|
**Permissive in, precise out.** Public entries take `NuriLike` (`Nuri | string`) and
|
||||||
|
validate at the door, so a value coming from storage, a URL or a form needs no
|
||||||
|
narrowing and no cast on your side; what they *return* is a precise `Nuri`. The
|
||||||
|
runtime checks stay regardless — a JavaScript caller never meets the compiler.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const saved = localStorage.getItem("doc"); // string | null
|
||||||
|
if (saved) await readUnion([saved]); // ✓ validated at the door
|
||||||
|
```
|
||||||
+20
-20
@@ -1,10 +1,10 @@
|
|||||||
# SDK reference — reading data with `@ng-eventually/client`
|
# Polyfill reference — reading data with `@ng-eventually/polyfill`
|
||||||
|
|
||||||
**Audience:** anyone using `@ng-eventually/client` (the app that consumes it, and
|
**Audience:** anyone using `@ng-eventually/polyfill` (the app that consumes it, and
|
||||||
the lib itself when honoring the contract). This is the reference on the SDK's
|
the lib itself when honoring the contract). This is the reference on the polyfill's
|
||||||
**read/reactivity surface** — how you read data and how a read stays live.
|
**read/reactivity surface** — how you read data and how a read stays live.
|
||||||
|
|
||||||
`@ng-eventually/client` is written and consumed as if NextGraph were a **finished,
|
`@ng-eventually/polyfill` is written and consumed as if NextGraph were a **finished,
|
||||||
mature SDK**: documents per entity placed by scope, capabilities, inboxes, and a
|
mature SDK**: documents per entity placed by scope, capabilities, inboxes, and a
|
||||||
**reactive ORM**. This file documents that finished-SDK contract. Where today's
|
**reactive ORM**. This file documents that finished-SDK contract. Where today's
|
||||||
emulation does not yet deliver it, that is called out in one clearly-separated
|
emulation does not yet deliver it, that is called out in one clearly-separated
|
||||||
@@ -27,7 +27,7 @@ cited by `file:symbol` throughout so a future agent can re-verify cheaply.
|
|||||||
> reads are the exception, not the rule.**
|
> reads are the exception, not the rule.**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { useShape } from "@ng-eventually/client";
|
import { useShape } from "@ng-eventually/polyfill";
|
||||||
import { EventShapeType } from "…/shapes/orm/…";
|
import { EventShapeType } from "…/shapes/orm/…";
|
||||||
|
|
||||||
function EventList() {
|
function EventList() {
|
||||||
@@ -108,9 +108,9 @@ and every subsequent patch to a `DeepSignalSet`
|
|||||||
`useDeepSignal` (`@ng-org/alien-deepsignals/react`). Vue and Svelte adapters exist
|
`useDeepSignal` (`@ng-org/alien-deepsignals/react`). Vue and Svelte adapters exist
|
||||||
alongside the React one (`sdk/js/orm/src/frontendAdapters/{vue,svelte}/`).
|
alongside the React one (`sdk/js/orm/src/frontendAdapters/{vue,svelte}/`).
|
||||||
|
|
||||||
`@ng-eventually/client` re-exports `useShape` from
|
`@ng-eventually/polyfill` re-exports `useShape` from
|
||||||
[`../src/use-shape.ts`](../src/use-shape.ts); import it from the SDK
|
[`../src/surface/use-shape.ts`](../src/surface/use-shape.ts); import it from the SDK
|
||||||
(`@ng-eventually/client`), never from `@ng-org/orm` directly.
|
(`@ng-eventually/polyfill`), never from `@ng-org/orm` directly.
|
||||||
|
|
||||||
### What you get, in order
|
### What you get, in order
|
||||||
|
|
||||||
@@ -161,14 +161,14 @@ computes a result and returns once (`sparql_query`,
|
|||||||
`sdk/js/lib-wasm/src/lib.rs:352`/`553`; no "subscribe to a query" exists —
|
`sdk/js/lib-wasm/src/lib.rs:352`/`553`; no "subscribe to a query" exists —
|
||||||
`sparql_query` is not reactive).
|
`sparql_query` is not reactive).
|
||||||
|
|
||||||
In `@ng-eventually/client` the one-shot read is exposed as:
|
In `@ng-eventually/polyfill` the one-shot read is exposed as:
|
||||||
|
|
||||||
- **`docs.sparqlQuery(sid, query, base?, anchor?)`** — a raw anchored SPARQL query
|
- **`docs.sparqlQuery(sid, query, base?, anchor?)`** — a raw anchored SPARQL query
|
||||||
([`../src/docs.ts`](../src/docs.ts)). `anchor` = the document NURI to read; the
|
([`../src/surface/docs.ts`](../src/surface/docs.ts)). `anchor` = the document NURI to read; the
|
||||||
anchor restricts the query to that one repo's graph.
|
anchor restricts the query to that one repo's graph.
|
||||||
- **`readModel.readUnion(docs)`** — read a **bounded, by-need set** of document NURIs,
|
- **`readUnion(docs)`** — read a **bounded, by-need set** of document NURIs,
|
||||||
each with its own anchored query, grouped per subject
|
each with its own anchored query, grouped per subject
|
||||||
([`../src/read-model.ts`](../src/read-model.ts)). This is the polyfill's listing
|
([`../src/surface/read-model.ts`](../src/surface/read-model.ts)). This is the polyfill's listing
|
||||||
primitive (see [§ Current emulation status](#current-emulation-status) and
|
primitive (see [§ Current emulation status](#current-emulation-status) and
|
||||||
[`read-model.md`](../../../docs/read-model.md)).
|
[`read-model.md`](../../../docs/read-model.md)).
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ You do not need the write internals to read, but reads and writes share the same
|
|||||||
document model, so briefly:
|
document model, so briefly:
|
||||||
|
|
||||||
- **Create a document:** `docs.docCreate(sid, crdt, cls, dest, store?)`
|
- **Create a document:** `docs.docCreate(sid, crdt, cls, dest, store?)`
|
||||||
([`../src/docs.ts`](../src/docs.ts)) — mirrors `ng.doc_create`. **One document = one
|
([`../src/surface/docs.ts`](../src/surface/docs.ts)) — mirrors `ng.doc_create`. **One document = one
|
||||||
repo** (`did:ng:o:<RepoID>`); there is no separate `Document` type.
|
repo** (`did:ng:o:<RepoID>`); there is no separate `Document` type.
|
||||||
- **Write into it:** `docs.sparqlUpdate(sid, query, anchor)` — a SPARQL
|
- **Write into it:** `docs.sparqlUpdate(sid, query, anchor)` — a SPARQL
|
||||||
`INSERT/DELETE` scoped to the anchor document's graph. Or, at the ORM layer, the ORM
|
`INSERT/DELETE` scoped to the anchor document's graph. Or, at the ORM layer, the ORM
|
||||||
@@ -284,7 +284,7 @@ from the reactive contract:
|
|||||||
`engine/verifier/src/request_processor.rs` `resolve_target` →
|
`engine/verifier/src/request_processor.rs` `resolve_target` →
|
||||||
`self.repos.get(...).ok_or(RepoNotFound)`; see
|
`self.repos.get(...).ok_or(RepoNotFound)`; see
|
||||||
[`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) § *The ORM
|
[`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) § *The ORM
|
||||||
fan-out hang*). So the lib reads entity lists with **`readModel.readUnion`** — a
|
fan-out hang*). So the lib reads entity lists with **`readUnion`** — a
|
||||||
bounded set of one-shot anchored `sparql_query`s
|
bounded set of one-shot anchored `sparql_query`s
|
||||||
([`read-model.md`](../../../docs/read-model.md)) — and reassembles reactivity by
|
([`read-model.md`](../../../docs/read-model.md)) — and reassembles reactivity by
|
||||||
**re-querying on a change signal** (a lightweight `doc_subscribe` / single-store ORM
|
**re-querying on a change signal** (a lightweight `doc_subscribe` / single-store ORM
|
||||||
@@ -294,7 +294,7 @@ from the reactive contract:
|
|||||||
|
|
||||||
2. **The inbox uses a polling watcher.** The inbox is emulated
|
2. **The inbox uses a polling watcher.** The inbox is emulated
|
||||||
(`AppRequestCommandV0::InboxPost` has no verifier arm today; no wasm helper seals a
|
(`AppRequestCommandV0::InboxPost` has no verifier arm today; no wasm helper seals a
|
||||||
deposit), so `inbox.watch` ([`../src/inbox.ts`](../src/inbox.ts)) **polls** via
|
deposit), so `inbox.watch` ([`../src/surface/inbox.ts`](../src/surface/inbox.ts)) **polls** via
|
||||||
`setInterval` (default 1s) instead of subscribing. The finished contract is push
|
`setInterval` (default 1s) instead of subscribing. The finished contract is push
|
||||||
(the broker already routes the inbox natively); these become subscriptions when the
|
(the broker already routes the inbox natively); these become subscriptions when the
|
||||||
sealed-inbox path is exposed to JS (no such method exists today).
|
sealed-inbox path is exposed to JS (no such method exists today).
|
||||||
@@ -314,15 +314,15 @@ from the reactive contract:
|
|||||||
`sparqlUpdate` on a doc it is itself subscribed to (`subscribeDoc` /
|
`sparqlUpdate` on a doc it is itself subscribed to (`subscribeDoc` /
|
||||||
`ng.doc_subscribe`), the subscription callback appears NOT to fire for its OWN
|
`ng.doc_subscribe`), the subscription callback appears NOT to fire for its OWN
|
||||||
local commit in the same session — so the reactive re-read chain
|
local commit in the same session — so the reactive re-read chain
|
||||||
([`../src/watch-shape.ts`](../src/watch-shape.ts) `watchShape` → `reread` →
|
([`../src/surface/watch-shape.ts`](../src/surface/watch-shape.ts) `watchShape` → `reread` →
|
||||||
[`../src/read-model.ts`](../src/read-model.ts) `readUnion`) never runs, and
|
[`../src/surface/read-model.ts`](../src/surface/read-model.ts) `readUnion`) never runs, and
|
||||||
consumers keep the STALE value until the next connection delivers a fresh
|
consumers keep the STALE value until the next connection delivers a fresh
|
||||||
initial `State`. **Remote** commits DO push correctly (verified: cross-browser
|
initial `State`. **Remote** commits DO push correctly (verified: cross-browser
|
||||||
reactive update works). A code review verified the consumer wiring is correct,
|
reactive update works). A code review verified the consumer wiring is correct,
|
||||||
the doc IS in the subscribed set, and a triggered re-read WOULD return the new
|
the doc IS in the subscribed set, and a triggered re-read WOULD return the new
|
||||||
value — leaving the self-commit echo as the only suspect link. That link is
|
value — leaving the self-commit echo as the only suspect link. That link is
|
||||||
**INFERRED**, not observed: the real `ng.doc_subscribe` runtime is not readable
|
**INFERRED**, not observed: the real `ng.doc_subscribe` runtime is not readable
|
||||||
from source, and [`../src/subscribe.ts`](../src/subscribe.ts)'s own doc-comment
|
from source, and [`../src/surface/subscribe.ts`](../src/surface/subscribe.ts)'s own doc-comment
|
||||||
CLAIMS local writes push a `Patch` — contradicted by the observation. (This
|
CLAIMS local writes push a `Patch` — contradicted by the observation. (This
|
||||||
also sits in tension with § *The reactivity model* above, which documents the
|
also sits in tension with § *The reactivity model* above, which documents the
|
||||||
target contract — one commit, every subscriber pushed, local or remote.) The
|
target contract — one commit, every subscriber pushed, local or remote.) The
|
||||||
@@ -330,9 +330,9 @@ from the reactive contract:
|
|||||||
must propagate reactively to ALL viewers — other viewers (remote push, which
|
must propagate reactively to ALL viewers — other viewers (remote push, which
|
||||||
works) AND the writer's own view (this suspect link). **Treatment (PLANNED,
|
works) AND the writer's own view (this suspect link). **Treatment (PLANNED,
|
||||||
not done):** confirm first via the temporary instrumentation just added
|
not done):** confirm first via the temporary instrumentation just added
|
||||||
([`../src/subscribe.ts`](../src/subscribe.ts) ≈`:119` logs
|
([`../src/surface/subscribe.ts`](../src/surface/subscribe.ts) ≈`:119` logs
|
||||||
`doc_subscribe FIRE <nuri> (State|Patch)`;
|
`doc_subscribe FIRE <nuri> (State|Patch)`;
|
||||||
[`../src/watch-shape.ts`](../src/watch-shape.ts) ≈`:341` logs
|
[`../src/surface/watch-shape.ts`](../src/surface/watch-shape.ts) ≈`:341` logs
|
||||||
`reread TRIGGER by <nuri>` — line numbers volatile, grep the log strings);
|
`reread TRIGGER by <nuri>` — line numbers volatile, grep the log strings);
|
||||||
then, IF confirmed, fix **polyfill-side** — a
|
then, IF confirmed, fix **polyfill-side** — a
|
||||||
local commit should notify the doc's active `subscribeDoc` callbacks.
|
local commit should notify the doc's active `subscribeDoc` callbacks.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* What is POLYFILL-SPECIFIC in these suites' setup: the harness page and the wallet this
|
||||||
|
* repository's runs use.
|
||||||
|
*
|
||||||
|
* Everything generic — the wallet lifecycle, the broker crossing, profiles, bounds, the report
|
||||||
|
* shape, the recognition of the known failure modes — lives in `ng-e2e-helpers`, which knows
|
||||||
|
* nothing about this package and must keep knowing nothing about it: the polyfill is designed
|
||||||
|
* to DISAPPEAR at migration, and that machinery talks about NextGraph itself, so it outlives
|
||||||
|
* it. What is left here is the two things that genuinely belong to the polyfill: the page that
|
||||||
|
* exposes its surface to a browser (`polyfill-entry.ts`), and the name of the wallet its runs
|
||||||
|
* mint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
mintWalletProfile,
|
||||||
|
serveOnEphemeralPort,
|
||||||
|
type RunProfile,
|
||||||
|
type WalletCredentials,
|
||||||
|
} from "ng-e2e-helpers";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The throwaway credentials every suite in this package mints its own run's wallet with.
|
||||||
|
*
|
||||||
|
* A NAME, not an identity that survives: each run gets a profile of its own and mints this
|
||||||
|
* wallet into it, so two runs sharing the name share nothing else. See `ng-e2e-helpers`'
|
||||||
|
* `profiles.ts` for why one physical user per run is the rule, and why it is now a property of
|
||||||
|
* the directory rather than something a lock had to enforce.
|
||||||
|
*
|
||||||
|
* Which is also why the password can sit in a source file in the clear: it opens a wallet that
|
||||||
|
* exists for the length of one run and is deleted with the profile that holds it. A wallet
|
||||||
|
* meant to LAST — the one a deployment serves — must never be minted with these; it gets its
|
||||||
|
* own, chosen by whoever provisions it (`ng-e2e-helpers`' `bin/mint-wallet.ts`).
|
||||||
|
*/
|
||||||
|
export const WALLET: WalletCredentials = {
|
||||||
|
name: "ng-eventually-e2e",
|
||||||
|
password: "ng-eventually-e2e",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This run's physical user, in a profile of its own.
|
||||||
|
*
|
||||||
|
* The credentials are a PARAMETER defaulting to this package's throwaway pair, not a constant
|
||||||
|
* baked into the call: minting is the same work whoever wants it, and a caller that needs its
|
||||||
|
* own — anyone provisioning a wallet that outlives a run — must not have to reach for a
|
||||||
|
* different function to get it.
|
||||||
|
*/
|
||||||
|
export function mintBatchWallet(
|
||||||
|
suite: string,
|
||||||
|
credentials: WalletCredentials = WALLET,
|
||||||
|
): Promise<RunProfile> {
|
||||||
|
return mintWalletProfile(suite, credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
|
||||||
|
const BUILD_MS = 60_000;
|
||||||
|
|
||||||
|
const ENTRY = path.resolve(__dirname, "polyfill-entry.ts");
|
||||||
|
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "polyfill-entry.js");
|
||||||
|
|
||||||
|
export function buildBundle(): void {
|
||||||
|
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
|
||||||
|
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
|
||||||
|
stdio: "pipe",
|
||||||
|
cwd: path.resolve(__dirname, ".."),
|
||||||
|
timeout: BUILD_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serve the harness page — the polyfill's surface, reachable from Playwright as `window.__sdk`. */
|
||||||
|
export function serveHarness(): Promise<{ url: string; close: () => void }> {
|
||||||
|
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
|
||||||
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually polyfill e2e</title></head><body><div id="root"></div><script type="module" src="/polyfill-entry.js"></script></body></html>`;
|
||||||
|
return serveOnEphemeralPort((req, res) => {
|
||||||
|
if (req.url === "/polyfill-entry.js") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
||||||
|
res.end(bundle);
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||||
|
res.end(html);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SDK e2e harness entry — the MINIMAL page loaded inside the broker iframe.
|
* Polyfill e2e harness entry — the MINIMAL page loaded inside the broker iframe.
|
||||||
*
|
*
|
||||||
* It imports the REAL `@ng-org/web` `ng` + this package (`@ng-eventually/client`),
|
* It imports the REAL `@ng-org/web` `ng` + this package (`@ng-eventually/polyfill`),
|
||||||
* configures the polyfill session injection exactly the way a consumer does
|
* configures the polyfill session injection exactly the way a consumer does
|
||||||
* (`configure` + `configureStoreRegistry`), waits for the real broker to hand back
|
* (`configure` + `configureStoreRegistry`), waits for the real broker to hand back
|
||||||
* a session, then exposes `window.__sdk`: a flat bag of async methods the
|
* a session, then exposes `window.__sdk`: a flat bag of async methods the
|
||||||
@@ -17,33 +17,40 @@
|
|||||||
import { ng as realNg, init as realInit } from "@ng-org/web";
|
import { ng as realNg, init as realInit } from "@ng-org/web";
|
||||||
import {
|
import {
|
||||||
configure,
|
configure,
|
||||||
configureStoreRegistry,
|
|
||||||
setCurrentUser,
|
|
||||||
capFor,
|
|
||||||
getCaps,
|
|
||||||
resetCaps,
|
|
||||||
shareCap,
|
|
||||||
connectedUser,
|
|
||||||
} from "@ng-eventually/client/polyfill";
|
|
||||||
import {
|
|
||||||
docs,
|
docs,
|
||||||
|
init,
|
||||||
subscribeDoc,
|
subscribeDoc,
|
||||||
subscribeDocs,
|
subscribeDocs,
|
||||||
readModel,
|
readUnion,
|
||||||
inbox,
|
inbox,
|
||||||
storeRegistry,
|
storeRegistry,
|
||||||
useShape as libUseShape,
|
useShape as libUseShape,
|
||||||
watchShape,
|
watchShape,
|
||||||
} from "@ng-eventually/client";
|
} from "@ng-eventually/polyfill";
|
||||||
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
|
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
|
||||||
// application must not — but through the internal path, never the published entry.
|
// application must not — but through the internal path, never the published entry.
|
||||||
// `storeRegistry` above is the app-facing slice; these are the shim internals.
|
// `storeRegistry` above is the app-facing slice; these are the shim internals.
|
||||||
import * as registryInternals from "../src/store-registry";
|
import * as registryInternals from "../src/shared-wallet/account-registry";
|
||||||
import * as accounts from "../src/accounts";
|
// The harness plays SEVERAL identities on one page — something no application does, and
|
||||||
import { isNuri } from "@ng-eventually/client";
|
// the reason `setCurrentUser` / `configureStoreRegistry` are no longer published. It
|
||||||
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
|
// reaches them by their internal path, like the rest of its machinery.
|
||||||
|
import {
|
||||||
|
adoptCurrentUser,
|
||||||
|
configureStoreRegistry,
|
||||||
|
setCurrentUser,
|
||||||
|
getCaps,
|
||||||
|
getCurrentUser,
|
||||||
|
resetCaps,
|
||||||
|
} from "../src/shared-wallet/bootstrap";
|
||||||
|
import { connectedUser } from "../src/emulated-verifier/connect";
|
||||||
|
import * as virtualUsers from "../src/shared-wallet/virtual-users";
|
||||||
|
import { ensureIdentity } from "@ng-eventually/polyfill";
|
||||||
|
// The harness narrows for its OWN assertions; a consumer never has to (the entries take
|
||||||
|
// plain strings and validate inside). Internal path, like the rest of its machinery.
|
||||||
|
import { isNuri } from "../src/model/nuri";
|
||||||
|
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/polyfill";
|
||||||
|
|
||||||
const { IdentityStore } = accounts;
|
const { IdentityStore } = virtualUsers;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Playwright boundary. Every NURI reaching this harness crosses the bridge as
|
* The Playwright boundary. Every NURI reaching this harness crosses the bridge as
|
||||||
@@ -96,8 +103,37 @@ configure({
|
|||||||
ng: realNg,
|
ng: realNg,
|
||||||
useShape: fakeUseShape,
|
useShape: fakeUseShape,
|
||||||
init: realInit,
|
init: realInit,
|
||||||
|
// The harness already holds an open wallet (the suite imports it itself), so it never
|
||||||
|
// needs the gate's assisted import. These values exist only so the gate has something
|
||||||
|
// to render when the access-gate steps exercise it — they are never used to import.
|
||||||
|
sharedWallet: { fileUrl: "/harness-not-used.ngw", password: "harness" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who this page opens as — and why it has to open as somebody.
|
||||||
|
*
|
||||||
|
* The boot below goes through the POLYFILL's `init`, not the injected one, so that the
|
||||||
|
* 42 checks run over the ordering an application actually gets: settle the identity, THEN
|
||||||
|
* hand the page to the broker (`surface/lifecycle.ts`). A page with no identity would
|
||||||
|
* raise the barrier instead and never hand over, so the harness supplies one.
|
||||||
|
*
|
||||||
|
* Recorded with `adoptCurrentUser`, which names the identity and stops there — the
|
||||||
|
* session-free half, the same one the access gate settles with (`bootstrap.ts`). Naming it
|
||||||
|
* through `setCurrentUser` would FIRE the connection work: it costs the batch an account
|
||||||
|
* lookup and a broker round-trip for a boot identity nothing reads, and it registers a
|
||||||
|
* connection in flight for a user that does not exist. It used to fire nothing here for an
|
||||||
|
* incidental reason — the registry was still unwired at this line — and `configure` wires
|
||||||
|
* it now, so the intent is stated by the call instead of by the ordering. Every check that
|
||||||
|
* cares about identity sets its own anyway; this one is only what the page opened as.
|
||||||
|
*/
|
||||||
|
const BOOT_IDENTITY = "e2e-harness";
|
||||||
|
adoptCurrentUser(BOOT_IDENTITY);
|
||||||
|
|
||||||
|
// The harness keeps its OWN route to the session, substituted through the internal wiring
|
||||||
|
// path AFTER `configure` has pointed the registry at the package's. Not redundancy: the
|
||||||
|
// checks below tear a session down and start another (`session_stop` + `session_start`, the
|
||||||
|
// reconnection cold-start), and only this page knows about the second one — the package's
|
||||||
|
// holder is fed by `init()`'s callback, which the broker fires once per page.
|
||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
// The registry (+ subscribe/inbox/read-model) reach the session
|
// The registry (+ subscribe/inbox/read-model) reach the session
|
||||||
// through this. It resolves once the broker connects.
|
// through this. It resolves once the broker connects.
|
||||||
@@ -131,7 +167,7 @@ configureStoreRegistry({
|
|||||||
|
|
||||||
const state: { status: string; error?: string } = { status: "connecting" };
|
const state: { status: string; error?: string } = { status: "connecting" };
|
||||||
|
|
||||||
// Identity store over the iframe's localStorage (the real AccountStorage).
|
// Identity store over the iframe's localStorage (the real VirtualUserStorage).
|
||||||
const identity = new IdentityStore(
|
const identity = new IdentityStore(
|
||||||
typeof window !== "undefined" && window.localStorage ? window.localStorage : null,
|
typeof window !== "undefined" && window.localStorage ? window.localStorage : null,
|
||||||
);
|
);
|
||||||
@@ -167,6 +203,65 @@ const identity = new IdentityStore(
|
|||||||
return { walletName, b64: btoa(bin), len: bytes.length };
|
return { walletName, b64: btoa(bin), len: bytes.length };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE ACCESS GATE, in a real browser — ported from the consumer's
|
||||||
|
* `barriere-acces-identifiant` feature, which the library took over with the flow.
|
||||||
|
*
|
||||||
|
* Unit tests pin the resolution ORDER (`test/access-gate.test.ts`); only a real DOM can
|
||||||
|
* pin the barrier itself: that it appears on a first access, that entering a value
|
||||||
|
* settles the identity normalized, and that it stands aside once the identity is known.
|
||||||
|
*
|
||||||
|
* That last one holds HERE because this page runs inside the broker iframe, and only
|
||||||
|
* there. Top-level the barrier shows on every load — a known identifier prefills the
|
||||||
|
* field instead of skipping the screen, since knowing who someone is says nothing about
|
||||||
|
* whether their browser still holds the wallet, and the broker's answer for someone who
|
||||||
|
* does not is a dead end with no way back. That side is the applicative suite's to walk
|
||||||
|
* (`e2e/notebook.ts`), with a real page and a real Back button.
|
||||||
|
*/
|
||||||
|
async accessGateFirstVisit(raw: string) {
|
||||||
|
setCurrentUser(null);
|
||||||
|
try { window.localStorage.removeItem("ng-eventually:identity"); } catch {}
|
||||||
|
// A first visit has no `?ng-id=` either, and the URL is the branch the gate consults
|
||||||
|
// FIRST — so clearing storage alone stopped describing a first visit the moment the
|
||||||
|
// boot started settling an identity (which writes the parameter, as every settling
|
||||||
|
// path must). Leaving it there would make this check pass for the wrong reason on a
|
||||||
|
// gate that had stopped asking at all.
|
||||||
|
try {
|
||||||
|
const withoutIdentity = new URL(window.location.href);
|
||||||
|
withoutIdentity.searchParams.delete("ng-id");
|
||||||
|
window.history.replaceState(null, "", withoutIdentity.toString());
|
||||||
|
} catch {}
|
||||||
|
const done = ensureIdentity();
|
||||||
|
const gate = document.querySelector('[data-ng-eventually="access-gate"]');
|
||||||
|
const root = gate?.shadowRoot ?? null;
|
||||||
|
const input = root?.querySelector("input") as HTMLInputElement | null;
|
||||||
|
const button = root?.querySelector("button.go") as HTMLButtonElement | null;
|
||||||
|
const shown = input !== null && button !== null;
|
||||||
|
const disabledWhenEmpty = button?.disabled ?? null;
|
||||||
|
if (input && button) {
|
||||||
|
input.value = raw;
|
||||||
|
input.dispatchEvent(new Event("input"));
|
||||||
|
button.click();
|
||||||
|
}
|
||||||
|
await done;
|
||||||
|
return {
|
||||||
|
shown,
|
||||||
|
disabledWhenEmpty,
|
||||||
|
identity: getCurrentUser(),
|
||||||
|
stillMounted: document.querySelector('[data-ng-eventually="access-gate"]') !== null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Inside the iframe, the barrier must stay away once an identity is known. */
|
||||||
|
async accessGateIdentityAlreadyKnown(known: string) {
|
||||||
|
setCurrentUser(known);
|
||||||
|
await ensureIdentity();
|
||||||
|
return {
|
||||||
|
shown: document.querySelector('[data-ng-eventually="access-gate"]') !== null,
|
||||||
|
identity: getCurrentUser(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
// ── docs primitives ──────────────────────────────────────────────────────
|
// ── docs primitives ──────────────────────────────────────────────────────
|
||||||
async docCreate() {
|
async docCreate() {
|
||||||
const s = await sessionReady;
|
const s = await sessionReady;
|
||||||
@@ -292,9 +387,27 @@ const identity = new IdentityStore(
|
|||||||
);
|
);
|
||||||
docNuris.push(d);
|
docNuris.push(d);
|
||||||
}
|
}
|
||||||
|
// Two SUBJECTS in the LAST document, so the probe can tell "one entry per document"
|
||||||
|
// from "one entry per subject". Until 2026-08-10 `readUnion` folded every triple of a
|
||||||
|
// document into one bag keyed by the document, and this step stayed green throughout
|
||||||
|
// because it only ever counted entries and never looked at what they were.
|
||||||
|
if (n > 0) {
|
||||||
|
await docs.sparqlUpdate(
|
||||||
|
s.session_id,
|
||||||
|
`INSERT DATA { <urn:e2e:rm:extra> <urn:e2e:idx> "extra" }`,
|
||||||
|
docNuris[n - 1]!,
|
||||||
|
);
|
||||||
|
}
|
||||||
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
|
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
|
||||||
const subjects = await readModel.readUnion(toRead);
|
const subjects = await readUnion(toRead);
|
||||||
return { docNuris, subjectCount: subjects.length, subjects };
|
return {
|
||||||
|
docNuris,
|
||||||
|
subjectCount: subjects.length,
|
||||||
|
// What each entry actually IS — the assertion the count could not make.
|
||||||
|
subjectIris: subjects.map((x) => x.subject),
|
||||||
|
graphs: subjects.map((x) => x.graph),
|
||||||
|
subjects,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* readUnion possession gate: create a doc as owner O (whose keyring gets its
|
* readUnion possession gate: create a doc as owner O (whose keyring gets its
|
||||||
@@ -309,9 +422,9 @@ const identity = new IdentityStore(
|
|||||||
setCurrentUser("owner-O");
|
setCurrentUser("owner-O");
|
||||||
getCaps().open(doc, "protected");
|
getCaps().open(doc, "protected");
|
||||||
setCurrentUser("someone-else");
|
setCurrentUser("someone-else");
|
||||||
const asStranger = await readModel.readUnion([doc]);
|
const asStranger = await readUnion([doc]);
|
||||||
setCurrentUser("owner-O");
|
setCurrentUser("owner-O");
|
||||||
const asOwner = await readModel.readUnion([doc]);
|
const asOwner = await readUnion([doc]);
|
||||||
resetCaps();
|
resetCaps();
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
return { strangerCount: asStranger.length, ownerCount: asOwner.length };
|
return { strangerCount: asStranger.length, ownerCount: asOwner.length };
|
||||||
@@ -382,9 +495,9 @@ const identity = new IdentityStore(
|
|||||||
async inboxPostRead(id: string, payloadA: unknown, payloadB: unknown) {
|
async inboxPostRead(id: string, payloadA: unknown, payloadB: unknown) {
|
||||||
// The target must be that user's OWN inbox, not an arbitrary document: you may
|
// The target must be that user's OWN inbox, not an arbitrary document: you may
|
||||||
// deposit into anyone's, you may only read your own. Establishing the identity
|
// deposit into anyone's, you may only read your own. Establishing the identity
|
||||||
// FIRST is what makes `walletInbox` resolve (and file) that user's inbox.
|
// FIRST is what makes `userInbox` resolve (and file) that user's inbox.
|
||||||
setCurrentUser(id);
|
setCurrentUser(id);
|
||||||
const target = await storeRegistry.walletInbox(id);
|
const target = await registryInternals.userInbox(id, "protected");
|
||||||
await inbox.post(target, { payload: payloadA, from: null, ts: 1000 });
|
await inbox.post(target, { payload: payloadA, from: null, ts: 1000 });
|
||||||
await inbox.post(target, { payload: payloadB, from: null, ts: 2000 });
|
await inbox.post(target, { payload: payloadB, from: null, ts: 2000 });
|
||||||
const deposits = await inbox.read(target);
|
const deposits = await inbox.read(target);
|
||||||
@@ -398,7 +511,7 @@ const identity = new IdentityStore(
|
|||||||
// Watching an inbox is READING it continuously, so the watcher stays connected
|
// Watching an inbox is READING it continuously, so the watcher stays connected
|
||||||
// for the whole probe — including across `inboxWatchDeposit`.
|
// for the whole probe — including across `inboxWatchDeposit`.
|
||||||
setCurrentUser(id);
|
setCurrentUser(id);
|
||||||
const target = await storeRegistry.walletInbox(id);
|
const target = await registryInternals.userInbox(id, "protected");
|
||||||
const rec = { fires: 0, lastLen: -1, unsub: () => {}, target };
|
const rec = { fires: 0, lastLen: -1, unsub: () => {}, target };
|
||||||
(window as any).__sdk._inboxWatch = rec;
|
(window as any).__sdk._inboxWatch = rec;
|
||||||
rec.unsub = inbox.watch(target, (deposits) => {
|
rec.unsub = inbox.watch(target, (deposits) => {
|
||||||
@@ -421,9 +534,12 @@ const identity = new IdentityStore(
|
|||||||
},
|
},
|
||||||
// spoof guard: depositing as another principal throws.
|
// spoof guard: depositing as another principal throws.
|
||||||
async inboxSpoofGuard() {
|
async inboxSpoofGuard() {
|
||||||
const s = await sessionReady;
|
// A REAL inbox, obtained from the system. It used to be a plain `docs.docCreate`
|
||||||
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
// document — a state the library never produces, and `inbox.post` now refuses it
|
||||||
|
// (a deposit is addressed to an inbox, never to a document). The step is about the
|
||||||
|
// `from` spoof guard; it should not also assert something the model forbids.
|
||||||
setCurrentUser("alice");
|
setCurrentUser("alice");
|
||||||
|
const target = await registryInternals.userInbox("alice", "protected");
|
||||||
let threw = false;
|
let threw = false;
|
||||||
try {
|
try {
|
||||||
await inbox.post(target, { payload: { x: 1 }, from: "bob" });
|
await inbox.post(target, { payload: { x: 1 }, from: "bob" });
|
||||||
@@ -459,16 +575,16 @@ const identity = new IdentityStore(
|
|||||||
// and the caps of what you create are filed under the identity you were acting
|
// and the caps of what you create are filed under the identity you were acting
|
||||||
// as. Creating B's document while connected as A is not a thing the model has.
|
// as. Creating B's document while connected as A is not a thing the model has.
|
||||||
setCurrentUser(idA);
|
setCurrentUser(idA);
|
||||||
const dA1 = await storeRegistry.createEntityDoc(idA, "public");
|
const dA1 = await storeRegistry.createEntityDoc("public");
|
||||||
const dA2 = await storeRegistry.createEntityDoc(idA, "public");
|
const dA2 = await storeRegistry.createEntityDoc("public");
|
||||||
setCurrentUser(idB);
|
setCurrentUser(idB);
|
||||||
const dB1 = await storeRegistry.createEntityDoc(idB, "public");
|
const dB1 = await storeRegistry.createEntityDoc("public");
|
||||||
// listMyEntityDocs(A) → only A's docs (poll: the index append can lag).
|
// listMyEntityDocs(A) → only A's docs (poll: the index append can lag).
|
||||||
setCurrentUser(idA);
|
setCurrentUser(idA);
|
||||||
let listA: string[] = [];
|
let listA: string[] = [];
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
registryInternals.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
listA = await storeRegistry.listMyEntityDocs(idA, "public");
|
listA = await storeRegistry.listMyEntityDocs("public");
|
||||||
if (listA.includes(dA1) && listA.includes(dA2)) break;
|
if (listA.includes(dA1) && listA.includes(dA2)) break;
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
@@ -500,7 +616,7 @@ const identity = new IdentityStore(
|
|||||||
// document is filed under nobody and the very session that created it is
|
// document is filed under nobody and the very session that created it is
|
||||||
// refused the write below.
|
// refused the write below.
|
||||||
setCurrentUser(id);
|
setCurrentUser(id);
|
||||||
const entityNuri = await storeRegistry.createEntityDoc(id, scope);
|
const entityNuri = await storeRegistry.createEntityDoc(scope);
|
||||||
const marker = "recon-" + Date.now();
|
const marker = "recon-" + Date.now();
|
||||||
await docs.sparqlUpdate(
|
await docs.sparqlUpdate(
|
||||||
s.session_id,
|
s.session_id,
|
||||||
@@ -513,7 +629,7 @@ const identity = new IdentityStore(
|
|||||||
let listed: string[] = [];
|
let listed: string[] = [];
|
||||||
for (let i = 0; i < 15; i++) {
|
for (let i = 0; i < 15; i++) {
|
||||||
registryInternals.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
listed = await storeRegistry.listMyEntityDocs(id, scope);
|
listed = await storeRegistry.listMyEntityDocs(scope);
|
||||||
if (listed.includes(entityNuri)) break;
|
if (listed.includes(entityNuri)) break;
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
@@ -539,7 +655,7 @@ const identity = new IdentityStore(
|
|||||||
await connectedUser();
|
await connectedUser();
|
||||||
|
|
||||||
registryInternals.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const listed = await storeRegistry.listMyEntityDocs(id, scope);
|
const listed = await storeRegistry.listMyEntityDocs(scope);
|
||||||
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
|
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
|
||||||
// many rows the bare anchored query resolves for a not-yet-opened repo (the
|
// many rows the bare anchored query resolves for a not-yet-opened repo (the
|
||||||
// premise: 0 until opened). Uses the low-level docs primitive directly, bypassing
|
// premise: 0 until opened). Uses the low-level docs primitive directly, bypassing
|
||||||
@@ -556,7 +672,7 @@ const identity = new IdentityStore(
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
|
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
|
||||||
}
|
}
|
||||||
const subjects = await readModel.readUnion(listed.length ? listed : [asNuri(entityNuri)]);
|
const subjects = await readUnion(listed.length ? listed : [asNuri(entityNuri)]);
|
||||||
const markers: string[] = [];
|
const markers: string[] = [];
|
||||||
for (const subj of subjects) {
|
for (const subj of subjects) {
|
||||||
for (const vals of Object.values(subj.props)) {
|
for (const vals of Object.values(subj.props)) {
|
||||||
@@ -675,7 +791,7 @@ const identity = new IdentityStore(
|
|||||||
registryInternals.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const id = "@ws-" + handle;
|
const id = "@ws-" + handle;
|
||||||
setCurrentUser(id);
|
setCurrentUser(id);
|
||||||
const doc = await storeRegistry.createEntityDoc(id, "protected");
|
const doc = await storeRegistry.createEntityDoc("protected");
|
||||||
const s = await sessionReady;
|
const s = await sessionReady;
|
||||||
// Seed the entity doc with the shape's type + a title (anchored default graph).
|
// Seed the entity doc with the shape's type + a title (anchored default graph).
|
||||||
await docs.sparqlUpdate(
|
await docs.sparqlUpdate(
|
||||||
@@ -686,7 +802,7 @@ const identity = new IdentityStore(
|
|||||||
// Wait until this session sees the index append (data persisted on the broker).
|
// Wait until this session sees the index append (data persisted on the broker).
|
||||||
for (let i = 0; i < 15; i++) {
|
for (let i = 0; i < 15; i++) {
|
||||||
registryInternals.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const listed = await storeRegistry.listMyEntityDocs(id, "protected");
|
const listed = await storeRegistry.listMyEntityDocs("protected");
|
||||||
if (listed.includes(doc)) break;
|
if (listed.includes(doc)) break;
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
@@ -777,7 +893,7 @@ const identity = new IdentityStore(
|
|||||||
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
|
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
|
||||||
// NOT yet enforce per-doc read caps here (one shared wallet reads everything).
|
// NOT yet enforce per-doc read caps here (one shared wallet reads everything).
|
||||||
// We test what the SDK enforces: the in-memory read-filtered VIEW, which after
|
// We test what the SDK enforces: the in-memory read-filtered VIEW, which after
|
||||||
// P1a is KEY POSSESSION — you read what your keyring holds, nothing else.
|
// cap-surface is KEY POSSESSION — you read what your keyring holds, nothing else.
|
||||||
capsReadFilter() {
|
capsReadFilter() {
|
||||||
resetCaps();
|
resetCaps();
|
||||||
injectedSetItems = [
|
injectedSetItems = [
|
||||||
@@ -787,10 +903,11 @@ const identity = new IdentityStore(
|
|||||||
];
|
];
|
||||||
setCurrentUser("owner-O");
|
setCurrentUser("owner-O");
|
||||||
getCaps().open("did:ng:o:protdoc", "protected");
|
getCaps().open("did:ng:o:protdoc", "protected");
|
||||||
const link = getCaps().publishRepoLink("did:ng:o:pubdoc");
|
const link = getCaps().open("did:ng:o:pubdoc", "public");
|
||||||
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||||
// A stranger holds nothing — including the PUBLISHED document, until the repo
|
// A stranger holds nothing, and this VIEW asks nobody: it is pure possession, with
|
||||||
// link reaches them (§5: whoever has the URL reads the content).
|
// no round-trip to spend (see `read-filter.ts`). That a public store would serve
|
||||||
|
// the cap is proven on the read paths, not here.
|
||||||
setCurrentUser("stranger");
|
setCurrentUser("stranger");
|
||||||
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||||
getCaps().learn(link);
|
getCaps().learn(link);
|
||||||
@@ -800,90 +917,15 @@ const identity = new IdentityStore(
|
|||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
return { ownerView, strangerView, strangerWithLinkView };
|
return { ownerView, strangerView, strangerWithLinkView };
|
||||||
},
|
},
|
||||||
/**
|
// MOVED to the applicative suite, `e2e/notebook.ts` (2026-08-07):
|
||||||
* Sharing a cap the way the model does it: the owner deposits it into the
|
// - `documentInboxDeposit` → "Bob leaves a message on Alice's note, and only Alice
|
||||||
* recipient's INBOX, and the recipient processing that inbox absorbs it. No
|
// reads it". This one is WHY that suite exists: it was green here while the
|
||||||
* "receive" operation exists, and no principal is ever named to the registry.
|
// feature was unusable, because a harness can hand an inbox address across an
|
||||||
* Runs against the REAL broker inbox document, so it exercises the whole path.
|
// identity boundary through a variable and an application cannot.
|
||||||
*/
|
// - `capsShareCap` → "Alice's protected note stays shut until she gives Bob the key".
|
||||||
/**
|
//
|
||||||
* The DEPOSIT side of a document's inbox, end to end against the real broker: the
|
// What stays here is what an application does not do: primitives, characterisation,
|
||||||
* owner opens it, a third party RESOLVES its address from the document itself and
|
// and the cold-start regressions.
|
||||||
* deposits, the owner reads it back.
|
|
||||||
*
|
|
||||||
* The point of the step is the resolution: nothing hands `depositorId` the address.
|
|
||||||
* It gets the document's link (which is what circulates in this model) and must find
|
|
||||||
* where to deposit on its own — which is exactly what a consumer app has to do, and
|
|
||||||
* what a unit test passing the NURI through a variable cannot prove.
|
|
||||||
*/
|
|
||||||
async documentInboxDeposit(ownerId: string, depositorId: string) {
|
|
||||||
registryInternals.resetRegistryCache();
|
|
||||||
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
|
|
||||||
|
|
||||||
setCurrentUser(depositorId);
|
|
||||||
getCaps().learn(link);
|
|
||||||
const resolved = await storeRegistry.documentInboxAddress(doc);
|
|
||||||
// The one-call form an app actually uses: it names the DOCUMENT, never an inbox.
|
|
||||||
await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 });
|
|
||||||
// Opening one on someone else's document must be refused, not silently forked.
|
|
||||||
let openRefused = false;
|
|
||||||
try {
|
|
||||||
await storeRegistry.openDocumentInbox(doc);
|
|
||||||
} catch {
|
|
||||||
openRefused = true;
|
|
||||||
}
|
|
||||||
if (resolved) await inbox.post(resolved, { payload: { joining: true }, ts: 1000 });
|
|
||||||
|
|
||||||
setCurrentUser(ownerId);
|
|
||||||
const deposits = await inbox.read(ownerInbox);
|
|
||||||
// The address is machinery: it must not surface among the document's properties.
|
|
||||||
const subjects = await readModel.readUnion([doc]);
|
|
||||||
const props = Object.keys(subjects[0]?.props ?? {});
|
|
||||||
setCurrentUser(null);
|
|
||||||
return {
|
|
||||||
sameInbox: resolved === ownerInbox,
|
|
||||||
openRefused,
|
|
||||||
deposits: deposits.map((d) => d.payload),
|
|
||||||
props,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async capsShareCap(friendId: string) {
|
|
||||||
const s = await sessionReady;
|
|
||||||
resetCaps();
|
|
||||||
// The recipient's OWN inbox — the address a cap is delivered to. Resolved while
|
|
||||||
// connected as them, since that is who owns it and who may later read it.
|
|
||||||
// `friendId` is fresh per run: this test's assertions survive accumulated caps, but
|
|
||||||
// the recipient's durable Links would grow run after run on a persistent wallet,
|
|
||||||
// making every later `connectedUser()` re-apply a longer and longer history.
|
|
||||||
setCurrentUser(friendId);
|
|
||||||
const friendInbox = await storeRegistry.walletInbox(friendId);
|
|
||||||
|
|
||||||
setCurrentUser("owner-O");
|
|
||||||
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<any>)].length;
|
|
||||||
|
|
||||||
setCurrentUser("owner-O");
|
|
||||||
await shareCap(cap, friendInbox);
|
|
||||||
|
|
||||||
setCurrentUser(friendId);
|
|
||||||
const absorbed = await inbox.read(friendInbox); // processing it applies the cap
|
|
||||||
const after = [...(libUseShape(null, null) as Iterable<any>)].length;
|
|
||||||
|
|
||||||
resetCaps();
|
|
||||||
injectedSetItems = [];
|
|
||||||
setCurrentUser(null);
|
|
||||||
// `absorbed` must be EMPTY: a cap delivery is infrastructure, never surfaced
|
|
||||||
// to the consumer as a deposit.
|
|
||||||
return { before, after, surfacedDeposits: absorbed.length };
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── accounts (IdentityStore) ─────────────────────────────────────────────
|
// ── accounts (IdentityStore) ─────────────────────────────────────────────
|
||||||
identitySet(id: string) { return identity.set(id); },
|
identitySet(id: string) { return identity.set(id); },
|
||||||
@@ -1028,19 +1070,26 @@ const identity = new IdentityStore(
|
|||||||
// ── Connect to the real broker ─────────────────────────────────────────────
|
// ── Connect to the real broker ─────────────────────────────────────────────
|
||||||
// Mirrors ngSession.ts: register the init callback; the broker (this iframe is
|
// Mirrors ngSession.ts: register the init callback; the broker (this iframe is
|
||||||
// loaded by it) drives the connection and calls back with the session.
|
// loaded by it) drives the connection and calls back with the session.
|
||||||
|
//
|
||||||
|
// Through the POLYFILL's `init`, not the injected `realInit` — even though `realInit` is
|
||||||
|
// what ends up being called (it is what `configure` injects, above). Calling it directly
|
||||||
|
// skipped the forwarder that settles the identity before delegating, so the 42 checks ran
|
||||||
|
// over an ordering no application has, and the defect that ordering exists to prevent —
|
||||||
|
// the hand-over happening before the identity reaches the address bar — could not have
|
||||||
|
// been caught here. What an application writes is this line.
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await (realInit as any)(
|
await init(
|
||||||
(event: any) => {
|
(event: { session: BrokerSession }) => {
|
||||||
session = event.session as BrokerSession;
|
session = event.session;
|
||||||
state.status = "connected";
|
state.status = "connected";
|
||||||
sessionResolve(session);
|
sessionResolve(session);
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
state.status = "error";
|
state.status = "error";
|
||||||
state.error = String(e?.message ?? e);
|
state.error = e instanceof Error ? e.message : String(e);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
+9
-14
@@ -21,22 +21,16 @@
|
|||||||
*
|
*
|
||||||
* Standalone (NOT `bun test`). Run:
|
* Standalone (NOT `bun test`). Run:
|
||||||
* bun run e2e/reactivity-doc-subscribe.ts
|
* bun run e2e/reactivity-doc-subscribe.ts
|
||||||
* (or `bun run test:e2e:reactivity` from packages/client)
|
* (or `bun run test:e2e:reactivity` from packages/polyfill)
|
||||||
*
|
*
|
||||||
* It reuses the exact real-broker plumbing of run.ts / broker.ts: the dedicated lib
|
* It reuses the exact real-broker plumbing of run.ts (`ng-e2e-helpers`): the dedicated lib
|
||||||
* wallet, the broker iframe, `window.__sdk`. The CROSS case opens a SECOND page on
|
* wallet, the broker iframe, `window.__sdk`. The CROSS case opens a SECOND page on
|
||||||
* the SAME persistent wallet context — a second concurrent verifier session on one
|
* the SAME persistent wallet context — a second concurrent verifier session on one
|
||||||
* shared wallet (as faithfulReconnect does) — and writes from it.
|
* shared wallet (as faithfulReconnect does) — and writes from it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Frame, Page, BrowserContext } from "playwright";
|
import { launchWatchedContext, setupBrokerPage, type RunProfile, type Frame, type Page, type BrowserContext } from "ng-e2e-helpers";
|
||||||
import {
|
import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
|
||||||
buildBundle,
|
|
||||||
serveHarness,
|
|
||||||
ensureWallet,
|
|
||||||
launchWalletContext,
|
|
||||||
setupBrokerPage,
|
|
||||||
} from "./broker";
|
|
||||||
|
|
||||||
type Check = { name: string; ok: boolean; detail?: string };
|
type Check = { name: string; ok: boolean; detail?: string };
|
||||||
const results: Check[] = [];
|
const results: Check[] = [];
|
||||||
@@ -92,7 +86,7 @@ async function openSession(
|
|||||||
if (m.type() === "error") console.error(`[iframe console:${tag}]`, t);
|
if (m.type() === "error") console.error(`[iframe console:${tag}]`, t);
|
||||||
else if (t.includes("doc_subscribe FIRE")) console.log(`[${tag}] ${t}`);
|
else if (t.includes("doc_subscribe FIRE")) console.log(`[${tag}] ${t}`);
|
||||||
});
|
});
|
||||||
const frame = await setupBrokerPage(page, url);
|
const frame = await setupBrokerPage(page, url, WALLET.password);
|
||||||
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
||||||
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
@@ -110,14 +104,14 @@ const STATE_TIMEOUT_MS = 20000;
|
|||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
console.log("[reactivity] building SDK page bundle...");
|
console.log("[reactivity] building SDK page bundle...");
|
||||||
buildBundle();
|
buildBundle();
|
||||||
console.log("[reactivity] ensuring dedicated lib wallet...");
|
console.log("[reactivity] minting this run's wallet...");
|
||||||
await ensureWallet();
|
const wallet: RunProfile = await mintBatchWallet("the reactivity suite (e2e/reactivity-doc-subscribe.ts)");
|
||||||
const { url, close: closeServer } = await serveHarness();
|
const { url, close: closeServer } = await serveHarness();
|
||||||
console.log(`[reactivity] harness served at ${url}`);
|
console.log(`[reactivity] harness served at ${url}`);
|
||||||
|
|
||||||
let ctx: BrowserContext | null = null;
|
let ctx: BrowserContext | null = null;
|
||||||
try {
|
try {
|
||||||
ctx = await launchWalletContext();
|
ctx = await launchWatchedContext("reactivity", wallet.dir);
|
||||||
|
|
||||||
// ── Session A (the subscriber for both cases) ────────────────────────────
|
// ── Session A (the subscriber for both cases) ────────────────────────────
|
||||||
const A = await openSession(ctx, url, "A");
|
const A = await openSession(ctx, url, "A");
|
||||||
@@ -263,6 +257,7 @@ async function main(): Promise<void> {
|
|||||||
} finally {
|
} finally {
|
||||||
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
||||||
closeServer();
|
closeServer();
|
||||||
|
wallet.discard();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Determination summary (not a pass/fail gate — this is a probe) ──────────
|
// ── Determination summary (not a pass/fail gate — this is a probe) ──────────
|
||||||
+15
-9
@@ -21,9 +21,8 @@
|
|||||||
* Run: `bun run e2e/repro-fresh-wallet.ts`.
|
* Run: `bun run e2e/repro-fresh-wallet.ts`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
import { mintWalletProfileKeepingContext, setupBrokerPage, type RunProfile, type Frame, type Page, type BrowserContext } from "ng-e2e-helpers";
|
||||||
import type { Frame, Page, BrowserContext } from "playwright";
|
import { WALLET, buildBundle, serveHarness } from "./harness-page";
|
||||||
import { buildBundle, serveHarness, createFreshWalletContext, setupBrokerPage } from "./broker";
|
|
||||||
|
|
||||||
type Check = { name: string; ok: boolean; detail?: string };
|
type Check = { name: string; ok: boolean; detail?: string };
|
||||||
const results: Check[] = [];
|
const results: Check[] = [];
|
||||||
@@ -47,13 +46,20 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
|
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
|
||||||
let ctx: BrowserContext | null = null;
|
let ctx: BrowserContext | null = null;
|
||||||
let dir: string | null = null;
|
let profile: RunProfile | null = null;
|
||||||
let page: Page | null = null;
|
let page: Page | null = null;
|
||||||
try {
|
try {
|
||||||
const fresh = await createFreshWalletContext();
|
// A name of its own, not the batch wallet's: what this reproduction needs is a wallet
|
||||||
|
// whose private-store repo has never been opened by an application, and reusing a name
|
||||||
|
// would not give one.
|
||||||
|
const credentials = {
|
||||||
|
name: `ng-fresh-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
password: WALLET.password,
|
||||||
|
};
|
||||||
|
const fresh = await mintWalletProfileKeepingContext("the cold-start reproduction", credentials);
|
||||||
ctx = fresh.ctx;
|
ctx = fresh.ctx;
|
||||||
dir = fresh.dir;
|
profile = fresh.profile;
|
||||||
console.log(`[repro] fresh wallet: ${fresh.name}`);
|
console.log(`[repro] fresh wallet: ${credentials.name}`);
|
||||||
|
|
||||||
page = await ctx.newPage();
|
page = await ctx.newPage();
|
||||||
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
|
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
|
||||||
@@ -62,7 +68,7 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)...");
|
console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)...");
|
||||||
const frame = await setupBrokerPage(page, url);
|
const frame = await setupBrokerPage(page, url, WALLET.password);
|
||||||
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
||||||
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
@@ -102,7 +108,7 @@ async function main(): Promise<void> {
|
|||||||
} finally {
|
} finally {
|
||||||
try { if (page) await page.close(); } catch { /* ignore */ }
|
try { if (page) await page.close(); } catch { /* ignore */ }
|
||||||
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
||||||
try { if (dir) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
profile?.discard();
|
||||||
closeServer();
|
closeServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Real-broker e2e runner for `@ng-eventually/client` — the polyfill's OWN suite,
|
* Real-broker e2e runner for `@ng-eventually/polyfill` — the polyfill's OWN suite,
|
||||||
* in the SDK domain (no application concepts), with a DEDICATED wallet.
|
* in the SDK domain (no application concepts), with a DEDICATED wallet.
|
||||||
*
|
*
|
||||||
* Standalone (NOT `bun test`), so it never mixes into the fake-ng unit suite.
|
* Standalone (NOT `bun test`), so it never mixes into the fake-ng unit suite.
|
||||||
* Run: `bun run e2e/run.ts` (or `bun run test:e2e` from packages/client).
|
* Run: `bun run e2e/run.ts` (or `bun run test:e2e` from packages/polyfill).
|
||||||
*
|
*
|
||||||
* It: builds the SDK page bundle, creates/reuses the dedicated lib wallet, opens
|
* It: builds the SDK page bundle, creates/reuses the dedicated lib wallet, opens
|
||||||
* the broker iframe on the real broker with that wallet, waits for `window.__sdk`
|
* the broker iframe on the real broker with that wallet, waits for `window.__sdk`
|
||||||
@@ -15,16 +15,22 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { Frame, Page, BrowserContext } from "playwright";
|
|
||||||
import {
|
import {
|
||||||
buildBundle,
|
armSuiteDeadline,
|
||||||
serveHarness,
|
closeContext,
|
||||||
ensureWallet,
|
closeQuietly,
|
||||||
launchWalletContext,
|
emptyProfileContext,
|
||||||
launchCleanProfileContext,
|
importWalletFile,
|
||||||
importWalletViaFile,
|
launchWatchedContext,
|
||||||
|
newPage,
|
||||||
setupBrokerPage,
|
setupBrokerPage,
|
||||||
} from "./broker";
|
within,
|
||||||
|
type RunProfile,
|
||||||
|
type Frame,
|
||||||
|
type Page,
|
||||||
|
type BrowserContext,
|
||||||
|
} from "ng-e2e-helpers";
|
||||||
|
import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
|
||||||
|
|
||||||
type Check = { name: string; ok: boolean; detail?: string };
|
type Check = { name: string; ok: boolean; detail?: string };
|
||||||
const results: Check[] = [];
|
const results: Check[] = [];
|
||||||
@@ -36,27 +42,42 @@ function record(name: string, ok: boolean, detail?: string): void {
|
|||||||
function check(name: string, cond: boolean, detail?: string): void {
|
function check(name: string, cond: boolean, detail?: string): void {
|
||||||
record(name, !!cond, detail);
|
record(name, !!cond, detail);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* One step of the batch, under its own deadline.
|
||||||
|
*
|
||||||
|
* The bound is what makes the catch honest: recording a FAIL is the right answer for a step
|
||||||
|
* that fails, but a step that never RETURNS is caught by nothing — and from the outside
|
||||||
|
* that is indistinguishable from a machine that has stopped.
|
||||||
|
*/
|
||||||
async function step(name: string, fn: () => Promise<void>): Promise<void> {
|
async function step(name: string, fn: () => Promise<void>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fn();
|
await within(`the step "${name}"`, STEP_MS, fn);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
record(name, false, "threw: " + String(e?.message ?? e));
|
record(name, false, "threw: " + String(e?.message ?? e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A short helper: call a bridge method inside the iframe.
|
/**
|
||||||
|
* Call a bridge method inside the iframe — under a deadline, because `frame.evaluate()`
|
||||||
|
* has none.
|
||||||
|
*
|
||||||
|
* This is the single most important bound in the file: every one of the thirty-odd
|
||||||
|
* `sdk(...)` calls below is an `evaluate`, and Playwright will wait on one for ever. A
|
||||||
|
* bridge method that never settles — a broker round-trip that gets no answer — used to
|
||||||
|
* stop the batch dead with nothing printed and no way to tell which call it was. The name
|
||||||
|
* carried into the error is the method's own, so the report says which.
|
||||||
|
*/
|
||||||
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||||
return frame.evaluate(
|
return within(`__sdk.${method}() in the broker iframe`, BRIDGE_MS, () =>
|
||||||
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
frame.evaluate(
|
||||||
[method, args] as const,
|
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
||||||
|
[method, args] as const,
|
||||||
|
),
|
||||||
) as Promise<T>;
|
) as Promise<T>;
|
||||||
}
|
}
|
||||||
function sdkGet<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
function sdkGet<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||||
// Same as sdk() but for synchronous getters (no await inside the bridge).
|
// Same as sdk() but for synchronous getters (no await inside the bridge).
|
||||||
return frame.evaluate(
|
return sdk<T>(frame, method, ...args);
|
||||||
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
|
||||||
[method, args] as const,
|
|
||||||
) as Promise<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,37 +101,90 @@ async function faithfulReconnect(
|
|||||||
ctx: BrowserContext,
|
ctx: BrowserContext,
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<{ page: Page; frame: Frame }> {
|
): Promise<{ page: Page; frame: Frame }> {
|
||||||
const p = await ctx.newPage();
|
const p = await newPage("the faithful reconnect", ctx);
|
||||||
p.on("pageerror", (e) => console.error("[iframe error:reconnect]", e.message));
|
p.on("pageerror", (e) => console.error("[iframe error:reconnect]", e.message));
|
||||||
p.on("console", (m) => {
|
p.on("console", (m) => {
|
||||||
if (m.type() === "error") console.error("[iframe console:reconnect]", m.text());
|
if (m.type() === "error") console.error("[iframe console:reconnect]", m.text());
|
||||||
});
|
});
|
||||||
const frame = await setupBrokerPage(p, url);
|
const frame = await setupBrokerPage(p, url, WALLET.password);
|
||||||
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
||||||
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
|
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
|
||||||
return { page: p, frame };
|
return { page: p, frame };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The batch's own budget, and the measurement that explains an overrun.
|
||||||
|
*
|
||||||
|
* Not a `timeout` wrapped around the command from outside: when that fired it killed the
|
||||||
|
* browser, and the suite reported `Target page, context or browser has been closed` —
|
||||||
|
* which reads as an application bug and was twice diagnosed as one. A budget belongs to
|
||||||
|
* the thing that knows what it is spending it on, and it must say so when it runs out.
|
||||||
|
*
|
||||||
|
* ENFORCED WHILE IT RUNS, not merely checked at the end. Declared as 45 min and only ever
|
||||||
|
* asserted after the last step, it could not fire on the one case that matters — a batch
|
||||||
|
* that never reaches its last step. 25 min is the number now, against a healthy batch of
|
||||||
|
* ~3.3 min: the drift the old figure was sized against (a physical user growing across
|
||||||
|
* batches, so an O(size) cold resync) is gone since each batch mints its own user, and a
|
||||||
|
* budget that cannot interrupt anything is not a budget.
|
||||||
|
*/
|
||||||
|
const BATCH_BUDGET_MS = 25 * 60 * 1000;
|
||||||
|
/**
|
||||||
|
* One `__sdk` bridge call. The slowest measured on this broker is a reconnect read at
|
||||||
|
* ~90-105s (open-repo heal + anti-fork retry + anchored read, all round-tripping); four
|
||||||
|
* minutes is well past that and still names a stuck call in minutes rather than never.
|
||||||
|
*/
|
||||||
|
const BRIDGE_MS = 4 * 60 * 1000;
|
||||||
|
/**
|
||||||
|
* One step. The longest are the reconnect contracts, which poll for up to 120s per scope
|
||||||
|
* on top of a fresh broker login — a few minutes when healthy, ten before we call it stuck.
|
||||||
|
*/
|
||||||
|
const STEP_MS = 10 * 60 * 1000;
|
||||||
|
const batchStart = Date.now();
|
||||||
|
/**
|
||||||
|
* The slowest cold resynchronisation of the batch — the number that drifted from 250s to
|
||||||
|
* 286s over a month without anyone looking, because it only ever appeared inside one
|
||||||
|
* step's detail line. It is the health indicator of the physical user, so it is reported
|
||||||
|
* with the summary.
|
||||||
|
*/
|
||||||
|
let coldSyncMs = 0;
|
||||||
|
|
||||||
|
/** Fail with the cause named, rather than letting a killed browser look like a defect. */
|
||||||
|
function assertWithinBudget(): void {
|
||||||
|
const spent = Date.now() - batchStart;
|
||||||
|
if (spent > BATCH_BUDGET_MS) {
|
||||||
|
throw new Error(
|
||||||
|
`[e2e] batch budget exceeded (${Math.round(spent / 60000)} min > ` +
|
||||||
|
`${BATCH_BUDGET_MS / 60000} min). This is almost always the physical user having ` +
|
||||||
|
"grown: a cold resync is O(its size). Check the cold-sync figure printed above — " +
|
||||||
|
"it should be stable from batch to batch now that each gets a fresh wallet.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
armSuiteDeadline("the polyfill suite", BATCH_BUDGET_MS);
|
||||||
console.log("[e2e] building SDK page bundle...");
|
console.log("[e2e] building SDK page bundle...");
|
||||||
buildBundle();
|
buildBundle();
|
||||||
console.log("[e2e] ensuring dedicated lib wallet...");
|
// This batch's own physical user, in a profile directory of its own. Nothing to serialise
|
||||||
await ensureWallet();
|
// against another run: there is no shared directory left for two runs to fight over, so a
|
||||||
|
// suite from a consuming application can drive the same broker at the same time.
|
||||||
|
console.log("[e2e] minting this batch's wallet...");
|
||||||
|
const wallet: RunProfile = await mintBatchWallet("the polyfill suite (e2e/run.ts)");
|
||||||
const { url, close: closeServer } = await serveHarness();
|
const { url, close: closeServer } = await serveHarness();
|
||||||
console.log(`[e2e] harness served at ${url}`);
|
console.log(`[e2e] harness served at ${url}`);
|
||||||
|
|
||||||
let ctx: BrowserContext | null = null;
|
let ctx: BrowserContext | null = null;
|
||||||
let page: Page | null = null;
|
let page: Page | null = null;
|
||||||
try {
|
try {
|
||||||
ctx = await launchWalletContext();
|
ctx = await launchWatchedContext("sdk-harness", wallet.dir);
|
||||||
page = await ctx.newPage();
|
page = await newPage("the SDK harness", ctx);
|
||||||
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
|
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
|
||||||
page.on("console", (m) => {
|
page.on("console", (m) => {
|
||||||
if (m.type() === "error") console.error("[iframe console]", m.text());
|
if (m.type() === "error") console.error("[iframe console]", m.text());
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[e2e] loading SDK page in broker iframe...");
|
console.log("[e2e] loading SDK page in broker iframe...");
|
||||||
const frame = await setupBrokerPage(page, url);
|
const frame = await setupBrokerPage(page, url, WALLET.password);
|
||||||
|
|
||||||
// Wait for the bridge to exist + the broker session to connect.
|
// Wait for the bridge to exist + the broker session to connect.
|
||||||
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
||||||
@@ -120,6 +194,25 @@ async function main(): Promise<void> {
|
|||||||
const info = await sdkGet<any>(frame, "sessionInfo");
|
const info = await sdkGet<any>(frame, "sessionInfo");
|
||||||
check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`);
|
check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`);
|
||||||
|
|
||||||
|
// ── access gate ─────────────────────────────────────────────────────────
|
||||||
|
console.log("\n── access gate ──");
|
||||||
|
await step("the gate asks on a first access, and settles the identity normalized", async () => {
|
||||||
|
const r = await sdk<any>(frame, "accessGateFirstVisit", "@Erin");
|
||||||
|
check(
|
||||||
|
"barrier shown, Entrer disabled while empty, identity normalized, barrier removed",
|
||||||
|
r.shown === true && r.disabledWhenEmpty === true && r.identity === "erin" && r.stillMounted === false,
|
||||||
|
`shown=${r.shown} disabledWhenEmpty=${r.disabledWhenEmpty} identity=${r.identity} stillMounted=${r.stillMounted}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// This harness page runs INSIDE the broker iframe — which is the whole of what makes
|
||||||
|
// the check below true. Top-level the barrier shows on every load, known identity or
|
||||||
|
// not, because a top-level page is one redirect away from a dead end for anyone whose
|
||||||
|
// browser has no wallet. The applicative suite walks that side (`e2e/notebook.ts`).
|
||||||
|
await step("past the round-trip, a known identity stands the barrier down", async () => {
|
||||||
|
const r = await sdk<any>(frame, "accessGateIdentityAlreadyKnown", "erin");
|
||||||
|
check("no barrier inside the broker iframe once the identity is known", r.shown === false && r.identity === "erin", `shown=${r.shown}`);
|
||||||
|
});
|
||||||
|
|
||||||
// ── docs primitives ─────────────────────────────────────────────────────
|
// ── docs primitives ─────────────────────────────────────────────────────
|
||||||
console.log("\n── docs primitives ──");
|
console.log("\n── docs primitives ──");
|
||||||
await step("docCreate returns a usable NURI", async () => {
|
await step("docCreate returns a usable NURI", async () => {
|
||||||
@@ -168,13 +261,32 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// ── read-model ──────────────────────────────────────────────────────────
|
// ── read-model ──────────────────────────────────────────────────────────
|
||||||
console.log("\n── read-model ──");
|
console.log("\n── read-model ──");
|
||||||
await step("readUnion over N docs → per-doc subjects", async () => {
|
await step("readUnion returns one entry per SUBJECT, with the subject it was written under", async () => {
|
||||||
|
// 3 documents, and the last carries TWO subjects → 4 entries, not 3. Counting alone
|
||||||
|
// could not distinguish grouping-by-document from grouping-by-subject, which is why
|
||||||
|
// this step stayed green while `readUnion` conflated them (fixed 2026-08-10).
|
||||||
const r = await sdk<any>(frame, "readUnionOverDocs", 3, false);
|
const r = await sdk<any>(frame, "readUnionOverDocs", 3, false);
|
||||||
check("readUnion returns one subject per doc", r.subjectCount === 3, `subjects=${r.subjectCount}/3`);
|
const iris: string[] = r.subjectIris ?? [];
|
||||||
|
check(
|
||||||
|
"one entry per subject, not per document",
|
||||||
|
r.subjectCount === 4 && iris.includes("urn:e2e:rm:extra"),
|
||||||
|
`entries=${r.subjectCount}/4 subjects=${JSON.stringify(iris)}`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"each entry carries the subject it was written under, not the document",
|
||||||
|
iris.every((s) => s.startsWith("urn:e2e:rm:")),
|
||||||
|
JSON.stringify(iris),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"…and its `graph` is the document reference",
|
||||||
|
(r.graphs ?? []).every((g: string) => g.startsWith("did:ng:")),
|
||||||
|
JSON.stringify(r.graphs),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await step("readUnion per-doc tolerance (bad NURI skipped)", async () => {
|
await step("readUnion per-doc tolerance (bad NURI skipped)", async () => {
|
||||||
const r = await sdk<any>(frame, "readUnionOverDocs", 2, true);
|
const r = await sdk<any>(frame, "readUnionOverDocs", 2, true);
|
||||||
check("bad NURI does not abort the batch", r.subjectCount === 2, `subjects=${r.subjectCount}/2 (+1 bad)`);
|
// 2 documents, the last carrying two subjects → 3 entries.
|
||||||
|
check("bad NURI does not abort the batch", r.subjectCount === 3, `entries=${r.subjectCount}/3 (+1 bad NURI)`);
|
||||||
});
|
});
|
||||||
await step("readUnion cap gate", async () => {
|
await step("readUnion cap gate", async () => {
|
||||||
const r = await sdk<any>(frame, "readUnionCapGate");
|
const r = await sdk<any>(frame, "readUnionCapGate");
|
||||||
@@ -248,18 +360,11 @@ async function main(): Promise<void> {
|
|||||||
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
|
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
|
||||||
await sdk(frame, "inboxWatchStop");
|
await sdk(frame, "inboxWatchStop");
|
||||||
});
|
});
|
||||||
await step("a document's inbox: owner opens, a third party resolves and deposits", async () => {
|
// MOVED to the applicative suite (`e2e/notebook.ts`, "Bob leaves a message on Alice's
|
||||||
const t = Date.now();
|
// note, and only Alice reads it"). This is the step that motivated that suite: it was
|
||||||
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
|
// green here while the feature was unusable, because the harness could hand the inbox
|
||||||
check(
|
// address across an identity boundary through a variable — a channel no application
|
||||||
"the depositor RESOLVES the same inbox from the document, deposits into it, and the address stays out of the data",
|
// has. Driven through two screens, the address has to be FOUND or the journey fails.
|
||||||
r.sameInbox === true &&
|
|
||||||
r.openRefused === true &&
|
|
||||||
JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) &&
|
|
||||||
!r.props.some((p: string) => p.startsWith("urn:ng-eventually:")),
|
|
||||||
`sameInbox=${r.sameInbox} openRefused=${r.openRefused} deposits=${JSON.stringify(r.deposits)} props=${JSON.stringify(r.props)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await step("inbox spoof guard", async () => {
|
await step("inbox spoof guard", async () => {
|
||||||
const r = await sdk<any>(frame, "inboxSpoofGuard");
|
const r = await sdk<any>(frame, "inboxSpoofGuard");
|
||||||
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
|
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
|
||||||
@@ -337,19 +442,14 @@ async function main(): Promise<void> {
|
|||||||
const linkOpensPublic =
|
const linkOpensPublic =
|
||||||
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
|
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
|
||||||
check(
|
check(
|
||||||
"owner reads held docs only; stranger reads nothing; the repo link opens the published one",
|
"the read-filtered view decides on possession alone: owner sees what he holds, a stranger nothing, and a filed cap opens it",
|
||||||
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
|
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
|
||||||
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withLink=${JSON.stringify(r.strangerWithLinkView)}`,
|
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`,
|
||||||
);
|
|
||||||
});
|
|
||||||
await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
|
|
||||||
const r = await sdk<any>(frame, "capsShareCap", "@friend-" + Date.now());
|
|
||||||
check(
|
|
||||||
"shareCap → inbox processed → the shared doc becomes readable, and the delivery is not surfaced",
|
|
||||||
r.before === 0 && r.after === 1 && r.surfacedDeposits === 0,
|
|
||||||
`before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
// MOVED to the applicative suite (`e2e/notebook.ts`, "Alice's protected note stays
|
||||||
|
// shut until she gives Bob the key"): sharing is a journey, and it is worth more
|
||||||
|
// driven through two screens than through two calls on one page.
|
||||||
|
|
||||||
// ── accounts (IdentityStore) ────────────────────────────────────────────
|
// ── accounts (IdentityStore) ────────────────────────────────────────────
|
||||||
console.log("\n── accounts (IdentityStore) ──");
|
console.log("\n── accounts (IdentityStore) ──");
|
||||||
@@ -397,20 +497,20 @@ async function main(): Promise<void> {
|
|||||||
fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64"));
|
fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64"));
|
||||||
|
|
||||||
let cleanCtx: BrowserContext | null = null;
|
let cleanCtx: BrowserContext | null = null;
|
||||||
let cleanDir: string | null = null;
|
let cleanProfile: RunProfile | null = null;
|
||||||
let cleanPage: Page | null = null;
|
let cleanPage: Page | null = null;
|
||||||
try {
|
try {
|
||||||
const launched = await launchCleanProfileContext();
|
const launched = await emptyProfileContext("the clean-profile cold read");
|
||||||
cleanCtx = launched.ctx;
|
cleanCtx = launched.ctx;
|
||||||
cleanDir = launched.dir;
|
cleanProfile = launched.profile;
|
||||||
cleanPage = await cleanCtx.newPage();
|
cleanPage = await newPage("the clean-profile session", cleanCtx);
|
||||||
cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message));
|
cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message));
|
||||||
cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); });
|
cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); });
|
||||||
|
|
||||||
// Import the SAME wallet into the empty profile (broker-only repos), then open
|
// Import the SAME wallet into the empty profile (broker-only repos), then open
|
||||||
// the SDK page in a fresh broker session over it.
|
// the SDK page in a fresh broker session over it.
|
||||||
await importWalletViaFile(cleanPage, ngwPath);
|
await importWalletFile(cleanPage, ngwPath, WALLET.password);
|
||||||
const cleanFrame = await setupBrokerPage(cleanPage, url);
|
const cleanFrame = await setupBrokerPage(cleanPage, url, WALLET.password);
|
||||||
await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
||||||
await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
|
await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
|
||||||
const cleanInfo = await sdkGet<any>(cleanFrame, "sessionInfo");
|
const cleanInfo = await sdkGet<any>(cleanFrame, "sessionInfo");
|
||||||
@@ -427,9 +527,9 @@ async function main(): Promise<void> {
|
|||||||
`rawAnchoredNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount} markerPresent=${r.markerPresent}`,
|
`rawAnchoredNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount} markerPresent=${r.markerPresent}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
try { if (cleanPage) await cleanPage.close(); } catch { /* ignore */ }
|
if (cleanPage) await closeQuietly("the clean-profile page", () => cleanPage!.close());
|
||||||
try { if (cleanCtx) await cleanCtx.close(); } catch { /* ignore */ }
|
if (cleanCtx) await closeContext("clean-profile", cleanCtx);
|
||||||
try { if (cleanDir) fs.rmSync(cleanDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
cleanProfile?.discard();
|
||||||
try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ }
|
try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -507,12 +607,13 @@ async function main(): Promise<void> {
|
|||||||
`[SYNC] reconnect re-reads its OWN persisted ${scope} marker (cold-read)`,
|
`[SYNC] reconnect re-reads its OWN persisted ${scope} marker (cold-read)`,
|
||||||
found,
|
found,
|
||||||
found
|
found
|
||||||
? `synced in ${syncMs}ms (reconnect-login ${loginMs}ms) — ${lastDetail}`
|
? ((coldSyncMs = Math.max(coldSyncMs, syncMs)),
|
||||||
|
`synced in ${syncMs}ms (reconnect-login ${loginMs}ms) — ${lastDetail}`)
|
||||||
: `NEVER synced within 120s (reconnect-login ${loginMs}ms) — ${lastDetail}`,
|
: `NEVER synced within 120s (reconnect-login ${loginMs}ms) — ${lastDetail}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
try { if (rp) await rp.close(); } catch { /* ignore */ }
|
if (rp) await closeQuietly("the reconnect page", () => rp!.close());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -569,7 +670,7 @@ async function main(): Promise<void> {
|
|||||||
: `FORKED — orig pub=${String(orig.docPublic).slice(0, 20)}… got pub=${String(last?.docPublic).slice(0, 20)}… (differs)`,
|
: `FORKED — orig pub=${String(orig.docPublic).slice(0, 20)}… got pub=${String(last?.docPublic).slice(0, 20)}… (differs)`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
try { if (rp) await rp.close(); } catch { /* ignore */ }
|
if (rp) await closeQuietly("the reconnect page", () => rp!.close());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -721,15 +822,28 @@ async function main(): Promise<void> {
|
|||||||
await sdk(frame, "stateProbeStop");
|
await sdk(frame, "stateProbeStop");
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
try { if (page) await page.close(); } catch { /* ignore */ }
|
// Bounded, and it has to be: `BrowserContext.close()` on a browser that has already
|
||||||
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
// gone never resolves, and this `finally` is where that hang swallowed the summary.
|
||||||
|
if (page) await closeQuietly("the SDK harness page", () => page!.close());
|
||||||
|
if (ctx) await closeContext("sdk-harness", ctx);
|
||||||
closeServer();
|
closeServer();
|
||||||
|
// This run's physical user goes with it. Explicit here and also registered on the way
|
||||||
|
// out, so a run that is killed mid-batch still takes its profile — and the Chromium
|
||||||
|
// holding it — with it, instead of leaving both for a host that has to keep running.
|
||||||
|
wallet.discard();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Summary ───────────────────────────────────────────────────────────────
|
// ── Summary ───────────────────────────────────────────────────────────────
|
||||||
const passed = results.filter((r) => r.ok).length;
|
const passed = results.filter((r) => r.ok).length;
|
||||||
const failed = results.length - passed;
|
const failed = results.length - passed;
|
||||||
console.log(`\n══ SDK e2e summary: ${passed} passed, ${failed} failed, ${results.length} total ══`);
|
const batchMin = ((Date.now() - batchStart) / 60000).toFixed(1);
|
||||||
|
console.log(
|
||||||
|
`\n══ Polyfill e2e summary: ${passed} passed, ${failed} failed, ${results.length} total ` +
|
||||||
|
`— batch ${batchMin} min, slowest cold sync ${Math.round(coldSyncMs / 1000)}s ══`,
|
||||||
|
);
|
||||||
|
// A fresh wallet per batch is what should keep the cold sync flat; if it climbs from
|
||||||
|
// one batch to the next, the per-batch wallet is not being discarded.
|
||||||
|
assertWithinBudget();
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
console.log("Failures:");
|
console.log("Failures:");
|
||||||
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
|
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@ng-eventually/client",
|
"name": "@ng-eventually/polyfill",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0-dev.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "SDK-identical client wrapper over @ng-org/web + @ng-org/orm with emulated capabilities and inbox. Drop-in; remove at migration.",
|
"description": "Polyfill of the NextGraph JS SDK over @ng-org/web + @ng-org/orm, with emulated capabilities and inbox. Drop-in; remove at migration.",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts"
|
||||||
"./polyfill": "./src/polyfill.ts"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@ng-org/web": "*",
|
"@ng-org/web": "*",
|
||||||
@@ -16,10 +15,18 @@
|
|||||||
"@ng-org/alien-deepsignals": "*"
|
"@ng-org/alien-deepsignals": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@ng-org/web": { "optional": true },
|
"@ng-org/web": {
|
||||||
"@ng-org/orm": { "optional": true },
|
"optional": true
|
||||||
"@ng-org/shex-orm": { "optional": true },
|
},
|
||||||
"@ng-org/alien-deepsignals": { "optional": true }
|
"@ng-org/orm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@ng-org/shex-orm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@ng-org/alien-deepsignals": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ng-org/web": "0.1.2-alpha.13",
|
"@ng-org/web": "0.1.2-alpha.13",
|
||||||
@@ -29,6 +36,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:e2e": "bun run e2e/run.ts",
|
"test:e2e": "bun run e2e/run.ts",
|
||||||
|
"test:e2e:app": "bun run e2e/notebook.ts",
|
||||||
"test:e2e:reactivity": "bun run e2e/reactivity-doc-subscribe.ts"
|
"test:e2e:reactivity": "bun run e2e/reactivity-doc-subscribe.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,885 @@
|
|||||||
|
/**
|
||||||
|
* The durable cap/inbox registers — this library's stand-in for the compartments the
|
||||||
|
* verifier maintains on a repo's own branches.
|
||||||
|
*
|
||||||
|
* Upstream these are not RDF at all: they are streams of service commits on branches
|
||||||
|
* whose CRDT is `BranchCrdt::None` (`engine/repo/src/types.rs:1420`). Each register here
|
||||||
|
* names its native counterpart:
|
||||||
|
*
|
||||||
|
* - **Store branch** — `AddRepo { read_cap }` (`engine/repo/src/types.rs:1890-1899`):
|
||||||
|
* the cap of a document you CREATED, filed beside the store that holds it. Replaying
|
||||||
|
* it is what reloads a store's documents with their keys (`AddRepo::verify` ->
|
||||||
|
* `Verifier::load_repo_from_read_cap`, `engine/verifier/src/verifier.rs:2237`).
|
||||||
|
* - **User branch, links** — `AddLink { read_cap }` (`types.rs:1939-1948`), *"so that a
|
||||||
|
* user can share with all its device a new Link they received"*, external repos only.
|
||||||
|
* - **User branch, inbox caps** — `AddInboxCap { repo_id, overlay, priv_key }`
|
||||||
|
* (`types.rs:1969-1981`): which inboxes you may READ. Keyed by `repo_id`, hence valid
|
||||||
|
* for ANY repo — `update_inbox_cap_v0` applies it with no `is_store` check
|
||||||
|
* (`engine/verifier/src/verifier.rs:1920`).
|
||||||
|
* - **Header branch** — a document's deposit ADDRESS, readable by any holder of it.
|
||||||
|
* The one register with NO native counterpart: upstream an address is TRANSMITTED
|
||||||
|
* (a message, a profile QR code), never published, and `inboxes: PubKey -> RepoId` is
|
||||||
|
* a per-session local table (`verifier.rs:105`). Publishing is our divergence, taken
|
||||||
|
* because an emulation has no message channel — see
|
||||||
|
* `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
||||||
|
*
|
||||||
|
* Why separate from the shim next door: these emulate the VERIFIER's bookkeeping and
|
||||||
|
* survive conceptually — at migration the native side keeps them, only our RDF
|
||||||
|
* representation goes. `shared-wallet/account-registry.ts` has no counterpart at all and
|
||||||
|
* evaporates. One file until 2026-08-03, two fates.
|
||||||
|
*
|
||||||
|
* The imports back into `shared-wallet/` are deliberate, visible cross-fate edges: a
|
||||||
|
* register needs the shim to know WHOSE it is, and where its store document lives. Every
|
||||||
|
* use sits inside a function body, so the module cycle is inert at evaluation time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sparqlQuery } from "../surface/docs";
|
||||||
|
import { readForHolder, registerUpdate } from "./register-write";
|
||||||
|
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||||
|
import { escapeLiteral } from "../surface/sparql";
|
||||||
|
import { hasReadCap, isNuri, targetOf, toNuri } from "../model/nuri";
|
||||||
|
import { mustNotAttempt } from "./reach";
|
||||||
|
import { fetchReadCap } from "./public-store";
|
||||||
|
import { ensureRepoOpen } from "./open-repo";
|
||||||
|
import { accessLogPrefix } from "../shared-wallet/access-log";
|
||||||
|
import {
|
||||||
|
P,
|
||||||
|
USER_BRANCH_SUBJECT,
|
||||||
|
STORE_BRANCH_SUBJECT,
|
||||||
|
HEADER_BRANCH_SUBJECT,
|
||||||
|
accountKey,
|
||||||
|
session,
|
||||||
|
readBindings,
|
||||||
|
bindingValue,
|
||||||
|
lookupAccount,
|
||||||
|
resolveAccount,
|
||||||
|
storeOf,
|
||||||
|
readUserStore,
|
||||||
|
userInbox,
|
||||||
|
createDoc,
|
||||||
|
ensureAccount,
|
||||||
|
recordInbox,
|
||||||
|
type VirtualUserRecord,
|
||||||
|
} from "../shared-wallet/account-registry";
|
||||||
|
import type { InboxScope, Nuri, NuriLike, PrincipalId, ReadCap, Scope } from "../model/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the
|
||||||
|
* inbox read guard consults (`inbox.ts`). Anonymous holds no inbox, so it is false
|
||||||
|
* for everyone until an identity is set.
|
||||||
|
*/
|
||||||
|
// @provenance isOwnInbox kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — 'which inboxes may I read' is answered by the User branch's records
|
||||||
|
export async function isOwnInbox(nuri: Nuri): Promise<boolean> {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null) return false;
|
||||||
|
// Either of the user's two inboxes counts as its own.
|
||||||
|
for (const scope of ["public", "protected"] as const) {
|
||||||
|
if ((await userInbox(holder, scope)) === nuri) return true;
|
||||||
|
}
|
||||||
|
// …and the inbox of any document this user opened one on (the emulated
|
||||||
|
// `AddInboxCap` records on its User branch).
|
||||||
|
return (await readInboxCapPairs()).some((p) => p.inbox === nuri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the cap side of a user's store ----------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File the caps of documents the CURRENT holder owns into what they hold — the
|
||||||
|
* emulated `AddRepo { read_cap }`.
|
||||||
|
*
|
||||||
|
* Upstream, creating a document commits an `AddRepo { read_cap }` into a typed
|
||||||
|
* branch of the store, and that branch — listing the store's documents, each with
|
||||||
|
* its read key — carries the owner's caps. Here the per-(account × scope) index
|
||||||
|
* document plays the store-container role, so it carries the caps too: a
|
||||||
|
* document appended to it on creation, or read back from it on a later session,
|
||||||
|
* puts its cap in the owner's hands with nothing for the consumer to do. That is
|
||||||
|
* what makes the invariant hold both ways — you never derive a cap from a bare
|
||||||
|
* reference, and yet a document's own creator is never locked out of it.
|
||||||
|
*
|
||||||
|
* Scoped to the current holder ON PURPOSE: another account's documents are listed
|
||||||
|
* by the cross-account fan-out (`listEntityDocs`), and those caps are emphatically
|
||||||
|
* not ours to hold. `id` is compared through the shim key, so it matches however
|
||||||
|
* the consumer spells the identity.
|
||||||
|
*/
|
||||||
|
// @provenance holdOwnCap kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the cap of a document you created, filed on the store's Store branch. WIRED upstream — `send_add_repo_to_store` constructs it
|
||||||
|
export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null || accountKey(holder) !== accountKey(id)) return;
|
||||||
|
const caps = getCaps();
|
||||||
|
// `learn(cap)`, not `open(doc, scope)` — the cap must be the SAME value that was
|
||||||
|
// written to the Store branch, not a second one minted from the NURI. They agree
|
||||||
|
// today only because the stand-in value is a constant; with a real key (cap-enforcement) a
|
||||||
|
// second mint would produce a DIFFERENT key and the document would be unreadable
|
||||||
|
// by the very session that created it. Mint once, store it, hold that one.
|
||||||
|
caps.learn(cap);
|
||||||
|
// Which store the document sits in is a registry fact, applied separately — and a
|
||||||
|
// MARK only, for the same reason the cap above is learned rather than re-minted.
|
||||||
|
if (scope === "public") caps.markInPublicStore(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File the caps of the documents a virtual user owns BY BEING one: its three
|
||||||
|
* stores, and its inbox. They are as much its documents as any entity it creates,
|
||||||
|
* and without them it cannot even list its own content — the boundary would lock a
|
||||||
|
* user out of itself.
|
||||||
|
*
|
||||||
|
* Scoped to the current holder, like {@link holdOwnCap}: another user's stores are
|
||||||
|
* emphatically not ours to hold.
|
||||||
|
*/
|
||||||
|
// @provenance fileOwnStructure kind=aligned level=1 ref=engine/verifier/src/site.rs:create_individual_ — a user's three stores are its own repos; it holds their caps by BEING a user
|
||||||
|
export function fileOwnStructure(id: string, record: VirtualUserRecord): void {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null || accountKey(holder) !== accountKey(id)) return;
|
||||||
|
const caps = getCaps();
|
||||||
|
if (record.docPublic) caps.open(record.docPublic, "public");
|
||||||
|
if (record.docProtected) caps.open(record.docProtected, "protected");
|
||||||
|
if (record.docPrivate) caps.open(record.docPrivate, "private");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same, for the user's own inbox — it is its document, and it must be able to
|
||||||
|
* read it. Depositing into someone else's needs no cap (see `register-write.depositInto`). */
|
||||||
|
// @provenance fileOwnInbox kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — upstream the record IS the key (`priv_key`); here the cap is minted for the holder the record belongs to
|
||||||
|
export function fileOwnInbox(id: string, inbox: Nuri): void {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null || accountKey(holder) !== accountKey(id)) return;
|
||||||
|
getCaps().open(inbox, "private");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- per-entity documents + per-scope index -------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish WHERE to deposit for `doc`, on its Header branch — the compartment any
|
||||||
|
* holder of the document can read.
|
||||||
|
*
|
||||||
|
* Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's
|
||||||
|
* `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option<PrivKey>`),
|
||||||
|
* so two addresses on one document is a state the model has no meaning for — and a
|
||||||
|
* depositor picking the stale one writes where nobody reads.
|
||||||
|
*
|
||||||
|
* **Propagates a failed write.** Publishing is the ONLY way a third party learns where to
|
||||||
|
* deposit for a document (we publish because an emulation has no message channel — see the
|
||||||
|
* module header), and `openDocumentInbox` short-circuits on the inbox it already recorded,
|
||||||
|
* so nothing ever tries again. Swallowing therefore produced an inbox its owner drains
|
||||||
|
* forever while no one can reach it — and worse when the `DELETE` landed and the `INSERT`
|
||||||
|
* did not: the address that WAS published is gone, and every future deposit on that
|
||||||
|
* document is refused as "this document has no inbox".
|
||||||
|
*/
|
||||||
|
// @provenance publishInboxAddress kind=divergent level=1 ref=engine/net/src/types.rs:ContactDetails — upstream an inbox address is TRANSMITTED (a message, a profile QR) and lands in the verifier's own per-user `inboxes` table; PUBLISHING it on the document is this library's deliberate divergence
|
||||||
|
export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
|
||||||
|
const s = await session();
|
||||||
|
try {
|
||||||
|
// Two separate updates, not one compound statement: `DELETE WHERE { … }` is the
|
||||||
|
// form verified against the real broker (see
|
||||||
|
// `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update
|
||||||
|
// is not exercised anywhere in this lib.
|
||||||
|
await registerUpdate(
|
||||||
|
s.sessionId,
|
||||||
|
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
|
||||||
|
doc,
|
||||||
|
"publishInboxAddress:clear",
|
||||||
|
);
|
||||||
|
await registerUpdate(
|
||||||
|
s.sessionId,
|
||||||
|
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`,
|
||||||
|
doc,
|
||||||
|
"publishInboxAddress",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " publishInboxAddress failed:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ReadCaps recorded on a store's Store branch — its documents, each with its
|
||||||
|
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
|
||||||
|
* what it owns without recomputing anything.
|
||||||
|
*
|
||||||
|
* **Propagates a failed read**, like {@link readLinks} beside it and for the same reason:
|
||||||
|
* empty and unreadable are the same value here and could not mean more different things —
|
||||||
|
* "I own nothing in this store" against "every document I own is invisible and nothing
|
||||||
|
* said so". {@link restoreOwnCaps} is the caller that cannot survive the confusion, since
|
||||||
|
* its whole contract is that the connection either did the restore or says it did not.
|
||||||
|
* `listMyEntityDocs` used to be the exception; it stopped being one on 2026-08-17, because
|
||||||
|
* a listing handed back without its keys is the same confusion one layer up (see it).
|
||||||
|
*/
|
||||||
|
// @provenance readStoreCaps kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the replay of the Store branch — what reloads a store's documents with their keys
|
||||||
|
export async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> {
|
||||||
|
const s = await session();
|
||||||
|
const out: ReadCap[] = [];
|
||||||
|
const res = await sparqlQuery(
|
||||||
|
s.sessionId,
|
||||||
|
`SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`,
|
||||||
|
undefined,
|
||||||
|
storeDoc,
|
||||||
|
"readStoreCaps",
|
||||||
|
);
|
||||||
|
for (const row of readBindings(res)) {
|
||||||
|
const v = bindingValue(row, "c");
|
||||||
|
if (v && hasReadCap(v)) out.push(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put back in the connected user's hands the key of EVERY document it owns — the three
|
||||||
|
* Store branches read back, which is the emulated replay of `AddRepo { read_cap }`
|
||||||
|
* (`AddRepo::verify` → `Verifier::load_repo_from_read_cap`,
|
||||||
|
* `engine/verifier/src/verifier.rs:2237`).
|
||||||
|
*
|
||||||
|
* ── Why connecting has to do this, and why the store record is not enough ──
|
||||||
|
* A document's cap is written in exactly one durable place — the Store branch of the
|
||||||
|
* store it was created in ({@link holdOwnCap}) — and until 2026-08-16 exactly one path
|
||||||
|
* read it back: `listMyEntityDocs`. So a creator that reloaded the page and went straight
|
||||||
|
* to the document it had just made, without listing that scope first, held nothing for it:
|
||||||
|
* the read was refused, the batch read (`readUnion`) dropped it silently, and the write
|
||||||
|
* was refused. Its own document, denied to its own author, on the first refresh.
|
||||||
|
*
|
||||||
|
* Restoring what a user HOLDS is already what connecting means here — that is what the
|
||||||
|
* User-branch Links step does for caps RECEIVED from others. Its own documents are the
|
||||||
|
* other half of the same act, and the more fundamental one: a received cap is a bonus, a
|
||||||
|
* created document is the user's own content.
|
||||||
|
*
|
||||||
|
* The scope stores are filed first ({@link fileOwnStructure}), because the Store branch
|
||||||
|
* lives INSIDE the store document and reading it is itself a guarded read. Not left to a
|
||||||
|
* side effect of whatever ran before: the order is carried by this composition rather than
|
||||||
|
* by where the call happens to sit.
|
||||||
|
*
|
||||||
|
* `holderKey` is the ring these land in — captured by the caller when it decided whose
|
||||||
|
* connection this is, and handed back here, exactly as `readLinks`' caller does. See
|
||||||
|
* `caps.holderKey`.
|
||||||
|
*
|
||||||
|
* Nothing to restore is not a failure: an identity with no account yet owns no store to
|
||||||
|
* read. A failed READ is one, and it propagates — the caller is `connect.connectedUser`,
|
||||||
|
* whose contract is that it did the work or said it did not.
|
||||||
|
*/
|
||||||
|
// @provenance restoreOwnCaps kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — `AddRepo::verify` loads each repo from its read cap at replay; this is that replay, at connection
|
||||||
|
export async function restoreOwnCaps(holderKey: string): Promise<void> {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null) return;
|
||||||
|
// `lookupAccount`, not `resolveAccount`: a read that could not ANSWER must not arrive
|
||||||
|
// here as "this identity has no account", which is the one absence entitled to silence.
|
||||||
|
// Its caller resolved the same account a moment ago, so this is a cache hit.
|
||||||
|
const record = await lookupAccount(holder);
|
||||||
|
if (record === null) return;
|
||||||
|
// The store documents themselves, or the reads below would be refused for want of the
|
||||||
|
// very structure this user owns by BEING a user.
|
||||||
|
fileOwnStructure(holder, record);
|
||||||
|
const caps = getCaps();
|
||||||
|
for (const scope of ["public", "protected", "private"] as const) {
|
||||||
|
const store = storeOf(record, scope);
|
||||||
|
if (!store) continue;
|
||||||
|
// COLD-START heal, and it is the whole point of this function running at CONNECTION
|
||||||
|
// time: on a fresh session the store repo is not yet in the verifier's `self.repos`,
|
||||||
|
// and an anchored read of a repo that is not open returns 0 rows — it does not fail.
|
||||||
|
// Without this the restore would answer "you own nothing" on exactly the session that
|
||||||
|
// needs it, silently, which is the failure-as-absence this whole path exists against.
|
||||||
|
// Same guard `readUserStore` and `readLinks` already apply to the same documents.
|
||||||
|
await ensureRepoOpen(store);
|
||||||
|
for (const cap of await readStoreCaps(store)) {
|
||||||
|
// `learnFor`, never a fresh mint: the cap must be the value the Store branch
|
||||||
|
// carries — see the note in `holdOwnCap` on why a second mint locks the owner out
|
||||||
|
// the day the stand-in value becomes a real key.
|
||||||
|
caps.learnFor(holderKey, cap);
|
||||||
|
// Which store a document sits in is a registry fact, not one recorded beside the
|
||||||
|
// caps, so it is re-applied here — marking only, like `listMyEntityDocs` does.
|
||||||
|
if (scope === "public") caps.markInPublicStore(targetOf(cap));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never
|
||||||
|
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
|
||||||
|
* function an app calls before `inbox.post`.
|
||||||
|
*
|
||||||
|
* Readable by whoever can read the document, because it lives on its Header branch —
|
||||||
|
* an address is public by nature (upstream a depositor needs only the inbox PUBLIC
|
||||||
|
* key). Conversely someone who cannot read the document learns nothing, which is
|
||||||
|
* faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to
|
||||||
|
* reach you.
|
||||||
|
*
|
||||||
|
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
|
||||||
|
* only its owner opens one, and only on its own document.
|
||||||
|
*/
|
||||||
|
// @provenance documentInboxAddress kind=divergent level=1 ref=engine/net/src/types.rs:ContactDetails — the deposit-side half of the same divergence: upstream you are TOLD an address, you never read one off a document
|
||||||
|
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
|
||||||
|
// RULE 2 — do not even attempt. Not holding the document, we have no address to
|
||||||
|
// find: upstream the inbox pubkey travels WITH what you can read, so "where do I
|
||||||
|
// deposit for a document I cannot read" is not a refused question, it is a question
|
||||||
|
// with no referent. Answering `undefined` here keeps the caller's shape (an address
|
||||||
|
// or none) instead of turning the boundary into an exception it must catch.
|
||||||
|
// …but ask the (emulated) network first: a document in a public store serves its cap
|
||||||
|
// to whoever asks (public-store.ts), and "where do I deposit for this public
|
||||||
|
// document" is exactly the question a third party arrives with, holding nothing but
|
||||||
|
// the reference.
|
||||||
|
await fetchReadCap(doc);
|
||||||
|
if (mustNotAttempt(doc)) return undefined;
|
||||||
|
const s = await session();
|
||||||
|
try {
|
||||||
|
const res = await sparqlQuery(
|
||||||
|
s.sessionId,
|
||||||
|
`SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
|
||||||
|
undefined,
|
||||||
|
doc,
|
||||||
|
"documentInboxAddress",
|
||||||
|
);
|
||||||
|
for (const row of readBindings(res)) {
|
||||||
|
const a = bindingValue(row, "a");
|
||||||
|
if (a && isNuri(a)) return a;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Unreadable document (no cap) or not synced → no address to give. Refusing to
|
||||||
|
// read is the boundary doing its job, not an error to propagate here.
|
||||||
|
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the connected user own `doc`? Answered from its **Store branches** — the
|
||||||
|
* register of the documents it created — across the three scopes, which is the only
|
||||||
|
* place that records authorship. Holding a cap is NOT ownership: a cap can be
|
||||||
|
* received, and a recipient must not be able to open an inbox on what it merely reads.
|
||||||
|
*
|
||||||
|
* The ONE caller that catches `readUserStore` rather than propagating it, deliberately.
|
||||||
|
* Every answer this function feeds is a REFUSAL — the write guard (`reach.mustNotAttempt`)
|
||||||
|
* and `openDocumentInbox` both only ever ask it for permission — so a store that did not
|
||||||
|
* answer closes the door, which is the safe side and writes nothing. That is the ruling
|
||||||
|
* made when this family was swept (commit `e32b6d0`): the sites that fail CLOSED were
|
||||||
|
* left, the ones that answer a caller with a fabricated value were not. The refusal names
|
||||||
|
* the wrong reason ("not yours" for "could not tell"), which is the price, and it is
|
||||||
|
* bounded — nobody acts on this answer except by being denied.
|
||||||
|
*/
|
||||||
|
// @provenance ownsDocument kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — authorship is what the Store branch records; holding a cap is NOT ownership, since a cap can be received
|
||||||
|
export async function ownsDocument(doc: Nuri): Promise<boolean> {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null) return false;
|
||||||
|
const record = await resolveAccount(holder);
|
||||||
|
if (record === null) return false;
|
||||||
|
for (const scope of ["public", "protected", "private"] as const) {
|
||||||
|
const store = storeOf(record, scope);
|
||||||
|
if (!store) continue;
|
||||||
|
try {
|
||||||
|
if ((await readUserStore(store)).includes(doc)) return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " ownsDocument: store unreadable:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `(document, inbox)` pairs recorded on this user's User branch. */
|
||||||
|
/**
|
||||||
|
* Encode the `(document, inbox)` pair of an emulated `AddInboxCap` record.
|
||||||
|
*
|
||||||
|
* Upstream this is a TYPED structure — `AddInboxCapV0 { repo_id, overlay, priv_key }`
|
||||||
|
* (`engine/repo/src/types.rs:1973`) — carried by a service commit, not a string. Ours is
|
||||||
|
* one RDF literal because our User branch is a subject in a document, so the pairing has
|
||||||
|
* to live inside a value. That is the emulation's shape, and it is what migration
|
||||||
|
* replaces: the fields become fields again.
|
||||||
|
*
|
||||||
|
* The separator is a space, which is safe for a reason worth stating rather than
|
||||||
|
* assuming: a NURI is `did:ng:` followed by base64url and `:`-separated segments
|
||||||
|
* (`NuriV0`, `engine/net/src/app_protocol.rs`), an alphabet that contains no space. The
|
||||||
|
* assertion below turns that from an implicit property into a checked one — a silently
|
||||||
|
* mis-split pair would file an inbox under a truncated document and lose deposits with
|
||||||
|
* no error, which is exactly the failure class this whole path already paid for once.
|
||||||
|
*/
|
||||||
|
function encodeInboxCap(doc: Nuri, inbox: Nuri): string {
|
||||||
|
if (doc.includes(" ") || inbox.includes(" ")) {
|
||||||
|
throw new Error(
|
||||||
|
"[ng-eventually] branch-registers: a NURI containing a space cannot be paired in " +
|
||||||
|
`an inbox-cap record — the separator would be ambiguous: ${JSON.stringify([doc, inbox])}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${doc} ${inbox}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The (document, inbox) pairs this user may read — the emulated `AddInboxCap` records.
|
||||||
|
*
|
||||||
|
* **Propagates a failed read**, for the reason spelled out on {@link readLinks}: this is
|
||||||
|
* half of the drain list `connect.connectedUser` works from, and an empty answer would
|
||||||
|
* make a document's queue silently un-drained — a share that was delivered and never
|
||||||
|
* applied, with nothing to see anywhere.
|
||||||
|
*
|
||||||
|
* **Barrier-AUTHORITATIVE, since 2026-08-17.** This was the only reader of a user's store
|
||||||
|
* in this file with no {@link ensureRepoOpen} of its own — `readLinks` next door,
|
||||||
|
* `restoreOwnCaps` and `readUserStore` all carry one — and on a fresh page over the same
|
||||||
|
* persistent wallet an anchored read of a not-yet-synced repo returns no rows, no error
|
||||||
|
* (`open-repo.ts`). What made that survive was caller ORDER: connecting opens the three
|
||||||
|
* stores ({@link restoreOwnCaps}) before anything asks. Order is not a guarantee, and the
|
||||||
|
* one caller that decides on the answer proved it — {@link openDocumentInbox} MINTS when
|
||||||
|
* this reads empty, so on a page that had settled an identity without connecting it yet,
|
||||||
|
* one call, no race, gave a note a SECOND inbox: two `AddInboxCap` records, and the
|
||||||
|
* address published on the document replaced by the new one, so later deposits land where
|
||||||
|
* none of the earlier ones are. Same ruling as the family around it (`e32b6d0`): only a
|
||||||
|
* VERIFIED absence may mint, and a read that could not answer is not one.
|
||||||
|
*
|
||||||
|
* Costs nothing on the paths that already connected — the open registry is per-session and
|
||||||
|
* a repo already open is a map hit (`open-repo.ts`).
|
||||||
|
*/
|
||||||
|
// @provenance readInboxCapPairs kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the record is keyed by `repo_id` and `update_inbox_cap_v0` applies it with no is-store check — but the engine only ever commits one for the two STORE repos, never for a plain document
|
||||||
|
export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null) return [];
|
||||||
|
const record = await resolveAccount(holder);
|
||||||
|
const store = record?.docPrivate;
|
||||||
|
if (!store) return [];
|
||||||
|
const s = await session();
|
||||||
|
const out: Array<{ doc: Nuri; inbox: Nuri }> = [];
|
||||||
|
// The sync barrier, before the read that decides — see the note above.
|
||||||
|
await ensureRepoOpen(store);
|
||||||
|
try {
|
||||||
|
const res = await sparqlQuery(
|
||||||
|
s.sessionId,
|
||||||
|
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> ?c }`,
|
||||||
|
undefined,
|
||||||
|
store,
|
||||||
|
"readInboxCaps",
|
||||||
|
);
|
||||||
|
for (const row of readBindings(res)) {
|
||||||
|
// See `encodeInboxCap` for why a space is a safe separator here, and why this
|
||||||
|
// pairing exists at all.
|
||||||
|
const [doc, inbox] = bindingValue(row, "c").split(" ");
|
||||||
|
if (doc && inbox && isNuri(doc) && isNuri(inbox)) out.push({ doc, inbox });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " readInboxCaps failed:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inbox recorded for one document, if this user opened one. */
|
||||||
|
// @provenance readInboxCapsFor kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the per-document lookup over the same unwired record
|
||||||
|
export async function readInboxCapsFor(doc: Nuri): Promise<Nuri | undefined> {
|
||||||
|
return (await readInboxCapPairs()).find((p) => p.doc === doc)?.inbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every inbox this user may READ: its own, plus one per document it opened an
|
||||||
|
* inbox on. What `connect.connectedUser` drains, and what `isOwnInbox` answers from.
|
||||||
|
*
|
||||||
|
* ── Listing an inbox PUTS IT IN YOUR HANDS, both levels alike ─────────────
|
||||||
|
* An `AddInboxCap` record is not a note that an inbox exists — upstream it IS the key:
|
||||||
|
* `AddInboxCapV0 { repo_id, overlay, priv_key }` (`engine/repo/src/types.rs:1973`), which
|
||||||
|
* `update_inbox_cap_v0` applies straight into the repo (`verifier.rs:1920`). So a session
|
||||||
|
* that can enumerate an inbox from that record can read it, by construction.
|
||||||
|
*
|
||||||
|
* Here the record carries the pair and not the key, so the entitlement it expresses is
|
||||||
|
* honoured the same way {@link fileOwnInbox} already honours it for a user's OWN two
|
||||||
|
* inboxes, one line above: the cap is minted from the NURI for the holder the record
|
||||||
|
* belongs to. Same emulation, same stand-in, and at migration both are replaced by the
|
||||||
|
* `priv_key` the record carries.
|
||||||
|
*
|
||||||
|
* Left out until 2026-08-16 for the DOCUMENT inboxes, and the consequence was not a
|
||||||
|
* missing feature: connecting enumerated an inbox it then had no key to read, the guarded
|
||||||
|
* read refused it, and the refusal came back out of `ensureIdentity()` — a rejected
|
||||||
|
* sign-in, on a document the application had never named, repeating at every connection
|
||||||
|
* because the record is durable. Reported live, three sign-ins out of three.
|
||||||
|
*/
|
||||||
|
// @provenance myInboxes kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the User branch answers 'which inboxes may I read'. The document-inbox half of this list is declared-not-wired — see `readInboxCapPairs`
|
||||||
|
export async function myInboxes(): Promise<Nuri[]> {
|
||||||
|
const { inboxes, incomplete } = await enumerateMyInboxes();
|
||||||
|
// The whole list or the failure that stopped it — the contract this function has always
|
||||||
|
// had, and the one `connect.connectedUser` is built on: not knowing which queues exist is
|
||||||
|
// the session failing to establish, and a short list would silently leave a delivered
|
||||||
|
// share un-drained. The original error is re-thrown, not wrapped: its caller reads it.
|
||||||
|
if (incomplete !== null) throw incomplete.error;
|
||||||
|
return inboxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What could be listed of {@link myInboxes}, and whether that is ALL of it.
|
||||||
|
*
|
||||||
|
* `incomplete` is `null` when the list is whole, and otherwise carries what stopped the rest
|
||||||
|
* from being listed. Not an error CODE and not a flag on the array: the point is that a
|
||||||
|
* caller cannot read this answer without meeting the question "was there a failure", which
|
||||||
|
* is exactly what a short array on its own let everybody skip.
|
||||||
|
*/
|
||||||
|
export interface InboxEnumeration {
|
||||||
|
/** Every inbox this holder may read that COULD be listed — possibly not all of them. */
|
||||||
|
inboxes: Nuri[];
|
||||||
|
/**
|
||||||
|
* What stopped the rest from being listed, or `null` when nothing did — and WHICH of the
|
||||||
|
* two registers stopped it, because the two shortfalls are not the same condition.
|
||||||
|
*
|
||||||
|
* `"account-record"` means the user's OWN two inboxes are missing, which is where a share
|
||||||
|
* addressed to a PERSON lands: the identity has nothing watched that a stranger can reach,
|
||||||
|
* and the document half was never even attempted (the account record is what it reads
|
||||||
|
* through). `"user-branch"` means those two are in hand and only the per-document inboxes
|
||||||
|
* are missing. Told apart because a caller that reports one of them has to be able to say
|
||||||
|
* that the OTHER one has now happened — `error` alone cannot, and the observation
|
||||||
|
* de-duplicating on "already said something" swallowed exactly that.
|
||||||
|
*/
|
||||||
|
incomplete: { error: unknown; register: InboxRegister } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHICH of the two registers behind {@link enumerateMyInboxes} could not be read.
|
||||||
|
*
|
||||||
|
* Named after the register, not after the failure, because that is what a reader has to go
|
||||||
|
* and look at: the account record in the doc-shim, and the User branch of the private store.
|
||||||
|
*/
|
||||||
|
export type InboxRegister = "account-record" | "user-branch";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link myInboxes}, for the caller that can use a PARTIAL answer — the live observation.
|
||||||
|
*
|
||||||
|
* The list is built from two independent registers: the account record, which names the
|
||||||
|
* user's own two store inboxes, and the User branch, which names one per document it opened
|
||||||
|
* an inbox on. Built in one `try`, one unreachable register discarded BOTH halves — the two
|
||||||
|
* user inboxes were already in hand when the second read threw, and the throw dropped them
|
||||||
|
* on the floor. A broker hiccup spanning sign-in therefore left the identity connected with
|
||||||
|
* ZERO inboxes watched, not with the one register it could not reach missing: every deposit,
|
||||||
|
* including the ones addressed to the person by name, waited for the next connection.
|
||||||
|
*
|
||||||
|
* So each half answers for itself, and what came back is returned WITH the failure rather
|
||||||
|
* than instead of it. That is the line this package draws everywhere: reaching a register is
|
||||||
|
* infrastructure and may fail, but a failure must never come back looking like an absence —
|
||||||
|
* hence {@link InboxEnumeration.incomplete}, which the caller has to look at.
|
||||||
|
*/
|
||||||
|
export async function enumerateMyInboxes(): Promise<InboxEnumeration> {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null) return { inboxes: [], incomplete: null };
|
||||||
|
const out: Nuri[] = [];
|
||||||
|
try {
|
||||||
|
// BOTH of the user's inboxes — public and protected — since upstream a site carries
|
||||||
|
// one on each of those two store repos (`engine/verifier/src/site.rs:127-152`).
|
||||||
|
if ((await resolveAccount(holder)) !== null) {
|
||||||
|
for (const scope of ["public", "protected"] as const) {
|
||||||
|
out.push(await userInbox(holder, scope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// The account record is what the other half reads THROUGH (`readInboxCapPairs` resolves
|
||||||
|
// the same record to find the private store), so there is no second half to attempt.
|
||||||
|
return { inboxes: out, incomplete: { error, register: "account-record" } };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const { inbox } of await readInboxCapPairs()) {
|
||||||
|
// The record entitles this holder to read it — see the note above.
|
||||||
|
fileOwnInbox(holder, inbox);
|
||||||
|
out.push(inbox);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { inboxes: out, incomplete: { error, register: "user-branch" } };
|
||||||
|
}
|
||||||
|
return { inboxes: out, incomplete: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File a cap received for someone ELSE's document — the emulated
|
||||||
|
* `AddLink { read_cap }` on the User branch of the current user's private store.
|
||||||
|
*
|
||||||
|
* This is what makes a received cap DURABLE. Before it, a shared document survived
|
||||||
|
* only by re-reading the inbox every session, which uses a queue as a database:
|
||||||
|
* upstream an inbox is consumed, and processing a message *applies* it. Applying a
|
||||||
|
* Link means writing it here.
|
||||||
|
*
|
||||||
|
* Idempotent — re-applying the same Link is a no-op, so re-processing an inbox
|
||||||
|
* (a second tab, a reconnect) costs nothing.
|
||||||
|
*
|
||||||
|
* `forHolder` names WHOSE User branch this lands on when it is not the connected
|
||||||
|
* identity's — the emulated inbox processor
|
||||||
|
* (`emulated-verifier/inbox-processor.ts`) applying a Link for an inbox's owner during
|
||||||
|
* someone else's session. Omitted, it is the connected identity, unchanged. Getting this
|
||||||
|
* wrong is not a near-miss: a Link filed under the wrong holder gives one user another's
|
||||||
|
* capability and leaves the real recipient with nothing.
|
||||||
|
*/
|
||||||
|
// @provenance addLink kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddLinkV0 — the type is DEFINED and its `CommitVerifier::verify` is a no-op `Ok(())`; nothing in the engine constructs one
|
||||||
|
export async function addLink(cap: ReadCap, forHolder?: PrincipalId): Promise<void> {
|
||||||
|
const holder = forHolder ?? getCurrentUser();
|
||||||
|
if (holder === null) return;
|
||||||
|
const record = await ensureAccount(holder);
|
||||||
|
const store = record.docPrivate;
|
||||||
|
if (!store) return;
|
||||||
|
if ((await readLinks(forHolder)).includes(cap)) return;
|
||||||
|
const s = await session();
|
||||||
|
try {
|
||||||
|
await registerUpdate(
|
||||||
|
s.sessionId,
|
||||||
|
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`,
|
||||||
|
store,
|
||||||
|
"addLink",
|
||||||
|
holderRing(forHolder),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " addLink failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cap-registry key of a NAMED holder, or `undefined` for "whoever is connected".
|
||||||
|
*
|
||||||
|
* One rule decides both, and it has to: the registry keys a holder's caps by
|
||||||
|
* `normalizeId` (`shared-wallet/bootstrap.ts` `capsHolder`), and the shim keys its accounts
|
||||||
|
* the same way ({@link accountKey}). A path that named a holder any other way would file
|
||||||
|
* into a ring the owner's own session never looks at.
|
||||||
|
*/
|
||||||
|
function holderRing(forHolder: PrincipalId | undefined): string | undefined {
|
||||||
|
return forHolder === undefined ? undefined : accountKey(forHolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caps this user has received and applied — the User branch read back. Called
|
||||||
|
* at connection to restore what was shared with them, without touching any inbox.
|
||||||
|
*
|
||||||
|
* **Propagates a failed read** rather than answering `[]`. Empty and unreadable are the
|
||||||
|
* same value here and could not be more different: "nobody has shared anything with me"
|
||||||
|
* against "everything shared with me is invisible and nothing said so". The caller that
|
||||||
|
* matters is `connect.connectedUser`, whose whole contract is that it either did the
|
||||||
|
* restore or says it did not (2026-08-13) — an empty answer would let it report success
|
||||||
|
* over a restore that never happened. Same reason `lookupAccount` exists beside
|
||||||
|
* `resolveAccount`.
|
||||||
|
*
|
||||||
|
* `forHolder` names whose User branch is read when it is not the connected identity's —
|
||||||
|
* see {@link addLink}, whose idempotence check is the caller that needs it.
|
||||||
|
*/
|
||||||
|
// @provenance readLinks kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddLinkV0 — same: defined, verifier is a no-op, constructed nowhere. NOT this library's invention — an absent implementation says nothing about the target
|
||||||
|
export async function readLinks(forHolder?: PrincipalId): Promise<ReadCap[]> {
|
||||||
|
const holder = forHolder ?? getCurrentUser();
|
||||||
|
if (holder === null) return [];
|
||||||
|
const record = await ensureAccount(holder);
|
||||||
|
const store = record.docPrivate;
|
||||||
|
if (!store) return [];
|
||||||
|
const s = await session();
|
||||||
|
const out: ReadCap[] = [];
|
||||||
|
const ring = holderRing(forHolder);
|
||||||
|
await ensureRepoOpen(store, ring);
|
||||||
|
const query = `SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`;
|
||||||
|
try {
|
||||||
|
// Two doors, one question. The connected identity's own register goes through the
|
||||||
|
// ordinary guarded read; a NAMED holder's goes through the processor's door, which
|
||||||
|
// asks the boundary about THAT holder's possession instead of the session's — see
|
||||||
|
// `register-write.readForHolder`.
|
||||||
|
const res =
|
||||||
|
ring === undefined
|
||||||
|
? await sparqlQuery(s.sessionId, query, undefined, store, "readLinks")
|
||||||
|
: await readForHolder(s.sessionId, query, store, ring, "readLinks");
|
||||||
|
for (const row of readBindings(res)) {
|
||||||
|
const v = bindingValue(row, "c");
|
||||||
|
if (v && hasReadCap(v)) out.push(v);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " readLinks failed:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `openDocumentInbox` calls currently in flight, keyed by `(holder, document)` —
|
||||||
|
* mirrors `account-registry.userInbox`'s `inboxInFlight`, which had the same read-then-
|
||||||
|
* mint shape and was made concurrency-safe first. This one was not, and the gap was
|
||||||
|
* reported from an application: four simultaneous calls on ONE document minted three
|
||||||
|
* inboxes, after which the owner drained one while deposits arrived in another.
|
||||||
|
*
|
||||||
|
* Note what did NOT happen: nothing failed. Every caller read the register, every read
|
||||||
|
* ANSWERED, and every answer was honestly "no inbox recorded" — because none of the writes
|
||||||
|
* had landed yet. So this is not the "a failure resolved like a success" family the rest of
|
||||||
|
* this file guards against; it is a read-then-write with no coalescing, and only the
|
||||||
|
* coalescing closes it.
|
||||||
|
*
|
||||||
|
* Keyed by the HOLDER as well as the document, because the answer is the holder's: the
|
||||||
|
* register lives on their User branch, and a non-owner asking gets a refusal, never an
|
||||||
|
* inbox. Keyed on `accountKey` so `@Alice` and `alice ` — one person — share one entry,
|
||||||
|
* and joined with `\u0000` for the reason `userInbox` uses it: a separator no identifier
|
||||||
|
* can contain is the only one that cannot make two different pairs share a key.
|
||||||
|
*
|
||||||
|
* Deliberately holds no RESOLVED value, unlike the `inboxCache` beside its counterpart:
|
||||||
|
* entries are dropped the instant the call settles, whether it answered or threw, so the
|
||||||
|
* next ask re-reads the durable register rather than trusting a memo — and a refusal
|
||||||
|
* never lingers as one. That is also why this map needs no reset hook: nothing in it can
|
||||||
|
* go stale, because nothing in it has finished.
|
||||||
|
*
|
||||||
|
* **Its reach is one JS realm, and that is the whole of what it promises.** Two browser
|
||||||
|
* tabs, or two sessions, share no map: each reads the register, each finds nothing, and
|
||||||
|
* each mints — the durable fork this cannot prevent. Preventing it needs a conditional
|
||||||
|
* write ("insert only if absent") that no layer of the target offers: a branch is an
|
||||||
|
* add-only CRDT, so two `AddInboxCap` records simply merge. Nor can it be reconciled
|
||||||
|
* after the fact the way `canonicalDoc` reconciles a forked account pointer: the owner
|
||||||
|
* resolves from the User branch and a depositor from the document's published address,
|
||||||
|
* two different records, and the second is last-write-wins by construction — upstream
|
||||||
|
* `inboxes: PubKey → RepoId` is a function and `repo.inbox` a single `Option`, so
|
||||||
|
* accumulating two addresses to pick a canonical one is a state the model has no meaning
|
||||||
|
* for. The contract says one realm; see `contract_polyfill-surface.md`.
|
||||||
|
*/
|
||||||
|
const openInboxInFlight = new Map<string, Promise<Nuri>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inbox of a document this user owns — resolved, and created on first ask.
|
||||||
|
*
|
||||||
|
* Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`):
|
||||||
|
* an inbox is a keypair on the repo, whose PRIVATE half its owner holds. That half is
|
||||||
|
* recorded with `AddInboxCap { repo_id, overlay, priv_key }` — *"into the user branch,
|
||||||
|
* so that a user can share with all its device"* (`engine/repo/src/types.rs:1973`), the
|
||||||
|
* same branch that carries `AddLink`. So "which inboxes may I read" is answered by the
|
||||||
|
* User branch, and that is what this emulates.
|
||||||
|
*
|
||||||
|
* **The engine SUPPORTS this; nothing exercises it automatically.** Those are two
|
||||||
|
* different statements, and conflating them is what made an earlier version of this
|
||||||
|
* comment call the feature an "anticipation". It is not. `inbox: Option<PrivKey>` is a
|
||||||
|
* field of EVERY `Repo` (`engine/repo/src/repo.rs:126`), not of a store structure;
|
||||||
|
* `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`); and
|
||||||
|
* `update_inbox_cap_v0` applies it with `self.repos.get_mut(repo_id)` and **no
|
||||||
|
* `is_store` check of any kind** (`engine/verifier/src/verifier.rs:1920`). Generic by
|
||||||
|
* construction, and at any time (see the User-branch note above).
|
||||||
|
*
|
||||||
|
* What is true is narrower: no code path CREATES one for a document — `new_store_default`
|
||||||
|
* attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None`,
|
||||||
|
* and the only two `AddInboxCap` commits in the engine are for the
|
||||||
|
* public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So the
|
||||||
|
* capability exists and is simply unexposed above level 1: this function is aligned on
|
||||||
|
* the engine's model, it does not bet past it.
|
||||||
|
*
|
||||||
|
* *(The `inbox: None` claim is true; its citation was wrong until 2026-08-10. It pointed
|
||||||
|
* at `repo.rs:574`, inside `Repo::new_with_member` (`engine/repo/src/repo.rs:543`) —
|
||||||
|
* a constructor reached only from `Repo::new_with_perms`, itself gated
|
||||||
|
* `#[cfg(any(test, feature = "testing"))]` (`repo.rs:186-192`), and from `#[cfg(test)]`
|
||||||
|
* blocks (`branch.rs:387,490`; `commit.rs:1659,1849,1919`). The PRODUCTION path is
|
||||||
|
* `doc_create` → `Verifier::new_repo_default` (`engine/verifier/src/verifier.rs:3004`,
|
||||||
|
* called at `request_processor.rs:689`) → `Store::create_repo_default`
|
||||||
|
* (`engine/repo/src/store.rs:264`) → `create_repo_with_keys` (`store.rs:284`), which
|
||||||
|
* builds the `Repo` with `inbox: None` at `store.rs:691`.)*
|
||||||
|
*
|
||||||
|
* Lazy on purpose, for the same reason: creating an inbox document for every entity up
|
||||||
|
* front would double every `createEntityDoc` for inboxes most documents never receive
|
||||||
|
* anything in. Upstream the keypair is cheap; here an inbox is a document, so it is
|
||||||
|
* minted when first asked for.
|
||||||
|
*
|
||||||
|
* *(Not covered: ROTATING an inbox key — the engine's "update" case with a new
|
||||||
|
* `priv_key`. This function is idempotent and returns the existing inbox instead. A
|
||||||
|
* known limit, not an oversight.)*
|
||||||
|
*
|
||||||
|
* Only for a document this user OWNS — see {@link ownsDocument}. Opening an inbox on
|
||||||
|
* someone else's document would be usurpation, not a courtesy: the opener keeps the
|
||||||
|
* reading half, so it would silently divert to itself the deposits meant for the
|
||||||
|
* owner. To deposit into someone else's document, resolve
|
||||||
|
* {@link documentInboxAddress} and `inbox.post` into it.
|
||||||
|
*
|
||||||
|
* **Idempotent, including under concurrency — within ONE JS realm.** Simultaneous asks
|
||||||
|
* for the same document by the same holder are coalesced onto a single call
|
||||||
|
* ({@link openInboxInFlight}), which is what stops N callers each reading "no inbox" and
|
||||||
|
* each minting one. Two TABS still fork, and cannot be stopped from here — read the note
|
||||||
|
* on that map before assuming otherwise.
|
||||||
|
*/
|
||||||
|
// @provenance storeRegistry.openDocumentInbox kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — every `Repo` carries `inbox: Option<PrivKey>` and the record is keyed by any `repo_id`, but `new_store_default` attaches one only to non-private STORES and `doc_create` leaves `inbox: None`. PUBLISHING the address is a separate, divergent act — see `publishInboxAddress`
|
||||||
|
export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
|
||||||
|
// Permissive in, precise out — see `model/nuri.ts`. Published through
|
||||||
|
// `surface/placement.ts`, so it is a door an application types against.
|
||||||
|
const doc = toNuri(docLike, "openDocumentInbox");
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
|
||||||
|
|
||||||
|
// Everything below is one read-then-write: it asks the register whether an inbox is
|
||||||
|
// already recorded, and mints when the answer is no. A dozen awaits separate the two,
|
||||||
|
// so callers that arrive together all read before any of them writes — each finds
|
||||||
|
// nothing, each mints, and the document ends up with several. Coalescing them onto ONE
|
||||||
|
// call is the whole fix, and it is where the guarantee is enforced rather than merely
|
||||||
|
// hoped for: a second caller never runs the body at all, it awaits the first.
|
||||||
|
const key = `${accountKey(holder)}\u0000${doc}`;
|
||||||
|
const pending = openInboxInFlight.get(key);
|
||||||
|
if (pending) return pending;
|
||||||
|
const p = resolveOrMintDocumentInbox(doc, holder);
|
||||||
|
openInboxInFlight.set(key, p);
|
||||||
|
try {
|
||||||
|
return await p;
|
||||||
|
} finally {
|
||||||
|
// Dropped whether it resolved or threw. A refusal (not the owner) or a failed persist
|
||||||
|
// must not linger as an answer: the next ask has to look again, exactly as it would
|
||||||
|
// have if it had arrived a moment later.
|
||||||
|
openInboxInFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body of {@link openDocumentInbox}, minus the coalescing — a separate function so
|
||||||
|
* the "one call per (holder, document)" invariant is carried by the composition rather
|
||||||
|
* than by where a check sits inside a long block. Never call it directly: it is the
|
||||||
|
* un-coalesced path, and reaching it twice concurrently is the bug.
|
||||||
|
*/
|
||||||
|
async function resolveOrMintDocumentInbox(doc: Nuri, holder: PrincipalId): Promise<Nuri> {
|
||||||
|
const known = (await readInboxCapsFor(doc)) ?? null;
|
||||||
|
if (known) return known;
|
||||||
|
|
||||||
|
// OWNERSHIP is the criterion, and holding a cap is NOT ownership — a cap can be
|
||||||
|
// received. Opening an inbox is what PUBLISHES this document's address, so a
|
||||||
|
// non-owner doing it would route the owner's deposits to itself, silently, on a
|
||||||
|
// document it merely reads.
|
||||||
|
//
|
||||||
|
// **This guard compensates OUR design, not an upstream constraint** — an earlier
|
||||||
|
// comment here claimed "upstream only the owner can commit `AddInboxCap`", which is
|
||||||
|
// false: that commit lands on the committer's OWN User branch, so anyone may write
|
||||||
|
// one naming anyone's repo. What protects upstream is that an inbox address is never
|
||||||
|
// PUBLISHED — it is TRANSMITTED (in a `ContactDetails` message, or a profile QR
|
||||||
|
// code), and `inboxes: PubKey → RepoId` is a table of the VERIFIER
|
||||||
|
// (`engine/verifier/src/verifier.rs:105`) — one per user. A forged pair reaches
|
||||||
|
// nobody because it only ever lands in the forger's OWN table; nobody else was told.
|
||||||
|
//
|
||||||
|
// The motive matters, and it was wrong here until 2026-08-10: this comment said the
|
||||||
|
// table is "rebuilt empty each session", which is not what the source does. It is
|
||||||
|
// initialized empty at construction (`:520`, `:2820`) and then REPOPULATED at every
|
||||||
|
// load — `Verifier::load` (`:534-566`) → `add_repo_without_saving` (`:2871`) →
|
||||||
|
// `add_repo_` (`:2887`), which re-inserts `repo.inbox.to_pub() → repo.id` for each
|
||||||
|
// repo it reloads — and the inbox private key itself is persisted per repo
|
||||||
|
// (`INBOX_CAP`, `engine/verifier/src/user_storage/repo.rs:61,171,207,362`). So the
|
||||||
|
// knowledge is durable; what it is not is SHARED. Per-verifier, not ephemeral.
|
||||||
|
//
|
||||||
|
// We publish instead of transmitting — the only way a third party can find the
|
||||||
|
// address at all here — which creates a vector upstream does not have: whoever can
|
||||||
|
// write the document can redirect its deposits. Hence this guard. It is a real
|
||||||
|
// divergence, deliberately taken; see `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
||||||
|
if (!(await ownsDocument(doc))) {
|
||||||
|
throw new Error(
|
||||||
|
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
|
||||||
|
"you own. To reach its owner, name the DOCUMENT: `inbox.postToDocument(doc, …)`, " +
|
||||||
|
`which resolves the address itself: ${JSON.stringify(doc)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inbox = await createDoc();
|
||||||
|
const s = await session();
|
||||||
|
const record = await ensureAccount(holder);
|
||||||
|
const store = record.docPrivate;
|
||||||
|
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
|
||||||
|
// …and the shim records that it IS an inbox, so a depositor can find that out without
|
||||||
|
// holding anything of it. See `recordInbox`: upstream a deposit cannot address a plain
|
||||||
|
// document at all, and this is what stands in for that impossibility — and WHOSE it is,
|
||||||
|
// which is the other half of the same routing fact. The owner is `holder`: this branch is
|
||||||
|
// past the ownership guard, so the opener owns the document the inbox belongs to.
|
||||||
|
await recordInbox(inbox, holder);
|
||||||
|
if (store) {
|
||||||
|
try {
|
||||||
|
await registerUpdate(
|
||||||
|
s.sessionId,
|
||||||
|
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(encodeInboxCap(doc, inbox))}" }`,
|
||||||
|
store,
|
||||||
|
"openDocumentInbox",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// This record is what makes the inbox DRAINABLE: `myInboxes` builds the connection's
|
||||||
|
// drain list from it, and `readInboxCapsFor` answers "have I already opened one"
|
||||||
|
// from it. Swallowing here went straight on to PUBLISH the address, so depositors
|
||||||
|
// were invited to write into a queue its own owner never enumerates — every message
|
||||||
|
// delivered and none ever applied, permanently. Failing before the address is
|
||||||
|
// published is the honest state: nobody is told where to deposit, and asking again
|
||||||
|
// opens a fresh inbox.
|
||||||
|
console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// …and the PUBLIC half, in the document itself, so a depositor can find it at all.
|
||||||
|
// Without this the inbox is reachable only by its owner — the opposite of what an
|
||||||
|
// inbox is for, and the bug this path shipped with.
|
||||||
|
await publishInboxAddress(doc, inbox);
|
||||||
|
return inbox;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
/**
|
||||||
|
* Capability emulation — key POSSESSION, not an authorization list.
|
||||||
|
*
|
||||||
|
* In NextGraph a ReadCap **is** the document's read key: whoever holds it reads,
|
||||||
|
* and there is no read-ACL anywhere. This module emulates that shape (see
|
||||||
|
* `docs/briefs/2026-07-27-p1a-cap-surface.md`), which means it answers exactly one
|
||||||
|
* question — *do I hold this document's cap?* — and cannot answer "may principal P
|
||||||
|
* read document D", because the real model cannot either.
|
||||||
|
*
|
||||||
|
* ── Where caps come from — and why this is NOT "a keyring" ────────────────
|
||||||
|
* There is no keyring object in NextGraph, and calling this one invited a wrong
|
||||||
|
* mental model: that some single place holds every key. It does not. Upstream the
|
||||||
|
* caps of a user are in **two** places, by origin (see
|
||||||
|
* `docs/readcap-and-nuri-model.md` §4quater/§4quinquies):
|
||||||
|
*
|
||||||
|
* - documents the user CREATED → `AddRepo { read_cap }` on the **Store branch**
|
||||||
|
* of the store they live in — one such branch per store;
|
||||||
|
* - caps RECEIVED for someone else's documents → `AddLink { read_cap }` on the
|
||||||
|
* **User branch** of the private store.
|
||||||
|
*
|
||||||
|
* The wallet itself holds exactly one key per user: the private store's read cap,
|
||||||
|
* from which everything else is reached. Hence the invariant:
|
||||||
|
*
|
||||||
|
* > You do not derive a cap from a bare reference. You look it up in what you
|
||||||
|
* > hold — or you were given it.
|
||||||
|
*
|
||||||
|
* This class is the in-memory record of what the connected holder currently holds:
|
||||||
|
* upstream's local user storage, not a durable register. The durable ones are
|
||||||
|
* emulated in `store-registry.ts` — for created documents, `holdOwnCap` writes and
|
||||||
|
* `readStoreCaps` reads the Store branch back; for received ones, `addLink` /
|
||||||
|
* `readLinks` on the User branch. `connect.ts` restores the Links at connection;
|
||||||
|
* the own-document caps come back through `listMyEntityDocs`.
|
||||||
|
*
|
||||||
|
* One record PER holder, since one shared wallet hosts every identity. Switching
|
||||||
|
* identity therefore SWITCHES records; it never wipes one (a wipe would make
|
||||||
|
* durability a lie and bring per-session re-declaration back under another name).
|
||||||
|
*
|
||||||
|
* ── Sharing ───────────────────────────────────────────────────────────────
|
||||||
|
* Not here: the unit of sharing is the document and the recipient is an INBOX, so
|
||||||
|
* sharing is `inbox.share(doc, toUser)` — a **Link** deposit — and receiving is
|
||||||
|
* the recipient processing their inbox. Handing over a store's cap is NOT the
|
||||||
|
* gesture: it would give away everything that store contains, present and future.
|
||||||
|
*
|
||||||
|
* And for a document in a PUBLIC store there is no sharing act at all: the store hands
|
||||||
|
* its cap to whoever asks (`public-store.ts`), so what circulates is the bare
|
||||||
|
* reference. Filed apart (`learnFromPublicStore`) because it grants reading only.
|
||||||
|
*
|
||||||
|
* ── What this module does NOT do ──────────────────────────────────────────
|
||||||
|
* Enforce. The shape is right, delivered by cap-surface; the isolation is still
|
||||||
|
* fake. Per-document encryption and closing the read paths that bypass the guard
|
||||||
|
* (an ANCHORLESS `docs.sparqlQuery`, the inbox, `store-registry`, `subscribe`,
|
||||||
|
* `open-repo`) are cap-enforcement's job. Nothing may be claimed "anonymous" or
|
||||||
|
* "private" until then.
|
||||||
|
*
|
||||||
|
* The write caps below (`grantWrite`, `governsWrite`, `canWrite`, `hasWritePolicy`) are
|
||||||
|
* **inert, not partial** — a distinction the docs got wrong until 2026-08-07, when an
|
||||||
|
* adversarial review measured it. `grantWrite` has NO production caller, so
|
||||||
|
* `hasWritePolicy()` is permanently false and the `ng-proxy` guard they feed never fires
|
||||||
|
* at all. Writing is governed instead by OWNERSHIP, at the write door (`reach.ts`
|
||||||
|
* `assertMayWrite`) — which is what upstream's `verify_permission` actually checks. These
|
||||||
|
* four are dead surface kept for cap-enforcement; do not read them as a working policy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CAP_SEGMENT, hasReadCap, targetOf } from "../model/nuri";
|
||||||
|
import type { Nuri, PrincipalId, ReadCap, Scope } from "../model/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stand-in cap value, and the minting point — moved here from `model/nuri.ts`
|
||||||
|
* on 2026-08-03 because it did not belong to the model.
|
||||||
|
*
|
||||||
|
* `model/` transcribes the target's addressing vocabulary; minting is not part of
|
||||||
|
* that vocabulary. Upstream nothing on the surface turns a bare reference into a cap:
|
||||||
|
* the engine mints at repo creation and you afterwards look a cap up in what you hold,
|
||||||
|
* or you were given it. Keeping `mintCap` in the model module contradicted that module's
|
||||||
|
* own header, and put the emulation's one invented value in the file that claims to hold
|
||||||
|
* only verified target vocabulary.
|
||||||
|
*
|
||||||
|
* cap-enforcement replaces this single constant with a real key; migration deletes both.
|
||||||
|
*/
|
||||||
|
const STAND_IN_CAP = "OK";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the cap-bearing form of `nuri` — `{target}:r:OK`. Passing an already
|
||||||
|
* cap-bearing reference yields the same value. INTERNAL to the emulated verifier.
|
||||||
|
*/
|
||||||
|
// @provenance mintCap kind=divergent level=1 ref=engine/repo/src/types.rs:readcap_nuri — upstream a cap is SERIALIZED from the ObjectRef at repo creation; here it is DERIVED from a NURI with a stand-in key. Unreachable from outside on purpose
|
||||||
|
export function mintCap(nuri: Nuri): ReadCap {
|
||||||
|
return `${targetOf(nuri)}${CAP_SEGMENT}${STAND_IN_CAP}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The map key of the anonymous holder (no identity established yet). */
|
||||||
|
const ANONYMOUS = "";
|
||||||
|
|
||||||
|
// @provenance CapRegistry kind=aligned level=1 ref=engine/repo/src/repo.rs:Repo — the session's record of which repos it holds a `read_cap` for; upstream that record is the verifier's opened repos, never a queryable registry
|
||||||
|
export class CapRegistry {
|
||||||
|
/** holder → the caps they hold, indexed by the cap-less NURI. */
|
||||||
|
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
|
||||||
|
/**
|
||||||
|
* holder → the documents they CREATED in this session, through {@link mint}.
|
||||||
|
*
|
||||||
|
* Authorship, for the one path that records it nowhere else. `storeRegistry`'s
|
||||||
|
* documents are recorded durably on a Store branch (the emulated `AddRepo`, which is
|
||||||
|
* what upstream's `doc_create` commits), so `ownsDocument` finds them on a later
|
||||||
|
* session. The raw `docs.docCreate` has no store to record into — so nothing about
|
||||||
|
* such a document survives its session, and an in-session note of who made it is
|
||||||
|
* exactly as durable as the thing it describes.
|
||||||
|
*
|
||||||
|
* Consulted by the write guard before it pays for a Store-branch read. Without it the
|
||||||
|
* guard refused a caller a write to a document it had just created — caught by the
|
||||||
|
* live-broker e2e, seven steps red, after the unit suite stayed green.
|
||||||
|
*/
|
||||||
|
private mintedByHolder = new Map<string, Set<Nuri>>();
|
||||||
|
/**
|
||||||
|
* Documents this session knows to sit in a PUBLIC store — a fact about each
|
||||||
|
* DOCUMENT, so global rather than per-holder, unlike everything else here.
|
||||||
|
*
|
||||||
|
* It is not itself a right. What being in a public store buys is that the document's
|
||||||
|
* cap can be DOWNLOADED by anyone who asks (`emulated-verifier/public-store.ts`,
|
||||||
|
* emulating `PublicRepoLinkV0`'s *"downloaded from the outerOverlay"*); once it has
|
||||||
|
* been, the holder holds it like any other and this set records only how it got there.
|
||||||
|
*/
|
||||||
|
private inPublicStore = new Set<Nuri>();
|
||||||
|
/** doc NURI → principals holding its WRITE cap. Decorative until cap-enforcement. */
|
||||||
|
private writers = new Map<Nuri, Set<PrincipalId>>();
|
||||||
|
/** Fired whenever a holder gains a cap — a cap delivered asynchronously must
|
||||||
|
* re-trigger the reads that were empty for want of it. */
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
/** Has any cap been issued at all? Gates the whole emulation (see {@link isEnforcing}). */
|
||||||
|
private issued = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param holder resolves WHO is holding — the current identity. Looked up through it on every
|
||||||
|
* call, so an identity switch switches records with nothing to reset. Defaults to the anonymous holder.
|
||||||
|
*/
|
||||||
|
constructor(private readonly holder: () => PrincipalId | null = () => null) {}
|
||||||
|
|
||||||
|
// --- what the holder holds ----------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key of the holder currently connected — capture it when you DECIDE that a cap is
|
||||||
|
* someone's, and hand it back to {@link learnFor} when you file.
|
||||||
|
*
|
||||||
|
* **A hazard closed, not a leak observed** — the distinction matters and I got it wrong
|
||||||
|
* once while writing this. Filing resolves the holder at the moment it runs, and three
|
||||||
|
* paths file several `await`s after the check that authorised them (connecting, reading
|
||||||
|
* an inbox, listing one's own documents). So an application switching identity in the
|
||||||
|
* gap COULD have the first identity's caps filed into the second one's ring. That is
|
||||||
|
* structural and visible by reading. What was NOT established is that it happens: the
|
||||||
|
* reproduction that seemed to show it turned out to be a broken test fake, and once the
|
||||||
|
* fake was corrected the leak did not reproduce.
|
||||||
|
*
|
||||||
|
* The pairing stays because it costs one argument and removes the hazard by
|
||||||
|
* construction, where a re-check at each of three sites is a discipline. It is not
|
||||||
|
* evidence of a bug that was found.
|
||||||
|
*/
|
||||||
|
holderKey(): string {
|
||||||
|
return this.holder() ?? ANONYMOUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the current holder holds, created on first use. */
|
||||||
|
private heldCaps(): Map<Nuri, ReadCap> {
|
||||||
|
return this.ringFor(this.holderKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ringFor(key: string): Map<Nuri, ReadCap> {
|
||||||
|
let ring = this.heldByHolder.get(key);
|
||||||
|
if (!ring) this.heldByHolder.set(key, (ring = new Map()));
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File `cap` among what the current holder holds — the ONE door in, so
|
||||||
|
* the invariant is carried here rather than by each caller remembering it.
|
||||||
|
*
|
||||||
|
* A reference with no `:r:` is REFUSED. `Nuri` and `ReadCap` are both `string`
|
||||||
|
* (deliberately — the real SDK takes `nuri: String`), so the compiler cannot
|
||||||
|
* catch a caller passing the naming form where the reading form is meant. Left
|
||||||
|
* unchecked, that mistake files a bare reference under its own name, `capFor`
|
||||||
|
* then returns it, and the document reads — turning "naming is not reading" into
|
||||||
|
* "naming is reading", which is the exact inversion this batch exists to remove.
|
||||||
|
* The check is cheap and it is the only thing standing between the two.
|
||||||
|
*
|
||||||
|
* Returns whether the cap was new.
|
||||||
|
*/
|
||||||
|
private file(cap: ReadCap, key: string = this.holderKey()): boolean {
|
||||||
|
if (!hasReadCap(cap)) {
|
||||||
|
throw new Error(
|
||||||
|
"[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " +
|
||||||
|
`reference — naming is not reading, and no cap derives from one: ${JSON.stringify(cap)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const target = targetOf(cap);
|
||||||
|
const ring = this.ringFor(key);
|
||||||
|
if (ring.get(target) === cap) return false;
|
||||||
|
ring.set(target, cap);
|
||||||
|
this.issued = true;
|
||||||
|
this.notify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cap of a document I just CREATED, filed among what I hold — the emulated
|
||||||
|
* `AddRepo { read_cap }`. Idempotent. Returns the cap.
|
||||||
|
*/
|
||||||
|
mint(nuri: Nuri): ReadCap {
|
||||||
|
const cap = mintCap(nuri);
|
||||||
|
this.file(cap);
|
||||||
|
const key = this.holder() ?? ANONYMOUS;
|
||||||
|
let made = this.mintedByHolder.get(key);
|
||||||
|
if (!made) this.mintedByHolder.set(key, (made = new Set()));
|
||||||
|
made.add(targetOf(nuri));
|
||||||
|
return cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Did the current holder CREATE this document in this session? Authorship, and
|
||||||
|
* therefore the right to write — see {@link mintedByHolder}.
|
||||||
|
*/
|
||||||
|
mintedHere(nuri: Nuri): boolean {
|
||||||
|
return this.mintedByHolder.get(this.holder() ?? ANONYMOUS)?.has(targetOf(nuri)) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File a cap I was GIVEN — an inbox deposit of kind `cap`, or a repo link found
|
||||||
|
* in world-readable content. This is the ONLY way a cap arrives from
|
||||||
|
* outside: nothing turns a bare reference into a cap.
|
||||||
|
*
|
||||||
|
* @throws if `cap` carries no `:r:` — see {@link file}. Passing a bare `Nuri`
|
||||||
|
* here is the one type confusion that would silently invert the model, and both
|
||||||
|
* forms are `string`, so it is rejected at runtime instead.
|
||||||
|
*/
|
||||||
|
learn(cap: ReadCap): void {
|
||||||
|
this.file(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File a cap for a NAMED holder — the one the caller decided for, not whoever happens
|
||||||
|
* to be connected when the `await` resumes. See {@link holderKey}.
|
||||||
|
*/
|
||||||
|
learnFor(key: string, cap: ReadCap): void {
|
||||||
|
this.file(cap, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the
|
||||||
|
* emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading
|
||||||
|
* needs no special case anywhere; recorded apart because of what it is NOT.
|
||||||
|
*
|
||||||
|
* It is a READ grant and nothing else. Upstream a public store makes its repos
|
||||||
|
* world-readable, never world-writable — writing needs the write cap, and
|
||||||
|
* `verify_permission` fires on WRITE only. Here the write guard still consults the
|
||||||
|
* read cap (write caps are decorative until cap-enforcement, see the module header), so without
|
||||||
|
* this distinction a bare reference to a public document would buy a WRITE — a
|
||||||
|
* consumer would build on it, and have to unlearn it at migration.
|
||||||
|
*
|
||||||
|
* A stronger claim on the same document erases the mark: {@link mint} (I created it)
|
||||||
|
* and {@link learn} (it was deposited for me) both go through {@link file}, which
|
||||||
|
* clears it. So a public document of my own is never read-only to me.
|
||||||
|
*/
|
||||||
|
learnFromPublicStore(cap: ReadCap): void {
|
||||||
|
this.file(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold
|
||||||
|
* none — which is the whole answer the model can give. Absorbs the former
|
||||||
|
* `canRead(doc, principal)`: there is no principal parameter, because there is
|
||||||
|
* no list to look a principal up in.
|
||||||
|
*/
|
||||||
|
capFor(nuri: Nuri): ReadCap | undefined {
|
||||||
|
return this.heldCaps().get(targetOf(nuri));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the NAMED holder hold `nuri`'s cap? The reading counterpart of {@link learnFor},
|
||||||
|
* and it exists for the same caller: work decided for one holder that runs while ANOTHER
|
||||||
|
* one is connected — the emulated inbox processor
|
||||||
|
* (`emulated-verifier/inbox-processor.ts`), which drains an inbox on behalf of its owner
|
||||||
|
* during someone else's session. Asking `capFor` there would consult the connected
|
||||||
|
* identity's ring, which is not the ring the question is about.
|
||||||
|
*
|
||||||
|
* Reads without creating a ring, unlike {@link heldCaps}: asking about a holder must not
|
||||||
|
* file one. Still no principal parameter in the model's sense — the question is "does
|
||||||
|
* THIS ring hold the key", never "may principal P read D".
|
||||||
|
*/
|
||||||
|
capForHolder(key: string, nuri: Nuri): ReadCap | undefined {
|
||||||
|
return this.heldByHolder.get(key)?.get(targetOf(nuri));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- publication (the public store) -------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that `nuri` sits in a PUBLIC store. A fact about the DOCUMENT, not a right
|
||||||
|
* of anyone — hence a global set rather than a per-holder one, and hence no minting
|
||||||
|
* here: what sitting in a public store buys is that the cap is **obtainable** by
|
||||||
|
* whoever asks (`emulated-verifier/public-store.ts`), which is a separate act from
|
||||||
|
* this one holding it.
|
||||||
|
*
|
||||||
|
* Marking and minting were one method (`recordInPublicStore`) until they were split:
|
||||||
|
* the fetch path files the cap it DOWNLOADED, and minting a second one beside it
|
||||||
|
* would produce a different key the day the stand-in constant becomes a real one —
|
||||||
|
* the same trap `holdOwnCap` already documents.
|
||||||
|
*
|
||||||
|
* Upstream nothing corresponds to this call: the store IS public, and the broker
|
||||||
|
* exposes its outer overlay (`expose_outer`,
|
||||||
|
* `engine/broker/src/server_storage/core/overlay.rs:103-133`). We record it because
|
||||||
|
* one broker here serves every virtual user identically.
|
||||||
|
*
|
||||||
|
* NOT recursive: a document in a public store may REFERENCE private ones, and the
|
||||||
|
* reference grants nothing on what it references. That non-recursiveness is what lets
|
||||||
|
* a public object point at private content without disclosing it.
|
||||||
|
*/
|
||||||
|
markInPublicStore(nuri: Nuri): void {
|
||||||
|
this.inPublicStore.add(targetOf(nuri));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is `nuri` recorded as sitting in a public store? A fact about the document. */
|
||||||
|
isInPublicStore(nuri: Nuri): boolean {
|
||||||
|
return this.inPublicStore.has(targetOf(nuri));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a document the current holder owns in `scope`: its cap lands among what
|
||||||
|
* they hold, and a `public` one is additionally marked as sitting in a public store.
|
||||||
|
* Returns the cap. Idempotent — the registry calls it both when creating a document
|
||||||
|
* and when listing the holder's own documents back, which is how a holder's caps are
|
||||||
|
* rebuilt on a fresh session.
|
||||||
|
*
|
||||||
|
* Deliberately does NOT touch write caps: those are decorative until cap-enforcement, and
|
||||||
|
* arming their guard here would be enforcement this batch does not do.
|
||||||
|
*/
|
||||||
|
open(nuri: Nuri, scope: Scope): ReadCap {
|
||||||
|
// `file`, NOT `mint` — and the difference is a hole that was open for one commit.
|
||||||
|
//
|
||||||
|
// Every caller of this method files a STRUCTURAL document: one of the holder's three
|
||||||
|
// store documents, or an inbox. Those are not authored content, they are registers —
|
||||||
|
// written only through `emulated-verifier/register-write.ts`. Minting them marked
|
||||||
|
// them "created by me", which let the write guard through, which let a holder append
|
||||||
|
// `contains "<anyone's document>"` to their own store index through the PUBLISHED
|
||||||
|
// `docs.sparqlUpdate` and forge ownership of it. `ownsDocument` reads that very
|
||||||
|
// index, so the guard was fully bypassable from the surface.
|
||||||
|
//
|
||||||
|
// Found by re-running the adversary on the fix (2026-08-07). Filing without minting
|
||||||
|
// closes it at the source: a structural document is owned by nobody in the authorship
|
||||||
|
// sense, so both halves of `assertMayWrite` say no, which is correct.
|
||||||
|
const cap = mintCap(nuri);
|
||||||
|
this.file(cap);
|
||||||
|
if (scope === "public") this.markInPublicStore(nuri);
|
||||||
|
return cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- enforcement gate ---------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the cap emulation in force? False until the first cap is issued, so a
|
||||||
|
* consumer that never touches caps keeps reading everything (no regression).
|
||||||
|
* Once ANY cap exists the regime is possession for EVERY holder — including one
|
||||||
|
* who holds nothing, which is exactly the isolation being emulated.
|
||||||
|
*/
|
||||||
|
isEnforcing(): boolean {
|
||||||
|
return this.issued;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- change signal ------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to changes in what the holder holds. A cap that arrives asynchronously (an inbox
|
||||||
|
* deposit) must make the views that were empty for want of it re-read; without
|
||||||
|
* this signal they stay stale until an unrelated change happens to fire.
|
||||||
|
*/
|
||||||
|
onChange(listener: () => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const l of this.listeners) {
|
||||||
|
try {
|
||||||
|
l();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[caps] change listener threw", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write caps (decorative until cap-enforcement) ----------------------------------
|
||||||
|
|
||||||
|
/** Grant `principal` the WRITE cap of document `doc`. */
|
||||||
|
grantWrite(doc: Nuri, principal: PrincipalId): void {
|
||||||
|
const target = targetOf(doc);
|
||||||
|
let s = this.writers.get(target);
|
||||||
|
if (!s) this.writers.set(target, (s = new Set()));
|
||||||
|
s.add(principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is `doc` under any WRITE-cap policy? */
|
||||||
|
governsWrite(doc: Nuri): boolean {
|
||||||
|
return this.writers.has(targetOf(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does `principal` hold a WRITE cap for `doc`? */
|
||||||
|
canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
|
||||||
|
if (principal === null) return false;
|
||||||
|
return this.writers.get(targetOf(doc))?.has(principal) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No WRITE policy declared → the write guard stays inert (passthrough). */
|
||||||
|
hasWritePolicy(): boolean {
|
||||||
|
return this.writers.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop every holder's caps and every publication. Tests / a fresh wallet only —
|
||||||
|
* NOT what an identity change does (that switches heldByHolder, see the header). */
|
||||||
|
clear(): void {
|
||||||
|
this.heldByHolder.clear();
|
||||||
|
this.mintedByHolder.clear();
|
||||||
|
this.inPublicStore.clear();
|
||||||
|
this.writers.clear();
|
||||||
|
this.issued = false;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* connect — what the polyfill does when the app connects a virtual user.
|
||||||
|
*
|
||||||
|
* ── Processing inboxes is the LIBRARY's job, not the app's ────────────────
|
||||||
|
* Stated by the PO, 2026-07-30. A consumer must not have to remember to drain its
|
||||||
|
* inbox for documents shared with it to become readable; forgetting would look
|
||||||
|
* like "the share did not work" rather than "nobody processed the queue". So the
|
||||||
|
* moment an identity is connected ({@link setCurrentUser}), this runs.
|
||||||
|
*
|
||||||
|
* Three steps, in order, and the order matters:
|
||||||
|
*
|
||||||
|
* 1. **Restore** — replay the durable registers back into what this user holds. All of
|
||||||
|
* them: the Store branches of its three stores (the emulated `AddRepo { read_cap }` —
|
||||||
|
* the documents it CREATED, `branch-registers.restoreOwnCaps`) and the User branch's
|
||||||
|
* Links (the emulated `AddLink` — the caps it was GIVEN, `readLinks`). Durable state,
|
||||||
|
* a handful of reads, no inbox needed.
|
||||||
|
* 2. **Process** — drain the user's inbox (`inbox.processInbox`), which files any
|
||||||
|
* new Link durably and puts it among what the user holds.
|
||||||
|
* 3. **Keep applying** — start watching those same inboxes, for as long as this identity
|
||||||
|
* stays connected (`emulated-verifier/inbox-observer.ts`). Step 2 is the backlog;
|
||||||
|
* this is the regime. It runs whatever became of the first two, including a step 1 that
|
||||||
|
* REJECTED: what is watched is *being connected*, and `setCurrentUser` has already made
|
||||||
|
* that true by the time any of this runs.
|
||||||
|
*
|
||||||
|
* Restoring first means a reconnecting user can read its documents immediately, without
|
||||||
|
* waiting on the inbox round-trip; watching last means the backlog is applied before the
|
||||||
|
* first push arrives to apply it again.
|
||||||
|
*
|
||||||
|
* ── Restoring means ALL the registers, and that is a scar ─────────────────
|
||||||
|
* Until 2026-08-16 this step read the Links and nothing else, so a fresh page put back the
|
||||||
|
* caps a person had been GIVEN and not the ones for documents they had MADE. The Store
|
||||||
|
* branch was read by exactly one other path — `listMyEntityDocs` — so a creator who
|
||||||
|
* reloaded and went straight back to its own note (a route, a deep link) was refused it:
|
||||||
|
* the read threw, the batch read dropped it silently, and the write was refused. Its own
|
||||||
|
* document, denied to its own author, on the first refresh.
|
||||||
|
*
|
||||||
|
* The lesson generalises past that one register: what connecting owes is *everything the
|
||||||
|
* wallet durably says this user holds*, and any register left out of this step is a
|
||||||
|
* capability that exists on disk and not in the session.
|
||||||
|
*
|
||||||
|
* ── Fire-and-forget, on purpose ───────────────────────────────────────────
|
||||||
|
* `setCurrentUser` is synchronous and every consumer calls it from synchronous
|
||||||
|
* code. Making it async would push the wait onto the app, which is exactly the
|
||||||
|
* obligation this removes. So the work runs in the background and announces itself
|
||||||
|
* through the registry's change signal (`CapRegistry.onChange`), which is what
|
||||||
|
* `watchShape` already listens to — a view that was empty for want of a cap
|
||||||
|
* re-reads when the cap lands. {@link connectedUser} is there for a caller that
|
||||||
|
* genuinely needs to await it (tests, an app that wants a deterministic start).
|
||||||
|
*
|
||||||
|
* ── Every inbox, at both levels ───────────────────────────────────────────
|
||||||
|
* The user's own inbox AND the inbox of every document it opened one on. Upstream
|
||||||
|
* both are answered by the same place — `AddInboxCap` records on the User branch
|
||||||
|
* (`engine/repo/src/types.rs:1969`) — so `storeRegistry.myInboxes()` enumerates
|
||||||
|
* them and this drains each in turn.
|
||||||
|
*
|
||||||
|
* ── And then it KEEPS going ───────────────────────────────────────────────
|
||||||
|
* Step 2 drains the backlog; it does not end the obligation. Upstream the backlog is the
|
||||||
|
* exception (`from_queue`) and the rule is that a session applies each message as it
|
||||||
|
* arrives, so connecting ends by starting the continuous observation of the same inboxes
|
||||||
|
* (`emulated-verifier/inbox-observer.ts`), which lasts as long as this identity is
|
||||||
|
* connected. Until 2026-08-17 there was no step 3, and a deposit made while its recipient
|
||||||
|
* sat connected in front of the application converged only when that person reloaded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||||
|
import { lookupAccount } from "../shared-wallet/account-registry";
|
||||||
|
import { accessLogPrefix, shortNuri } from "../shared-wallet/access-log";
|
||||||
|
import { myInboxes, readLinks, restoreOwnCaps } from "./branch-registers";
|
||||||
|
import { startObservingInboxes } from "./inbox-observer";
|
||||||
|
import { processInbox } from "../surface/inbox";
|
||||||
|
import type { Nuri } from "../model/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the failure of ONE queue goes.
|
||||||
|
*
|
||||||
|
* Not a rejection — see the rule on {@link connectedUser} for why a queue is not the
|
||||||
|
* connection — and emphatically not silence. Same channel, same prefix and the same
|
||||||
|
* ungated `console.error` as {@link startConnect} below and as the deferred drain
|
||||||
|
* (`emulated-verifier/inbox-processor.ts`, whose header argues this exact point): a
|
||||||
|
* diagnostic may be opt-in, a failure may not.
|
||||||
|
*/
|
||||||
|
function reportUndrained(inbox: Nuri, error: unknown): void {
|
||||||
|
console.error(
|
||||||
|
accessLogPrefix() +
|
||||||
|
" connected, but this queue could not be drained — a share deposited in it is not " +
|
||||||
|
"applied yet, and connecting again will try it again: " +
|
||||||
|
shortNuri(inbox) +
|
||||||
|
":",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The in-flight connection work, per user key — so two calls do not race. */
|
||||||
|
const inFlight = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore and drain for the connected user. Idempotent per user while in flight.
|
||||||
|
*
|
||||||
|
* ── It either DID THE WORK or SAYS IT DID NOT ─────────────────────────────
|
||||||
|
* `ensureIdentity()` awaits this, and the contract it publishes is that the call
|
||||||
|
* *completes the connection work it starts*. So the one thing this must never do is
|
||||||
|
* resolve after failing: an application then renders, shows empty lists, and nothing
|
||||||
|
* anywhere says the restore never happened. What a person sees is "the app works but the
|
||||||
|
* documents shared with me never appear" — which reads like a permission decision and is
|
||||||
|
* a swallowed error. It swallowed **every** failure until 2026-08-13, offline broker
|
||||||
|
* included, and that is what this shape replaces.
|
||||||
|
*
|
||||||
|
* The rule, one line: **failing to ESTABLISH the session rejects; failing to apply one of
|
||||||
|
* its queues is reported and does not deny anyone their session.** And "there was nothing
|
||||||
|
* to do" resolves quietly. Exactly three cases are nothing to do, and none is a failure:
|
||||||
|
*
|
||||||
|
* - **no identity connected** — anonymous holds nothing and owns no inbox;
|
||||||
|
* - **the identity has no account yet** — nothing to restore, no queue to drain, and
|
||||||
|
* connecting must not create one (see the note in `run` below). A GENUINE absence:
|
||||||
|
* `lookupAccount` is used precisely so a lookup that could not ANSWER throws instead
|
||||||
|
* of borrowing that silence;
|
||||||
|
* - **the identity changed under the run** — abandoning is correct, and the next
|
||||||
|
* connection picks the work up (see `stillConnected`).
|
||||||
|
*
|
||||||
|
* ── Why a queue is NOT the connection, corrected 2026-08-16 ───────────────
|
||||||
|
* This said "there is no third category here" and rejected on an undrained inbox too. The
|
||||||
|
* reasoning was that an unapplied share and a lost restore look the same to a person. What
|
||||||
|
* it missed is what happens NEXT: an inbox is not consumed by failing, so a queue that
|
||||||
|
* cannot be applied is still on the list at the following connection, and the one after
|
||||||
|
* that. One unapplicable item therefore did not delay a share — it **locked the person out
|
||||||
|
* of the application, permanently**, and got worse rather than better with time. Reported
|
||||||
|
* live by a consuming application, three sign-ins out of three.
|
||||||
|
*
|
||||||
|
* That is strictly worse than the silence the 2026-08-13 shape replaced, and it collapsed
|
||||||
|
* a distinction that has to stay open: **failing to REACH the queues** (the session cannot
|
||||||
|
* proceed at all — reject, unchanged) against **failing to apply what is in one** (data,
|
||||||
|
* one queue, one person's share — report it, drain the others, let them in).
|
||||||
|
*
|
||||||
|
* The split needs no error classification, and that is what makes it trustworthy: the
|
||||||
|
* restore step is a plain `await` and still rejects, so a broker that cannot answer fails
|
||||||
|
* the connection there, before any queue is reached. Only the per-inbox drain is caught.
|
||||||
|
*
|
||||||
|
* Not silence: {@link reportUndrained} is loud and ungated, and the caps a share carries
|
||||||
|
* are not lost — the deposit stays in the queue, so the next connection applies it.
|
||||||
|
*
|
||||||
|
* Not fixed with a retry, a timeout or a flag on purpose: deciding *what to do* about a
|
||||||
|
* broker that cannot answer belongs to the caller, and it can only decide if it is told.
|
||||||
|
*/
|
||||||
|
// @provenance connectedUser kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream the verifier restores, applies the queue handed over at connection and keeps applying what follows, with nothing for a caller to await; this is the awaitable form of the first two, for a deterministic start
|
||||||
|
export async function connectedUser(): Promise<void> {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null) return;
|
||||||
|
const pending = inFlight.get(holder);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is `holder` still the connected identity?
|
||||||
|
*
|
||||||
|
* This work is fired un-awaited by `setCurrentUser`, and everything below resolves the
|
||||||
|
* CURRENT holder when it reads a register — `readLinks` and `myInboxes` both ask
|
||||||
|
* `getCurrentUser()` at the moment they run. After a switch they would therefore read
|
||||||
|
* the WRONG user's registers.
|
||||||
|
*
|
||||||
|
* The observed symptom was narrower and entirely in the tests: in-flight work from one
|
||||||
|
* test file armed the cap emulation in the next, making the suite's green depend on
|
||||||
|
* file order. Abandoning is right for both reasons, and it is what upstream implies —
|
||||||
|
* a session belongs to one user, and switching user is another session. Nothing is
|
||||||
|
* lost: the next connection picks it up.
|
||||||
|
*/
|
||||||
|
const stillConnected = (): boolean => getCurrentUser() === holder;
|
||||||
|
// Captured with the identity, handed back at filing time — see `caps.holderKey`.
|
||||||
|
const holderKey = getCaps().holderKey();
|
||||||
|
|
||||||
|
const restoreAndDrain = async (): Promise<void> => {
|
||||||
|
// Connecting must not PROVISION. `ensureAccount` would create the user on
|
||||||
|
// first sight, so connecting an identity that does not exist yet would
|
||||||
|
// silently mint its stores and their caps — arming the whole emulation as a
|
||||||
|
// background side effect, at a moment nothing controls. An account that does
|
||||||
|
// not exist has nothing to restore and no inbox to drain.
|
||||||
|
//
|
||||||
|
// `lookupAccount`, not `resolveAccount`: the tolerant form answers `null` for a read
|
||||||
|
// that FAILED exactly as for one that found nothing, so an unreachable broker looked
|
||||||
|
// like "this identity has no account" — the one absence connecting is entitled to
|
||||||
|
// pass over in silence. The whole restore was skipped and the promise resolved like a
|
||||||
|
// success. That is how sharing broke once (`.project/concepts/sign-in/`
|
||||||
|
// `knowledge_settling-is-not-connecting`), and conflating absence with ignorance is
|
||||||
|
// the same fault `inbox.share` was fixed for on 2026-08-10.
|
||||||
|
if ((await lookupAccount(holder)) === null) {
|
||||||
|
// Nothing to restore and no queue to drain — but this identity is CONNECTED, and it
|
||||||
|
// will acquire both during the session: provisioning is lazy here, so a person's very
|
||||||
|
// first visit connects with no stores at all and gets its account the moment the
|
||||||
|
// application creates anything. Watching from now on provisions nothing (`myInboxes`
|
||||||
|
// answers `[]` for an identity with no account) and is what makes that first session
|
||||||
|
// behave like every other one — the alternative was a brand-new user watched from
|
||||||
|
// their SECOND visit onwards, which is precisely the person most likely to be sent
|
||||||
|
// something. (Started below, for every outcome of this function alike.)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!stillConnected()) return;
|
||||||
|
// 1. Durable first, and ALL of it — see the header. The documents this user MADE, from
|
||||||
|
// the Store branches of its own stores, and then the caps it was GIVEN, from the
|
||||||
|
// User branch. Own documents first: they are the user's own content, and a person
|
||||||
|
// coming back to the note they wrote should not be waiting on anybody's share.
|
||||||
|
await restoreOwnCaps(holderKey);
|
||||||
|
if (!stillConnected()) return;
|
||||||
|
const links = await readLinks();
|
||||||
|
if (!stillConnected()) return;
|
||||||
|
for (const cap of links) getCaps().learnFor(holderKey, cap);
|
||||||
|
// 2. Then the queues: ALL of them — the user's own inbox, plus one per
|
||||||
|
// document it opened an inbox on. Both levels, as the PO specified, and
|
||||||
|
// both are answered by the same User-branch record (`AddInboxCap`).
|
||||||
|
// Sequential rather than parallel: each `processInbox` writes what it
|
||||||
|
// applies to the SAME private store, and interleaving those writes buys
|
||||||
|
// nothing on a queue that is nearly always empty.
|
||||||
|
//
|
||||||
|
// A queue that fails is reported and the next one is still drained — see the rule
|
||||||
|
// above for why one unapplicable item must not deny a person their session, nor
|
||||||
|
// take the OTHER queues down with it. Enumerating them is a different act and is
|
||||||
|
// not caught: not knowing which queues exist is the session failing to establish.
|
||||||
|
const inboxes = await myInboxes();
|
||||||
|
for (const inbox of inboxes) {
|
||||||
|
if (!stillConnected()) return;
|
||||||
|
try {
|
||||||
|
await processInbox(inbox);
|
||||||
|
} catch (error) {
|
||||||
|
// An identity that moved MID-DRAIN throws here too — `processInbox` resolves the
|
||||||
|
// holder at each step, so the new one is refused an inbox that is not theirs. That
|
||||||
|
// is abandoning, which this function has always called "not a failure", and
|
||||||
|
// reporting it would put a broker-looking error in the log every time a page
|
||||||
|
// switches user. The loop is about to return for the same reason.
|
||||||
|
if (!stillConnected()) return;
|
||||||
|
reportUndrained(inbox, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await restoreAndDrain();
|
||||||
|
} finally {
|
||||||
|
// 3. …and from here on, KEEP applying. The backlog above is the special case, not the
|
||||||
|
// rule: upstream a session is handed each inbox message as it arrives and applies it
|
||||||
|
// inline, and only the messages waiting at connection are the "queue"
|
||||||
|
// (`from_queue`). Draining once and stopping made a deposit wait for the recipient
|
||||||
|
// to reload the page. See `emulated-verifier/inbox-observer.ts`.
|
||||||
|
//
|
||||||
|
// After the restore and the drain, not before: connecting owes the backlog first,
|
||||||
|
// and the observation subscribes to the same inboxes that loop just read.
|
||||||
|
//
|
||||||
|
// ── Unconditional on how they WENT, and that is the whole point ──
|
||||||
|
// An inbox that could not be drained is reported and denies nobody their session,
|
||||||
|
// and it must not deny them the observation of the OTHER inboxes either — nor of
|
||||||
|
// itself, since the next push is a fresh attempt at exactly the deposit that failed.
|
||||||
|
//
|
||||||
|
// The same holds one step up, and until 2026-08-17 it did not: a restore that
|
||||||
|
// REJECTED skipped this line, and the identity was left CONNECTED — `setCurrentUser`
|
||||||
|
// is synchronous and had already taken effect — with nothing watching its inboxes
|
||||||
|
// for the rest of the session. `startObservingInboxes` has no other caller, so one
|
||||||
|
// broker hiccup at sign-in cost that person every deposit made from then on, in
|
||||||
|
// silence, long after the broker had recovered.
|
||||||
|
//
|
||||||
|
// It does not blur the rule this function is built on. The rule is about what the
|
||||||
|
// CALLER is told — failing to reach the queues rejects, failing to apply one is
|
||||||
|
// reported — and rejecting is exactly what still happens: the error raised above
|
||||||
|
// propagates through this `finally` untouched. What changes is that being connected
|
||||||
|
// now means being watched, whatever the connection made of its own work.
|
||||||
|
//
|
||||||
|
// Awaited, and it never rejects: what connecting starts, connecting finishes, so a
|
||||||
|
// caller that got its promise back knows the watching is in place — not merely
|
||||||
|
// requested. (It does not wait for the applying that watching then triggers.)
|
||||||
|
if (stillConnected()) await startObservingInboxes();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inFlight.set(holder, run);
|
||||||
|
try {
|
||||||
|
await run;
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(holder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire the connection work without awaiting it. Called by `setCurrentUser`.
|
||||||
|
*
|
||||||
|
* There is no caller to reject at here — that is what fire-and-forget means — so this is
|
||||||
|
* the one place a failure cannot surface as a rejection. It is logged instead, and NOT
|
||||||
|
* left to become an unhandled rejection: that would take down whatever runtime the
|
||||||
|
* consumer is in, over a connection the consumer never awaited.
|
||||||
|
*
|
||||||
|
* A caller that needs the outcome awaits `connectedUser()` — it joins this very run and
|
||||||
|
* inherits it, failure included. That is what `ensureIdentity()` does, and it is the path
|
||||||
|
* on which the guarantee is published.
|
||||||
|
*/
|
||||||
|
// @provenance startConnect kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — the fire-and-forget form of the same — upstream no application asks for it at all
|
||||||
|
export function startConnect(): void {
|
||||||
|
void connectedUser().catch((error: unknown) => {
|
||||||
|
console.error(accessLogPrefix() + " connect failed:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Applying an inbox deposit is a DISPATCH on what the deposit declares itself to be —
|
||||||
|
* one arm per type, exactly as the target does it.
|
||||||
|
*
|
||||||
|
* ── The shape is upstream's, read in its source ───────────────────────────
|
||||||
|
* `Verifier::process_inbox(msg, content)` is literally `match content { … }`, one arm per
|
||||||
|
* `InboxMsgContent` variant, and ends `_ => return Err(VerifierError::NotImplemented)`
|
||||||
|
* (`engine/verifier/src/inbox_processor.rs`). An arm is a real program, not a branch: the
|
||||||
|
* `ContactDetails` arm validates the envelope (a missing `from_inbox` is
|
||||||
|
* `InvalidInboxPost`, a hard failure and not a skip), builds its addresses from the typed
|
||||||
|
* content, queries the recipient's OWN store to see whether the thing already exists
|
||||||
|
* (a SPARQL `ASK`), decides idempotently (`ContactAlreadyExists` rather than a duplicate),
|
||||||
|
* and only then writes. Validate, read, decide, write — that is what an arm owes.
|
||||||
|
*
|
||||||
|
* ── What each of ours is ──────────────────────────────────────────────────
|
||||||
|
* - {@link LINK_KIND} — a deposit carrying a `ReadCap`. Applying it means holding it and
|
||||||
|
* filing it durably (`branch-registers.addLink`, the emulated `AddLink { read_cap }`).
|
||||||
|
* - a **bare reference** — a deposit that is a NURI and nothing else, applied by
|
||||||
|
* `index-deposit.ts`. It carries no tag on the wire because the payload IS the
|
||||||
|
* reference; upstream a `SocialQueryRequest` addresses its content the same way.
|
||||||
|
* - anything else that declares itself in this package's own namespace has **no arm**,
|
||||||
|
* and says so out loud — upstream's `NotImplemented`, which is an answer and not a
|
||||||
|
* shrug.
|
||||||
|
*
|
||||||
|
* ── Consumer data is not a variant ────────────────────────────────────────
|
||||||
|
* Upstream every message is typed and none is handed to an application; here a deposit
|
||||||
|
* may carry an arbitrary payload the consumer alone interprets, which is a divergence
|
||||||
|
* `surface/inbox.ts` already names. Such a payload declares no kind of ours, so no arm
|
||||||
|
* claims it and nothing is reported about it — it is not an unapplied message, it is not
|
||||||
|
* a message. That is why the reserved namespace is the discriminator and a bare `kind`
|
||||||
|
* field is not: `{ kind: "join" }` is a consumer's word, and it stays one.
|
||||||
|
*
|
||||||
|
* Never exported from the package: an application must have no way to register an arm,
|
||||||
|
* to aim the dispatch, or to turn one off.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addLink } from "./branch-registers";
|
||||||
|
import { applyIndexDeposit } from "./index-deposit";
|
||||||
|
import { getCaps } from "../shared-wallet/bootstrap";
|
||||||
|
import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log";
|
||||||
|
import { hasReadCap, isNuri } from "../model/nuri";
|
||||||
|
import type { Nuri, PrincipalId, ReadCap } from "../model/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it is the
|
||||||
|
* same one at all three stages: `InboxMsgContent::Link` is the message
|
||||||
|
* (`engine/net/src/types.rs`, declared but payload-less so far), `AddLink { read_cap }` is
|
||||||
|
* where the recipient files it (`engine/repo/src/types.rs`), `RemoveLink` withdraws it.
|
||||||
|
* So giving access is: deposit a Link, and the recipient's processing files it.
|
||||||
|
*
|
||||||
|
* It travels the SAME channel as any other deposit, which is why key ROTATION needs no
|
||||||
|
* special case on the surface — a re-delivered cap is just another Link.
|
||||||
|
*/
|
||||||
|
const LINK_KIND = "urn:ng-eventually:inbox:link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The namespace every kind this package declares lives under — and the discriminator.
|
||||||
|
*
|
||||||
|
* `urn:ng-eventually:` is this library's reserved namespace; a consumer's own payload
|
||||||
|
* never lands in it, so "does this deposit declare itself to be one of ours?" has an
|
||||||
|
* answer that no application can accidentally give. Keying on the mere PRESENCE of a
|
||||||
|
* `kind` field would have claimed `{ kind: "join" }` — a consumer's word for a consumer's
|
||||||
|
* message.
|
||||||
|
*/
|
||||||
|
const DECLARED_NAMESPACE = "urn:ng-eventually:inbox:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bare-reference variant. It is a tag this module synthesizes, never one that travels:
|
||||||
|
* the payload of such a deposit is the reference itself, with no envelope at all (see
|
||||||
|
* `index-deposit.ts` for why it carries nothing else). Naming it here is what lets the
|
||||||
|
* match below have one arm per type instead of a shape test in the middle of it.
|
||||||
|
*/
|
||||||
|
const REFERENCE_KIND = "urn:ng-eventually:inbox:reference";
|
||||||
|
|
||||||
|
/** WHOSE inbox is being applied, and by whom — every arm acts for this identity alone. */
|
||||||
|
export interface Applying {
|
||||||
|
/** The inbox's owner. Every act an arm performs is done for them and for nobody else. */
|
||||||
|
readonly owner: PrincipalId;
|
||||||
|
/** The cap-registry ring of {@link owner} — `accountKey(owner)`. One rule keys it for
|
||||||
|
* both cases: the owner's own session, and a drain running under someone else's. */
|
||||||
|
readonly ring: string;
|
||||||
|
/** The inbox the deposit was found in. Upstream the address IS the identification of
|
||||||
|
* the recipient, so no arm needs the deposit to name a target. */
|
||||||
|
readonly inbox: Nuri;
|
||||||
|
/**
|
||||||
|
* Whether {@link owner} is ALSO the identity holding the session.
|
||||||
|
*
|
||||||
|
* It decides what an arm may do, and the line is not arbitrary. A drain running under
|
||||||
|
* SOMEONE ELSE's session (the deferred stand-in for an absent owner,
|
||||||
|
* `emulated-verifier/inbox-processor.ts`) usurps the owner's identity, and it does so
|
||||||
|
* for the verifier's own bookkeeping only — a register write on the owner's User
|
||||||
|
* branch. Writing APPLICATION data into the owner's documents from a stranger's page is
|
||||||
|
* a different act, and this package does not take it: the deposit is not consumed, so
|
||||||
|
* it stays for the owner's own next connection, which is the real path.
|
||||||
|
*/
|
||||||
|
readonly ownerHoldsTheSession: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this deposit DECLARES itself to be — `null` when it declares nothing of ours.
|
||||||
|
*
|
||||||
|
* The one place the wire form is turned into a variant, so `surface/inbox.ts` and the
|
||||||
|
* dispatch below cannot come to disagree about what counts as this package's business.
|
||||||
|
*/
|
||||||
|
// @provenance kindOf kind=aligned level=1 ref=engine/net/src/types.rs:InboxMsgContent — upstream the VARIANT is the type and nothing else can be sent; here it is read off a JSON payload, because a deposit carries an arbitrary one
|
||||||
|
export function kindOf(payload: unknown): string | null {
|
||||||
|
if (typeof payload === "string") return isNuri(payload) ? REFERENCE_KIND : null;
|
||||||
|
if (payload === null || typeof payload !== "object") return null;
|
||||||
|
const kind: unknown = (payload as { kind?: unknown }).kind;
|
||||||
|
return typeof kind === "string" && kind.startsWith(DECLARED_NAMESPACE) ? kind : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cap a Link deposit carries, or `null` when it carries none that reads. */
|
||||||
|
// @provenance capOfPayload kind=declared-not-wired level=1 ref=engine/net/src/types.rs:InboxMsgContent — the `Link` variant is DECLARED and carries nothing, so the cap this reads has no upstream field yet
|
||||||
|
export function capOfPayload(payload: unknown): ReadCap | null {
|
||||||
|
const p = payload as { kind?: unknown; cap?: unknown } | null;
|
||||||
|
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
|
||||||
|
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The payload of a Link deposit carrying `cap` — the one place `share` composes one. */
|
||||||
|
// @provenance linkPayload kind=declared-not-wired level=1 ref=engine/net/src/types.rs:InboxMsgContent — the composing half of the same declared-and-empty variant
|
||||||
|
export function linkPayload(cap: ReadCap): { kind: string; cap: ReadCap } {
|
||||||
|
return { kind: LINK_KIND, cap };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an arm's refusal goes: the package's own log stream, NOT gated by the access-log
|
||||||
|
* flag. A diagnostic may be opt-in; a message that could not be applied may not.
|
||||||
|
*
|
||||||
|
* It does not throw, and that is the rule this package already runs on: one unapplicable
|
||||||
|
* item is reported and denies nobody their session, nor the OTHER deposits their turn. A
|
||||||
|
* genuine I/O failure is a different thing and still propagates — the caller reports the
|
||||||
|
* whole inbox and moves to the next one.
|
||||||
|
*/
|
||||||
|
function report(ctx: Applying, why: string): void {
|
||||||
|
console.error(
|
||||||
|
accessLogPrefix() + " could not apply a deposit in " + shortNuri(ctx.inbox) +
|
||||||
|
" — it stays in it and the next attempt will try again: " + why,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply ONE deposit for the owner of its inbox. Answers whether it produced an effect —
|
||||||
|
* which is what a drain counts, and all any caller has ever needed from it.
|
||||||
|
*
|
||||||
|
* Nothing is consumed here, by any arm: an inbox stays re-readable, so every abandon and
|
||||||
|
* every refusal above is harmless — what was not applied is still there for the next push
|
||||||
|
* or the next connection.
|
||||||
|
*/
|
||||||
|
// @provenance applyDeposit kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — the same `match` over the declared content, one arm per variant, `NotImplemented` for the rest
|
||||||
|
export async function applyDeposit(payload: unknown, ctx: Applying): Promise<boolean> {
|
||||||
|
const kind = kindOf(payload);
|
||||||
|
switch (kind) {
|
||||||
|
case null:
|
||||||
|
// Consumer data. No arm claims it, and nothing is reported: it is not an unapplied
|
||||||
|
// message, it is not a message.
|
||||||
|
return false;
|
||||||
|
case LINK_KIND:
|
||||||
|
return applyLink(payload, ctx);
|
||||||
|
case REFERENCE_KIND:
|
||||||
|
return applyReference(payload as Nuri, ctx);
|
||||||
|
default:
|
||||||
|
// Upstream's `_ => Err(NotImplemented)`. A kind in this package's own namespace that
|
||||||
|
// no arm answers is a version skew, not consumer data — and until 2026-08-21 it was
|
||||||
|
// read, seen, and produced nothing at all: no effect and no reported failure, which
|
||||||
|
// is the "failure disguised as an absence" defect this package has closed repeatedly.
|
||||||
|
report(ctx, `no arm applies deposits of kind ${JSON.stringify(kind)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The **Link** arm: validate the envelope, hold the cap, file it durably.
|
||||||
|
*
|
||||||
|
* `learnFor` before `addLink`, and both NAMED rather than resolved: the ring is the one
|
||||||
|
* the caller decided for when its guard ran, not whoever happens to be connected when the
|
||||||
|
* `await` resumes. In the owner's own session the two are the same ring and the first act
|
||||||
|
* is a no-op — `read` already learned it in memory; only this makes it durable.
|
||||||
|
*
|
||||||
|
* A payload that declares itself a Link and carries no readable cap is REFUSED rather than
|
||||||
|
* passed over. That is the envelope validation upstream's arms open with (`from_inbox`
|
||||||
|
* missing is `InvalidInboxPost`, a hard failure), and passing it over would have been the
|
||||||
|
* same silence the `_` arm above exists to end.
|
||||||
|
*/
|
||||||
|
async function applyLink(payload: unknown, ctx: Applying): Promise<boolean> {
|
||||||
|
const cap = capOfPayload(payload);
|
||||||
|
if (cap === null) {
|
||||||
|
report(ctx, "a deposit declares itself a Link and carries no readable cap");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
getCaps().learnFor(ctx.ring, cap);
|
||||||
|
await addLink(cap, ctx.owner);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The **reference** arm: resolve it and look — see `index-deposit.ts` for the whole of it.
|
||||||
|
*
|
||||||
|
* Only in the owner's OWN session, for the reason {@link Applying.ownerHoldsTheSession}
|
||||||
|
* gives: an entry is application data in the owner's document, and a drain running under a
|
||||||
|
* stranger's session writes the verifier's registers and nothing else. Left waiting is not
|
||||||
|
* left silent, and it is not a failure either — the deposit is not consumed, so the owner's
|
||||||
|
* next connection applies it, which is the path this package documents.
|
||||||
|
*/
|
||||||
|
async function applyReference(reference: Nuri, ctx: Applying): Promise<boolean> {
|
||||||
|
if (!ctx.ownerHoldsTheSession) {
|
||||||
|
logStage(
|
||||||
|
"WAITING " + shortNuri(ctx.inbox) + " — a reference deposit is applied by its own " +
|
||||||
|
"owner's session; it stays for " + ctx.owner,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const application = await applyIndexDeposit(reference, ctx.inbox);
|
||||||
|
switch (application.outcome) {
|
||||||
|
case "applied":
|
||||||
|
logStage(
|
||||||
|
"INDEXED " + shortNuri(application.object) + " in the index behind " +
|
||||||
|
shortNuri(ctx.inbox) + " under " + JSON.stringify(application.value),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
case "unchanged":
|
||||||
|
return false;
|
||||||
|
case "skipped":
|
||||||
|
// A property of the OBJECT, not a failure: it resolved, and there is nothing to
|
||||||
|
// index it by. Traced, not reported — see `index-deposit.ts`.
|
||||||
|
logStage(
|
||||||
|
"NOT INDEXED " + shortNuri(application.object) + " — " + application.reason,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
case "refused":
|
||||||
|
report(ctx, application.why);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* ONE drain at a time per inbox — the serializer both things that drain an inbox go through.
|
||||||
|
*
|
||||||
|
* Two of them exist in this package, and they are not coordinated by anything else: the
|
||||||
|
* CONTINUOUS observation of the connected identity's own inboxes
|
||||||
|
* (`emulated-verifier/inbox-observer.ts`) and the deferred timer that stands in for an
|
||||||
|
* ABSENT owner (`emulated-verifier/inbox-processor.ts`). Both can be pointed at the same
|
||||||
|
* inbox in the same page — a deposit into an inbox whose owner is also connected here arms
|
||||||
|
* the timer AND pushes to the observation.
|
||||||
|
*
|
||||||
|
* Draining writes what it applies (`branch-registers.addLink`, on the owner's User branch),
|
||||||
|
* so two passes over one queue interleave their reads and writes and apply the same records
|
||||||
|
* twice. Sequencing them is the same reasoning `connect.connectedUser` drains its inboxes
|
||||||
|
* one after another for, and it belongs HERE rather than in either caller: an invariant that
|
||||||
|
* holds only while both callers remember it is not an invariant.
|
||||||
|
*
|
||||||
|
* It is a queue, not a lock: a run that arrives while another is going is not dropped, it
|
||||||
|
* follows. Dropping would be wrong — the second run exists because something new landed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { accessLogPrefix, shortNuri } from "../shared-wallet/access-log";
|
||||||
|
import type { Nuri } from "../model/types";
|
||||||
|
|
||||||
|
/** Drains in flight or queued, per inbox — the chain a new run appends itself to. */
|
||||||
|
const running = new Map<Nuri, Promise<void>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a drain's failure goes.
|
||||||
|
*
|
||||||
|
* It cannot throw: nothing awaits these — neither the timer nor a push is a caller — and an
|
||||||
|
* unhandled rejection would take down whatever runtime it is in over work the application
|
||||||
|
* never requested. But "nowhere" is not the alternative. A drain that swallows its failure
|
||||||
|
* is indistinguishable from one that succeeded, which is precisely the defect family removed
|
||||||
|
* from this package on 2026-08-13, and a CONTINUOUS drain that swallowed would repeat that
|
||||||
|
* silence for as long as the session lasts. So it goes to the package's own log stream — the
|
||||||
|
* same `console.error` with the same `[<identity>][polyfill]` prefix `startConnect` and
|
||||||
|
* `inbox.watch` use — and it is NOT gated by the access-log flag: a diagnostic may be
|
||||||
|
* opt-in, a failure may not.
|
||||||
|
*
|
||||||
|
* One failed item denies nothing, here as at connection: the inbox is not consumed by
|
||||||
|
* failing, the deposit stays in it, and the next push or the next connection applies it.
|
||||||
|
*/
|
||||||
|
function reportFailure(inbox: Nuri, error: unknown): void {
|
||||||
|
console.error(
|
||||||
|
accessLogPrefix() + " could not apply what is in this inbox — its deposits stay in it " +
|
||||||
|
"and the next attempt will try again: " + shortNuri(inbox) + ":",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `process` for `inbox`, behind any run of the same inbox still in flight.
|
||||||
|
*
|
||||||
|
* Resolves when THIS run has finished (or failed, having reported itself), so a caller
|
||||||
|
* that wants to wait can — while a caller that does not simply drops the promise.
|
||||||
|
*/
|
||||||
|
// @provenance drainInboxSerially kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream one verifier owns an inbox and applies its messages one at a time as they arrive; this is that serialization, for the two paths this package drains from
|
||||||
|
export function drainInboxSerially(inbox: Nuri, process: () => Promise<void>): Promise<void> {
|
||||||
|
const previous = running.get(inbox) ?? Promise.resolve();
|
||||||
|
const next = previous
|
||||||
|
// A previous run that FAILED must not cancel this one: its failure was reported where
|
||||||
|
// failures go, and the deposits it did not apply are exactly what this run is for.
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(process)
|
||||||
|
.catch((error: unknown) => reportFailure(inbox, error))
|
||||||
|
.then(() => {
|
||||||
|
if (running.get(inbox) === next) running.delete(inbox);
|
||||||
|
});
|
||||||
|
running.set(inbox, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until no drain is in flight. The seam a suite needs to observe convergence without
|
||||||
|
* waiting for it — and the one `runScheduledInboxProcessingNow` awaits after firing its
|
||||||
|
* windows. Loops, because a drain can be chained behind the one being awaited.
|
||||||
|
*/
|
||||||
|
// @provenance drainsSettled kind=invention level=none ref=none — a test/lifecycle seam over the queue above; upstream nothing exposes "is the verifier done applying"
|
||||||
|
export async function drainsSettled(): Promise<void> {
|
||||||
|
while (running.size > 0) {
|
||||||
|
await Promise.all([...running.values()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
/**
|
||||||
|
* While an identity is connected, its inboxes are WATCHED and what arrives is APPLIED.
|
||||||
|
*
|
||||||
|
* ── The regime this restores, and the one it replaces ─────────────────────
|
||||||
|
* Upstream, applying an inbox is not something that happens at connection — it is what a
|
||||||
|
* session DOES. A sealed message reaches the recipient's own verifier as it arrives
|
||||||
|
* (`LocalBrokerMessage::Inbox` → `session.verifier.inbox(msg, from_queue)`,
|
||||||
|
* `sdk/rust/src/local_broker.rs`), which unseals it and applies it inline
|
||||||
|
* (`engine/verifier/src/inbox_processor.rs`); `from_queue` distinguishes the backlog handed
|
||||||
|
* over at connection from the messages that follow, and BOTH go through the same door. So
|
||||||
|
* the backlog is the special case, and the continuity is the rule.
|
||||||
|
*
|
||||||
|
* This package had emulated only the backlog. `inbox.processInbox` was called from exactly
|
||||||
|
* one place — `connect.connectedUser`, at connection — and nothing anywhere applied a
|
||||||
|
* deposit after that. A share deposited into an inbox whose owner was sitting connected in
|
||||||
|
* front of it converged only when that person reloaded the page, and the deposit's own
|
||||||
|
* session covered the gap by usurping the owner's identity on a twenty-second timer
|
||||||
|
* (`emulated-verifier/inbox-processor.ts`) — which does nothing at all if the depositor
|
||||||
|
* closes their tab, and tells the connected owner nothing either way.
|
||||||
|
*
|
||||||
|
* So: for as long as an identity is connected, every inbox it may read is subscribed to,
|
||||||
|
* and every push over one of them applies what is in it.
|
||||||
|
*
|
||||||
|
* ── Watching is not applying ──────────────────────────────────────────────
|
||||||
|
* `inbox.watch` looks like this and is not: it NOTIFIES an application that made a call and
|
||||||
|
* named one inbox. Its read does absorb the caps it passes over into memory for the session
|
||||||
|
* — that much is unavoidable, reading is what surfaces them — but it FILES nothing durably,
|
||||||
|
* which only `processInbox` does. This applies, on the whole set, without anybody asking —
|
||||||
|
* because processing an inbox is the library's job and not the app's, which is the same
|
||||||
|
* ruling `connect.ts` opens with.
|
||||||
|
*
|
||||||
|
* ── Subscription, never a poll ────────────────────────────────────────────
|
||||||
|
* Every half is push-driven. The inboxes themselves are subscribed to individually. WHICH
|
||||||
|
* inboxes exist is itself a subscription, on two channels that do not overlap:
|
||||||
|
*
|
||||||
|
* - the REGISTER — an inbox opened mid-session appends an `AddInboxCap` record to the
|
||||||
|
* User branch of the private store (`branch-registers.openDocumentInbox`), so a push on
|
||||||
|
* that document re-enumerates `myInboxes()`;
|
||||||
|
* - WHAT THIS IDENTITY HOLDS (`CapRegistry.onChange`) — the same signal `watchShape`
|
||||||
|
* re-reads on. It answers the case the register cannot: an identity that connects
|
||||||
|
* before it has an account at all. Provisioning is lazy here, so a person's FIRST visit
|
||||||
|
* connects with no stores, no private store to follow and no inbox to watch; the
|
||||||
|
* account appears later, the moment the application creates anything, and filing its
|
||||||
|
* caps is what says so. Without this channel a brand-new user was watched from their
|
||||||
|
* SECOND visit onwards — which is exactly the person most likely to be sent something.
|
||||||
|
*
|
||||||
|
* So a document whose inbox this identity opened five minutes into the session is watched
|
||||||
|
* like the rest, and so is the very first inbox of a person who had none when they arrived.
|
||||||
|
*
|
||||||
|
* ── It belongs to ONE holder, and stops when that holder does ─────────────
|
||||||
|
* Everything below resolves the CURRENT holder when it runs — `myInboxes`, `processInbox`
|
||||||
|
* and everything under them ask `getCurrentUser()` at the moment they execute. So an
|
||||||
|
* observation started for Alice must not still be firing when Bob is connected: it would
|
||||||
|
* read Alice's registers under Bob, or file Alice's capabilities into Bob's ring. The
|
||||||
|
* observation is therefore stamped with its holder, every step re-checks it, and switching
|
||||||
|
* identity (or disconnecting) tears it down — the same rule, and the same reason,
|
||||||
|
* `connect.connectedUser` carries `stillConnected()` for.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||||
|
import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log";
|
||||||
|
import { enumerateMyInboxes, type InboxEnumeration, type InboxRegister } from "./branch-registers";
|
||||||
|
import { drainInboxSerially, drainsSettled } from "./inbox-drain";
|
||||||
|
import { lookupAccount } from "../shared-wallet/account-registry";
|
||||||
|
import { processInbox } from "../surface/inbox";
|
||||||
|
import { subscribeDoc, subscribeDocReportingSetupFailure, type Unsubscribe } from "../surface/subscribe";
|
||||||
|
import type { Nuri, PrincipalId } from "../model/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One identity's live observation. Held whole rather than as loose module variables so
|
||||||
|
* that a step started under it can ask "am I still the current one" by IDENTITY of the
|
||||||
|
* object, not by comparing a name that a re-connection may have restored in between.
|
||||||
|
*/
|
||||||
|
interface Observation {
|
||||||
|
/** WHO this observation belongs to. Every step re-checks it before acting. */
|
||||||
|
holder: PrincipalId;
|
||||||
|
/** One subscription per inbox observed, keyed by it. */
|
||||||
|
inboxes: Map<Nuri, Unsubscribe>;
|
||||||
|
/** The subscription on the register that says WHICH inboxes exist. */
|
||||||
|
register: Unsubscribe | null;
|
||||||
|
/** Unsubscribe from the held-caps change signal — the second "which inboxes" channel. */
|
||||||
|
holdings: Unsubscribe | null;
|
||||||
|
/** True while a re-enumeration is running, so its own effects do not restart it. */
|
||||||
|
enumerating: boolean;
|
||||||
|
/** A trigger that arrived mid-cycle: the running one repeats once rather than lose it. */
|
||||||
|
enumerateAgain: boolean;
|
||||||
|
/**
|
||||||
|
* WHICH shortfall was reported last, or `null` when the last list came back WHOLE — so a
|
||||||
|
* register that stays unreadable is reported once, not once per cycle.
|
||||||
|
*
|
||||||
|
* Every signal this observation listens to runs a full cycle, and a persistent shortfall
|
||||||
|
* is re-read by each of them, so the condition is reported as many times as the identity
|
||||||
|
* is nudged: one deposit produced three copies of the same line. That is what buries the
|
||||||
|
* report that matters under the report that repeats. Cleared the moment a list comes back
|
||||||
|
* WHOLE, so a shortfall that goes away and returns is a new occurrence and says so.
|
||||||
|
*
|
||||||
|
* ── The register, and not a bare "already said something" ─────────────────
|
||||||
|
* There are TWO shortfalls, they are disjoint, and one is far worse than the other: the
|
||||||
|
* account record failing leaves this identity with NOTHING watched, its own two inboxes
|
||||||
|
* included, while the User branch failing leaves those two watched and only the
|
||||||
|
* per-document inboxes missing ({@link InboxEnumeration.incomplete}). A boolean here made
|
||||||
|
* the second silence the first — the condition CHANGED, to a materially different one, and
|
||||||
|
* the log said nothing because something had already been said. Keyed by register, a
|
||||||
|
* change of shortfall is what it is: news.
|
||||||
|
*/
|
||||||
|
shortfallReported: InboxRegister | null;
|
||||||
|
/** Enumerations and applications in flight — what {@link observationSettled} waits on. */
|
||||||
|
pending: Set<Promise<void>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let observation: Observation | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a failure to WATCH goes — distinct from a failure to APPLY, which
|
||||||
|
* `inbox-drain.ts` reports.
|
||||||
|
*
|
||||||
|
* Not a rejection: nothing awaits this work, exactly as at connection. And not silence, for
|
||||||
|
* the same reason as everywhere else in this package — an observation that cannot be
|
||||||
|
* established leaves deposits unapplied for the whole session, and the only thing a person
|
||||||
|
* would notice is that a share never arrives.
|
||||||
|
*/
|
||||||
|
function reportUnobserved(what: string, error: unknown): void {
|
||||||
|
console.error(
|
||||||
|
accessLogPrefix() + " connected, but " + what + " — deposits made from now on may not be " +
|
||||||
|
"applied until the next connection:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a shortfall on `register` leaves unwatched, in the words the log uses.
|
||||||
|
*
|
||||||
|
* The two lines have to READ differently, not merely be counted differently: a reader who
|
||||||
|
* sees the second one after the first has to be able to tell that the condition changed, and
|
||||||
|
* two identical sentences make an escalation look like a repeat. Both keep the phrase the
|
||||||
|
* report has always ended on, because it is the one thing this line is scanned for.
|
||||||
|
*/
|
||||||
|
function shortfallSaid(register: InboxRegister): string {
|
||||||
|
return register === "account-record"
|
||||||
|
? "the inboxes to watch could not all be listed (the account record — your own two " +
|
||||||
|
"inboxes, where a share addressed to you by name lands, are NOT among the ones watched)"
|
||||||
|
: "the inboxes to watch could not all be listed (the register of the inboxes opened on " +
|
||||||
|
"documents — your own two are watched)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is `obs` still the live observation, for the identity it belongs to? */
|
||||||
|
function current(obs: Observation): boolean {
|
||||||
|
return observation === obs && getCurrentUser() === obs.holder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Track a piece of in-flight work so {@link observationSettled} can wait for it. */
|
||||||
|
function track(obs: Observation, work: Promise<void>): void {
|
||||||
|
obs.pending.add(work);
|
||||||
|
void work.finally(() => obs.pending.delete(work));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply whatever is in `inbox`, for the holder this observation belongs to.
|
||||||
|
*
|
||||||
|
* Through the shared per-inbox queue, so this cannot interleave with a drain the deferred
|
||||||
|
* timer started on the same inbox (nor with another push of its own): two passes over one
|
||||||
|
* queue apply the same records twice. A failure is reported there and stops nothing — the
|
||||||
|
* next push over this inbox tries again, and the other inboxes were never involved.
|
||||||
|
*/
|
||||||
|
async function applyWhatArrived(obs: Observation, inbox: Nuri): Promise<void> {
|
||||||
|
if (!current(obs)) return;
|
||||||
|
await drainInboxSerially(inbox, async () => {
|
||||||
|
// Re-checked INSIDE the queue: this run may have waited behind another one, and the
|
||||||
|
// identity can have moved while it waited. Running it for the wrong holder is not a
|
||||||
|
// near-miss — it reads someone else's registers and files into someone else's ring.
|
||||||
|
//
|
||||||
|
// This check is NECESSARY and it is not SUFFICIENT, and until 2026-08-17 this comment
|
||||||
|
// claimed it was. It cannot be: the identity can move after it passes, while
|
||||||
|
// `processInbox` is mid-read. The claim was that `processInbox` "resolves the holder at
|
||||||
|
// each step, so the new holder is refused an inbox that is not theirs somewhere in the
|
||||||
|
// middle" — but its ownership guard runs ONCE, at entry, and a switch after that reached
|
||||||
|
// the filing with nobody left to refuse it. What it filed was the previous holder's cap,
|
||||||
|
// into the new holder's ring, durably. The guard that makes this run safe is therefore
|
||||||
|
// the one INSIDE `processInbox`, which captures the holder its guard authorised; this one
|
||||||
|
// only spares the work when the switch is already visible.
|
||||||
|
if (!current(obs)) return;
|
||||||
|
try {
|
||||||
|
await processInbox(inbox);
|
||||||
|
} catch (error) {
|
||||||
|
// An identity that moved MID-DRAIN can throw here too — the switch may land before
|
||||||
|
// `processInbox`'s own ownership guard, which then refuses the new holder an inbox that
|
||||||
|
// is not theirs. (Landing after it, the run ABANDONS quietly instead and returns; both
|
||||||
|
// leave the deposit where it is.) Abandoning is what this package has always called
|
||||||
|
// "not a failure", and reporting it would put a broker-looking error in the log every
|
||||||
|
// time a page switches user. Nothing is lost: an inbox is not consumed by being
|
||||||
|
// abandoned, so the deposit is still there for its owner's next connection. Same rule,
|
||||||
|
// same words, as the drain loop in `connect.connectedUser`.
|
||||||
|
if (!current(obs)) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Re-)enumerate the inboxes this identity may read and subscribe to the ones not yet
|
||||||
|
* watched. Idempotent: an inbox already observed is left alone, so a re-enumeration costs
|
||||||
|
* one read and nothing else.
|
||||||
|
*
|
||||||
|
* Never removes: the list only grows within a session (an `AddInboxCap` record is durable),
|
||||||
|
* and dropping a subscription on a guess would silently stop applying an inbox.
|
||||||
|
*
|
||||||
|
* ── A list that came back SHORT still gets watched ────────────────────────
|
||||||
|
* `enumerateMyInboxes` and not `myInboxes`: the list is read from two independent registers
|
||||||
|
* and one of them being unreachable used to discard the other's answer too, so a broker
|
||||||
|
* hiccup spanning sign-in left the identity connected with ZERO inboxes watched rather than
|
||||||
|
* with one register's worth missing. Whatever came back is watched here and now — including,
|
||||||
|
* crucially, the user's own two inboxes, which are where a share addressed to a PERSON
|
||||||
|
* lands, and therefore the channel by which a read-only identity converges at all.
|
||||||
|
*
|
||||||
|
* The shortfall is reported, never passed over: an incomplete list is not a complete one,
|
||||||
|
* and the inboxes it is missing are watched on the next enumeration — which is what the
|
||||||
|
* register push and the holdings signal are for. Applying what lands in a user inbox is
|
||||||
|
* itself one of those signals (it files a cap), so the ordinary case repairs itself.
|
||||||
|
*
|
||||||
|
* Once per OCCURRENCE, though, not once per enumeration — see
|
||||||
|
* {@link Observation.shortfallReported}. The retry that repairs it is also what re-reads it,
|
||||||
|
* so a condition that persists is re-discovered by every signal; reporting each discovery
|
||||||
|
* says "it happened again" about the single thing that never stopped happening. An
|
||||||
|
* occurrence is per REGISTER, because the two shortfalls are two conditions: the list going
|
||||||
|
* from "missing the document inboxes" to "missing your own two" is not the same fault
|
||||||
|
* continuing, it is a worse one starting.
|
||||||
|
*/
|
||||||
|
async function watchTheInboxes(obs: Observation): Promise<void> {
|
||||||
|
if (!current(obs)) return;
|
||||||
|
let listed: InboxEnumeration;
|
||||||
|
try {
|
||||||
|
listed = await enumerateMyInboxes();
|
||||||
|
} catch (error) {
|
||||||
|
// Not knowing WHICH inboxes exist AT ALL is the whole observation failing, not one queue.
|
||||||
|
reportUnobserved("the inboxes to watch could not be listed", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!current(obs)) return;
|
||||||
|
if (listed.incomplete === null) {
|
||||||
|
obs.shortfallReported = null;
|
||||||
|
} else if (obs.shortfallReported !== listed.incomplete.register) {
|
||||||
|
obs.shortfallReported = listed.incomplete.register;
|
||||||
|
reportUnobserved(shortfallSaid(listed.incomplete.register), listed.incomplete.error);
|
||||||
|
}
|
||||||
|
for (const inbox of listed.inboxes) {
|
||||||
|
if (obs.inboxes.has(inbox)) continue;
|
||||||
|
try {
|
||||||
|
// The push is the signal; the drain is the work. The FIRST push is the initial
|
||||||
|
// `State`, which drains an inbox connection has usually just drained — idempotent,
|
||||||
|
// and the alternative (skip the first) would lose a deposit that landed in the gap
|
||||||
|
// between the two.
|
||||||
|
//
|
||||||
|
// The failure channel is not decoration: `subscribeDoc` returns SYNCHRONOUSLY and its
|
||||||
|
// one real `doc_subscribe` is opened afterwards, so a rejection there reaches nobody.
|
||||||
|
// Without it, this map held an entry for an inbox that was never watched, the loop
|
||||||
|
// above skipped it at every later enumeration, and the only observable difference from
|
||||||
|
// a healthy session was that shares stopped arriving.
|
||||||
|
obs.inboxes.set(
|
||||||
|
inbox,
|
||||||
|
subscribeDocReportingSetupFailure(
|
||||||
|
inbox,
|
||||||
|
() => track(obs, applyWhatArrived(obs, inbox)),
|
||||||
|
(error) => watchFailed(obs, inbox, error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
logStage("OBSERVING " + shortNuri(inbox) + " for " + obs.holder);
|
||||||
|
} catch (error) {
|
||||||
|
reportUnobserved("this inbox could not be watched: " + shortNuri(inbox), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subscription on `inbox` could not be OPENED — report it, and forget it.
|
||||||
|
*
|
||||||
|
* **Forget it**, because the entry this observation holds is the whole record of "already
|
||||||
|
* watched": leaving a dead one in place is how one rejection at connection turned into a
|
||||||
|
* session-long silence. Removed, the next enumeration subscribes again as if it had never
|
||||||
|
* been attempted.
|
||||||
|
*
|
||||||
|
* ── And that is ALL it does: recovery waits for the next SIGNAL ───────────
|
||||||
|
* Until 2026-08-17 this also re-enumerated on the spot, once per inbox. That call runs
|
||||||
|
* synchronously out of the very rejection it is reacting to, so it asks the broker that has
|
||||||
|
* just refused, in the same turn, with nothing having changed — and spends the one extra
|
||||||
|
* attempt it was allowed doing it. Deleting it left the whole suite green, which is the
|
||||||
|
* measure of what it achieved.
|
||||||
|
*
|
||||||
|
* What actually repairs this is an event, and this module already listens to the two that
|
||||||
|
* exist: the register push (`watchTheRegister`) and the held-caps signal
|
||||||
|
* (`CapRegistry.onChange`), each of which re-enters {@link enumerate} and finds this inbox
|
||||||
|
* unwatched. Applying anything at all fires the second one, so a session that is doing
|
||||||
|
* something catches up on its own. A session that is doing nothing waits for its next
|
||||||
|
* connection — where an unapplied deposit has always waited, since an inbox is not consumed
|
||||||
|
* by being unread. Polling for it is ruled out here for the same reason it is in
|
||||||
|
* `inbox-processor.ts`: this regime is push-driven, and a timer is what it replaced.
|
||||||
|
*/
|
||||||
|
function watchFailed(obs: Observation, inbox: Nuri, error: unknown): void {
|
||||||
|
if (!current(obs)) return;
|
||||||
|
const stale = obs.inboxes.get(inbox);
|
||||||
|
obs.inboxes.delete(inbox);
|
||||||
|
if (stale) {
|
||||||
|
try {
|
||||||
|
stale();
|
||||||
|
} catch (thrown) {
|
||||||
|
console.error(accessLogPrefix() + " releasing a failed inbox observation failed:", thrown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reportUnobserved("this inbox could not be watched: " + shortNuri(inbox), error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow the register that says which inboxes exist, so one opened MID-SESSION is picked
|
||||||
|
* up. See the module header: `openDocumentInbox` appends its record to the User branch of
|
||||||
|
* the private store, and a same-session write pushes to its own subscribers.
|
||||||
|
*
|
||||||
|
* Idempotent, and RETRIED rather than given up on: an identity that connects before it has
|
||||||
|
* an account has no private store to follow yet, and acquires one the first time the
|
||||||
|
* application creates anything. Re-entered from the holdings signal, it catches up then.
|
||||||
|
*/
|
||||||
|
async function watchTheRegister(obs: Observation): Promise<void> {
|
||||||
|
if (!current(obs) || obs.register !== null) return;
|
||||||
|
let store: Nuri | undefined;
|
||||||
|
try {
|
||||||
|
// `lookupAccount`, not `resolveAccount`: the tolerant form answers `null` for a read
|
||||||
|
// that FAILED exactly as for one that found nothing, and the two could not mean more
|
||||||
|
// different things here — "this identity has no account yet" is a state to wait
|
||||||
|
// quietly through, "the broker did not answer" is a failure to report. The same
|
||||||
|
// distinction `connect.connectedUser` and `inbox.share` are built on.
|
||||||
|
store = (await lookupAccount(obs.holder))?.docPrivate;
|
||||||
|
} catch (error) {
|
||||||
|
reportUnobserved("the register of your inboxes could not be reached", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No account yet — nothing to follow, and asking for one would PROVISION it, which
|
||||||
|
// connecting deliberately does not do (`connect.connectedUser`). The holdings signal
|
||||||
|
// brings us back here the moment there is something to follow.
|
||||||
|
if (!store || !current(obs) || obs.register !== null) return;
|
||||||
|
try {
|
||||||
|
obs.register = subscribeDoc(store, () => track(obs, enumerate(obs)));
|
||||||
|
} catch (error) {
|
||||||
|
reportUnobserved("an inbox opened later in this session will not be watched", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole "which inboxes am I watching" cycle: catch up on the register, then on the
|
||||||
|
* inboxes. Both halves are idempotent, so this is what every trigger runs.
|
||||||
|
*
|
||||||
|
* A trigger that arrives while a cycle is running does not start a second one — the cycle
|
||||||
|
* FILES caps for the inboxes it lists (`myInboxes` → `fileOwnInbox`), which is one of the
|
||||||
|
* signals that runs it, so overlapping runs would pile up on their own effects. It is
|
||||||
|
* REMEMBERED instead of dropped: the running cycle repeats once more at the end, because a
|
||||||
|
* signal that arrived mid-cycle may be about something that cycle had already read past.
|
||||||
|
*
|
||||||
|
* That terminates rather than ping-ponging: filing a cap this identity already holds fires
|
||||||
|
* nothing (`caps.file` notifies only on a genuinely new one), so the repeat that finds
|
||||||
|
* nothing new sets no flag and the loop ends.
|
||||||
|
*/
|
||||||
|
async function enumerate(obs: Observation): Promise<void> {
|
||||||
|
if (!current(obs)) return;
|
||||||
|
if (obs.enumerating) {
|
||||||
|
obs.enumerateAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
obs.enumerating = true;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
obs.enumerateAgain = false;
|
||||||
|
await watchTheRegister(obs);
|
||||||
|
await watchTheInboxes(obs);
|
||||||
|
} while (obs.enumerateAgain && current(obs));
|
||||||
|
} finally {
|
||||||
|
obs.enumerating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start observing the connected identity's inboxes. Called at the end of the connection
|
||||||
|
* work (`connect.connectedUser`), and idempotent: calling it again for the identity already
|
||||||
|
* being observed changes nothing.
|
||||||
|
*
|
||||||
|
* ── It resolves when the WATCHING is in place, not when the applying is done ──
|
||||||
|
* Connecting publishes one guarantee — *the work it starts, it finishes* — and "your
|
||||||
|
* inboxes are now being watched" is part of that work, so this is awaited rather than
|
||||||
|
* fired. What it does NOT wait for is the applying: subscribing pushes an initial `State`,
|
||||||
|
* which drains each inbox again, and making a person's sign-in wait on a second pass over
|
||||||
|
* a backlog `connectedUser` has just applied would buy nothing.
|
||||||
|
*
|
||||||
|
* It never rejects. Failing to WATCH is reported where failures go and denies nobody their
|
||||||
|
* session — the same rule the drain loop above it follows, and for the same reason: what
|
||||||
|
* has not been applied stays in its queue for the next attempt.
|
||||||
|
*/
|
||||||
|
// @provenance startObservingInboxes kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream a connected verifier is handed each inbox message as it arrives and applies it; here the arrival signal is a document push, and the set of inboxes is the User branch's `AddInboxCap` records
|
||||||
|
export async function startObservingInboxes(): Promise<void> {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null) return;
|
||||||
|
if (observation !== null && observation.holder === holder) return;
|
||||||
|
stopObservingInboxes();
|
||||||
|
const obs: Observation = {
|
||||||
|
holder,
|
||||||
|
inboxes: new Map(),
|
||||||
|
register: null,
|
||||||
|
holdings: null,
|
||||||
|
enumerating: false,
|
||||||
|
enumerateAgain: false,
|
||||||
|
shortfallReported: null,
|
||||||
|
pending: new Set(),
|
||||||
|
};
|
||||||
|
observation = obs;
|
||||||
|
// The second "which inboxes exist" channel — see the module header. It is what makes a
|
||||||
|
// person's FIRST session watched at all: they connect holding nothing, and their account
|
||||||
|
// (with its inboxes) appears the moment the application creates something.
|
||||||
|
obs.holdings = getCaps().onChange(() => track(obs, enumerate(obs)));
|
||||||
|
const setup = enumerate(obs);
|
||||||
|
track(obs, setup);
|
||||||
|
await setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop observing — on an identity change, on disconnection, and when the library is
|
||||||
|
* un-configured.
|
||||||
|
*
|
||||||
|
* Work already inside the drain queue is not cancelled (nothing can un-issue a write
|
||||||
|
* already sent); it is neutralised instead, because every step re-checks that its
|
||||||
|
* observation is still the current one and returns rather than filing for the wrong holder.
|
||||||
|
*/
|
||||||
|
// @provenance stopObservingInboxes kind=aligned level=1 ref=engine/verifier/src/verifier.rs:Verifier — a verifier's inbox processing lives and dies with its session; switching identity is another session, so nothing of the previous one keeps applying
|
||||||
|
export function stopObservingInboxes(): void {
|
||||||
|
const obs = observation;
|
||||||
|
if (obs === null) return;
|
||||||
|
observation = null;
|
||||||
|
for (const unsub of obs.inboxes.values()) {
|
||||||
|
try {
|
||||||
|
unsub();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " stopping an inbox observation failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
obs.inboxes.clear();
|
||||||
|
for (const unsub of [obs.register, obs.holdings]) {
|
||||||
|
if (!unsub) continue;
|
||||||
|
try {
|
||||||
|
unsub();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " stopping an inbox observation failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
obs.register = null;
|
||||||
|
obs.holdings = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until the observation has nothing in flight — the enumerations it is running and the
|
||||||
|
* drains they started.
|
||||||
|
*
|
||||||
|
* A seam for a suite that has to observe convergence without waiting for it, in the same
|
||||||
|
* spirit as `runScheduledInboxProcessingNow`: it names no inbox and starts no work, so it
|
||||||
|
* can only wait for what a push has already caused. Never exported from the package.
|
||||||
|
*/
|
||||||
|
// @provenance observationSettled kind=invention level=none ref=none — a test seam over the observation; upstream nothing exposes "has the verifier finished applying"
|
||||||
|
export async function observationSettled(): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
const obs = observation;
|
||||||
|
const inFlight = obs ? [...obs.pending] : [];
|
||||||
|
if (inFlight.length === 0) break;
|
||||||
|
await Promise.all(inFlight);
|
||||||
|
}
|
||||||
|
await drainsSettled();
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* A stand-in for an inbox whose owner is NOT HERE — emulated by a timer.
|
||||||
|
*
|
||||||
|
* ── What is missing, and what stands in for it ────────────────────────────
|
||||||
|
* Upstream a deposit does not wait for anybody: the broker routes the sealed message
|
||||||
|
* (`inboxes: PubKey → RepoId`, `engine/verifier/src/verifier.rs:105,1677`) and the
|
||||||
|
* RECIPIENT's own verifier applies it as it arrives, for as long as that recipient has a
|
||||||
|
* session (`LocalBrokerMessage::Inbox` → `session.verifier.inbox(…)`,
|
||||||
|
* `sdk/rust/src/local_broker.rs`). Two different things follow from that, and this module
|
||||||
|
* is only one of them:
|
||||||
|
*
|
||||||
|
* - a connected identity's own inboxes are observed and applied continuously, which is
|
||||||
|
* `emulated-verifier/inbox-observer.ts` and needs no timer at all;
|
||||||
|
* - an identity that is NOT connected has nobody to apply anything for it. Upstream the
|
||||||
|
* broker queues the message until it comes back; here, on ONE shared wallet, the
|
||||||
|
* depositor's own page can stand in for it.
|
||||||
|
*
|
||||||
|
* So: a successful deposit arms a ONE-SHOT timer that processes the TARGET's inbox, twenty
|
||||||
|
* seconds later. Alice deposits into Bob's inbox; twenty seconds later this package drains
|
||||||
|
* Bob's inbox, while the connected session is still Alice's. When Bob IS connected in this
|
||||||
|
* page, the observation has already applied it long before the window closes and the timer
|
||||||
|
* finds nothing left to do — harmless (draining is idempotent) and not worth a special
|
||||||
|
* case: the two answer different questions, and only this one answers the absent owner.
|
||||||
|
*
|
||||||
|
* ── That is identity usurpation, and it is deliberate ─────────────────────
|
||||||
|
* It is only possible because one shared wallet holds every virtual identity, and it is
|
||||||
|
* acceptable for exactly one reason: an application learns nothing false from it. What an
|
||||||
|
* application observes is that **deposits converge** — a share becomes readable without the
|
||||||
|
* recipient having to re-open the page — and that stays true when a real service takes over.
|
||||||
|
* What must never leak is the MECHANISM: nothing here is published, there is no way for a
|
||||||
|
* caller to ask for it, to name another user's inbox, or to turn it off. Publishing any of
|
||||||
|
* that would publish a call that processes someone else's inbox, and the confidentiality of
|
||||||
|
* the whole shared-wallet emulation rests on that being unreachable.
|
||||||
|
*
|
||||||
|
* ── Its accepted limit, stated rather than papered over ───────────────────
|
||||||
|
* A timer lives in a page. If the page goes away before it fires, the deposit simply waits
|
||||||
|
* for its owner's next connection — exactly where it waited before. There is deliberately
|
||||||
|
* no persistence, no retry and no longer window to hide that: a stand-in for an absent
|
||||||
|
* service should look like what it is, and the fallback (the owner connects and drains) is
|
||||||
|
* the real path, not a repair.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logStage, shortNuri } from "../shared-wallet/access-log";
|
||||||
|
import { drainInboxSerially, drainsSettled } from "./inbox-drain";
|
||||||
|
import type { Nuri } from "../model/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a deposit waits before the emulated service picks it up.
|
||||||
|
*
|
||||||
|
* Not configurable, on purpose. A knob would be a published question — *how fast does
|
||||||
|
* delivery converge?* — that the target does not ask a caller, because upstream the answer
|
||||||
|
* belongs to the deployment. An application must be written so that the number does not
|
||||||
|
* matter, and the surest way to keep it that way is to give nobody a way to change it.
|
||||||
|
*/
|
||||||
|
const PROCESSING_DELAY_MS = 20_000;
|
||||||
|
|
||||||
|
/** A drain waiting to happen: the timer, and what it will do. */
|
||||||
|
interface Scheduled {
|
||||||
|
handle: ReturnType<typeof setTimeout>;
|
||||||
|
process: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At most ONE pending drain per inbox — the coalescing this map exists for.
|
||||||
|
*
|
||||||
|
* Several deposits inside the window must produce ONE drain, not several: processing writes
|
||||||
|
* what it applies (`branch-registers.addLink`, on the owner's User branch), so two runs over
|
||||||
|
* the same inbox race each other or apply the same Link twice. The FIRST deposit arms the
|
||||||
|
* window and later ones join it, rather than each pushing it further out — a busy inbox must
|
||||||
|
* still converge, not be starved by its own traffic.
|
||||||
|
*/
|
||||||
|
const scheduled = new Map<Nuri, Scheduled>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a pending drain from holding a runtime open.
|
||||||
|
*
|
||||||
|
* A browser's `setTimeout` answers a number and has no `unref`; a Node/Bun runtime answers a
|
||||||
|
* timer object that does, and without it a process would sit for twenty seconds after its
|
||||||
|
* last deposit waiting on work nobody asked for. Probed rather than assumed, because the two
|
||||||
|
* platforms genuinely differ and this library runs on both.
|
||||||
|
*/
|
||||||
|
function releaseFromTheEventLoop(handle: ReturnType<typeof setTimeout>): void {
|
||||||
|
const maybe: { unref?: unknown } = handle as unknown as { unref?: unknown };
|
||||||
|
if (typeof maybe.unref === "function") (maybe.unref as () => void).call(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the drain of `inbox`, behind any run of the same inbox still in flight — including a
|
||||||
|
* run the CONTINUOUS observation started, which is why the queue lives in
|
||||||
|
* `emulated-verifier/inbox-drain.ts` rather than here. That module also owns where a
|
||||||
|
* drain's failure goes; it cannot travel back to a caller, because a timer has none.
|
||||||
|
*/
|
||||||
|
function fire(inbox: Nuri, process: () => Promise<void>): void {
|
||||||
|
scheduled.delete(inbox);
|
||||||
|
void drainInboxSerially(inbox, process);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the emulated service for `inbox` — called by a deposit that LANDED, and by nothing
|
||||||
|
* else. A write that failed has nothing to converge.
|
||||||
|
*
|
||||||
|
* `process` is what to do when the window closes; the scheduler itself knows nothing about
|
||||||
|
* inboxes' contents, so the drain stays where the inbox's own vocabulary lives.
|
||||||
|
*
|
||||||
|
* Idempotent inside the window: a second deposit into the same inbox joins the pending run.
|
||||||
|
*/
|
||||||
|
// @provenance scheduleInboxProcessing kind=divergent level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream the RECIPIENT's own verifier applies its inbox and the broker queues for it while it is away; here another identity's session drains it on a timer, for a recipient that is not connected in this page (one that IS has its own observation). Deliberate, unpublished, and only possible on one shared wallet
|
||||||
|
export function scheduleInboxProcessing(inbox: Nuri, process: () => Promise<void>): void {
|
||||||
|
if (scheduled.has(inbox)) return;
|
||||||
|
const handle = setTimeout(() => fire(inbox, process), PROCESSING_DELAY_MS);
|
||||||
|
releaseFromTheEventLoop(handle);
|
||||||
|
scheduled.set(inbox, { handle, process });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close every open window NOW and wait for the drains to finish.
|
||||||
|
*
|
||||||
|
* **The emulation's clock, not a door.** It cannot name an inbox, so it can only bring
|
||||||
|
* forward work a deposit has already armed — the same runs, at the same holder, with the
|
||||||
|
* same effects, minus the twenty seconds. That is what makes it usable by a suite that has
|
||||||
|
* to observe convergence without waiting for it, and useless as a way to reach anyone's
|
||||||
|
* inbox. Never exported from the package.
|
||||||
|
*
|
||||||
|
* Resolves rather than rejects, like the timer path it stands in for: a drain's failure is
|
||||||
|
* reported where failures go, and a caller of this is not the party that asked for the work.
|
||||||
|
*/
|
||||||
|
// @provenance runScheduledInboxProcessingNow kind=divergent level=1 ref=engine/verifier/src/verifier.rs:inbox — the same drain, run at once; a test seam over the divergence above
|
||||||
|
export async function runScheduledInboxProcessingNow(): Promise<void> {
|
||||||
|
for (const [inbox, entry] of [...scheduled]) {
|
||||||
|
clearTimeout(entry.handle);
|
||||||
|
fire(inbox, entry.process);
|
||||||
|
}
|
||||||
|
await drainsSettled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop every pending drain without running it — what an un-configured library must do.
|
||||||
|
*
|
||||||
|
* Called by `resetConfig`: once the injected `ng` is gone, a drain cannot read or write
|
||||||
|
* anything, so leaving armed timers behind would produce a burst of failures about a
|
||||||
|
* session that no longer exists. In-flight runs are not cancelled (nothing can un-issue a
|
||||||
|
* write already sent); they end where they end.
|
||||||
|
*/
|
||||||
|
// @provenance cancelScheduledInboxProcessing kind=divergent level=1 ref=engine/verifier/src/verifier.rs:inbox — cancels a pending drain — upstream there is no pending anything to cancel
|
||||||
|
export function cancelScheduledInboxProcessing(): void {
|
||||||
|
for (const entry of scheduled.values()) clearTimeout(entry.handle);
|
||||||
|
scheduled.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trace one completed drain — diagnostics, so gated by the access-log flag. */
|
||||||
|
// @provenance traceProcessed kind=invention level=none ref=none — a diagnostic line in this package's own log stream
|
||||||
|
export function traceProcessed(inbox: Nuri, holder: string, applied: number): void {
|
||||||
|
logStage(
|
||||||
|
"DEFERRED PROCESS " + shortNuri(inbox) + " for " + holder + " → " + applied + " link(s) applied",
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user