Compare commits
133 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 | |||
| d07b3642aa | |||
| e5779ca850 | |||
| 2406afec8b | |||
| 88efe4fea4 | |||
| 07312cd0a2 | |||
| 6a3bd9c3a9 | |||
| 1de9be9167 | |||
| 4f5c3ed03b | |||
| fba0128a7e | |||
| 5a7009bd75 | |||
| 8a382f29f8 | |||
| e24a20cc46 | |||
| 33751388fe | |||
| 88f396a7ac | |||
| 9d3e2d2bfe | |||
| ae9c32e271 | |||
| 6f0d0586e2 | |||
| 0d52c82ba9 | |||
| 518292498a | |||
| b2cb774124 | |||
| 8764daff4f | |||
| 60a9fd3ede | |||
| d7e0ee6a4b | |||
| ead5aececf | |||
| f2c5b30527 | |||
| 1791c31f42 | |||
| 127ca3159e | |||
| 138d37c02f | |||
| cf9500f0cf | |||
| 1f0bae461e | |||
| 70de7afa3c | |||
| 3547967d37 | |||
| 5e91771da6 | |||
| 5cdc6ce77f | |||
| 3046ead08f | |||
| 078d675bbf | |||
| 9103996dbe | |||
| bd48b16e31 | |||
| 7c233df5c0 | |||
| 38b152136b |
+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
|
||||||
@@ -29,11 +29,10 @@ Docs (this library's own engineering doctrine, under [`docs/`](./docs/)):
|
|||||||
behaviour on one shared wallet (shim, per-document ReadCaps, emulated inbox,
|
behaviour on one shared wallet (shim, per-document ReadCaps, emulated inbox,
|
||||||
write guard, the two axes, the double-proxy constraint).
|
write guard, the two axes, the double-proxy constraint).
|
||||||
- [`docs/read-model.md`](./docs/read-model.md) — the read model the polyfill
|
- [`docs/read-model.md`](./docs/read-model.md) — the read model the polyfill
|
||||||
implements: events via the global index, everything else by following a shared
|
implements: you follow links, you never enumerate; listing via a bounded set of
|
||||||
graph; listing via a bounded set of per-doc anchored `sparql_query`s; reactivity
|
per-doc anchored `sparql_query`s; reactivity via re-query on a change signal.
|
||||||
via re-query on a change signal.
|
|
||||||
- [`docs/decisions/`](./docs/decisions/) — current-SDK ADRs (private-store scope,
|
- [`docs/decisions/`](./docs/decisions/) — current-SDK ADRs (private-store scope,
|
||||||
SPARQL delete, shared-wallet identity, discovery mechanism).
|
SPARQL delete, shared-wallet identity).
|
||||||
- [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch /
|
- [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch /
|
||||||
self-host inbox path not taken (kept as a fallback).
|
self-host inbox path not taken (kept as a fallback).
|
||||||
- [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when
|
- [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when
|
||||||
@@ -52,30 +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 wallets — 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 | Declares a document's read policy via `getCaps().open(doc, scope, owner)`, then issues directed read grants (`grantRead(doc, granteeId)`) | 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`) | Bug/gap for emulation purposes: there is no cap-introspection API — a client cannot ask "may this identity read this doc?", so the polyfill cannot mirror the broker's decision from NextGraph itself | An emulated `CapRegistry` (`caps.ts`, per-document read/write caps) + a read filter (`read-filter.ts`, a defence-in-depth view) that keep only documents the current identity may read; `canRead`/`governsRead` are emulation-only, with no NextGraph API behind them |
|
| 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 for each relationship issues directed read grants on the owner's protected documents | A native per-document ReadCap issued to a specific identity — but note this target is itself not-yet-built in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0{grantee}` is unpersisted scaffolding and cap-send is `unimplemented!()`, so directing a grant to another identity has no working platform primitive yet | Not-yet-implemented: sending a cap to another identity is `unimplemented!()`, and no relationship/mutuality primitive exists — relationship is an application concept, not a platform one | The app selects the owner's protected documents via `getCaps().protectedDocsOf(owner)` and calls `grantRead(doc, granteeId)` per grantee; the lib records the per-document grant |
|
| 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-yet-implemented: the sender-side seal-into-inbox call (`inbox_post_link`) is proposed/future, not exposed in the JS SDK | 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 | `submitToIndex(ref)` / `readIndex()` | A real owned global document (owner undecided — a singleton-app path), fed via its native inbox, read as a materialized index | Not-yet-implemented / undecided: an identity's apps and services see only what it shares, so there is no global backend index yet | A global index document owned by a reserved special account (`@index`), fed via its inbox, read with dedup; a stable NURI every client resolves |
|
| ~~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. In NextGraph an identity's apps and 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.
|
||||||
see only what it shares, so there is no multi-identity backend. A global index
|
|
||||||
would come from a singleton app (a global document administered by the developer),
|
|
||||||
which is not implemented and undecided; simpler paths may exist. So there is no
|
|
||||||
second package for now — it will be introduced once the global-index mechanism is
|
|
||||||
decided, and it will be separate from the client.
|
|
||||||
|
|
||||||
## Design principle
|
## Design principle
|
||||||
|
|
||||||
@@ -83,35 +77,120 @@ The application code is written as if the target NextGraph existed. All
|
|||||||
compensation lives here, beside the app. As NextGraph matures, this layer falls
|
compensation lives here, beside the app. As NextGraph matures, this layer falls
|
||||||
away; the app code (SDK-shaped) is unchanged.
|
away; the app code (SDK-shaped) is unchanged.
|
||||||
|
|
||||||
|
**Both halves are binding, and the second is the one that gets traded away.** The
|
||||||
|
SURFACE must be as close as possible to the future SDK — that much is obvious, it is
|
||||||
|
what the consumer codes against. But the IMPLEMENTATION must be as close as possible to
|
||||||
|
what NextGraph actually plans, and there is no exception to that. Where upstream's
|
||||||
|
behaviour is known, it is a specification, not a reference: **when it is known, hold to
|
||||||
|
it**. What "known" means here is narrow — read in `nextgraph-rs` or stated by the
|
||||||
|
NextGraph developer, never inferred from what an npm package happens to expose, and
|
||||||
|
never inferred from an absent implementation ("the engine has no X" says nothing about
|
||||||
|
whether the target will).
|
||||||
|
|
||||||
|
The pressure to deviate never announces itself as a deviation. It shows up as a cost, a
|
||||||
|
latency, an ergonomic wrinkle — a real one. Two instances, both caught only by asking
|
||||||
|
the question:
|
||||||
|
|
||||||
|
- *Every document has a native inbox* was written into the docs from general
|
||||||
|
reasoning. It is false, and it had already become an implementation.
|
||||||
|
- A per-document inbox was made to point at **the owner's** inbox, to avoid a measured
|
||||||
|
cost (9m37 → 21m30 on the consumer's suite). It emulates a many-to-one relation
|
||||||
|
upstream cannot express: the verifier routes by `inboxes: PubKey → RepoId` and unseals
|
||||||
|
with that one repo's key (`engine/verifier/src/verifier.rs:1677,1928`), and a message
|
||||||
|
carries no target document because it needs none. Reverted. The cost was then solved
|
||||||
|
without touching the shape — only documents meant to receive open an inbox.
|
||||||
|
|
||||||
|
The tell in both: an implementation choice that would make the consumer learn something
|
||||||
|
it must **unlearn** at migration. That is the thing this library exists to prevent, so
|
||||||
|
it outranks cost, latency and convenience. When the shape and the cost conflict, keep
|
||||||
|
the shape and attack the cost elsewhere — and if it truly cannot be solved, say so
|
||||||
|
rather than bend the model quietly.
|
||||||
|
|
||||||
|
### The three references, numbered bottom-up
|
||||||
|
|
||||||
|
"NextGraph" is not one layer, and conflating them is how a fact about one gets asserted
|
||||||
|
about another. They are **stacked**, each built on the one below, so they are numbered
|
||||||
|
from the bottom:
|
||||||
|
|
||||||
|
| # | Layer | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| **3** | **JS SDK / ORM** | `@ng-org/orm`, `@ng-org/shex-orm` — source in `sdk/js/orm` (TypeScript) |
|
||||||
|
| **2** | **wasm binding** | `@ng-org/web` — source in `sdk/js/lib-wasm` (77 exported methods) |
|
||||||
|
| **1** | **Rust engine** | `engine/` — `repo`, `verifier`, `net`, `broker`, `wallet` |
|
||||||
|
|
||||||
|
**These are REFERENCES, not places we write code.** Every line this library ships lives
|
||||||
|
in the polyfill; none of these three layers is ours to touch, and `nextgraph-rs` is a
|
||||||
|
read-only source of truth. Saying "level 1" about a piece of our code means *"it is
|
||||||
|
aligned on the engine's model"* — never *"it lives in the engine"*.
|
||||||
|
|
||||||
|
**Which reference to align on: take the HIGHEST one that answers, and go down only when
|
||||||
|
it does not.**
|
||||||
|
|
||||||
|
- **Level 3 answers fully → do not implement it here.** Pass through. Compensation code
|
||||||
|
that doubles a working SDK function is code to delete later, and it diverges meanwhile.
|
||||||
|
- **Level 3 is absent or unsatisfactory → align on the level-2 call** that does the job.
|
||||||
|
Ergonomics are lost, semantics are kept — and migrating later means moving up one
|
||||||
|
step, not rewriting.
|
||||||
|
- **Nothing at level 2 either → align on the level-1 MODEL**: cardinalities, addressing
|
||||||
|
units, what a structure can and cannot express.
|
||||||
|
|
||||||
|
Level 1's facts are the hardest-won, but aligning there means **inventing a JS surface**,
|
||||||
|
since none exists yet. So always **say which level a choice came from**. A level-3
|
||||||
|
passthrough is a fact; a level-1 shape is a bet constrained by the engine. Presenting
|
||||||
|
them alike is what manufactures false certainty — `inbox_post_link` was cited across
|
||||||
|
eight files as a planned NextGraph API when it was only a name proposed in
|
||||||
|
[`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md).
|
||||||
|
|
||||||
|
Concretely for the inbox: level 3 has nothing, level 2 has **no `inbox` method at all**
|
||||||
|
(and the verifier has no `InboxPost` arm), so `inbox.*` is aligned on level 1 — the
|
||||||
|
engine's model (one inbox ↔ one repo, addressed by `(overlay, pubkey)`, no target
|
||||||
|
document in the message) with a JS surface of our own making.
|
||||||
|
|
||||||
|
This cascade answers *"we need X — what do we align on?"*. It is **not** a checklist to
|
||||||
|
run over what the target exposes: an unused binding method is not a debt, and "it was in
|
||||||
|
the unused list" is not a reason to investigate it.
|
||||||
|
|
||||||
|
> **Do not confuse these levels with the other "three levels" in this repo.**
|
||||||
|
> [`docs/readcap-and-nuri-model.md`](./docs/readcap-and-nuri-model.md) §4quinquies numbers
|
||||||
|
> *where a cap is stored* (wallet root key → the Store/User branch registers → the local
|
||||||
|
> in-memory cache). Same word, unrelated axis: these three are **layers of NextGraph to
|
||||||
|
> align on**, those three are **places a key lives**. When it is not obvious from the
|
||||||
|
> sentence, say "reference level" or "storage level".
|
||||||
|
|
||||||
- SDK-identical surface: the client wraps the real `ng` (a Proxy that forwards
|
- SDK-identical surface: the client wraps the real `ng` (a Proxy that forwards
|
||||||
everything and overrides only what must be emulated) and `useShape`. The real
|
everything and overrides only what must be emulated) and `useShape`. The real
|
||||||
SDK is injected via `configure()` (no hard import → build-alias safe and
|
SDK is injected via `configure()` (no hard import → build-alias safe and
|
||||||
testable).
|
testable).
|
||||||
- Authorization is emulated capabilities: documents carry grants; the client
|
- Authorization is emulated capabilities: documents carry grants; the client
|
||||||
enforces them generically (read filter + write guard). The app declares a
|
enforces them generically (read filter + write guard). The app declares a
|
||||||
document's read policy and issues directed grants — the same acts it will
|
document, shares one document's cap to an inbox — the same acts it will
|
||||||
perform in the target. No policy is injected.
|
perform in the target. No policy is injected.
|
||||||
- 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` / `listEntityDocs` + per-scope index, 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 when a policy is declared.
|
`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
|
||||||
|
|||||||
@@ -10,8 +10,27 @@
|
|||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/client": {
|
"examples/notebook": {
|
||||||
"name": "@ng-eventually/client",
|
"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",
|
"version": "0.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
||||||
@@ -33,45 +52,47 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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/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=="],
|
"@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/client": ["@ng-eventually/client@workspace:packages/client"],
|
"@ng-eventually/example-notebook": ["@ng-eventually/example-notebook@workspace:examples/notebook"],
|
||||||
|
|
||||||
"@ng-org/alien-deepsignals": ["@ng-org/alien-deepsignals@0.1.2-alpha.11", "", { "dependencies": { "alien-signals": "^2.0.7" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0", "svelte": "^5.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "svelte", "vue"] }, "sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ=="],
|
"@ng-eventually/polyfill": ["@ng-eventually/polyfill@workspace:packages/polyfill"],
|
||||||
|
|
||||||
"@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.24", "commander": "^14.0.1", "dts-dom": "~3.6.0", "ejs": "^3.1.8", "fs-extra": "^10.1.0", "jsonld2graphobject": "^0.0.5", "loading-cli": "^1.1.0", "prettier": "^3.0.3" }, "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0" }, "bin": { "rdf-orm": "dist/cli.js" } }, "sha512-jogxiLfUtI3WkuF5O3PPwRBRWylpapk/JlvElGUqZnEavXXlEMmEGbK3VwqMUsbp1pweNA0sEKIFLIk3Lzt2Gg=="],
|
"@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/web": ["@ng-org/web@0.1.2-alpha.13", "", { "dependencies": { "async-proxy": "^0.4.1" } }, "sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ=="],
|
"@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=="],
|
||||||
|
|
||||||
"@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="],
|
"@ng-org/web": ["@ng-org/web@0.1.2-alpha.13", "", { "dependencies": { "async-proxy": "0.4.1" } }, "sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ=="],
|
||||||
|
|
||||||
"@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=="],
|
"@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "26.1.1" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="],
|
||||||
|
|
||||||
"@shexjs/term": ["@shexjs/term@1.0.0-alpha.27", "", { "dependencies": { "@types/shexj": "^2.1.6", "rdf-data-factory": "^1.1.2", "relativize-url": "^0.1.0" } }, "sha512-+D7P7pglRPTZC2RkwaQuq+cgBZImx+61JZtcN77uEJVqcGaIscQK5hScsKhAPIo16/I+4jhIUCEFojXqw6otpg=="],
|
"@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/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.6", "hierarchy-closure": "^1.2.2", "sync-request": "^6.1.0" } }, "sha512-L8pBokTU/5eNRJPkC8R9SIgPw6/JDh/bHKdV5TZzf8/FkOMNJwKIy6UDHXM1I8FJ+c8u2gOOHp2MA+7b+md+0A=="],
|
"@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=="],
|
"@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/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/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=="],
|
"@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/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": "*" } }, "sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA=="],
|
"@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": "*" } }, "sha512-8BSvG1kGm83cyJITQMZSulnl6QV8jqAGreJsc5tPu1Jq0vTSOiY/k24Wx82JRpWwZSqrala6sd5rWi6aNXvqcw=="],
|
"@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": "*" } }, "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw=="],
|
"@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/jsonld": ["@types/jsonld@1.5.15", "", {}, "sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
|
"@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/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
|
||||||
|
|
||||||
@@ -83,67 +104,67 @@
|
|||||||
|
|
||||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
"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=="],
|
"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=="],
|
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
"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=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
"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-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=="],
|
"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=="],
|
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
|
||||||
|
|
||||||
"colors-cli": ["colors-cli@1.0.33", "", { "bin": { "colors": "bin/colors" } }, "sha512-PWGsmoJFdOB0t+BeHgmtuoRZUQucOLl5ii81NBzOOGVxlgE04muFNHlR5j8i8MKbOPELBl3243AI6lGBTj5ICQ=="],
|
"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=="],
|
"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=="],
|
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
"dts-dom": ["dts-dom@3.6.0", "", {}, "sha512-on5jxTgt+A6r0Zyyz6ZRHXaAO7J1VPnOd6+AmvI1vH440AlAZZNc5rUHzgPuTjGlrVr1rOWQYNl7ZJK6rDohbw=="],
|
"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.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"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.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"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-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
"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-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.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
"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.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
"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=="],
|
"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.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
"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=="],
|
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"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.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"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-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.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
"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=="],
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
@@ -151,31 +172,31 @@
|
|||||||
|
|
||||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
"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=="],
|
"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=="],
|
"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.1", "parse-cache-control": "^1.0.1" } }, "sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw=="],
|
"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.3", "", {}, "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ=="],
|
"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.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="],
|
"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=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||||
|
|
||||||
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
"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.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
"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.1", "@types/node": "^18.0.0", "cross-fetch": "^3.0.6", "http-link-header": "^1.0.2", "relative-to-absolute-iri": "^1.0.5" }, "bin": { "jsonld-context-parse": "bin/jsonld-context-parse.js" } }, "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA=="],
|
"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.0.1", "@types/jsonld": "^1.5.6", "jsonld-context-parser": "^2.1.5", "uuid": "^8.3.2" } }, "sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw=="],
|
"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.26" } }, "sha512-M1ntfXHpdGoQxfaqKBOQPwSrTr9EIoTgj664Q9UVSbSnJvAFdribo+Ij//1jvACgrGHaTvfKoD9PG3NOxGj44g=="],
|
"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=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
@@ -183,9 +204,11 @@
|
|||||||
|
|
||||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"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.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.2" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||||
|
|
||||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
"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-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
@@ -199,17 +222,17 @@
|
|||||||
|
|
||||||
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
|
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
|
||||||
|
|
||||||
"prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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.0.0" } }, "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ=="],
|
"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.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
"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=="],
|
"relative-to-absolute-iri": ["relative-to-absolute-iri@1.0.8", "", {}, "sha512-U1TmhrhCmXKkDL9mI8gBbF5TN6TKcuv28k5+H3gMCAjoz0TyyHAICHlaGDZsTEBSu2Y3HhDKc8e6X9n33qeIqA=="],
|
||||||
|
|
||||||
@@ -217,21 +240,21 @@
|
|||||||
|
|
||||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
"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": ["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-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.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
"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.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
"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.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
"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.1", "sync-rpc": "^1.2.1", "then-request": "^6.0.0" } }, "sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw=="],
|
"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.6", "", { "dependencies": { "get-port": "^3.1.0" } }, "sha512-J8jTXuZzRlvU7HemDgHi3pGnh/rkoqR/OZSjhTyyZrEkkYQbk7Z33AXp37mkPfPpfdOuj7Ex3H/TJM1z48uPQw=="],
|
"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.0", "@types/form-data": "0.0.33", "@types/node": "^8.0.0", "@types/qs": "^6.2.31", "caseless": "~0.12.0", "concat-stream": "^1.6.0", "form-data": "^2.2.0", "http-basic": "^8.1.1", "http-response-object": "^3.0.1", "promise": "^8.0.0", "qs": "^6.4.0" } }, "sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA=="],
|
"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=="],
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
@@ -239,7 +262,7 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
@@ -249,11 +272,17 @@
|
|||||||
|
|
||||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
"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.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
"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=="],
|
"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.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
"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=="],
|
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
@@ -261,6 +290,8 @@
|
|||||||
|
|
||||||
"then-request/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="],
|
"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=="],
|
"jsonld-context-parser/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,742 @@
|
|||||||
|
# API contract — what `@ng-eventually/polyfill` exposes today, and what the future SDK should expose per subject
|
||||||
|
|
||||||
|
> **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 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:
|
||||||
|
|
||||||
|
- **PASSTHROUGH (level 3 / level 2, VERIFIED)** — the target function exists today; the lib forwards to it. Citation into `nextgraph-rs` or the installed `.d.ts`. Level numbers per `README.md` § *The three references*: 3 = JS ORM (`sdk/js/orm`), 2 = wasm binding / `@ng-org/web` (`sdk/js/lib-wasm`, `sdk/js/web`), 1 = Rust engine (`engine/`).
|
||||||
|
- **LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED)** — the engine's model constrains the shape and is cited, but **no JS surface exists at any level**, so the signature offered here is this library's invention. The future SDK's name and parameter order for it are unknown.
|
||||||
|
- **ASSUMPTION** — nothing at any layer constrains this; the bet and what bounds it are stated.
|
||||||
|
- **NO COUNTERPART** — the subject has no image in the target at any layer, usually because it is shared-wallet machinery that disappears at migration. That is a finding about the emulation, not a gap in the target.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill`: **one call**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// shared-wallet/bootstrap.ts
|
||||||
|
export interface EventuallyConfig {
|
||||||
|
ng: NgLike; // the REAL @ng-org/web ng
|
||||||
|
useShape: UseShapeLike; // the REAL @ng-org/orm useShape
|
||||||
|
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||||
|
sharedWallet?: SharedWalletConfig; // the gate's, § 2bis
|
||||||
|
debugAccessLog?: boolean;
|
||||||
|
init?: (...args: any[]) => any;
|
||||||
|
initNg?: (...args: any[]) => any;
|
||||||
|
}
|
||||||
|
export function configure(c: EventuallyConfig): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**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`".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Lifecycle
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 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;
|
||||||
|
// lifecycle.ts:18 — forwards to the real @ng-org/orm initNg injected at configure()
|
||||||
|
export function initNg(...args: any[]): any;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
**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
|
||||||
|
// level 2 — @ng-org/web: index.d.ts:108, source sdk/js/web/src/index.ts:51
|
||||||
|
export declare const init: (callback: Function | null, singleton: boolean, access_requests: any) => Promise<void>;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// level 2 — the Session initNg consumes: index.d.ts:265-273 (source sdk/js/web/src/index.ts:15-22)
|
||||||
|
export declare type Session = {
|
||||||
|
session_id: string | number;
|
||||||
|
protected_store_id: string;
|
||||||
|
private_store_id: string;
|
||||||
|
public_store_id: string;
|
||||||
|
ng: typeof NGModule;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Divergence: none in behaviour (pure forwarding), but the wrapper erases the parameter types. A consumer typing calls against the wrapper learns nothing it must unlearn — it just gets no compile-time help the real SDK would give.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2bis. The access gate — `ensureIdentity`
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export async function ensureIdentity(): Promise<PrincipalId>; // shared-wallet/access-gate.ts
|
||||||
|
export interface SharedWalletConfig { fileUrl: string; password: string; importUrl?: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**NO COUNTERPART in substance, and a surviving CALL SITE — this section exists because those two are not the same thing.**
|
||||||
|
|
||||||
|
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.
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// use-shape.ts:12
|
||||||
|
export function useShape(shapeType: unknown, scope: unknown): unknown;
|
||||||
|
// type re-exports, index.ts:48-49
|
||||||
|
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
|
||||||
|
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour: forwards to the injected real `useShape`; once any emulated cap exists (`caps.isEnforcing()`), the returned set is wrapped in a read-filtered view keeping only items whose document cap the current holder has.
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
**PASSTHROUGH (level 3, VERIFIED), with a signature the wrapper widens.** The real hook:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// level 3 — @ng-org/orm/react: sdk/js/orm/src/frontendAdapters/react/useShape.ts:86-124
|
||||||
|
const useShape = <T extends BaseType>(
|
||||||
|
shape: ShapeType<T>,
|
||||||
|
scope: Scope | string | undefined
|
||||||
|
) => DeepSignalSet<T>;
|
||||||
|
|
||||||
|
// 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 = {
|
||||||
|
graphs?: string[] | string;
|
||||||
|
subjects?: string[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The read filter disappears at migration: in the target, isolation is cryptographic — a repo whose cap the wallet does not hold is never decrypted, a union read over it yields nothing, and a targeted read errors `RepoNotFound` (`engine/verifier/src/request_processor.rs:155,163` via `resolve_target`). VERIFIED at level 1; the *consumer-visible* result (you only see what you hold) is the same, which is the point of the emulation.
|
||||||
|
|
||||||
|
Divergence to note: the wrapper types everything `unknown`, losing the generic `T`. A consumer wanting typed sets today must cast; at migration the real generic signature gives it back. Nothing to unlearn, only ergonomics deferred.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Reactive typed reads with load state — `watchShape`
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// watch-shape.ts:73
|
||||||
|
export interface ShapeQuery<T = UnionSubject> {
|
||||||
|
data: T[];
|
||||||
|
isPending: boolean;
|
||||||
|
isSuccess: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
// watch-shape.ts:90
|
||||||
|
export interface ShapeObservable<T = UnionSubject> {
|
||||||
|
getSnapshot(): ShapeQuery<T>;
|
||||||
|
subscribe(onChange: () => void): () => void;
|
||||||
|
refetch(): void;
|
||||||
|
}
|
||||||
|
// watch-shape.ts:166
|
||||||
|
export function watchShape<T = UnionSubject>(
|
||||||
|
shapeType: unknown,
|
||||||
|
scope: Scope,
|
||||||
|
): 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
|
||||||
|
|
||||||
|
**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
|
||||||
|
// level 3, VERIFIED — sdk/js/orm/src/connector/GraphOrmSubscription.ts:228,260,274
|
||||||
|
OrmSubscription.getOrCreate<T extends BaseType>(shape: ShapeType<T>, scope: NormalizedScope): OrmSubscription<T>;
|
||||||
|
get readyPromise(): Promise<void>; // resolves when the subscription is synced — the native "no longer pending" signal
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// read-model.ts:60
|
||||||
|
export interface UnionSubject {
|
||||||
|
subject: string; // the subject IRI as the document carries it — any IRI, not a Nuri
|
||||||
|
graph: Nuri; // the document reference the caller passed, unchanged
|
||||||
|
props: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
// read-model.ts:166
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
Two verified counterparts, one per level; neither returns `UnionSubject` — that grouping is lib-invented:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// level 2, VERIFIED — the primitive readUnion composes: index.d.ts:295, source sdk/js/lib-wasm/src/lib.rs:352 (nodejs) / :555 (web)
|
||||||
|
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
|
||||||
|
|
||||||
|
// level 3, VERIFIED — the one-shot typed read: sdk/js/orm/src/connector/getObjects.ts:23
|
||||||
|
export async function getObjects<T extends BaseType>(
|
||||||
|
shapeType: ShapeType<T>,
|
||||||
|
scope: Scope | string
|
||||||
|
); // returns a deep-cloned Set of matching objects
|
||||||
|
```
|
||||||
|
|
||||||
|
The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query to that repo's graph as default graph (`resolve_target_for_sparql`, `engine/verifier/src/request_processor.rs:256-285`), an anchorless query unions every named graph in the session store (same function, `UserSite → None` → `set_default_graph_as_union`). At migration `readUnion` survives as composition (the anchored per-doc read is native); a consumer that wants typed results should be on `useShape`/`getObjects`, not on `UnionSubject` — the property-bag shape is a polyfill artifact, kept generic precisely so the consumer maps it into its own types and can drop it later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Raw document / SPARQL primitives — `docs.*`
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill` (namespace `docs`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// docs.ts:53
|
||||||
|
export async function docCreate(
|
||||||
|
sessionId: string | number,
|
||||||
|
crdt: string,
|
||||||
|
cls: string,
|
||||||
|
dest: string,
|
||||||
|
store?: unknown,
|
||||||
|
): Promise<Nuri>;
|
||||||
|
// docs.ts:101
|
||||||
|
export async function sparqlUpdate(
|
||||||
|
sessionId: string | number,
|
||||||
|
query: string,
|
||||||
|
anchor?: Nuri,
|
||||||
|
label = "sparqlUpdate",
|
||||||
|
): Promise<unknown>;
|
||||||
|
// docs.ts:146
|
||||||
|
export async function sparqlQuery(
|
||||||
|
sessionId: string | number,
|
||||||
|
query: string,
|
||||||
|
base?: string,
|
||||||
|
anchor?: Nuri,
|
||||||
|
label = "sparqlQuery",
|
||||||
|
): Promise<unknown>;
|
||||||
|
// docs.ts:113 — machinery, see § 15
|
||||||
|
// NOT published since 2026-08-07 — moved to `emulated-verifier/register-write.ts`.
|
||||||
|
// It skips the boundary by design ("the one write that legitimately crosses"), and a door
|
||||||
|
// that skips a guard must not be one an application can open: holding nothing but a public
|
||||||
|
// document's bare reference, one could rewrite the inbox address posted on it and divert
|
||||||
|
// every deposit meant for its owner. Go through `inbox.post` / `inbox.share`.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
**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
|
||||||
|
// index.d.ts:60 — the installed web SDK's doc_create
|
||||||
|
declare function doc_create(session_id: any, crdt: string, class_name: string, destination: string, store_repo: any): Promise<any>;
|
||||||
|
// index.d.ts:297
|
||||||
|
declare function sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>;
|
||||||
|
// index.d.ts:295
|
||||||
|
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 — and it is no longer published (see the block above).
|
||||||
|
|
||||||
|
**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 **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`).
|
||||||
|
|
||||||
|
So the target's direction for scope placement is **already visible in the source** (level 2, VERIFIED, nodejs SDK): name the store by type + repo id strings. The migration-guide's anticipated `getNativeStore(scope)`-style resolver should expect to produce exactly that pair (or the serde `StoreRepo` once a web helper lands) — not a new concept.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Per-document subscription — `subscribeDoc`
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// subscribe.ts:47,60,79
|
||||||
|
export type DocChange = unknown;
|
||||||
|
export type DocChangeType = string | undefined;
|
||||||
|
export type Unsubscribe = () => void;
|
||||||
|
// subscribe.ts:69
|
||||||
|
export function docChangeType(resp: DocChange): DocChangeType;
|
||||||
|
// subscribe.ts:104
|
||||||
|
export function subscribeDoc(
|
||||||
|
nuri: Nuri,
|
||||||
|
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||||
|
): Unsubscribe;
|
||||||
|
// subscribe.ts:184
|
||||||
|
export function subscribeDocs(
|
||||||
|
nuris: Nuri[],
|
||||||
|
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
|
||||||
|
): Unsubscribe;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
**Annotated `aligned` (level 2, VERIFIED) — the deltas below are what makes it `aligned` rather than `passthrough`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// index.d.ts:66, source sdk/js/lib-wasm/src/lib.rs:1908
|
||||||
|
declare function doc_subscribe(repo_o: string, session_id: any, callback: Function): Promise<any>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- The real call is `async` and resolves to an unsubscribe function; the wrapper returns the unsubscribe **synchronously** and honours an early cancel when the promise settles. A consumer coding against the sync return will keep working against the real SDK only through an adapter — a small, known unlearn, traded for not forcing `await` on every subscription site.
|
||||||
|
- The real callback receives one argument, the serialized `AppResponse` (`{ V0: { State | Patch | TabInfo | … } }`); the wrapper adds a second, pre-extracted `type`. `docChangeType` is a convenience over the verified payload shape (pinned by the e2e CONTRACT-3 probe), not an upstream API.
|
||||||
|
- `subscribeDocs` has **NO COUNTERPART and needs none**: it is client-side composition (a set of `doc_subscribe` with per-doc error isolation). The upstream fan-out primitive that looks like it (`orm_start_graph(graph_scope, …)`, `index.d.ts:243`) aborts wholesale on one `RepoNotFound` (`sdk/js/orm` → `engine/verifier/src/request_processor.rs:53-66`) — the reason this composition exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Inbox — deposits, and cap delivery
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill` (namespace `inbox`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Deposit {
|
||||||
|
from: PrincipalId | null;
|
||||||
|
payload: unknown;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
export interface PostOptions {
|
||||||
|
from?: PrincipalId | null;
|
||||||
|
payload: unknown;
|
||||||
|
ts?: number;
|
||||||
|
}
|
||||||
|
export async function post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
|
||||||
|
export async function postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
|
||||||
|
export async function share(doc: NuriLike, toUser: string): Promise<void>;
|
||||||
|
export async function read(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||||
|
export async function readForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||||
|
// `materialize` — a second published name for `read` — was REMOVED on 2026-08-14. It was
|
||||||
|
// an alias and nothing else: no call site, and upstream has no such member, so it was a
|
||||||
|
// symbol an application could learn and would then have to unlearn. Use `read`.
|
||||||
|
export async function readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||||
|
export async function readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||||
|
export async function processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||||
|
export function watch(
|
||||||
|
targetInbox: NuriLike,
|
||||||
|
onDeposits: (deposits: Deposit[]) => void,
|
||||||
|
_opts?: { intervalMs?: number },
|
||||||
|
): () => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
**LEVEL-1 SHAPE throughout — the model is VERIFIED, every JS signature here is this library's invention.** There is no inbox method in `@ng-org/web` (none in the 77 `index.d.ts` exports, re-verified), and the verifier's dispatch has no `InboxPost` arm (arms actually handled listed at `engine/verifier/src/request_processor.rs:53-1444`, re-verified). The engine model that constrains the shape:
|
||||||
|
|
||||||
|
- An inbox is a keypair on **exactly one repo**: `pub inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); routing is `inboxes: HashMap<PubKey, RepoId>` on the verifier (`engine/verifier/src/verifier.rs:105`, looked up at `:1677`, inserted at `:1928`).
|
||||||
|
- A message is sealed to the inbox pubkey and carries **no target document** — `InboxMsgBody { to_overlay, to_inbox: PubKey, from_overlay: Option<OverlayId>, from_inbox: Option<PubKey>, … }` (`engine/net/src/types.rs:4265`). The address identifies the recipient repo; nothing else is needed. This is why `Deposit` has no document field and why `post` takes only the inbox NURI.
|
||||||
|
- `from` optional upstream (`from_inbox: Option<PubKey>`) — the "identified if known, anonymous otherwise" behaviour `PostOptions.from` mirrors, including the `null`-means-anonymous case.
|
||||||
|
- The recipient's own verifier unseals and **applies** queued messages when it processes its inbox (`engine/verifier/src/verifier.rs:1674-1690` → `process_inbox`); an inbox is a consumed queue, not a store you re-read.
|
||||||
|
|
||||||
|
Consequences per function:
|
||||||
|
|
||||||
|
- `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown** — `docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the address→repo association lives in `inboxes: PubKey → RepoId`, a table of the **verifier** — one per user, `verifier.rs:105`). *(Corrected 2026-08-10: this said the table was "session-local, rebuilt empty". It is initialized empty (`verifier.rs:520,2820`) and then repopulated at every load — `Verifier::load` → `add_repo_without_saving` → `add_repo_`, `verifier.rs:534-566,2871,2887` — with the inbox private key persisted per repo, `user_storage/repo.rs:61,171,207,362`. The property that matters is that it is **per verifier**, not that it is ephemeral.)* Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
||||||
|
- `share` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<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` / `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **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
|
||||||
|
|
||||||
|
### Today
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// @ng-eventually/polyfill — model/types.ts. The published cap surface is now ONE type.
|
||||||
|
export type Nuri = `did:ng:${string}`;
|
||||||
|
export type NuriLike = Nuri | string;
|
||||||
|
|
||||||
|
// NOT published, each deliberately:
|
||||||
|
// ReadCap — `did:ng:${string}:r:${string}`. Unpublished 2026-08-10, when
|
||||||
|
// `export * from "./model/types"` became a named list. It remains the library's
|
||||||
|
// internal type for a cap-bearing reference, but NO published signature takes or
|
||||||
|
// returns one: within `surface/inbox.ts` only two private helpers use it
|
||||||
|
// (`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.
|
||||||
|
|
||||||
|
// INTERNAL — `emulated-verifier/caps.ts` (class CapRegistry). Never published; listed for the maintainer.
|
||||||
|
constructor(holder?: () => PrincipalId | null);
|
||||||
|
mint(nuri: Nuri): ReadCap;
|
||||||
|
learn(cap: ReadCap): void;
|
||||||
|
capFor(nuri: Nuri): ReadCap | undefined;
|
||||||
|
learnFromPublicStore(cap: ReadCap): void; // a cap the public store SERVED — read only
|
||||||
|
isReadOnlyPublicCap(nuri: Nuri): boolean;
|
||||||
|
markInPublicStore(nuri: Nuri): void;
|
||||||
|
isInPublicStore(nuri: Nuri): boolean;
|
||||||
|
open(nuri: Nuri, scope: Scope): ReadCap;
|
||||||
|
isEnforcing(): boolean;
|
||||||
|
onChange(listener: () => void): () => void;
|
||||||
|
grantWrite(doc: Nuri, principal: PrincipalId): void; // decorative until cap-enforcement
|
||||||
|
governsWrite(doc: Nuri): boolean; // decorative until cap-enforcement
|
||||||
|
canWrite(doc: Nuri, principal: PrincipalId | null): boolean; // decorative until cap-enforcement
|
||||||
|
hasWritePolicy(): boolean; // decorative until cap-enforcement
|
||||||
|
clear(): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
**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 `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`).
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### Today — nothing. The entry publishes **no** string utility and **no** type guard.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// NOT published — internal, and each for a stated reason:
|
||||||
|
// surface/sparql.ts escapeLiteral, escapeIri, assertNuri
|
||||||
|
// model/nuri.ts isNuri, hasReadCap, targetOf, parseNuri, toNuri
|
||||||
|
// 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill` (namespace `storeRegistry`) — plus `Scope` from `types.ts`
|
||||||
|
|
||||||
|
> **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
|
||||||
|
// types.ts:38 — NB: NOT the ORM's Scope (a graphs/subjects filter); this is the store scope
|
||||||
|
export type Scope = "public" | "protected" | "private";
|
||||||
|
|
||||||
|
// store-registry.ts:90,234
|
||||||
|
// VirtualUserRecord is INTERNAL (shape kept here for the ruling below).
|
||||||
|
interface VirtualUserRecord {
|
||||||
|
id: string;
|
||||||
|
docPublic: Nuri;
|
||||||
|
docProtected: Nuri;
|
||||||
|
docPrivate: Nuri;
|
||||||
|
}
|
||||||
|
// RegistrySession is INTERNAL since 2026-08-12 (shape kept here for the ruling below).
|
||||||
|
interface RegistrySession {
|
||||||
|
sessionId: string | number; // relayed untouched — upstream's own type (§ 7)
|
||||||
|
privateStoreId: string;
|
||||||
|
protectedStoreId?: string;
|
||||||
|
publicStoreId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUBLISHED — the whole `storeRegistry` namespace, and nothing else.
|
||||||
|
// NO identity parameter, since 2026-08-10: a session belongs to one user, and the
|
||||||
|
// target's own `doc_create(session_id, …)` carries no user at all. Passing one's own
|
||||||
|
// 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 resolveWriteGraph(scope: Scope): Promise<Nuri>;
|
||||||
|
export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
|
||||||
|
|
||||||
|
// NOT published — internal, kept here because the target rulings below still cover them.
|
||||||
|
// userStoreDoc, userInbox, documentInboxAddress, isOwnInbox, myInboxes,
|
||||||
|
// addLink, readLinks, resolveAccount, ensureAccount, reservedAccount,
|
||||||
|
// resetRegistryCache, and the VirtualUserRecord type.
|
||||||
|
// `RegistrySession` joined them on 2026-08-12: it was published for ONE reason — a consumer
|
||||||
|
// 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
**`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
|
||||||
|
|
||||||
|
- **`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.
|
||||||
|
- **`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.
|
||||||
|
- **`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` → `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()` → 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` / `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Identity and connection
|
||||||
|
|
||||||
|
### Today
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// PUBLISHED: nothing. Identity is established by `ensureIdentity()` (§ 2bis) and the
|
||||||
|
// connection is awaited inside it.
|
||||||
|
//
|
||||||
|
// NOT published, and each removal is a gesture an application no longer performs:
|
||||||
|
// setCurrentUser (2026-08-07) naming one's own identity — the step that inverts the
|
||||||
|
// model. The gate does it; the e2e harness, which plays
|
||||||
|
// several identities on one page, reaches it internally.
|
||||||
|
// connectedUser (2026-08-07) awaited inside `ensureIdentity`; upstream, opening the
|
||||||
|
// session IS the connection.
|
||||||
|
// getCurrentUser (2026-08-05) an application knows who it signed in.
|
||||||
|
// IdentityStore, browserIdentityStore, VirtualUserStorage, ACCOUNT_STORAGE_KEY
|
||||||
|
// (2026-08-05) persisting an identity is the application's job
|
||||||
|
// upstream too; the gate persists what IT needs.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
**PASSTHROUGH-to-be at level 2, VERIFIED signatures.** In the target the identity is established by opening one's own wallet and starting a per-user session — there is no "set the current user" call because the session IS the user:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// index.d.ts:276, 280, 313, 315
|
||||||
|
declare function session_start(wallet_name: string, user_id: any): Promise<any>;
|
||||||
|
declare function session_stop(user_id: string): Promise<void>;
|
||||||
|
declare function user_connect(client_info: any, user_id: string, location?: string | null): Promise<any>;
|
||||||
|
declare function user_disconnect(user_id: string): Promise<void>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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` — **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()` (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
|
||||||
|
|
||||||
|
`@ng-eventually/polyfill` re-exports, type-only (erased at build, `src/index.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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";
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
Exported, but not SDK surface. Coding against these builds knowledge that migration deletes:
|
||||||
|
|
||||||
|
- ~~**`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.
|
||||||
|
- **`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 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`, `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.
|
||||||
|
- ~~**`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.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".
|
||||||
|
- ~~**`share` is importable from both entries.**~~ **FIXED 2026-08-07** with the entry merge: there is one entry and one `share`, under `inbox`.
|
||||||
|
- **One entry means the import line no longer says what disappears.** Until 2026-08-07 a second import path (`/polyfill`) WAS the deletion list. It is now the `POLYFILL-ERA` block in `src/index.ts`, this appendix's note above, and the per-subject rulings in this document. That is a documentation-carried signal where it used to be a mechanical one — the appendix is pinned by a test, the grouping is not.
|
||||||
|
- **`inbox.read` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely.
|
||||||
|
- **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API.
|
||||||
|
- **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `watchShape`'s design already assumes).
|
||||||
|
- **The sync-returning `subscribeDoc` unsubscribe** vs the target's promise-resolved one (§ 8) — a deliberate, documented ergonomic delta; an adapter is one line at migration, but it is a delta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix — full export inventory (for diffing)
|
||||||
|
|
||||||
|
*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/polyfill` — `src/index.ts` (the only entry since 2026-08-07)
|
||||||
|
|
||||||
|
```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.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Brief — align the caps emulation with the real NextGraph model
|
||||||
|
|
||||||
|
**Brief (incubation) — 2026-07-20.** See the reference `docs/readcap-and-nuri-model.md`.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
The polyfill does **NOT match** the security of finished NextGraph, and does not try to. The shared wallet plus the absence of crypto make the emulation **deliberately insecure** (everything is in plaintext, any marker is forgeable) — a dev/staging vehicle, not a goal. **Sole objective**: expose the **RIGHT SHAPE** of the future primitives so that consumers (Festipod) are coded against the **correct mental model** and **do not have to be rewritten** when NextGraph is finished.
|
||||||
|
|
||||||
|
Corollary: **"no crypto" is not a problem**; what matters is being **in the same logic, with RIGOR**. A criticism of the form "an attacker reads the plaintext / forges a marker" is **correct but out of scope**. What is **unacceptable** = exposing the **wrong shape** (e.g. an ACL where the real thing is key possession) → the consumer codes against a model that will not exist. **The ACL inversion of ReadCaps IS that lack of rigor** — the central defect to fix.
|
||||||
|
|
||||||
|
## Enforcement mechanism: LIGHTWEIGHT crypto simulation (anti-ACL, anti-shortcut)
|
||||||
|
|
||||||
|
For the shape to be **really** key-possession (and not an ACL in disguise), a doc's data is **stored encrypted** (per-doc symmetric encryption, however lightweight) and the **ReadCap = the key**. Invariant (cf. `docs/vision.md`):
|
||||||
|
|
||||||
|
> a **bare `did` (without a ReadCap)** does **NOT** allow reading; a **NURI with a ReadCap** is **sufficient and required**.
|
||||||
|
|
||||||
|
This **prevents the shortcuts** the adversary pointed out (#4/#6: reading the plaintext, `sparqlQuery`/`inbox.read` bypassing the filter) and **forbids** falling back on an ACL — that is the heart of "same logic, with rigor".
|
||||||
|
|
||||||
|
**Target — NOT the current state**: every surface that returns data will have to go through decryption-with-key. **Today this is FALSE, and far more broadly than this brief first stated** — mapping of 2026-07-27, VERIFIED: **only 4 sites consult the caps** (`use-shape`, `read-filter`, `read-model.readUnion`, `discovery.submitToIndex`). Everything else returns data with no guard:
|
||||||
|
|
||||||
|
| Surface | State |
|
||||||
|
|---|---|
|
||||||
|
| `docs.sparqlQuery` / `sparqlUpdate` | **bypasses** — they call the injected `ng` **directly** (an accepted constraint, to avoid a double-Proxy `DataCloneError`). **The widest breach**: a session id + a NURI are enough to read everything. |
|
||||||
|
| `inbox` (`read` / `readSynced` / `materialize` / `watch`) | **bypasses** — no cap consulted; the drops go to whoever asks for them |
|
||||||
|
| `store-registry` (**zero** reference to caps in the whole file) | **bypasses** — the account→NURI root of trust is universally readable |
|
||||||
|
| `discovery.readIndex` | **bypasses** on read (caps checked on write only) |
|
||||||
|
| `subscribe`, `open-repo` | **bypass** — the subscription push carries the doc state with no check |
|
||||||
|
| `watch-shape` | deliberately delegates to `readUnion` (does not re-filter) |
|
||||||
|
|
||||||
|
**And the WRITE guard is already stillborn**: `ng-proxy` guards `sparql_update`, but `docs` bypasses the proxy **by design**, and **all** internal writers go through `docs`. So the guard only fires for an app calling `ng.sparql_update` on the exported `ng` — which Festipod does not do. `grantWrite` / `canWrite` are **decorative**. *(This finding reinforces §1 of the adversarial review: writing is not an axis "to be added", it is an axis we believed was covered and is not.)*
|
||||||
|
|
||||||
|
**This inventory IS the scope of P1b.** The only existing guard (`caps.canRead`) is moreover a **set-membership ACL** — the very inversion the vision forbids.
|
||||||
|
|
||||||
|
1. **Two distinct reference shapes**: cap-less (names/locates without reading — aligned with the NURI without `:r:`) vs cap-bearing (id + key/token). Absent today.
|
||||||
|
2. **Grant = delivering a cap-token to a recipient** (emulating the sealing: the recipient *receives* the token in their inbox; it is **possession** of the token that authorizes reading — not an ACL row checked per principal).
|
||||||
|
3. **Enforcement by possession**: readers (`read-filter`, `use-shape`) only see what they **hold the token for**, not "what they are in the readers set for".
|
||||||
|
4. **Resolving a cap-less** = naming / proving existence / counting, **without** exposing the content (support for anonymous presence).
|
||||||
|
5. **Revocation = re-key** emulated: invalidate the old token, re-deliver a new one to the remaining authorized holders; **non-retroactive**.
|
||||||
|
|
||||||
|
## ~~Widened scope: the WriteCap (= membership)~~ — DROPPED (2026-07-21)
|
||||||
|
|
||||||
|
**This section was wrong and is kept struck through as a guardrail.** It imported a notion of *membership* read from the **current state** of `nextgraph-rs` (`AddMember`, `PermissionV0`, `member_pubkey`) and promoted it into a **target shape**. But (a) those types are **inert scaffolding** at runtime — `verify_sig` / `verify_perm` are only called in unit tests, and `Repo`s are built with `members: HashMap::new()`; and (b) the target model **has no notion of membership at all**: only **keys and URLs**, symmetric and asymmetric. A shape in terms of `member`/`role`/`permission` is therefore exactly the **wrong shape** that this brief exists to prevent.
|
||||||
|
|
||||||
|
**The methodological lesson, which is worth more than the dropped section**: reading NextGraph's current state in order to **deduce** the target shape is a mistake — the current state contains unfinished work that must not be frozen into the polyfill. The source serves to verify an existing **mechanism**, never to infer an **intent**.
|
||||||
|
|
||||||
|
Erroneous content kept below as a record:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Dropped section</summary>
|
||||||
|
|
||||||
|
**Why this is here and not elsewhere.** The brief was at first ReadCap-centric; an adversarial finding showed that it **does not compose** with its consumer: the Festipod brief on "Set-based sign-ups" needs to **deduplicate** participations (one user = one participation per event), and the only non-application-level basis available is the commit's **author signature** — hence a **write** primitive, not a read one. A polyfill that only exposes the ReadCap shape leaves the consumer to invent its own application-level dedup → exactly the wrong shape.
|
||||||
|
|
||||||
|
**The real shape (VERIFIED, cf. `readcap-and-nuri-model.md` §1)** — and it is **asymmetric** with reading, which is the easiest point to miss:
|
||||||
|
|
||||||
|
- **Reading = possession of a key.** No ACL. Whoever holds, reads.
|
||||||
|
- **Writing = membership + permissions** (`AddMember`, `AddPermission` on the `RootBranch`). It really **is an authorization list** — not possession. Emulating writing "by token possession" would be just as wrong as the current read ACL, in mirror image.
|
||||||
|
- **Commits ARE signed** by a `UserId` (a **technical** key, distinct from the profile) — so a dedup identifier **exists** natively, with no application-level pseudonym.
|
||||||
|
- **But verifying a signature requires being a member of the repo** (access to the `member_pubkey`). A non-member third party sees a signed commit without being able to attribute it.
|
||||||
|
- **The inbox drop is NOT authenticated** (anonymous sealed box): a declared `from` is content, not proof.
|
||||||
|
|
||||||
|
**What that imposes on the polyfill.** Expose `membership` as a primitive **distinct** from cap possession, with at minimum: adding/removing a member of a repo, reading the members map **when one is a member**, and **verifying the author of a commit** (→ an author digest, **per-overlay hence per-store**). It is this last point that unblocks the dedup on the Festipod side.
|
||||||
|
|
||||||
|
**The shape consequence, to be documented explicitly** (otherwise the consumer picks the wrong model): the author digest being **per-store**, the choice of how stores are carved up **is** the choice of the correlation level. A **per-user stable** store gives an identifier traceable **across events**; a **per-event** store gives a pseudonym **local to the event** — dedup possible, correlation impossible. Festipod needs the second. So the polyfill must make this carving **expressible**, not freeze it.
|
||||||
|
|
||||||
|
**Still open**: "can the creator of an event be a member of the store that contains the participations, without holding its read key?" — that is, membership (writing/verification) and possession (reading) genuinely **orthogonal**. If NextGraph couples them, verified dedup and anonymity are mutually exclusive, and it is the Festipod brief that must settle what it sacrifices. **To be verified before shaping the API.**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
*(Question now moot: there is no membership. The dedup does not go through signature verification — see the Festipod brief on "sign-ups".)*
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **SETTLED (PO directive, 2026-07-21)**: we **simulate the crypto** (per-doc encrypted data, cap = key). "Semantics only" (a token registry) is **discarded** — it turns back into an ACL and lets the plaintext be read. What remains to settle is the **level** of simulation (real lightweight encryption vs masked read-model projection), **before P1**.
|
||||||
|
- NURI representation, cap-less vs cap-bearing, in the emulation (mirror `:r:`).
|
||||||
|
- ~~Should **keyless fetch** be allowed (resolving a cap-less into existence/count without the content)~~ — **SETTLED, and negatively (2026-07-27)**: not constructible. Addressing itself presupposes the cap, so there is nothing to expose. See the corrected Q1 verdict below. Kept struck through rather than deleted: the hypothesis is intuitive and will otherwise be re-formed.
|
||||||
|
- API migration: `declareConnections`/`grantRead` → `seal(cap, recipient)` + `inbox → received caps`. Breaks consumers (the app-side `declareConnections` disappears).
|
||||||
|
|
||||||
|
## P0 — "keyless-resolve" spike (the blocker, BEFORE any P1)
|
||||||
|
|
||||||
|
**Load-bearing question**: can a holder of a **cap-less reference** (`did:ng:o:{id}:v:{overlay}`, without `:r:`), **without ever reading the content**:
|
||||||
|
- **Q1 — Existence / fetch**: prove/retrieve the presence of the (encrypted) blocks from the broker? Or does the broker require a ReadCap/membership in order to serve the blocks?
|
||||||
|
- **Q2 — Deletion**: distinguish "exists" from "deleted"? *(The FRAGILE point: NextGraph is an append-only CRDT — a withdrawal = a **tombstone commit** that one would have to **read** in order to know about → potentially **the key is required**. And the **decrement on leave** depends on it.)*
|
||||||
|
- **Q3 — Confidentiality**: does the key remain **required** in order to decrypt (keyless never gives the content)?
|
||||||
|
|
||||||
|
**Why this is the blocker**: the whole **anonymous counter** (counting/validating cap-less refs without reading) AND the **decrement on leave** depend on it. **If NO** → "anonymous counter via cap-less ref" is **not constructible in the target** → Festipod must **not** code that shape (guaranteed rewrite). **If YES** → P1 exposes `resolveCapLess(nuri) → {exists|deleted}` (never any content), and the emulation simulates it faithfully.
|
||||||
|
|
||||||
|
**Method** (cheap, decisive):
|
||||||
|
1. **Trace** in `nextgraph-rs` the broker/verifier **fetch authorization** path: who serves the blocks (`BlocksGet`/`TopicSync`/`OverlaySync`)? is a cap/membership checked, or is `id+overlay` enough? is the *outer* overlay public? is a deletion observable without the key?
|
||||||
|
2. *(Optional)* **decisive e2e test** (in the style of `e2e/reactivity-doc-subscribe.ts`): B holds the cap-less ref, attempts fetch/existence **without** the key, verifies that it **does not reach** the content. Empirical proof > source.
|
||||||
|
3. *(Or)* confirm with the NextGraph dev — the fastest.
|
||||||
|
|
||||||
|
**Deliverable**: YES/NO/PARTIAL per Q1/Q2/Q3 + the exact primitive (file:line) + the API shape to expose (if YES), or the finding that the counter changes (if NO).
|
||||||
|
**Gated decision**: YES → P1; NO → the sign-ups brief revisits the counter (not anonymous, or another primitive).
|
||||||
|
|
||||||
|
### Spike verdict (2026-07-21) — VERIFIED in `nextgraph-rs`
|
||||||
|
|
||||||
|
| | Answer | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| **Q1 — existence/fetch without a cap** | **NO** *(corrected on 2026-07-27 — the initial "partial YES" verdict over-read the evidence)* | Read access control does indeed let you through (reads are not cap-gated) — **but addressing presupposes the cap**: no existence command at the SDK level; the only probe is internal to the crate, requires `BlockId`s **and** a loaded repo, and targets the **inner** overlay derived from the read secret. A cap-less reference has neither `BlockId` nor the required overlay. See `readcap-and-nuri-model.md`. |
|
||||||
|
| **Q2 — detect a deletion without the key** | **NO** | Append-only broker; a deletion is an **encrypted tombstone commit** (`RemoveRepo`), a no-op on the verifier side. Without the key one observes "some activity", never "a deletion". |
|
||||||
|
| **Q3 — confidentiality** | **YES** | Blocks stored as ciphertext; the key is `#[serde(skip)]` (`types.rs`), derived from the `ReadCapSecret`. Keyless **never** gives the content. |
|
||||||
|
|
||||||
|
**What that decides.**
|
||||||
|
- **P1 is unblocked**: `resolveCapLess(nuri) → { exists }` is the right shape — but **`{ exists | deleted }` is NOT**. Do not expose a `deleted` state; that would be inventing a capability the target will never have (precisely the failure mode this brief fights).
|
||||||
|
- **Withdrawal has to be a message, not an observation.** On the consumer side: an explicit *nudge*. The polyfill has **nothing** to emulate for that — it just must not pretend otherwise.
|
||||||
|
- **Settled by the Q1 correction**: the anonymous counter can**not** rest on an existence validation — that is not constructible. So it rests on something **declarative**, which is acceptable (outside the security scope) as long as the **exposed shape does not lie**: do not expose an existence primitive that the target will not offer.
|
||||||
|
|
||||||
|
## P1a — the surface — **DONE (2026-07-28)**
|
||||||
|
|
||||||
|
**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: `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.
|
||||||
|
|
||||||
|
This brief remains the **overall effort**: P0 verdicts, P1b scope, P2–P4 batches, and the adversarial reviews.
|
||||||
|
|
||||||
|
## Phase sketch
|
||||||
|
|
||||||
|
- ~~**P1a** — **the surface**~~ **DONE 2026-07-28**: one new type (`ReadCap`), a keyring (`capFor`), per-document sharing to an inbox (`shareCap`). It was the only batch blocking Festipod, and it no longer does. See [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md) for what landed where. *(An earlier draft listed `DocRef`/`DocCap` branded types, `resolveCapLess` and a durable `sealCapTo` here — all three were **dropped** after adversarial review; the note says why.)*
|
||||||
|
- **P1b** — **the enforcement**: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed. **Requalified 2026-07-30**: the bypass inventory below is really a **virtual user boundary** problem, and it is now specified on its own in [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md). That lot precedes or absorbs this one — encrypting each document while any wallet can reach any document secures the windows with the door open.
|
||||||
|
- **P2** — replace the ACL with a **token possession** model (grant = deliver to a recipient; enforcement = possession). *Requalified by the adversarial review: the real content of P2 is **durability + cap-less + re-sharing by the holder**, not "inverting the ACL" — without crypto, inverting produces no observable delta.*
|
||||||
|
- **P3** — revocation by re-key (invalidation + re-delivery, non-retroactive).
|
||||||
|
- ~~**PW** — WriteCap = membership~~ **DROPPED (2026-07-27)**. This batch rested on a notion of membership that **does not exist** in the model (everything is keys and URLs); see the struck-through section above. It was moreover justified by a need for **dedup via signature verification** that the consumer turns out not to have: its dedup rests on the overlay, which is store-scoped. *For the record, two facts verified along the way, not to be re-discovered*: author signature verification **is not called at runtime**; and the author digest is **not** keyed under the read secret — it is keyed by the **outer** overlay, which is public *(it is the commit's **content** that is encrypted, hence the fact that verifying still presupposes being able to read)*. Detail in `nextgraph-current-state.md`.
|
||||||
|
- **P4** — adapt the consumer API + `migration-guide.md`. *The adversarial review requalifies this batch: it is not an API swap but a **consumer re-architecture** (the grant moves to connection acceptance and becomes persistent; `declareConnections` disappears).*
|
||||||
|
|
||||||
|
## Adversarial review (2026-07-20) — to be integrated
|
||||||
|
|
||||||
|
An adversary refuted the brief (7 findings — the 7th marked *(Plausible)*). **To be read through the filter of the Objective above** (shape, not security). The purely **security** criticisms — readable plaintext content (#4), forgeable markers — are **ACCEPTED / out of scope**: the polyfill does not seek to prevent them. What remains are the real **SHAPE / rigor** defects (to be fixed), and a question of **future model** (#5):
|
||||||
|
|
||||||
|
1. **WriteCap forgotten, and "possession" is FALSE there.** Writing is **membership/permissions** (`AddMember`) — an **authorization list**, not key possession (ref. §1); `ng-proxy.ts:28-48` guards every `sparql_update`. → keep a **WriteCap = membership track**; **possession concerns ONLY reading**.
|
||||||
|
2. **P2 "possession without crypto" = the ACL renamed.** Without crypto, "who holds which token" = `Map<doc, Set<holder>>` = the current `readers`: **no observable delta**. The real deltas are **durability + cap-less + re-sharing by the holder** — THAT is the content of P2, not "inverting the ACL".
|
||||||
|
3. **Non-retroactive revocation NOT EMULABLE** without versioning: `read-model.ts:112-118` only reads the current state → "invalidate the old token" = total removal = the inverse of the real thing (the former holder decrypts the **prior** versions). → emulate only "no new reads after re-key" + **document non-retroactivity as non-emulable**.
|
||||||
|
4. **cap-less "without exposing the content" ILLUSORY in the emulation**: content in **plaintext** in the shared wallet; `sparqlQuery`/`inbox.read` **bypass** the filter; `read-filter.ts:30-35` is all-or-nothing. → cap-less anonymity requires either **real crypto** or a **masked read-model projection** (counting without reading). "Replacement not overhaul" is **overstated**.
|
||||||
|
5. **Keyless-fetch = INFERRED and load-bearing**: add a **P0 spike** that verifies it **before** P1 (otherwise the model — polyfill AND Festipod — is not constructible).
|
||||||
|
6. **Migration ≠ API swap.** `declareConnections` is replayed every session because the map is ephemeral; durable seals move the grant to **connection acceptance** + persist "already sealed" — no analogue of `protectedDocsOf` + the re-derivation loop. **Consumer re-architecture.**
|
||||||
|
7. *(Plausible)* delivering a cap through the async inbox **does not re-trigger** `watchShape` (which subscribes to data docs, not to caps) → unreadable views left **stale** until another change. → plan for a cap-mutation signal.
|
||||||
|
|
||||||
|
**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/sdk/src/emulated-verifier/caps.ts`. On the consumer side, the Festipod brief "realign the sign-ups" depends on this effort.
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# Brief — P1a: the capability surface
|
||||||
|
|
||||||
|
**Status: IMPLEMENTED 2026-07-28, awaiting review.** Extracted from `2026-07-20-caps-emulation-alignment.md` (which remains the wider chantier: P0 findings, P1b enforcement, P2–P4, and the adversarial reviews). This file is the actionable lot; read it alone to implement.
|
||||||
|
|
||||||
|
The spec below is unchanged — read it first. Everything from here to *Why this lot exists* is the implementation report: what landed, the exact surface a consumer codes against, the decisions taken, and what is **not** verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implementation report (2026-07-28)
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
> - *"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.
|
||||||
|
> - 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.
|
||||||
|
> - `fileOwnCaps` — **renamed and split.** Writing a created document's cap is `holdOwnCap`, reading them back is `readStoreCaps`, and a user's own structure (three stores + inbox) is `fileOwnStructure`. Searching the code for `fileOwnCaps` finds nothing.
|
||||||
|
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
| Spec | Where |
|
||||||
|
|---|---|
|
||||||
|
| `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/emulated-verifier/caps.ts` (`CapRegistry`), surfaced as `capFor` in `src/polyfill.ts` |
|
||||||
|
| 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/surface/inbox.ts` (`shareCap`, and the inline absorption in `read`) |
|
||||||
|
| `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/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/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.* |
|
||||||
|
| Cross-user scenario (§5 non-recursiveness) | `test/cross-user-access.test.ts` — see below |
|
||||||
|
|
||||||
|
### The cross-user scenario, as the PO specified it (`test/cross-user-access.test.ts`)
|
||||||
|
|
||||||
|
Alice owns a **protected** document holding a secret, and a **public** document that carries a **reference** to it — a bare NURI. Then:
|
||||||
|
|
||||||
|
- **Bob** holds the public document's link. He reads it, finds the reference, and can NAME Alice's protected document while reading nothing of it. Publication is **not recursive**.
|
||||||
|
- **Charlie** holds the same link, plus the protected document's cap — delivered by Alice to his inbox. Same document, same reference, same path: he reads through it.
|
||||||
|
- **The only difference between them is what their keyring holds.** Nobody was named to the registry; Alice addressed an inbox.
|
||||||
|
- **Dynamic**: Bob is refused, Alice delivers the cap to *his* inbox, his client processes it — and the read that was empty yields the content. Filing the cap fires `CapRegistry.onChange`, so a reader wired to that signal (which is what `watchShape` does internally) re-reads on its own.
|
||||||
|
|
||||||
|
One property this makes explicit and that is worth confirming: **the bare NURI of a PUBLIC document is not enough either** — its repo link is. See *Publication travels as a link* below.
|
||||||
|
|
||||||
|
## The exact surface a consumer codes against
|
||||||
|
|
||||||
|
From `@ng-eventually/sdk/polyfill`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
capFor(nuri: Nuri): ReadCap | undefined // the keyring lookup
|
||||||
|
shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>
|
||||||
|
getCaps(): CapRegistry
|
||||||
|
resetCaps(): void // tests / fresh wallet ONLY — never on identity change
|
||||||
|
setCurrentUser(id: PrincipalId | null): void // selects WHICH keyring is consulted
|
||||||
|
```
|
||||||
|
|
||||||
|
On `CapRegistry` (reached via `getCaps()`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
open(nuri: Nuri, scope: Scope): ReadCap // "this document is mine, in this scope"
|
||||||
|
mint(nuri: Nuri): ReadCap // …its protected/private half
|
||||||
|
publishRepoLink(nuri: Nuri): ReadCap // …its public half — returns the SHAREABLE LINK
|
||||||
|
learn(cap: ReadCap): void // file a cap I was given (throws on a bare reference)
|
||||||
|
capFor(nuri: Nuri): ReadCap | undefined
|
||||||
|
isPublished(nuri: Nuri): boolean
|
||||||
|
isEnforcing(): boolean // false until the first cap exists
|
||||||
|
onChange(listener: () => void): () => void // keyring mutations
|
||||||
|
grantWrite / canWrite / governsWrite / hasWritePolicy // unchanged, decorative, P1b
|
||||||
|
clear(): void
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus the narrowing guards, from the SDK-identical entry:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
isNuri(s: string): s is Nuri // an untrusted string → a Nuri
|
||||||
|
hasReadCap(s: string): s is ReadCap // …→ a ReadCap; the ONLY such narrowing
|
||||||
|
```
|
||||||
|
|
||||||
|
Types: `Nuri`, `ReadCap`, `Scope`, `PrincipalId` are all exported from the SDK-identical entry (`export * from "./types"`). `Scope` is a literal union, so `open(doc, "protected")` is compiler-checked. `capFor` returns `ReadCap | undefined`, so under `strict` the consumer is forced to handle "I hold nothing".
|
||||||
|
|
||||||
|
Every call accepting a `Nuri` also accepts the cap-bearing form and normalizes it (`targetOf`), so passing a cap where a NURI is expected is never a silent mismatch — and it type-checks, because `ReadCap` is assignable to `Nuri`.
|
||||||
|
|
||||||
|
## Typing — template literal types, not `string`, not branded types
|
||||||
|
|
||||||
|
**Amended on the PO's instruction (2026-07-30), after the first pass shipped both as `type X = string`.** The types are now:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Nuri = `did:ng:${string}`
|
||||||
|
type ReadCap = `did:ng:${string}:r:${string}`
|
||||||
|
```
|
||||||
|
|
||||||
|
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`. §1's two documented objections to branded types do not apply: there is nothing to un-type at migration, and the cost at the ORM/SPARQL boundaries was **measured at zero** (see below). What the template buys is the single asymmetry that matters: a `ReadCap` is freely usable wherever a `Nuri` is expected (a cap IS a NURI with the key inside — upstream's one `NuriV0`), while a bare `Nuri` where a `ReadCap` is required is a **compile error**.
|
||||||
|
|
||||||
|
This is the one place the implementation departs from the letter of §7 (*"the discrimination lives in what you can obtain, not in what the compiler permits"*). It was an explicit PO decision: the consumer app benefits from the distinction, and possession is still what actually decides — the compiler only stops the app from writing a call the model has no meaning for.
|
||||||
|
|
||||||
|
**Cost, measured on the whole repo**: typing both aliases produced 16 errors, all of them at genuine boundaries, and every one resolved by narrowing rather than casting:
|
||||||
|
|
||||||
|
| Boundary | Resolution |
|
||||||
|
|---|---|
|
||||||
|
| Broker (`docs.docCreate`, whose `ng` is `any`) | Validates with `isNuri` and throws — the declared `Promise<Nuri>` was an unchecked promise every typed NURI downstream rested on |
|
||||||
|
| SPARQL (`store-registry` `canonicalDoc`, `readScopeIndex`) | Narrow with `isNuri`; a stored value that is not a reference is now discarded instead of flowing through as a "document NURI" |
|
||||||
|
| ORM (`read-filter` `docOf`, an untyped `@graph`) | Narrow with `isNuri` |
|
||||||
|
| Inbox payload | Free — it already tested `hasReadCap`, which is now a **type guard** (`s is ReadCap`) |
|
||||||
|
| `assertNuri` | Made generic (`<T extends string>(nuri: T): T`) so the caller's type flows through instead of widening to `string` |
|
||||||
|
| `nuri.ts` `targetOf` | **The one cast in the library**, in the primitive that defines the contract, so no caller needs one |
|
||||||
|
| Playwright bridge (e2e) | An `asNuri` helper that throws — arguments cross the bridge as plain strings |
|
||||||
|
|
||||||
|
`isNuri` and `hasReadCap` are **exported from the SDK-identical entry**, so a consumer narrows its own strings (storage, URL, JSON, a form) the same way instead of casting.
|
||||||
|
|
||||||
|
**The runtime guards stay, and are not redundant**: a JavaScript consumer never meets the compiler, and a cap read back from storage and *cast* rather than narrowed reaches the library just the same. `CapRegistry.file` — the single door into any keyring — refuses a reference with no `:r:`, and `inbox.shareCap` does likewise. That guard was added during implementation after the trap was demonstrated: `learn(someBareNuri)` filed the bare reference under its own name, `capFor` returned it, and the document read — "naming is not reading" silently becoming "naming is reading".
|
||||||
|
|
||||||
|
*(An earlier version of this section flagged a gap around what a consumer put into a discovery `ref`. Moot since 2026-07-30: `discovery.ts` was removed — there is no discovery. Circulating a link is now an explicit act, `shareCap(link, inbox)`, whose argument is typed `ReadCap` and checked at runtime.)*
|
||||||
|
|
||||||
|
## How this articulates with the virtual users
|
||||||
|
|
||||||
|
This is the part worth reviewing closely, because P1a puts a NextGraph concept (the keyring = the wallet) on top of an emulation that already fakes wallets.
|
||||||
|
|
||||||
|
**Upstream, the keyring IS the wallet.** Here there is ONE physical user that everybody opens, and an "identity" is a *virtual* wallet: a shim account in `store-registry`, mapped to three scope-index documents. So the registry holds **one keyring per virtual user** — `Map<accountKey, Map<Nuri, ReadCap>>` — and `setCurrentUser(id)` selects which one is consulted. Switching identity switches keyrings structurally; there is nothing to reset and nothing is wiped.
|
||||||
|
|
||||||
|
**Where a virtual user's caps come from, and what makes them survive a reload:**
|
||||||
|
|
||||||
|
- *Its own documents* — the scope-index document of the (account × scope) plays the role of the store branch that carries `AddRepo { read_cap }` upstream. `createEntityDoc` files the cap on creation; `listMyEntityDocs` refiles them on any later session. Nothing is persisted as a key store: the emulated key is derived from the NURI, so listing the documents is enough to hold them again. **This is why a fresh page reads its own documents with nothing re-declared.**
|
||||||
|
- *Documents shared with it* — the cap lives in the recipient's **inbox document**, which is persistent in the shared wallet. It re-enters the keyring when the consumer processes that inbox.
|
||||||
|
|
||||||
|
**Today, caps received are refiled only when the inbox is read** — which means the consumer's startup sequence has to do it. **The PO has ruled that this is wrong** (see *Follow-up* below): inbox processing belongs to the polyfill, on connection, not to the app.
|
||||||
|
|
||||||
|
**A defect found while writing this up, and fixed.** The keyring was keyed on the **raw** `currentUser`, while the shim keys accounts through the consumer-injected `normalizeId`. So `setCurrentUser("@Alice")` and `setCurrentUser("alice")` — ONE shim account, one set of documents — produced **two keyrings**, and the second one was empty: the identity stopped reading its own documents. The keyring now keys the same way the shim does, so one virtual user has exactly one keyring however its id is spelled. Locked by `test/isolation-active.test.ts` *one keyring per virtual WALLET, not per spelling of its id*.
|
||||||
|
|
||||||
|
**Still per-process, and that is correct**: the keyring is in memory, so two tabs have two keyrings. Each rebuilds itself the same way (scope index + inbox), which is exactly how a real wallet behaves on two devices.
|
||||||
|
|
||||||
|
## Where NURIs and ReadCaps are actually stored
|
||||||
|
|
||||||
|
Worth stating plainly, because "the keyring" is in memory and that sounds fragile until you see what backs it.
|
||||||
|
|
||||||
|
**NURIs are persisted, in RDF, in the shared wallet** — they always were:
|
||||||
|
|
||||||
|
| What | Where it lives | Written by |
|
||||||
|
|---|---|---|
|
||||||
|
| account → its 3 scope-index documents | the **doc-shim**, itself named by a write-once pointer triple in the private store-root | `store-registry.writeRecord` |
|
||||||
|
| scope index → the NURIs of that scope's entity documents | the per-(account × scope) **index document**, as `shim:contains` literals | `store-registry.createEntityDoc` |
|
||||||
|
| a document's own content, including any reference to another document | that **document's** graph | the consumer's write path |
|
||||||
|
| an inbox deposit (payload, ts, from) | the **inbox document's** graph | `inbox.post` |
|
||||||
|
|
||||||
|
**ReadCaps are NOT persisted as caps anywhere.** There is no key store, no trousseau document, nothing on disk that says "this identity holds these keys". The keyring is a plain in-memory `Map<accountKey, Map<Nuri, ReadCap>>` inside `CapRegistry`, rebuilt from scratch on every page load out of two persisted sources:
|
||||||
|
|
||||||
|
- **my own documents** → `listMyEntityDocs` reads the store's document list (persisted NURIs) and re-mints each cap, whose value is the constant `OK`. So *knowing which documents are mine is knowing their caps*. Upstream the key really is stored, on the Store branch (`AddRepo { read_cap }`); emulating that storage rather than re-minting is a separate lot.
|
||||||
|
- **documents shared with me** → the cap sits **inside an inbox deposit's JSON payload**, which IS persisted (it is an ordinary triple in the inbox document's graph). Processing the inbox re-files it. So a shared cap survives a reload because the *delivery* is durable, not because we stored a key — which is exactly the upstream shape, where the seal sits in the inbox until the verifier applies it.
|
||||||
|
|
||||||
|
Consequence to keep in view: **a cap is only as durable as its delivery**. That is why the PO's follow-up below (the polyfill processing inboxes on connection) matters more than it looks — until it lands, the durability of a shared cap depends on the app remembering to read its inbox.
|
||||||
|
|
||||||
|
Second consequence, on the emulated key being derived: anyone can compute any document's cap from its NURI. That is the P1a/P1b line, stated once more — possession is a **shape** here, not a protection.
|
||||||
|
|
||||||
|
## Publication travels as a link — a choice to confirm
|
||||||
|
|
||||||
|
§5 says a public item is read by "whoever has the URL", and §8 says a harvested **bare** reference must yield nothing. Both hold only if what circulates for a public document is its **repo link** (`publishRepoLink` → `did:ng:o:…:r:…`), not its bare NURI. That is what was implemented, and the cross-user test pins it: Bob holding only the public document's bare NURI reads nothing; holding its link, he reads it.
|
||||||
|
|
||||||
|
The alternative — making a published document readable from its bare NURI — was rejected because the "published" fact would then live **only in the local registry**: another tab, another process, another user would have no way to know a document was published, and the emulation would stop being portable. Carrying the fact **in the data** (the link) is what makes it work across processes, and it matches `RepoLinkV0` upstream.
|
||||||
|
|
||||||
|
This is the point where the emulation is furthest from the eventual target, where the public store may not encrypt at all and a bare NURI would suffice. Per §5 that is fine — *"if the public store does not behave as this principle describes, this library adapts, not the consumer"* — but it is a deliberate divergence and the PO should confirm it.
|
||||||
|
|
||||||
|
## A debt this lot created — the unguarded inbox — **CLOSED 2026-07-30**
|
||||||
|
|
||||||
|
`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.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
|
||||||
|
|
||||||
|
**Processing inboxes is the polyfill's job, not the app's** (PO, 2026-07-30). As soon as the app connects a user/wallet, the polyfill must process that identity's inboxes itself — the consumer should never have to remember to do it for its shared documents to become readable.
|
||||||
|
|
||||||
|
And it is inboxes, plural, at **two levels**:
|
||||||
|
|
||||||
|
- the **user/wallet** inbox — where ReadCaps arrive;
|
||||||
|
- the inbox of **every document the user owns**, so deposits land per document too. *(The justification originally given here — "a document has its own inbox upstream" — is **false**; see the correction in [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md) step 7. The design decision stands on the consumer's need and on the record's per-`repo_id` shape, not on an upstream document inbox that does not exist.)*
|
||||||
|
|
||||||
|
What exists today and is reusable: `inbox.readSynced` (the cold, barrier-gated read meant exactly for "process the inbox at (re)connection"), the inline cap absorption in `inbox.read`, and `CapRegistry.onChange` to re-trigger the reads a late cap unblocks. What is missing is the **orchestration**: a connection hook that enumerates the identity's inboxes (wallet-level + one per owned document, via the scope indexes) and processes them, idempotently and without polling.
|
||||||
|
|
||||||
|
Not started. It changes the consumer contract in the right direction (one less obligation), so it should land before the consumer re-architecture settles.
|
||||||
|
|
||||||
|
## Decisions taken while implementing, none contradicting the spec
|
||||||
|
|
||||||
|
- **`open(nuri, scope)` was kept** (it is in neither the §6 table nor the removals) as the single "this document is mine, in this scope" act — `mint` for protected/private, `publishRepoLink` for public. It no longer touches write caps: arming that guard would be enforcement this batch does not do.
|
||||||
|
- **`grantWrite` / `canWrite` were left exactly as they were** (an authorization list, decorative, guard bypassed by every internal writer) and now have to be called explicitly — `open` used to set them as a side effect. They belong to P1b.
|
||||||
|
- **`shareCap` is implemented in `inbox.ts`** and re-exported from `/polyfill`, so it is reachable both as `inbox.shareCap` (SDK-identical entry, via `export * as inbox`) and from the polyfill surface. Deliberate: sharing a cap **is** an inbox deposit upstream, and at migration this call becomes the native sealed deposit — a real SDK method, whatever it ends up being called (`inbox_post_link` was our own proposed name, not an announced API) — so hiding it from the SDK entry would have been the less faithful choice. §7's boundary holds where it matters: the registry, `capFor` and `CapRegistry` stay on the polyfill side, and every signature is a plain string.
|
||||||
|
- **The stand-in key is the constant `OK`** (`nuri.ts`; it was a derived FNV-1a digest until the PO simplified it on 2026-07-30). The only question the emulation answers is *do I hold this cap or not*, so the value says that and nothing more — a digest merely looked like a key. Possession is a shape here, not a protection; P1b replaces the constant with a real key.
|
||||||
|
- **`resetCaps()` clears in place** rather than rebuilding the registry, so a `watchShape` subscribed to the change signal does not end up holding a listener on an orphaned instance.
|
||||||
|
- **The scope-index feed is holder-scoped** (`fileOwnCaps` compares through the shim key): the cross-account fan-out `listEntityDocs` files nothing, because other accounts' caps are emphatically not ours to hold.
|
||||||
|
|
||||||
|
## Verification status
|
||||||
|
|
||||||
|
- **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 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/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 `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.
|
||||||
|
|
||||||
|
## Documentation 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Written 2026-07-27, after two adversarial reviews and three corrections from the PO. Background: `../vision.md` (why this library exists), `../readcap-and-nuri-model.md` (the target model, verified against `nextgraph-rs`).
|
||||||
|
|
||||||
|
## Why this lot exists
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## Scope: shape only, not enforcement
|
||||||
|
|
||||||
|
- **P1a (this brief)** — the surface consumers see.
|
||||||
|
- **P1b (separate)** — per-doc encryption and closing the read paths that bypass the guard.
|
||||||
|
|
||||||
|
Only P1a blocks the consumer, because the consumer must be written as if NextGraph were finished. P1b can follow.
|
||||||
|
|
||||||
|
> **After P1a the shape is right and the isolation is still fake.** Nothing may be claimed as "anonymous" or "private" until P1b lands. Say so in the README if it helps.
|
||||||
|
|
||||||
|
## Guiding constraint: stay close to NextGraph's concepts
|
||||||
|
|
||||||
|
Stated by the PO, and it is the acceptance criterion for the design as much as for the code:
|
||||||
|
|
||||||
|
> Stay as close as possible to NextGraph's concepts — and to its SDK's — to keep development simple and to keep the number of notions someone must discover small when they already know NextGraph and open this library.
|
||||||
|
|
||||||
|
Every invented name is **vocabulary debt**: the reader has to carry a translation table in their head. The first draft of this spec introduced eight new notions; adversarial review reduced it to two. Hold that line.
|
||||||
|
|
||||||
|
## The design
|
||||||
|
|
||||||
|
### 1. Types — one new name
|
||||||
|
|
||||||
|
A NURI is **one object**, with or without the key inside — upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. `did:ng:` is the **URI scheme prefix**, present on inboxes, branches and overlays alike; it does not mean "without cap". The discriminant is the **`:r:` segment** *(the spec said `:k:`; corrected 2026-07-30 on a report from NextGraph's developer — `:k:` belongs to objects/files/commits, a ReadCap is `r:{base64url(serde_bare(ObjectRef))}`, `repo/types.rs:518`)*.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Nuri = string // did:ng:o:{doc}:v:{overlay} — names, does not read
|
||||||
|
type ReadCap = string // …:r:{cap} — names AND reads
|
||||||
|
```
|
||||||
|
|
||||||
|
`Nuri` **keeps its current meaning** in this package (~90 call sites, untouched): the cap-less form. `ReadCap` is the upstream name — do not invent another.
|
||||||
|
|
||||||
|
A parsed form `{ target, readCap? }` — a 1:1 mirror of `NuriV0 { target, access }` — may be used **inside** the library. It must not surface in the SDK-identical entry's signatures.
|
||||||
|
|
||||||
|
**Do not use branded types.** They were in the first draft and were dropped deliberately: the real SDK takes `nuri: String` and enforces at **runtime, through cryptography**. A compile-time guarantee is a concept NextGraph does not have, and a consumer who typed everything would have to *un-type* it when the real SDK arrives — the opposite of the goal. The cost was also measured: branded types force a cast at every ORM and SPARQL boundary.
|
||||||
|
|
||||||
|
### 2. The keyring — where caps come from
|
||||||
|
|
||||||
|
`doc_create` returns a **cap-less** NURI. So a rule like "no function ever goes from a bare reference to a cap" is wrong: it would leave a document's own creator unable to obtain that document's cap.
|
||||||
|
|
||||||
|
The real mechanism: on every document creation, an `AddRepo { read_cap }` is committed to a **branch of the store** (the store is itself a repo, with typed branches — "branch" here has nothing to do with git). That branch lists the store's documents, each with its read key. **It is the owner's keyring.** Upstream, the keyring is the **wallet**.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
capFor(nuri: Nuri): ReadCap | undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
The invariant, correctly stated:
|
||||||
|
|
||||||
|
> **You do not derive a cap from a bare reference. You look it up in your keyring — or you were given it.**
|
||||||
|
|
||||||
|
`capFor` absorbs `canRead(doc)` (`capFor(n) !== undefined`) and drops its ACL verb.
|
||||||
|
|
||||||
|
**The keyring is not the sharing mechanism.** Handing over a store cap would give away everything the store contains, present and future. That is not the gesture (see §3). This confusion is easy and expensive — it was made once already during design.
|
||||||
|
|
||||||
|
### 3. Sharing — one document, to one or more recipients
|
||||||
|
|
||||||
|
**The unit of sharing is the document**, consistent with the consumer's own doctrine ("the document is the unit of sharing and of rights").
|
||||||
|
|
||||||
|
```ts
|
||||||
|
shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
Recipients are addressed as **inboxes** — which `inbox.post(targetInbox: Nuri)` already does in this package. There is no `PrincipalId` here: that notion exists nowhere upstream, and the first draft removed `principal` from `canRead` (calling it the ACL inversion) only to reintroduce it here.
|
||||||
|
|
||||||
|
**Caps received need no dedicated operation.** They arrive as inbox deposits of kind `cap`, consumed by the **existing** `inbox.watch`. This also fixes a known gap: a cap delivered asynchronously now triggers a re-read naturally, instead of leaving stale views.
|
||||||
|
|
||||||
|
> **Upstream status: this is a GAP, not a disagreement.** The field exists (`ContactDetails.read_cap`, commented "*if user wants to share the content of profile*") 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. We emulate it meanwhile — filed as `orm-tests/INBOX/2026-07-27-inbox-cap-delivery-not-implemented.md`, including what to remove from this library once upstream lands it.
|
||||||
|
|
||||||
|
### 4. Key rotation — automatic redelivery, not loss of access
|
||||||
|
|
||||||
|
When a key is rotated, the new one is **sent to the inbox** of users who keep access, and that inbox is **processed automatically** as soon as one of the user's clients connects.
|
||||||
|
|
||||||
|
So access is not lost, it is **deferred** until the next connection — consistent with local-first. Consequences for the surface:
|
||||||
|
|
||||||
|
- **No subscription obligation to expose.** The consumer implements nothing to "keep" an access.
|
||||||
|
- Redelivery uses **the same channel** as the initial delivery, so §3 covers both with no special case.
|
||||||
|
- **Revocation** stays what it is: stop redelivering, non-retroactive.
|
||||||
|
|
||||||
|
> An earlier draft said the opposite ("whoever does not stay subscribed loses access"). That came from an upstream comment describing the **current state**, read as if it gave the **intention**. It does not. Source verifies a mechanism; it never states a direction.
|
||||||
|
|
||||||
|
### 5. Public content — readable by URL, and NOT recursive
|
||||||
|
|
||||||
|
> **An item in the public store is public: whoever has the URL reads the content.** But **not recursively** — public content may *reference* private content, and the reference does not grant access to what it references.
|
||||||
|
|
||||||
|
This is a **second mechanism** alongside key possession, not an exception to it. The non-recursiveness is what carries the value: it allows a public object that **points at** private identity without disclosing it — exactly the pattern the consumer needs.
|
||||||
|
|
||||||
|
*Implementation detail the shape must not depend on*: NextGraph is moving toward **not encrypting** public store content (data still signed). And if the public store does not behave as this principle describes, **this library adapts** — not the consumer.
|
||||||
|
|
||||||
|
### 6. What disappears or is renamed
|
||||||
|
|
||||||
|
| Today | Becomes |
|
||||||
|
|---|---|
|
||||||
|
| `grantRead(doc, grantee)` | `shareCap(cap, toInbox)` |
|
||||||
|
| `canRead(doc, principal)` | absorbed by `capFor(nuri)` — the `principal` parameter **was** the ACL inversion |
|
||||||
|
| `protectedDocsOf(owner)` | **removed** — the re-derivation loop disappears |
|
||||||
|
| `makePublic(doc)` | `publishRepoLink` — the shareable link has an upstream name (`RepoLinkV0`) |
|
||||||
|
| `grantWrite` / `canWrite` | deferred to P1b — currently **decorative** (the guard never fires) |
|
||||||
|
| `resetCaps()` on identity change | **switch** keyrings, do **not** wipe |
|
||||||
|
| `PrincipalId` in the cap surface | **removed** — recipients are inboxes |
|
||||||
|
|
||||||
|
`resetCaps()` is the trap that can make this lot look finished while it is not: if switching identity still wipes, durability is a lie and the per-session re-declaration comes back under another name.
|
||||||
|
|
||||||
|
### 7. Boundary: SDK-identical entry vs `/polyfill`
|
||||||
|
|
||||||
|
Caps live under `/polyfill` today; `index.ts` is the SDK-identical entry. Keep it that way, and keep `index.ts` signatures on plain strings — that **is** what the real SDK does. The discrimination lives in what you can **obtain** (the keyring), not in what the compiler permits.
|
||||||
|
|
||||||
|
### 8. Acceptance test — no cryptography required
|
||||||
|
|
||||||
|
`watch-shape` currently harvests **every** `did:ng:` string it finds in a discovery reference and folds those documents into the **read** set. A bare reference therefore grants **full read** today — the semantics exactly inverted.
|
||||||
|
|
||||||
|
After P1a: a harvested bare reference yields **nothing**, for want of a cap in the keyring — which is what real NextGraph does. The test holds without a line of encryption, which is what makes the P1a/P1b split honest rather than cosmetic.
|
||||||
|
|
||||||
|
## Consumer impact
|
||||||
|
|
||||||
|
`declareConnections` **disappears**. This is not an API swap: today it re-declares every grant on every session because the ACL is in-memory. With delivered caps, the grant moves to the moment a connection is **accepted**, and persists. Plan for consumer re-architecture, and update `../migration-guide.md`.
|
||||||
|
|
||||||
|
## What this lot does NOT do
|
||||||
|
|
||||||
|
Closing the read paths that bypass the guard — `docs.sparqlQuery`/`sparqlUpdate`, the whole inbox, `store-registry`, `discovery.readIndex`, `subscribe`, `open-repo`. Only four sites consult caps today. That inventory is P1b's scope and is listed in `2026-07-20-caps-emulation-alignment.md`.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Brief — align on NextGraph's own model: users, stores, branches
|
||||||
|
|
||||||
|
> ## REFUTED by adversarial review, 2026-07-30 — do not implement as written
|
||||||
|
>
|
||||||
|
> Seven defects, four of them factual errors about NextGraph. The three that break the plan outright:
|
||||||
|
>
|
||||||
|
> 1. **D4 rests on a false premise.** There IS a register for received caps: `AddLink { read_cap }` on the **User branch** of the private store — *"so that a user can share with all its device a new Link they received… Only external repos are accepted"* (`engine/repo/src/types.rs:1934-1950`, verifier arm `commits/mod.rs:681`). It is wallet-resident and **cross-device** — the exact opposite of D4's per-browser localStorage. Corrected in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies.
|
||||||
|
> 2. **D2's rejection of the named graph is factually wrong.** A `GRAPH <…:v:…:b:…>` quad IS resolved to that branch and committed on **its own** topic (`engine/verifier/src/commits/transaction.rs:386-434`); the verifier does exactly this in `update_header`. An unknown branch id errors rather than silently landing on Main. And a branch **is** a valid SPARQL-update target: `TargetBranchV0::is_valid_for_sparql_update` returns true for `BranchId` (`engine/net/src/app_protocol.rs:77-82`) — the fact table's claim to the contrary was wrong twice over.
|
||||||
|
> 3. **The Store branch holds no triples at all.** `BranchCrdt::None`, *"used by Overlay, Store and User BranchTypes"* (`engine/repo/src/types.rs:1420`). It is a stream of service commits (`AddRepo`/`RemoveRepo`), not a graph — so all three D2 candidates were RDF inventions dressed as fidelity.
|
||||||
|
>
|
||||||
|
> And four more, all confirmed:
|
||||||
|
>
|
||||||
|
> 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(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).
|
||||||
|
> 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.**
|
||||||
|
>
|
||||||
|
> Also flagged: "store" already means the *native* store in this codebase (`RegistrySession.privateStoreId`), so D1's `privateStore`/`storeDoc` collide head-on; "the keyring notion disappears" contradicts `readcap-and-nuri-model.md` §4quater, which calls the Store branch the owner's keyring; upstream `ldp#contains` takes an **IRI** object while the polyfill writes a **literal**, so D1 is not "nothing behavioural".
|
||||||
|
>
|
||||||
|
> Rewrite required. The verified facts are being folded back into `readcap-and-nuri-model.md` first; the plan is re-derived from there, not from this text.
|
||||||
|
|
||||||
|
**Status: REFUTED 2026-07-30 — superseded, kept as the record of what was wrong. Original header follows.**
|
||||||
|
|
||||||
|
**Status: plan, not started. 2026-07-30.** Companion to [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md) (whose steps 3–4 are still pending) and to the caps chantier. This lot is about saying — and storing — what NextGraph says and stores, instead of a parallel vocabulary of our own.
|
||||||
|
|
||||||
|
## Why this lot exists
|
||||||
|
|
||||||
|
Two PO corrections, 2026-07-30:
|
||||||
|
|
||||||
|
> If NextGraph stores the key next to the document, then that is what we do. Without encryption we simply store a stand-in value. Stay as close as possible to how NextGraph works.
|
||||||
|
|
||||||
|
> I don't like the name "scope container". Let's keep NextGraph's names. We adapt for the polyfill when we need to, but there too we must stay as close as possible.
|
||||||
|
|
||||||
|
And the vocabulary correction underneath both:
|
||||||
|
|
||||||
|
> In the NextGraph code "wallet" is called "user", because a wallet is only a keyring. Virtual user → **virtual user**; physical user → **physical user**.
|
||||||
|
|
||||||
|
The library currently invents three things NextGraph does not have — a *keyring*, a *scope index*, a *virtual user* — and recomputes a key NextGraph stores. Each invention is a translation table a reader must carry, and each is a place where the consumer learns a model that will not exist.
|
||||||
|
|
||||||
|
## Verified facts this plan rests on
|
||||||
|
|
||||||
|
All read in `nextgraph-rs` (`git 213338f6`) on 2026-07-30, recorded in full in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies. The load-bearing ones:
|
||||||
|
|
||||||
|
| Fact | Source |
|
||||||
|
|---|---|
|
||||||
|
| A wallet holds SEVERAL users: `SensitiveWalletV0.sites: HashMap<String, SiteV0>` | `engine/wallet/src/types.rs:434,457` |
|
||||||
|
| A user (site) owns the three stores; `UserId = PubKey` | `engine/verifier/src/site.rs:23`; `engine/repo/src/types.rs:453` |
|
||||||
|
| The wallet holds ONE root key per user — the private store's read cap | `site.rs:52` (`get_individual_site_private_store_read_cap`) |
|
||||||
|
| `doc_create` writes TWICE: `AddRepo { read_cap }` on the **Store branch**, and `ldp:contains` on the **Main branch** | `engine/verifier/src/request_processor.rs:697-710`; `verifier.rs:2172-2199` |
|
||||||
|
| Replaying the Store branch is what reloads the repos with their keys | `engine/verifier/src/commits/mod.rs:644-664` |
|
||||||
|
| A branch is a NAMED GRAPH `did:ng:o:{repo}:v:{overlay}:b:{branch}`, with its own pub/sub topic and CRDT | `engine/net/src/app_protocol.rs:267-273`; `engine/repo/src/types.rs:1478-1501` |
|
||||||
|
| Anchoring on a document targets its **Main** branch | `request_processor.rs:161-168` |
|
||||||
|
| SPARQL cannot be anchored on a branch — every other target is `unimplemented!()` | `request_processor.rs:283` |
|
||||||
|
| A `Store` branch exists only on a real store, created on a path `doc_create` does not take | `engine/repo/src/store.rs:425-440` |
|
||||||
|
| A received cap has NO register: `ContactDetails.read_cap` is never read by the receiver | `engine/verifier/src/inbox_processor.rs:778-847` |
|
||||||
|
| Durability of a received cap comes from OPENING the repo, which persists its `read_cap` in local user storage | `engine/verifier/src/user_storage/repo.rs:109,219,248,359`; `verifier.rs:542-544` |
|
||||||
|
|
||||||
|
**Not verified, and therefore not relied on anywhere below**: whether a `GRAPH <…:b:…>` write from the JS SDK round-trips through a real broker. It is assumed BROKEN and avoided.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1 — Vocabulary follows NextGraph
|
||||||
|
|
||||||
|
| Ours today | Becomes | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| virtual user | **virtual user** | a wallet is a keyring; what owns three stores is a user (site) |
|
||||||
|
| physical user | **physical user** | NextGraph sees exactly one user; our identities are virtual ones |
|
||||||
|
| keyring (`CapRegistry`) | *(the notion disappears — see D3)* | there is no keyring object in NextGraph, and the wallet does NOT hold every key |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
The store document gains, beside its `ldp:contains` list, the read cap of each document it lists — the emulation of `AddRepo { read_cap }` on the Store branch.
|
||||||
|
|
||||||
|
Three candidate shapes were considered:
|
||||||
|
|
||||||
|
- **A distinct named graph** (`GRAPH <…:b:store>`) — **rejected**. No branch would exist behind it; the content would be committed on the anchor's Main branch while claiming to live elsewhere. It misrepresents the structure, and its sync behaviour against a real broker is unverified (see above).
|
||||||
|
- **A separate document per branch** — structurally closest (a document is what the JS SDK gives us that most resembles a branch: own topic, own sync, independently addressable), but it doubles the documents per store and adds an indirection to resolve them, to imitate a second pub/sub flow the polyfill will not use.
|
||||||
|
- **A distinct subject in the same document** — **chosen**. It buys what actually matters: the key stored *next to* the document, separate from the list, read rather than recomputed. It does not buy a second event flow, which the polyfill cannot reproduce anyway.
|
||||||
|
|
||||||
|
*The honest cost of this choice*: our two "branches" share one commit stream and one topic, where NextGraph has two. Anything that comes to depend on them being separately subscribable will have to move to the separate-document shape.
|
||||||
|
|
||||||
|
### D3 — Deriving becomes minting, and `CapRegistry` stops being a keyring
|
||||||
|
|
||||||
|
Today `fileOwnCaps` **re-derives** each cap from its NURI, which only works because the emulated key is a function of the NURI. After D2 the cap is **read from the store document**. Derivation survives only inside `nuri.ts` as how a stand-in value is *minted at creation* — the single function P1b replaces with a real key.
|
||||||
|
|
||||||
|
The in-memory `CapRegistry` then stops being "the keyring" and becomes what it actually mirrors: **the verifier's local user storage** (fact table, last row) — the per-user cache of every opened repo and its read cap.
|
||||||
|
|
||||||
|
### D4 — Received caps: persist as local user storage, not as a document
|
||||||
|
|
||||||
|
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 `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.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. **D1 vocabulary**, mechanically and in one pass — code, tests, docs. Nothing behavioural. Doing it first stops every later diff from being written in two vocabularies.
|
||||||
|
2. **D2 + D3**: the store document carries each listed document's cap; `fileOwnCaps` reads it instead of re-deriving; `mintCap` keeps minting at creation only. Round-trip test: create → drop all in-memory state → re-list → the cap comes back **read, not recomputed** (assert by minting a *different* stand-in value in the test and checking the stored one wins).
|
||||||
|
3. **D4** local per-user persistence of opened caps, replacing inbox re-reading.
|
||||||
|
4. Then resume the boundary lot's steps 3–4 (guard at the four passage points; remove the cross-account fan-out), which are written in the new vocabulary.
|
||||||
|
|
||||||
|
## What this breaks
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
- D2's "distinct subject" may be too weak a reading of "stay close to NextGraph" — the separate-document shape is defensible and I may be under-weighting it.
|
||||||
|
- D3 assumes reading the stored cap is always possible where deriving was — i.e. that every path reaching `fileOwnCaps` has the store document at hand.
|
||||||
|
- D4 introduces browser-local state to a library that currently keeps everything in the shared wallet; that may be a bigger departure than it looks.
|
||||||
|
- The vocabulary change touches the boundary brief and the P1a brief, which are mid-flight.
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# Brief — the virtual user boundary
|
||||||
|
|
||||||
|
**Status: specified 2026-07-30; all seven steps done** (four planned, three added as the work uncovered them). Sits alongside `2026-07-20-caps-emulation-alignment.md` (the wider caps chantier) and `2026-07-27-p1a-cap-surface.md` (the surface, implemented). This lot is about something more fundamental than either: **what a virtual user is allowed to reach.**
|
||||||
|
|
||||||
|
## Why this lot exists
|
||||||
|
|
||||||
|
A virtual user must **simulate the boundary of the future single-user wallet**. Today it does not: it is a grouping fiction — a shim account listing three index documents — and nothing enforces it. Every access function reaches any document of any identity, given a session id and a NURI.
|
||||||
|
|
||||||
|
Stated by the PO on 2026-07-30, on discovering the state:
|
||||||
|
|
||||||
|
> A virtual user must simulate the boundary of the future mono-user wallet. So the access functions must all be restricted to the virtual user currently "connected" (`setCurrentUser`). No cross-wallet access may be permitted, otherwise we are building on a fundamentally wrong model.
|
||||||
|
|
||||||
|
This is the same failure mode the whole caps chantier exists to prevent, one level down. P1a fixed the *shape* of reading (possession, not an ACL). It left the *reach* unbounded — and a consumer coded against an unbounded reach is coded against a world that will never exist, exactly like one coded against an ACL.
|
||||||
|
|
||||||
|
## The rule
|
||||||
|
|
||||||
|
> **The only reads/writes not confined to a virtual user are those that make multi-wallet operation possible at all** (e.g. the index of virtual users). — PO, 2026-07-30
|
||||||
|
|
||||||
|
And its sharpened form, which decides the hard cases:
|
||||||
|
|
||||||
|
> **Nothing common — only indexing mechanisms to make the virtual users work.** — PO, 2026-07-30
|
||||||
|
|
||||||
|
So an exemption must be *plumbing*, never *pooled user data*. The test: **does removing it stop the virtual users from functioning, or does it merely stop users from seeing each other's content?** Only the first justifies living outside a wallet. The shim passes (remove it and no wallet is resolvable); a shared index of user announcements does not (remove it and every wallet still works — you simply have to be given links).
|
||||||
|
|
||||||
|
Everything else is confined. The exemption list is short, explicitly named, and each entry has to justify itself against those two sentences — an exemption that merely *helps* is not an exemption.
|
||||||
|
|
||||||
|
## The premise that collapsed: there is no discovery
|
||||||
|
|
||||||
|
Recorded here because it removes a whole module rather than guarding it (PO, 2026-07-30 — see [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4ter-bis, where the principle is documented in full):
|
||||||
|
|
||||||
|
> **You cannot discover. You can only follow links.** NextGraph is local-first: publishing is *place the data in your public store* **and** *circulate the link* — into inboxes, or into somewhere already reachable by the people concerned. It is seen only by those who received the link. Private distribution is the same act plus the ReadCap.
|
||||||
|
|
||||||
|
`discovery.ts` therefore fails on **both** counts: it emulates a global-list capability the target will never have, and it is pooled user data across wallets. It is not a boundary to guard, it is a module to remove — with `watchShape('public')`'s discovery fold, `INDEX_ACCOUNT`, and the `submitToIndex` guard along with it.
|
||||||
|
|
||||||
|
What replaces it is not a mechanism but the model itself: a link reaches you through an **inbox**, or through a document you already hold. Which makes the inbox the bootstrap of the entire reachability graph — the reason its guard (below) and its automatic processing matter more than they first appear.
|
||||||
|
|
||||||
|
*Consequence for P1a's acceptance test, resolved*: `test/watch-shape.test.ts` (e) proved "a harvested bare reference reads nothing, the repo link reads the document" **on the discovery fold**. The property is independent of discovery and survives — `test/cross-user-access.test.ts` already proves it on the model's own terms (Bob follows a reference found in a document he holds), so (e) was dropped rather than re-based.
|
||||||
|
|
||||||
|
## The good news: the boundary already exists
|
||||||
|
|
||||||
|
**The keyring is the boundary.** A document is legitimately reachable when `capFor(doc)` answers — either because this wallet created it (its scope index refiles the cap, the emulated `AddRepo { read_cap }`) or because someone delivered the cap to it. No new notion is needed; the guard is written. What is missing is applying it.
|
||||||
|
|
||||||
|
And the surface to guard is small. Everything in the library reaches NextGraph through **four functions in two modules**:
|
||||||
|
|
||||||
|
- `docs.docCreate`, `docs.sparqlUpdate`, `docs.sparqlQuery`
|
||||||
|
- `subscribe`'s `ng.doc_subscribe`
|
||||||
|
|
||||||
|
Nothing else touches `ng`. (`open-repo` only tests whether `doc_subscribe` exists; `ng-proxy` is the app-facing proxy.)
|
||||||
|
|
||||||
|
## What is confined, and what is exempt
|
||||||
|
|
||||||
|
**Exempt — each one passes the rule:**
|
||||||
|
|
||||||
|
| Exemption | Why it makes multi-wallet operation possible |
|
||||||
|
|---|---|
|
||||||
|
| The store-root **pointer** + the **doc-shim**, to resolve THE CURRENT account | This is the index of virtual users. Without it no virtual user is resolvable at all. **Resolution only** — enumerating every account is not covered (see below). |
|
||||||
|
| The **reserved accounts** (the inbox anchor; `@index` is gone with `discovery.ts`) | They host infrastructure documents; they are not anybody's wallet. |
|
||||||
|
| **Depositing** into another wallet's inbox (write-only) | Without a cross-wallet write channel there is no sharing, hence no useful multi-wallet — and it carries no pooled data: a deposit is addressed to one wallet, not shared between them. This IS the NextGraph model: an inbox deposit is anonymous and sealed, and grants the depositor nothing in return. |
|
||||||
|
|
||||||
|
~~Reading a discovery index~~ — **withdrawn 2026-07-30**, on both counts: it emulates a capability that does not exist, and it is pooled user data. See *The premise that collapsed* above.
|
||||||
|
|
||||||
|
**Confined — none of these passes the rule:**
|
||||||
|
|
||||||
|
| Path | Today | Becomes |
|
||||||
|
|---|---|---|
|
||||||
|
| `docs.sparqlQuery` / `sparqlUpdate` (**exported from the SDK entry**) | any document, any wallet | guarded on the anchor: the cap must be held |
|
||||||
|
| `inbox.read` / `readSynced` / `watch` | **any inbox, including someone else's** | only inboxes belonging to the current wallet |
|
||||||
|
| `subscribeDoc` | any document | only documents whose cap is held |
|
||||||
|
| `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.ensureAccount(id)` | any id | the current identity, plus the reserved accounts |
|
||||||
|
| `readUnion` | ✅ already guarded (P1a) | unchanged |
|
||||||
|
|
||||||
|
## The breach P1a opened, and which this lot must close first
|
||||||
|
|
||||||
|
`inbox.read` has no guard, and since P1a it **absorbs caps into the reader's keyring**. So:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
setCurrentUser("mallory");
|
||||||
|
await inbox.read(bobsInbox); // mallory pockets the caps addressed to Bob
|
||||||
|
```
|
||||||
|
|
||||||
|
Directed sharing is therefore defeatable by anyone who knows an inbox NURI. Strictly speaking this is not a regression — the inbox was never guarded — but before P1a it carried nothing that granted access, and now it does. **This is the first thing to fix**, and it is arguably P1a's own debt rather than this lot's.
|
||||||
|
|
||||||
|
Closing it needs a notion that does not exist yet: **"my inbox"**. Today an inbox is an arbitrary NURI supplied by the caller. Which is the same brick as the PO's other instruction, so they should land together:
|
||||||
|
|
||||||
|
> Processing inboxes is the polyfill's job, not the app's — as soon as the app connects a user/wallet, at **two levels**: the wallet inbox (where ReadCaps arrive) and the inbox of **every document the user owns**.
|
||||||
|
|
||||||
|
## Design notes for the implementation
|
||||||
|
|
||||||
|
**Scaffolding in the keyring, not in the exemption list.** The current account's three scope-index documents belong to its wallet, so they should be *in its keyring* rather than exempted. That keeps the exemption list down to what genuinely serves multi-wallet operation (the shim, the reserved accounts). Only the pointer and the doc-shim stay outside.
|
||||||
|
|
||||||
|
**Exemptions are named, never inferred.** A NURI is exempt because it is *the* shim document or *a* reserved account's document, resolved as such — never because it "looks like infrastructure". An inferred exemption is a hole.
|
||||||
|
|
||||||
|
**Write-only really means write-only.** Depositing into another wallet's inbox must not make that inbox readable, subscribable, or listable as a side effect. This is the one asymmetric permission in the model and it needs its own test.
|
||||||
|
|
||||||
|
**The guard belongs to a function, not to a position.** One named predicate ("may the current wallet reach this document?"), called at each of the four passage points — not four inline checks that drift apart.
|
||||||
|
|
||||||
|
## Hardening is the polyfill's responsibility, not a negotiation with the consumer
|
||||||
|
|
||||||
|
Stated by the PO on 2026-07-30, closing the question "what does the app actually use?":
|
||||||
|
|
||||||
|
> We do not need to know what the app uses: the polyfill must harden **everything it exposes**. That is its responsibility. **Nothing may allow its own mechanisms to be bypassed** — the virtual user in particular.
|
||||||
|
|
||||||
|
This settles how the lot proceeds, and it generalizes past it. A surface that lets a caller go around the wallet boundary does not merely risk misuse: it **teaches a model that will not exist**, which is the one thing this library exists to prevent. So an exposed function that can bypass a mechanism the polyfill provides is a defect *whether or not anyone calls it that way*, and "the consumer might depend on it" is not an argument for keeping it — if the consumer depends on it, the consumer depends on something the target will refuse.
|
||||||
|
|
||||||
|
Applies to every exported surface, including ones added later: **if it is exposed, it is guarded**.
|
||||||
|
|
||||||
|
## What this breaks
|
||||||
|
|
||||||
|
`docs.*` is exported from the SDK-identical entry and can reach any document; the cross-account fan-out enumerates every wallet; `discovery.*` disappears entirely. The consumer will have to change where it relied on any of them. That is the point, not a side effect: each one is the API starting to tell the truth about a boundary that will exist. Update `../migration-guide.md` accordingly.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Two defects this step surfaced — the first still open, the second closed by steps 5–6
|
||||||
|
|
||||||
|
**`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:
|
||||||
|
|
||||||
|
> If NextGraph stores the key next to the document, then that is what we do. Without encryption we simply store a stand-in value. Stay as close as possible to how NextGraph works.
|
||||||
|
|
||||||
|
So this is not a P1b concern, it is a **shape** concern — the one thing this library exists to get right — and the stand-in key is stored exactly where the real one will be. Two storage sites, mirroring upstream:
|
||||||
|
|
||||||
|
- **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.)*
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
The redundancy is the point, and a test pins it: a caller that forgets rule 2 is still refused by rule 1, so a bookkeeping lapse fails loudly instead of succeeding quietly.
|
||||||
|
|
||||||
|
**Possession decides, never the shape of the reference in hand.** A caller legitimately manipulates a bare NURI while holding its cap elsewhere — references travel bare through content and indexes, the cap sits in what the user holds. `targetOf` first, so both forms answer alike. (An earlier reading of the directive checked the string for `:r:` instead; corrected on the PO's clarification.)
|
||||||
|
|
||||||
|
Exemptions are **declared**, never inferred from a NURI's shape (`declareInfrastructure`, called by the store-registry for the store-root pointer and the doc-shim — the index of virtual users, the only thing that passes the "remove it and no user resolves at all" test). A user also reaches its own three stores and its own inbox, or the boundary would lock it out of itself.
|
||||||
|
|
||||||
|
Not done: `subscribeDoc` is not yet guarded — it interacts with `ensureRepoOpen`, which opens documents before their cap is known on some cold-start paths. Left for step 4 with the barrier tests in view.
|
||||||
|
4. ~~**Remove the cross-account fan-out**~~ — **DONE 2026-07-30.** `listEntityDocs`, `resolveReadGraphs`, `allAccounts`, `loadShim` and the full-shim cache are gone. Nothing in the library used them any more once `discovery` was removed; only their own tests did. `subscribeDoc` is now guarded too (rule 1) — a subscription IS an access, since the push carries the document's state, so leaving it open would have been a door beside the gate.
|
||||||
|
|
||||||
|
### Machinery vs virtual user: two APIs, and only one is the app's
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|
> Does this operate on the **index of virtual users** (the shim), or on the **content of one virtual user**? The first is machinery; everything else is the user's, and is confined.
|
||||||
|
|
||||||
|
So the store-root pointer, the doc-shim and the account records go through the machinery; a virtual user's stores, its inbox and its documents go through the guarded `docs.*`, even though the library is what calls them on the user's behalf.
|
||||||
|
|
||||||
|
**This is strictly stronger than the exemption list it replaces.** `declareInfrastructure` is deleted. The machinery no longer calls the guarded primitive and gets waved through — it calls a different primitive that was never guarded. There is no list to widen, to get wrong, or to infer from a NURI's shape, and the boundary now has no `if` in it that could be talked into saying yes.
|
||||||
|
|
||||||
|
5. ~~**The Link, and inbox processing on connection**~~ — **DONE 2026-07-30**, after the four steps above.
|
||||||
|
|
||||||
|
**The Link.** Giving access is a `Link` deposited into the recipient's inbox — upstream's word at all three stages (`InboxMsgContent::Link` for the message, `AddLink { read_cap }` for the filing, `RemoveLink` for the withdrawal). `shareCap` deposits one; the deposit kind is `…:inbox:link`.
|
||||||
|
|
||||||
|
**Applying it durably.** `storeRegistry.addLink` / `readLinks` emulate `AddLink` on the **User branch of the private store** — a distinct subject (`shim:userBranch`) in the private store document, kept separate from the `ldp:contains` listing exactly as upstream keeps the User branch separate from Main. Idempotent, so re-processing costs nothing.
|
||||||
|
|
||||||
|
**The split that matters**: `inbox.read` KEEPS a Link (in the session's keyring) but does not FILE it — reading a queue must not write to a user's store. `inbox.processInbox` *applies*: it reads, then files. That is what an inbox is upstream — **a queue you consume, not a store you re-read**.
|
||||||
|
|
||||||
|
**On connection.** `setCurrentUser` fires `connect.connectedUser()`: restore the already-applied Links from the User branch, then drain the inbox. Restore-first means a reconnecting user reads its shared documents immediately, without waiting on the queue. Fire-and-forget, because the setter is synchronous and every consumer calls it from synchronous code — the work announces itself through `CapRegistry.onChange`, which `watchShape` already listens to. `connectedUser()` is exported for a caller that needs to await it.
|
||||||
|
|
||||||
|
**Two things it deliberately does NOT do.** It does not **provision**: connecting an identity that does not exist creates nothing (`resolveAccount`, not `ensureAccount`) — otherwise connecting would mint a user's stores and caps as a background side effect, arming the whole emulation at a moment nothing controls. And it does not drain **per-document** inboxes, which do not exist yet.
|
||||||
|
|
||||||
|
Proven by `test/cross-user-access.test.ts`: a cap shared to Bob survives **with his inbox emptied** and every in-memory cap dropped — restored from the User branch, not from the queue.
|
||||||
|
|
||||||
|
*Cost noted*: `setCurrentUser` now has observable asynchronous effects (it reads, and it logs). Three log-assertion tests had to await `connectedUser()` before counting lines. That is the honest price of moving the obligation off the app, and it is worth naming rather than discovering.
|
||||||
|
|
||||||
|
6. ~~**The Store branch**~~ — **DONE 2026-08-03.** A document's cap is now STORED when it is created — `shim:readCap` on a `storeBranch` subject of the store document, the emulated `AddRepo { read_cap }` — and READ back by `listMyEntityDocs`, never recomputed. That closes the asymmetry left by the Link work, where received caps were filed durably while created ones were re-minted.
|
||||||
|
|
||||||
|
Two things this pinned down, both of which would have cost more later:
|
||||||
|
|
||||||
|
- **The listing and the keys stay separate**, as Main and Store branches are upstream: `contains` on one subject, `readCap` on another, written as two statements because upstream they are two commits.
|
||||||
|
- **Creation mints the cap exactly once.** It used to mint twice — once to write, once to hold — which agreed only because the stand-in value is a constant. With P1b's real key those would be two different keys, and a creator would hold one that does not open its own document. A test pins it, and another proves the cap is read rather than recomputed by corrupting the stored value and checking the corruption wins.
|
||||||
|
|
||||||
|
Honest about the emulation: upstream the Store branch carries **no triples at all** (`BranchCrdt::None`). Representing it as RDF is ours; what is faithful is the storage beside the document and the separation from the listing.
|
||||||
|
|
||||||
|
7. ~~**Per-document inboxes**~~ — **DONE 2026-08-03.** Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look.
|
||||||
|
|
||||||
|
*(Renamed and split on 2026-08-03 — `documentInbox` became `openDocumentInbox` (own the inbox) + `documentInboxAddress` (find where to deposit). Conflating the two made per-document inboxes unusable by anyone but their owner; see [`2026-08-03-document-inbox-addressing.md`](2026-08-03-document-inbox-addressing.md).)*
|
||||||
|
|
||||||
|
`storeRegistry.openDocumentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call.
|
||||||
|
|
||||||
|
The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it.
|
||||||
|
|
||||||
|
**Correction 2026-08-03, twice.** *First:* the paragraph above overstated the upstream fact — an adversarial doc review caught it. *Then the correction itself overshot*, calling the step an ANTICIPATION: the engine **supports** an inbox on any repo (`inbox` is a field of every `Repo`, `AddInboxCapV0` is keyed by `repo_id`, and `update_inbox_cap_v0` applies it with no `is_store` check — `engine/verifier/src/verifier.rs:1920`). What no code path does is CREATE one for a document. Verified: `new_store_default` attaches an inbox only `if !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** two `AddInboxCap` commits in the engine are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So today **no document is GIVEN an inbox, and neither is the private store** — which says nothing about what may have one. The step stands as delivered, and it is aligned on the engine's model rather than betting past it.
|
||||||
|
|
||||||
|
## Relation to P1b
|
||||||
|
|
||||||
|
P1b (per-document encryption, closing the read paths that bypass the guard) largely **becomes** this lot, better framed. Encrypting each document while leaving the wallet boundary open would be securing the windows with the door open — and conversely, once every access is confined to the connected wallet, "the emulated key is derivable" stops being the load-bearing weakness. This lot should therefore precede P1b, or absorb it.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Brief — a document's inbox has to be addressable by anyone holding the document
|
||||||
|
|
||||||
|
**Raised 2026-08-03, from the consumer side (Festipod), after an attempt to solve it in the app proved it does not belong there.**
|
||||||
|
|
||||||
|
> ## IMPLEMENTED 2026-08-03 — shape 2 (the library publishes), with the cost objection taken as binding
|
||||||
|
>
|
||||||
|
> `storeRegistry.documentInboxAddress(doc)` answers *"where do I deposit for this document"* for **any holder**, and `inbox.post` into it. The address is published **at creation**, in a compartment the library owns — so it never enters a consumer shape.
|
||||||
|
>
|
||||||
|
> **On the cost — and the wrong answer that was tried first.** The measured regression (9m37 → 21m30) came from creating a second **DOCUMENT** per document. The first fix pointed every document's published address at the owner's **own** inbox: no second document, cost amortized. **It was reverted the same day**, because it emulates a relation upstream cannot express — the verifier routes an incoming message by `inboxes: PubKey → RepoId` and unseals it with THAT repo's private half (`engine/verifier/src/verifier.rs:1677,1928`), and `InboxMsgBody` carries no target document (`engine/net/src/types.rs:4265`) because the address already identifies it. Many documents behind one inbox would have forced consumers to tag deposits with their document — a habit to unlearn at migration, which is precisely what this library exists to prevent.
|
||||||
|
>
|
||||||
|
> **The cost, actually answered:** only documents meant to RECEIVE open an inbox, and their owner is who knows. `createEntityDoc` publishes nothing; an app calls `openDocumentInbox(doc)` for the documents that need one (in the consumer's case: events, not every entity). Cost becomes proportional to the need, with the shape intact.
|
||||||
|
>
|
||||||
|
> Shape 1 of this brief (derivation) was **not available**: our inbox is a document, and a derived NURI would name a repo `doc_create` never created — upstream can derive because an inbox there is a keypair on the repo, not a document.
|
||||||
|
>
|
||||||
|
> **`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/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.
|
||||||
|
>
|
||||||
|
> **Also fixed, and it was the root of the reported symptom.** `openDocumentInbox` (formerly `documentInbox`) called by a non-owner used to mint a parallel inbox and record it for the caller — no error, deposits lost. It now refuses, on OWNERSHIP (read from the Store branches), not on cap possession: a cap can be received, and a recipient must not be able to redirect the owner's deposits to itself.
|
||||||
|
|
||||||
|
## The problem, in one sentence
|
||||||
|
|
||||||
|
`documentInbox(doc)` answers *"which inbox do **I** know for this document?"* — and mints a fresh one when the answer is none. So a third party never reaches the owner's inbox: they get one of their own, which the owner never reads, and their deposit vanishes without an error.
|
||||||
|
|
||||||
|
## Why that breaks the consumer
|
||||||
|
|
||||||
|
Festipod's central act is signing up to **someone else's** event. The participant deposits into the event's inbox; the owner drains it at their next connection and materializes the count. With addressing scoped to the caller, only the owner can deposit into their own event — every other sign-up is silently lost.
|
||||||
|
|
||||||
|
The same wall stands in front of the directory document the app needs to rebuild discovery: creators must deposit a reference into a document they do not own.
|
||||||
|
|
||||||
|
## What was tried in the app, and why it was wrong
|
||||||
|
|
||||||
|
The consumer published the address on the document itself: resolve `documentInbox(eventDoc)` once at creation, write the NURI into the public event, and have participants read it from there instead of deriving it.
|
||||||
|
|
||||||
|
It works. It is still the wrong place, for three reasons — and the third is the one that settles it.
|
||||||
|
|
||||||
|
- **It puts infrastructure in the domain.** The `Event` shape starts carrying a technical address, and the domain model encodes a temporary state of the library.
|
||||||
|
- **It costs.** `documentInbox` *creates a document*. Publishing the address at creation takes every event creation from one document to two — and document creation is a round-trip that does not overlap. Measured on the test suite: the `@data` run went from **9m37 to 21m30**, with sign-up scenarios timing out on their setup step (4 failures out of 7 on a **fresh** profile, so not test-wallet bloat). That cost belongs to whoever can amortize or defer it. The app can do neither, because it does not own the mechanism.
|
||||||
|
- **The library already claims this job.** The commit that introduced per-document inboxes says: *"Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's."* Draining was taken as the library's job. Addressing was left to the caller. Those two halves belong together.
|
||||||
|
|
||||||
|
The consumer's own doctrine says the same thing from the other side: when something does not work, the question is never how to work around it in the app, but what the library has to compensate. The app-side change has been reverted.
|
||||||
|
|
||||||
|
## What is being asked
|
||||||
|
|
||||||
|
**Given a document, any holder of that document should be able to name the inbox its owner reads** — without owning it, and without being handed the address out of band.
|
||||||
|
|
||||||
|
How is the library's call. Two shapes come to mind, neither prescriptive:
|
||||||
|
|
||||||
|
- Make the address **derivable** from the document, so `documentInbox(doc)` returns the same NURI for everyone, and only the owner can *read* it — the read guard already enforces that, and it is where the asymmetry belongs.
|
||||||
|
- Or have the library **publish** the address itself, at document creation, somewhere it controls — so it stays out of the consumer's shapes and the library keeps the freedom to make it lazy.
|
||||||
|
|
||||||
|
The second keeps the current create-time cost unless it is deferred; the first has none, and matches how the rest of the model works — an overlay is derived, a keyring is looked up, nothing is handed over out of band.
|
||||||
|
|
||||||
|
## Open question worth settling first
|
||||||
|
|
||||||
|
Is *"a document has an inbox addressable by any holder"* part of NextGraph's target model, or an invention of this library?
|
||||||
|
|
||||||
|
If it is upstream, this is emulation to align. If it is the library's own, it is a design decision to take deliberately — and the answer decides whether the note about it belongs in the shared NextGraph inbox as a gap, or here.
|
||||||
|
|
||||||
|
## Consumer state meanwhile
|
||||||
|
|
||||||
|
Sign-ups across identities do not converge, and the app does not pretend otherwise. Nothing in Festipod works around it.
|
||||||
@@ -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.
|
||||||
@@ -1,8 +1,29 @@
|
|||||||
# ADR — Discovery mechanism (inbox-fed index, fan-out)
|
# ADR — Discovery mechanism (inbox-fed index, fan-out)
|
||||||
|
|
||||||
**Date:** 2026-06-16 · **Status:** mechanism accepted; target owner undecided.
|
> ## SUPERSEDED — 2026-07-30. The premise does not hold.
|
||||||
|
>
|
||||||
|
> **There is no discovery in NextGraph. You cannot discover; you can only follow links** (PO, 2026-07-30 — the principle is documented in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4ter-bis). Publishing is two acts: place the data in your public store, **and** circulate the link — into inboxes, or into somewhere already reachable by the people concerned. It is seen only by those who received the link. This is a foundation of local-first, not a gap to be filled.
|
||||||
|
>
|
||||||
|
> A global index therefore fails on **two independent counts**:
|
||||||
|
>
|
||||||
|
> 1. it emulates a capability the target will never have — teaching consumers a model that does not exist, which is the one failure mode this library exists to prevent;
|
||||||
|
> 2. it is **data common to several users/wallets**, and nothing may be common — only indexing mechanisms that make the virtual users work (the shim qualifies; a shared index of user announcements does not).
|
||||||
|
>
|
||||||
|
> This ADR already recorded the first half of that verdict — *"a dedicated service with its own wallet sharing a freely-readable index is not a NextGraph shape"*, resting on a singleton-app path *"not implemented, uncertain"*. That reservation is now the conclusion.
|
||||||
|
>
|
||||||
|
> **Amended 2026-08-03 — the singleton-app half must be re-put, not cited as closed.** The NextGraph developer has since named the singleton app as *the* way an application holds data common to all its users: a document or store shared by all users, hardcoded in the app, write-owned by the developer and delegable — never delegated to all users, so user contributions arrive **through an inbox**. Still unimplemented, but no longer uncertain in direction. This does **not** reinstate discovery — the "you cannot discover, you can only follow links" verdict stands on its own footing, and count 2 above is about pooling users' data across wallets, not about an app's own data. What is void is treating "the singleton path is uncertain" as a settled argument. See [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Apps & services.
|
||||||
|
>
|
||||||
|
> **`discovery.ts` and its tests were removed on 2026-07-30**, along with `watchShape`'s public-scope fold and `INDEX_ACCOUNT`. See [`../briefs/2026-07-30-virtual-wallet-boundary.md`](../briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||||
|
>
|
||||||
|
> One factual error below is worth naming so it is not carried into a future design: *"a native inbox (a primitive present on every document)"* is **false**. No document has an inbox upstream — only the public and protected STORE repos do (`engine/verifier/src/site.rs:128,149`; `doc_create` leaves `inbox: None`, `engine/repo/src/repo.rs:574`). See [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Inbox.
|
||||||
|
>
|
||||||
|
> What survives, and is worth keeping from the text below: the **3-stage frame** (`discovery → synchronization → query`) is still exactly right, with stage 1 re-read as *"a link reached you"* rather than *"you consulted an index"*. You still cannot query what you have not synchronized, and you still do not synchronize what nobody gave you. The **inbox** is what feeds stage 1 — which makes it the bootstrap of the whole reachability graph, not a side feature.
|
||||||
|
>
|
||||||
|
> Kept in full below as a record of what was built and why, and of the reasoning that has to be re-read through the correction above.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
@@ -63,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.
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ the shim opens repos. Original context: the consuming app.
|
|||||||
> **protected** store (`did:ng:${protected_store_id}`), verified to open without
|
> **protected** store (`did:ng:${protected_store_id}`), verified to open without
|
||||||
> `RepoNotFound`. **The central insight of this ADR still holds** and now applies
|
> `RepoNotFound`. **The central insight of this ADR still holds** and now applies
|
||||||
> to **both** stores: you must open the repo via the store's NURI
|
> to **both** stores: you must open the repo via the store's NURI
|
||||||
> (`orm_start_graph`) or you get `RepoNotFound`.
|
> or you get `RepoNotFound`. *(How it is opened has since changed — see the note
|
||||||
|
> under Decision.)*
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
@@ -39,10 +40,17 @@ entity to reuse) → falls back to `doc_create` and the same `RepoNotFound`.
|
|||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
**Option B**: use the store NURI as both the `useShape` scope AND the write
|
**Option B**: use the store NURI as both the `useShape` scope AND the write
|
||||||
`@graph`, exactly like `expense-tracker-rdf`. This is why this lib's shim opens a
|
`@graph`, exactly like `expense-tracker-rdf`. This is why this lib's shim opens the
|
||||||
store repo via `orm_start_graph` before writing, and why **`did:ng:i` must never
|
store repo before writing, and why **`did:ng:i` must never be used as a scope** (it
|
||||||
be used as a scope** (it breaks writes with `RepoNotFound`). See the
|
breaks writes with `RepoNotFound`). See the scope rule in
|
||||||
`orm_start_graph` scope rule in [`../simulation.md`](../simulation.md).
|
[`../simulation.md`](../simulation.md).
|
||||||
|
|
||||||
|
*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`
|
||||||
|
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
|
||||||
|
read here is the invariant *"open the repo, by its store NURI, before writing"*, not the
|
||||||
|
call that used to implement it.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Write loss on socket death (`SerializationError`)
|
||||||
|
|
||||||
|
**Post-mortem — 2026-07-14 · Status: OPEN (not addressed).**
|
||||||
|
|
||||||
|
An entity written just before a period of inactivity can be **silently lost**: it is absent on reconnection. *(Whether the write never durably reached the broker, or reached it and is not read back on a cold reconnection, is **not settled** — see Epistemic caveat below. The wording here deliberately states only the observed symptom.)* The **account / identity survives** (no fork). Observed in real conditions (Festipod, Firefox) during a pause after login/creation.
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
1. The user logs in, the app creates an entity (a Festipod event).
|
||||||
|
2. A period of inactivity follows (idle, tab in the background…).
|
||||||
|
3. The broker socket dies spontaneously with `SOCKET IS CLOSED Some(Left(SerializationError))`.
|
||||||
|
4. On reconnection, the created entity has disappeared; the app reads back its own scope **empty**.
|
||||||
|
|
||||||
|
## Evidence (VERIFIED — live Firefox logs, verbatim)
|
||||||
|
|
||||||
|
```
|
||||||
|
… REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay>
|
||||||
|
… NEED REPLAY true
|
||||||
|
… SENDING EVENTS FROM OUTBOX RETURNED: Err(TopicNotFound)
|
||||||
|
[user1][polyfill] resolveAccount(user1) → 1 record ← the account SURVIVES (no fork)
|
||||||
|
[user1][polyfill] readScopeIndex(…) → 0 entities ← but the scope is EMPTY
|
||||||
|
… set reçu: 0 objets Event (public)
|
||||||
|
… SOCKET IS CLOSED Some(Left(SerializationError)) [51, 3, 223, …]
|
||||||
|
```
|
||||||
|
|
||||||
|
Interpretation (**plausible mechanism, not settled**): the write was pushed into the local **outbox**, but the socket died before it was **durably flushed** into the broker topic; on reconnection, the outbox replay fails (`Err(TopicNotFound)`) because the topic was **never created on the broker side** → the event is abandoned. The account, for its part, had already been durably resolved (`resolveAccount → 1 record`): it is neither lost nor forked.
|
||||||
|
|
||||||
|
> **Epistemic caveat.** The evidence establishes the *symptom* (loss + `Err(TopicNotFound)` + the scope read returning 0 — logged above as `readScopeIndex`, since renamed `readUserStore`). The exact *mechanism* is not settled between **(i) loss at write time** (the write never durably reaches the broker) and **(ii) cold-rehydration failure** (the write *is* on the broker but a fresh session does not reopen its own scope). The `Err(TopicNotFound)` on the outbox replay leans toward **(i) in this Firefox case**. See the @data repro below, which exhibits a neighboring symptom but **does not settle** (i) vs (ii).
|
||||||
|
|
||||||
|
## Causal chain (TRACED — reading of the NextGraph core, to be re-verified)
|
||||||
|
|
||||||
|
- The `SerializationError` closes the socket. The core emits the disconnection in **two** places, and they are not the same file: `engine/net/src/broker.rs:1074` sends `LocalBrokerMessage::Disconnected`, which the SDK turns into `disconnections_sender.send(...)` at `sdk/rust/src/local_broker.rs:648`. Navigate by symbol — the line numbers are volatile, and the earlier note in this file put the `send` in `broker.rs`, which it never was.
|
||||||
|
- This disconnection is **pushed** to subscribers via `disconnections_subscribe(cb)` (PUSH stream).
|
||||||
|
- **NextGraph reconnection is an unimplemented `// TODO`** (≈ `broker.rs:1051-1076`): nothing re-establishes the socket nor re-flushes the outbox.
|
||||||
|
- `user_connect` returns a **snapshot** `{ server_id, server_ip, error, since }` at call time — not a stream, unusable for detecting a later drop.
|
||||||
|
- **No write-durability confirmation API**: a caller cannot `await` the guarantee that a write has reached the broker.
|
||||||
|
|
||||||
|
## What the SDK exposes but does not consume
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
- **Observed on Firefox only** to date. A manual test on another browser did not trigger the `SerializationError` nor its consequences.
|
||||||
|
- **@data reproduction (Chromium, real broker) — 2026-07-14, decisive.** The existing @data reconnection test (`reconnexion-meme-identite`) was a **false green**: it read A's repos back from the persistent profile's **local IndexedDB**, never from the broker. A **genuinely cold** reader (non-persistent `freshBrowser` context, the **same** wallet/account A, no local state — seeded from the wallet captured before the event) reads **0** events from A (`BARRIER timed-out (8000ms)`, `CONNECTION ESTABLISHED`). A **different** signature from the Firefox case (no socket death; the `OUTBOX empty` is the reader's, trivially empty) and it **does not settle** (i) vs (ii) — an empty barrier is compatible with both. Established on the other hand: **@data has never verified the broker durability of A's own reads**, and cold rehydration from the broker fails. Repro: `src/modules/event/features/reconnexion-froide-sans-local.feature` (Festipod).
|
||||||
|
- **To settle (i) vs (ii)**: independently verify that A's write reaches the broker — e.g. a *warm* reader / a second identity reads the event's public doc (the two-identity isolation scenario). If it sees it → the write is durable → the cold reader's 0 is a **(ii)** (rehydration). Otherwise → **(i)**.
|
||||||
|
|
||||||
|
## Fix leads (not arbitrated)
|
||||||
|
|
||||||
|
1. **Core** — fix the `SerializationError` **and** implement the reconnection TODO (re-establish the socket + re-flush the outbox).
|
||||||
|
2. **SDK / polyfill** — consume `disconnections_subscribe` → reconnection + outbox re-flush as a mitigation, independently of the core.
|
||||||
|
3. **Durability API** — expose a confirmation that a write has reached the broker, so that the caller can `await` it.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- `docs/nextgraph-current-state.md` — current state of the core (disconnection / reconnection to be cross-referenced here).
|
||||||
|
- Product impact + consumer-side caveat: Festipod concept `data-layer` → `caveat_write-durability-across-disconnect`.
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# Internal contract — what `@ng-eventually/polyfill` keeps off its surface, and what NextGraph does or would do about each subject
|
||||||
|
|
||||||
|
> **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 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The wrapped `ng` factory — `surface/ng-proxy.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ng-proxy.ts:10
|
||||||
|
export function makeNg(): Record<string, any>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds the published `ng` Proxy (consumed once, `index.ts:61`): forwards every property to the injected real `ng`, overriding `login`/`session_start` (passthrough with a shared-wallet-credentials TODO) and `sparql_update` (the emulated write-cap guard).
|
||||||
|
|
||||||
|
- The factory itself is **NO COUNTERPART, by design** — the target has no "wrap the SDK" step; at migration `ng` IS `@ng-org/web`'s and `makeNg` is deleted.
|
||||||
|
- The `sparql_update` guard stands in for the engine's write-permission model (`verify_perm` inside `Commit::verify`, `engine/repo/src/commit.rs:892-899`) — same analysis as `docs/api-contract.md` § 3.
|
||||||
|
- **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).
|
||||||
|
|
||||||
|
## 2. NURI internals — the unexported slice of `nuri.ts`, plus the minting point
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// model/nuri.ts
|
||||||
|
export function targetOf(nuri: Nuri): Nuri;
|
||||||
|
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap };
|
||||||
|
// 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;
|
||||||
|
```
|
||||||
|
|
||||||
|
`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**: 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 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 — `emulated-verifier/reach.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// reach.ts:66
|
||||||
|
export function declareInfrastructure(nuri: Nuri): void;
|
||||||
|
// reach.ts:71
|
||||||
|
export function isInfrastructure(nuri: Nuri): boolean;
|
||||||
|
// reach.ts:76
|
||||||
|
export function resetInfrastructure(): void;
|
||||||
|
// reach.ts:93
|
||||||
|
export function mayReach(nuri: Nuri): boolean;
|
||||||
|
// reach.ts:110
|
||||||
|
export function assertMayReach(nuri: Nuri, op: string): void;
|
||||||
|
// reach.ts:131
|
||||||
|
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()`).
|
||||||
|
|
||||||
|
- **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 `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.
|
||||||
|
|
||||||
|
## 4. The physical user's primitives — `shared-wallet/physical.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// physical.ts:54
|
||||||
|
export async function physicalCreate(sessionId: string, crdt = "Graph", cls = "data:graph", dest = "store", store?: unknown): Promise<Nuri>;
|
||||||
|
// physical.ts:80
|
||||||
|
export async function physicalQuery(sessionId: string, query: string, base: string | undefined, anchor: Nuri, label = "physicalQuery"): Promise<unknown>;
|
||||||
|
// physical.ts:94
|
||||||
|
export async function physicalUpdate(sessionId: string, query: string, anchor: Nuri, label = "physicalUpdate"): Promise<void>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The unguarded counterparts of `docs.docCreate` / `sparqlQuery` / `sparqlUpdate`, callable only by the library's own machinery on the shim's documents (store-root pointer, doc-shim, provisioning). Separated as FUNCTIONS rather than as an exemption list so machinery never gets "waved through" a guard (module header, `physical.ts:19-27`).
|
||||||
|
|
||||||
|
- As wire calls: **PASSTHROUGH (level 2, VERIFIED)** — the same `doc_create` / `sparql_query` / `sparql_update` the published `docs.*` forwards to (`index.d.ts:60,295,297`; sources `sdk/js/lib-wasm/src/lib.rs:1575` web / `:1618` nodejs, `:352`/`:555`), minus the lib-internal `label`.
|
||||||
|
- As a CONCEPT: **NO COUNTERPART, shared-wallet machinery.** The physical/virtual user split exists only because one wallet hosts many identities; the target has exactly one user per wallet and no privileged "machinery caller". The module disappears with the shim.
|
||||||
|
|
||||||
|
## 5. Physical subscription — the unexported slice of `subscribe.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// subscribe.ts:118
|
||||||
|
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 `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 `shared-wallet/physical.ts`.
|
||||||
|
|
||||||
|
## 6. Bootstrap repo opening — `emulated-verifier/open-repo.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// open-repo.ts:75
|
||||||
|
export type SyncState = "syncing" | "synced" | "timed-out";
|
||||||
|
// open-repo.ts:104 — TEST-ONLY
|
||||||
|
export function setOpenTimeoutForTests(ms: number): void;
|
||||||
|
// open-repo.ts:110
|
||||||
|
export function resetOpenedRepos(): void;
|
||||||
|
// open-repo.ts:135
|
||||||
|
export function getSyncState(nuri: Nuri): SyncState | "unknown";
|
||||||
|
// open-repo.ts:167
|
||||||
|
export async function ensureRepoOpen(nuri: Nuri): Promise<void>;
|
||||||
|
// open-repo.ts:184
|
||||||
|
export async function ensurePhysicalRepoOpen(nuri: Nuri): Promise<void>;
|
||||||
|
// open-repo.ts:259
|
||||||
|
export async function ensureReposOpen(nuris: Nuri[]): Promise<void>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Heals the cold-start defect of the anchored read path: on a fresh session a not-yet-open repo reads empty, so before an anchored read the repo is opened by subscribing (`subscribePhysicalDoc`) and awaiting the first `State` push — the sync barrier — with a bounded timeout. The subscription is held for the session; per-nuri `SyncState` keeps `synced` and `timed-out` apart.
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- **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).
|
||||||
|
- At migration the whole module becomes "open the store by cap at bootstrap" (native) and is removed with the shim.
|
||||||
|
|
||||||
|
## 7. The read filter — `emulated-verifier/read-filter.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// read-filter.ts:47
|
||||||
|
export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[];
|
||||||
|
// read-filter.ts:60
|
||||||
|
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 `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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 8. Connection trigger — the unexported slice of `emulated-verifier/connect.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// connect.ts:91
|
||||||
|
export function startConnect(): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
- `startConnect` itself disappears at migration; the automatic-processing behaviour it fabricates is native.
|
||||||
|
|
||||||
|
## 9. The shim registry — the unexported slice of `shared-wallet/account-registry.ts`
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// store-registry.ts:213
|
||||||
|
export function reservedAccount(name: string): string;
|
||||||
|
// store-registry.ts:278
|
||||||
|
export function resetRegistryCache(): void;
|
||||||
|
// store-registry.ts:542
|
||||||
|
export async function resolveAccount(id: string): Promise<VirtualUserRecord | null>;
|
||||||
|
// store-registry.ts:631
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **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()` (`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
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// store-registry.ts:1079
|
||||||
|
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The scope-INDEX document of one account (what `watchShape` subscribes to for container changes). Same resolution as the published `resolveWriteGraph`, without doubling as a write target.
|
||||||
|
|
||||||
|
- **Level 2, VERIFIED counterpart with the indirection removed**: in the target the container IS the store, and its id is on the session (`Session.private_store_id | protected_store_id | public_store_id`, `index.d.ts:264-272`); the listing a subscriber watches is the store's `ldp:contains` graph, written natively by `doc_create` (`request_processor.rs:706-708`). The function's question survives; the per-account parameter and the index-document indirection disappear.
|
||||||
|
|
||||||
|
### 9c. Inbox ownership predicates
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// store-registry.ts:837
|
||||||
|
export async function isOwnInbox(nuri: Nuri): Promise<boolean>;
|
||||||
|
// store-registry.ts:1286
|
||||||
|
export async function myInboxes(): Promise<Nuri[]>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`isOwnInbox` — the read guard's question (may the current identity READ this inbox); `myInboxes` — the drain list for `connectedUser` (own wallet inbox + one per document this user opened an inbox on).
|
||||||
|
|
||||||
|
- **LEVEL-1 SHAPE for the underlying record, VERIFIED; NO COUNTERPART as API.** Upstream "which inboxes may I read" is answered inside the verifier by the User branch's `AddInboxCap` records (`AddInboxCapV0 { repo_id, overlay, priv_key }`, `engine/repo/src/types.rs:1969-1981`, applied at `verifier.rs:1916-1932`) and consulted implicitly when a message arrives (`verifier.rs:1674-1690`); nothing suggests a JS API for the question at any level. Both functions exist only because the emulation must ANSWER it in JS (the read guard, the drain); they disappear with the emulated inbox read side.
|
||||||
|
|
||||||
|
### 9d. The durable Link register
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// store-registry.ts:1307
|
||||||
|
export async function addLink(cap: ReadCap): Promise<void>;
|
||||||
|
// store-registry.ts:1331
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 10. The machinery namespace — `emulated-verifier/machinery.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// machinery.ts:32
|
||||||
|
export const MACHINERY_NS = "urn:ng-eventually:";
|
||||||
|
// machinery.ts:40
|
||||||
|
export function isMachinerySubject(subject: string | undefined): boolean;
|
||||||
|
```
|
||||||
|
|
||||||
|
The URN prefix every triple the lib writes for itself lives under, and the one predicate read paths use to keep machinery out of consumer data.
|
||||||
|
|
||||||
|
- **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 — `shared-wallet/access-log.ts` and `shared-wallet/outbox-log.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// access-log.ts:25,45,50,63,76,87,99,116
|
||||||
|
export type AccessOp = "READ" | "WRITE";
|
||||||
|
export function setAccessLog(on: boolean): void;
|
||||||
|
export function enabled(): boolean;
|
||||||
|
export function activeIdentity(): string;
|
||||||
|
export function accessLogPrefix(): string;
|
||||||
|
export function logStage(line: string): void;
|
||||||
|
export function shortNuri(nuri: string): string;
|
||||||
|
export function logAccess(op: AccessOp, nuri: string, label: string, extra?: string): void;
|
||||||
|
|
||||||
|
// outbox-log.ts:62
|
||||||
|
export function inspectOutbox(): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `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
|
||||||
|
|
||||||
|
**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 — `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.
|
||||||
|
|
||||||
|
**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()` (`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):**
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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; `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)
|
||||||
|
|
||||||
|
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: `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`, `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.
|
||||||
+64
-26
@@ -14,13 +14,37 @@ has no clear target image, that is a drift signal (see
|
|||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
### 1. Emulated ReadCaps → real capabilities
|
### 1. Emulated ReadCaps → real capabilities
|
||||||
Translate the per-document `CapRegistry` (`caps.ts`) into real NextGraph caps: the
|
The shape is already the target's (the cap-surface work): a `ReadCap` is the document's key, a
|
||||||
broker/verifier enforces them, and `useShape` already returns only authorized
|
each identity holds a set of caps, and there is no read-ACL anywhere. So
|
||||||
documents. The directed `grantRead(doc, granteeId)` maps to a native per-document
|
this step swaps the *emulated* key for the real one, not the model:
|
||||||
ReadCap issued to that identity. The read filter (`read-filter.ts`) and the write
|
|
||||||
guard (`ng-proxy.ts` `sparql_update` override) are then dead code — remove them. The
|
- `emulated-verifier/caps.ts`'s per-identity record becomes the verifier's own local user storage —
|
||||||
access unit is already the document (`@graph`), matching the native per-repo cap
|
it was always the cache, not the register. The two durable registers we emulate
|
||||||
model, so this is a data step, not a reshape.
|
(`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
|
||||||
|
already hold them.
|
||||||
|
- the stand-in cap value — the constant `OK` (`STAND_IN_CAP`,
|
||||||
|
`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` /
|
||||||
|
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
|
||||||
|
- `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
|
||||||
|
recipient's own verifier applying queued messages. **The consumer's call does not
|
||||||
|
change.**
|
||||||
|
- `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.
|
||||||
|
- `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
|
||||||
|
whose cap the wallet holds. Remove them.
|
||||||
|
- 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
|
||||||
|
cap-enforcement batch, not here.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### 2. Place documents in real native stores
|
### 2. Place documents in real native stores
|
||||||
Today `docCreate(..., undefined)` writes every document into the shared wallet's
|
Today `docCreate(..., undefined)` writes every document into the shared wallet's
|
||||||
@@ -28,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`.
|
||||||
@@ -36,50 +60,64 @@ 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`, `listEntityDocs`,
|
surface facing the consumer application (`createEntityDoc`, `listMyEntityDocs`,
|
||||||
resolvers) is designed to survive that swap unchanged.
|
resolvers) is designed to survive that swap unchanged.
|
||||||
|
|
||||||
### 3. Drop the resolver / shim
|
### 3. Drop the resolver / shim
|
||||||
The `sharedWalletShim` (account → 3 scope-document NURIs, RDF in the private store)
|
The `sharedWalletShim` (account → 3 scope-document NURIs, held in a subscribable
|
||||||
has no target equivalent — the target has no central directory. Remove it:
|
doc-shim reached via a write-once pointer in the store-root — see
|
||||||
`store-registry.ts`, `configureStoreRegistry`, the shim SPARQL. Cross-wallet reads
|
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim
|
||||||
replace the fan-out; per-user wallets replace the shared one.
|
indirection*) has no target equivalent — the target has no central directory. Remove
|
||||||
|
it entirely: `shared-wallet/account-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim
|
||||||
|
resolution, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out;
|
||||||
|
per-user wallets replace the shared one.
|
||||||
|
|
||||||
### 4. Real inbox → drop the in-lib read emulation
|
### 4. Real inbox → drop the in-lib read emulation
|
||||||
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
|
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
|
||||||
document) with the native `inbox_post_link` (proposed/future). On the read side the
|
document) with the native sealed deposit, once one is exposed to JS. **Its name and shape are NOT known**: no inbox method exists in `@ng-org/web`, the verifier has no `InboxPost` arm, and `inbox_post_link` is OUR proposed name (`fork-inbox-fallback.md`), not an announced API. On the read side the
|
||||||
recipient's own verifier unseals each queued sealed message and applies it inline
|
recipient's own verifier unseals each queued sealed message and applies it inline
|
||||||
when it processes its inbox — there is no separate curator to build; the in-lib read
|
when it processes its inbox — there is no separate curator to build; the in-lib read
|
||||||
emulation simply goes away (see the deferred global-index note in the top-level
|
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.)*
|
||||||
README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)). The
|
|
||||||
single global index replaces the cross-account fan-out.
|
|
||||||
|
|
||||||
### 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)).
|
||||||
The flow shape ("broker redirect → app") does not change.
|
The flow shape ("broker redirect → app") does not change.
|
||||||
|
|
||||||
### 6. Drop the isolation scaffold
|
### 6. ~~Drop the isolation scaffold~~ — already gone
|
||||||
`isolation.ts` (application-visibility scaffold) disappears against a
|
`isolation.ts` (the old application-visibility filter) was deleted from the library;
|
||||||
different piece of infra than the caps: real per-account wallets, and the
|
nothing remains to remove at migration. Kept as a numbered step so the following
|
||||||
relationship concept the consumer application owns. Distinct axis from ReadCaps —
|
numbers stay stable across references.
|
||||||
remove independently.
|
|
||||||
|
|
||||||
### 7. Remove the build alias — the client becomes the real SDK
|
### 7. Remove the build alias — the client becomes the real SDK
|
||||||
The consumer application imports `@ng-org/web` / `@ng-org/orm` resolved to this lib
|
The consumer application imports `@ng-org/web` / `@ng-org/orm` resolved to this lib
|
||||||
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 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
|
||||||
|
every grant on every session (`declareConnections`). That call **disappears**: with
|
||||||
|
delivered caps the grant moves to the moment a connection is *accepted* — one
|
||||||
|
`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
|
||||||
|
reload. There is no analogue of `protectedDocsOf` + the re-derivation loop.
|
||||||
|
|
||||||
|
This is a consumer **re-architecture**, not an API swap, and it is the price of
|
||||||
|
being coded against a model that will exist. Nothing else about the migration below
|
||||||
|
touches consumer code.
|
||||||
|
|
||||||
## What does not change
|
## What does not change
|
||||||
|
|
||||||
The consumer application's code. Shapes, screens, the *acts* of granting
|
The consumer application's code. Shapes, screens, the *acts* of sharing
|
||||||
access, entity→scope mapping, the relationship graph — all injected, all untouched.
|
access, entity→scope mapping, the relationship graph — all injected, all untouched.
|
||||||
Migration is entirely inside this library plus removing the alias + the bootstrap
|
Migration is entirely inside this library plus removing the alias + the bootstrap
|
||||||
call. That asymmetry — a mature SDK face outward, all compensation inward — is the
|
call. That asymmetry — a mature SDK face outward, all compensation inward — is the
|
||||||
|
|||||||
+480
-40
@@ -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
|
||||||
@@ -22,14 +22,24 @@ Where the ground truth lives, so future re-verification is cheap:
|
|||||||
dispatch (the truth on what is actually *processed*).
|
dispatch (the truth on what is actually *processed*).
|
||||||
- `engine/net/src/types.rs` — inbox types (`InboxPost`, `InboxMsg`, `InboxMsgContent`).
|
- `engine/net/src/types.rs` — inbox types (`InboxPost`, `InboxMsg`, `InboxMsgContent`).
|
||||||
- `engine/verifier/src/inbox_processor.rs` — inbox message handling.
|
- `engine/verifier/src/inbox_processor.rs` — inbox message handling.
|
||||||
- `engine/verifier/src/verifier.rs:1423` — the `OpenRepo` TODO (cross-wallet read).
|
- `engine/verifier/src/verifier.rs:2237` — `load_repo_from_read_cap`, the one path that
|
||||||
|
brings a repo in FROM a cap (`pub(crate)`, see § *Capability / ReadCap granularity*).
|
||||||
|
- `engine/verifier/src/verifier.rs:1423` — the `OpenRepo` TODO. It is **not** about
|
||||||
|
loading an unheld repo: it sits inside `open_branch_`, past
|
||||||
|
`self.repos.get_mut(repo_id).ok_or(RepoNotFound)?` (`:1331`), so the repo is already
|
||||||
|
held by the time that line runs. What is missing is the broker-side `OpenRepo`
|
||||||
|
request, worked around with a pin.
|
||||||
- `engine/repo/src/types.rs` — `RootBranchV0.store: StoreOverlay` (repo → its store).
|
- `engine/repo/src/types.rs` — `RootBranchV0.store: StoreOverlay` (repo → its store).
|
||||||
|
|
||||||
## The 5 store types
|
## The 5 store types
|
||||||
|
|
||||||
Every wallet has the **3 default stores** out of the box (session fields
|
The **3 default stores** belong to a **user**, not to the wallet. A wallet holds
|
||||||
`private_store_id`, `protected_store_id`, `public_store_id`). Group and Dialog
|
`sites: HashMap<String, SiteV0>` (`engine/wallet/src/types.rs:456`), and it is `SiteV0`
|
||||||
are created on demand.
|
that carries `public` / `protected` / `private` (`engine/verifier/src/site.rs:31-37`) —
|
||||||
|
one wallet can hold several, which is exactly why "wallet" is the wrong unit to reason
|
||||||
|
in (see `docs/readcap-and-nuri-model.md` §4quinquies, *Nomenclature first*). A session exposes the three as
|
||||||
|
`private_store_id`, `protected_store_id`, `public_store_id` — those are the connected
|
||||||
|
USER's. Group and Dialog are created on demand.
|
||||||
|
|
||||||
| Store | Read | Write | Creation |
|
| Store | Read | Write | Creation |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -76,15 +86,17 @@ 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
|
||||||
> entity. Read isolation is cryptographic in the target: with no cap for a repo, a
|
> entity. Read isolation is cryptographic in the target: with no cap for a repo, a
|
||||||
> union / reactive read returns empty (the repo is never decrypted), while a
|
> union / reactive read returns empty (the repo is never decrypted), while a
|
||||||
> targeted read of an unheld repo returns `RepoNotFound`. There is no
|
> targeted read of an unheld repo returns `RepoNotFound`. There is no
|
||||||
> cap-introspection API — the polyfill's `canRead` / `governsRead` are
|
> cap-introspection API, and there is nothing to introspect: reading is key
|
||||||
> emulation-only, with no NextGraph API behind them.
|
> possession, so the polyfill asks the only question the model admits —
|
||||||
|
> `capFor(doc)`, "do I hold it?". Its *implementation* is emulation-only; its shape
|
||||||
|
> is the target's.
|
||||||
|
|
||||||
### Store ↔ document confusion (recurring)
|
### Store ↔ document confusion (recurring)
|
||||||
|
|
||||||
@@ -96,17 +108,78 @@ 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).
|
||||||
|
|
||||||
## Inbox
|
## Inbox
|
||||||
|
|
||||||
Every document has a native inbox. A non-editor can deposit a link (DID
|
**Only two repos have an inbox today: a user's public and protected STORES.** Not
|
||||||
cap) into it without being invited as an editor; the owner moderates. NURI:
|
documents, and not the private store. `new_store_default` attaches one solely `if
|
||||||
`did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
|
!private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` goes through
|
||||||
|
`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`
|
||||||
|
— 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
|
||||||
|
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
|
||||||
|
`repo_id` (`engine/repo/src/types.rs:1973`) — *"Repo the Inbox is opened for"*. And
|
||||||
|
`update_inbox_cap_v0` applies it via `self.repos.get_mut(repo_id)` with **no `is_store`
|
||||||
|
check of any kind** (`engine/verifier/src/verifier.rs:1920`). It is generic by
|
||||||
|
construction, and available at any time: `AddInboxCap` is a User-branch commit
|
||||||
|
(`engine/repo/src/commit.rs:1043-1050`) whose type documents the late case — *"DEPS to
|
||||||
|
the previous AddInboxCap commit(s) if it is an update"*.
|
||||||
|
|
||||||
|
So a per-document inbox is **not an anticipation**: it is an engine capability that no
|
||||||
|
code path exercises automatically and that no level-2 or level-3 API exposes. This lib
|
||||||
|
implements it aligned on the engine's model.
|
||||||
|
|
||||||
|
**An inbox address is TRANSMITTED, never published — and nothing in the engine says who
|
||||||
|
may open one.** Two facts that decide more than they look:
|
||||||
|
|
||||||
|
- `inboxes: HashMap<PubKey, RepoId>` is a field of the **Verifier**
|
||||||
|
(`engine/verifier/src/verifier.rs:105`), rebuilt empty on each construction (`:520`,
|
||||||
|
`:2820`). The inbox → repo association is **local to a session**, not a published
|
||||||
|
fact. A depositor learns a pubkey because it was **sent** to them — in a
|
||||||
|
`ContactDetails` message (`contact.inbox`) or through a profile QR code; the reply
|
||||||
|
path reads its own `repo.inbox` to include it (`request_processor.rs:736-750`).
|
||||||
|
- There is therefore **no engine guard on who opens an inbox for a repo**.
|
||||||
|
`AddInboxCap` lands on the committer's OWN User branch, so anyone may write one naming
|
||||||
|
anyone's repo. It simply reaches nobody: no one was told that pubkey means that
|
||||||
|
document.
|
||||||
|
|
||||||
|
*Consequence for this lib, and it is a real divergence:* we **publish** the address on
|
||||||
|
the document (its Header branch) because that is the only way a third party can find it
|
||||||
|
in an emulation with no message channel. That creates a vector the engine does not have
|
||||||
|
— whoever can write the document can redirect its deposits — so `openDocumentInbox`
|
||||||
|
guards on ownership. That guard compensates OUR design; it does not mirror an upstream
|
||||||
|
rule. Do not cite it as one.
|
||||||
|
|
||||||
|
A non-editor can deposit into an inbox without being invited as an editor; the owner
|
||||||
|
moderates. NURI: `did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
|
||||||
`DialogRequest`, `Link`, `Patch`, `ServiceRequest`, `ExtRequest`,
|
`DialogRequest`, `Link`, `Patch`, `ServiceRequest`, `ExtRequest`,
|
||||||
`RemoteQuery`, `SocialQuery`…). Messages are sealed (`crypto_box::seal`) to
|
`RemoteQuery`, `SocialQuery`…, `engine/net/src/types.rs:4249-4261`). Note what `Link`
|
||||||
|
is: a **unit variant, carrying nothing** — not a link, not a cap, just a discriminant.
|
||||||
|
Reading it as "the inbox can deliver a read capability" is the trap this file exists to
|
||||||
|
prevent; see the *Consequence for this lib* below, which says the same thing from the
|
||||||
|
other end. Messages are sealed (`crypto_box::seal`) to
|
||||||
the inbox pubkey, so only the owner decrypts. The `from` field is optional, so an
|
the inbox pubkey, so only the owner decrypts. The `from` field is optional, so an
|
||||||
anonymous sender is possible. This is the "identified if known, anonymous
|
anonymous sender is possible. This is the "identified if known, anonymous
|
||||||
otherwise" behaviour native to the protocol.
|
otherwise" behaviour native to the protocol.
|
||||||
@@ -123,8 +196,11 @@ it processes its inbox — there is no separate curator or materialization servi
|
|||||||
`QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost`
|
`QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost`
|
||||||
triggers nothing.
|
triggers nothing.
|
||||||
- Building an `InboxPost` requires crypto sealing on the Rust side; no wasm
|
- Building an `InboxPost` requires crypto sealing on the Rust side; no wasm
|
||||||
helper exposes it. A high-level `inbox_post_link` is a proposed/future API, not
|
helper exposes it, and **no `inbox` method exists in `@ng-org/web` at all**.
|
||||||
yet present.
|
*(`inbox_post_link`, named across this repo's docs, is OUR proposed name from
|
||||||
|
[`fork-inbox-fallback.md`](./fork-inbox-fallback.md) — grep `nextgraph-rs` and it
|
||||||
|
is nowhere. Nothing is announced about the eventual JS surface for inboxes: its
|
||||||
|
name and shape are unknown, not merely unimplemented.)*
|
||||||
- Inbox deposit is only triggered internally by `QrCodeProfileImport`
|
- Inbox deposit is only triggered internally by `QrCodeProfileImport`
|
||||||
(`post_to_inbox(new_contact_details)`) and `social_query_start` (contact
|
(`post_to_inbox(new_contact_details)`) and `social_query_start` (contact
|
||||||
propagation via inbox).
|
propagation via inbox).
|
||||||
@@ -137,6 +213,32 @@ A related exposed primitive: `social_query_start` (a federated query via inbox u
|
|||||||
`degree` hops) exists but is limited to contacts — it does not cover an anonymous
|
`degree` hops) exists but is limited to contacts — it does not cover an anonymous
|
||||||
notification to a non-connected host.
|
notification to a non-connected host.
|
||||||
|
|
||||||
|
### Delivering a ReadCap through the inbox — the field exists, the path does NOT — VERIFIED
|
||||||
|
|
||||||
|
The `ContactDetails` inbox message carries `read_cap: Option<ReadCap>`, commented
|
||||||
|
*"optional readcap on the profile, if user wants to share the content of profile"*
|
||||||
|
(`engine/net/src/types.rs`). Nothing behind that field is implemented:
|
||||||
|
|
||||||
|
- **Building it panics.** `InboxPost::new_contact_details(…, with_readcap: bool, …)`
|
||||||
|
(`engine/net/src/types.rs`) fills `read_cap` with `unimplemented!()` when
|
||||||
|
`with_readcap` is true, and `None` otherwise. Asking for a cap in the message is a
|
||||||
|
panic, not a feature.
|
||||||
|
- **Nobody asks for one.** Its ONLY caller is the `QrCodeProfileImport` path in
|
||||||
|
`engine/verifier/src/request_processor.rs`
|
||||||
|
(`post_to_inbox(InboxPost::new_contact_details(…))`), which passes `with_readcap =
|
||||||
|
false`. No message ever carries a cap.
|
||||||
|
- **The receiver discards it.** The `InboxMsgContent::ContactDetails(details)` arm of
|
||||||
|
`engine/verifier/src/inbox_processor.rs` reads `details.profile`, `details.name` and
|
||||||
|
`details.email` to build a `social:contact` document — it **never reads
|
||||||
|
`details.read_cap`**. Even a hand-crafted message carrying a cap would be dropped.
|
||||||
|
|
||||||
|
**Consequence for this lib:** there is no native channel to HAND a key to somebody. The
|
||||||
|
inbox transports an identity/profile pointer, not a read capability. Combined with
|
||||||
|
§ *The inbox is not usable from the JS SDK* (no `InboxPost` arm in the request processor
|
||||||
|
at all), cap delivery must be emulated end to end: the polyfill's emulated inbox and its
|
||||||
|
`CapRegistry` are not a shortcut around an existing mechanism, they stand in for a
|
||||||
|
mechanism that does not exist.
|
||||||
|
|
||||||
## The query capability — ONE local store, named graphs, union queries
|
## The query capability — ONE local store, named graphs, union queries
|
||||||
|
|
||||||
The single fact that makes read-time *listing* possible on the shared wallet, and
|
The single fact that makes read-time *listing* possible on the shared wallet, and
|
||||||
@@ -199,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,
|
||||||
@@ -213,10 +315,94 @@ with the whole wallet, which is why the read path is per-doc anchored: the ancho
|
|||||||
read makes a non-empty wallet irrelevant. At the real multi-store
|
read makes a non-empty wallet irrelevant. At the real multi-store
|
||||||
migration this is unchanged (the anchored read is native); only bringing a repo into
|
migration this is unchanged (the anchored read is native); only bringing a repo into
|
||||||
the session changes: opening a real per-user store repo by cap becomes a native
|
the session changes: opening a real per-user store repo by cap becomes a native
|
||||||
broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires the
|
broker sync, through `load_repo_from_read_cap` (`verifier.rs:2237`) — not through the
|
||||||
|
`OpenRepo` TODO at `:1423`, which concerns a repo already held. Opening still requires the
|
||||||
repo's NURI + ReadCap — there is no store-level read inheritance (see
|
repo's NURI + ReadCap — there is no store-level read inheritance (see
|
||||||
§ Capability / ReadCap granularity).
|
§ Capability / ReadCap granularity).
|
||||||
|
|
||||||
|
### Findable-without-lookup vs subscribable (first-`State` barrier) — DISJOINT
|
||||||
|
|
||||||
|
Two properties a fresh session might want from a document, and **no single document
|
||||||
|
has both**:
|
||||||
|
|
||||||
|
- **Findable without a lookup.** The ONLY NURI a fresh session can NAME with nothing
|
||||||
|
but the session in hand is the store-root, `did:ng:${privateStoreId}` (from
|
||||||
|
`session.private_store_id`). Any per-document repo is `did:ng:o:<RepoID>` with a
|
||||||
|
**random** RepoID minted by `doc_create` — **not derivable**, so it must be looked
|
||||||
|
up somewhere first.
|
||||||
|
- **Subscribable with a sync BARRIER.** `doc_subscribe(nuri)` delivers `TabInfo` then
|
||||||
|
an initial **`State`** (`verifier.rs:470`/`:476`); that first `State` is the sync
|
||||||
|
barrier — **after it, presence is guaranteed and absence is definitive** (pinned
|
||||||
|
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
|
||||||
|
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".
|
||||||
|
|
||||||
|
> **`doc_fetch_repo_subscribe` / `doc_fetch_private_subscribe` are NOT alternatives to
|
||||||
|
> `doc_subscribe`** — checked 2026-08-03, because they look like ready-made
|
||||||
|
> "open a repo" calls and they are not. Neither performs any I/O: each **builds an
|
||||||
|
> `AppRequest` and returns it serialized** (`sdk/js/lib-wasm/src/lib.rs:1890`, `:1900`),
|
||||||
|
> with no `session_id` and no callback. `doc_subscribe` builds the *same* request
|
||||||
|
> (`AppRequest::doc_fetch_repo_subscribe`, `engine/net/src/app_protocol.rs:930` →
|
||||||
|
> `Fetch(Subscribe)`), then adds the session id and runs it through
|
||||||
|
> `app_request_stream_` (`lib.rs:1921-1923`). They exist for a caller that wants to
|
||||||
|
> construct the request and dispatch it itself. So `ensureRepoOpen`'s
|
||||||
|
> `doc_subscribe` + wait-for-first-`State` is not duplicating an available call — using
|
||||||
|
> them instead would mean re-implementing what `doc_subscribe` already does. The
|
||||||
|
> difference in `doc_fetch_private_subscribe` is only its target
|
||||||
|
> (`NuriV0::new_private_store_target()`, the private store-root), which changes nothing
|
||||||
|
> about the barrier: a store-root still has none.
|
||||||
|
|
||||||
|
These two are **mutually exclusive**: the guessable target (store-root) is not
|
||||||
|
barrier-authoritative, and the barrier-authoritative target (`o:` repo) is not
|
||||||
|
guessable. **Consequence:** you cannot build a lookup table that is BOTH reachable
|
||||||
|
cold (findable) AND authoritative on a cold read (barrier). A cold "0 rows" read of a
|
||||||
|
store-root graph is therefore fundamentally ambiguous — which is the trap the shim's
|
||||||
|
account map fell into (see next section).
|
||||||
|
|
||||||
|
### The pointer → doc-shim indirection (how the polyfill shim resolves accounts)
|
||||||
|
|
||||||
|
`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
|
||||||
|
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
|
||||||
|
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`
|
||||||
|
barrier). **All `VirtualUserRecord`s live inside it.** Because it is subscribable, an
|
||||||
|
anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0
|
||||||
|
means the account is genuinely absent.
|
||||||
|
2. **pointer** — a single well-known, **write-once** triple in the store-root graph,
|
||||||
|
`<urn:ng-eventually:shim:root> <urn:ng-eventually:shim:shimDoc> <docShimNuri>`.
|
||||||
|
The store-root is findable-without-lookup, so a fresh session can always read it;
|
||||||
|
the pointer being the OLDEST, write-once triple in that graph, it is near-always
|
||||||
|
already synced on a cold read.
|
||||||
|
|
||||||
|
**Resolution** (`resolveShimDoc`): read the pointer from the store-root → open the
|
||||||
|
named doc-shim through its barrier (`ensureRepoOpen`) → read the account
|
||||||
|
AUTHORITATIVELY. First login (no pointer): `doc_create` the doc-shim, publish the
|
||||||
|
pointer, done. A **pointer fork** (two devices each writing a pointer before either
|
||||||
|
synced) is reconciled to the lexicographically-smallest doc-shim NURI
|
||||||
|
(content-addressed, so every device converges on the same doc-shim).
|
||||||
|
|
||||||
|
**The account-level retry is GONE.** Before this indirection the shim lived directly
|
||||||
|
in the store-root graph, so an account read had no barrier and a cold 0 was ambiguous;
|
||||||
|
the lib compensated with a **bounded account-level retry** (`provisionRetry` /
|
||||||
|
`resolveAccountReliably`) that re-read the account several times before concluding
|
||||||
|
"new". Moving the records behind the doc-shim barrier makes the account read
|
||||||
|
authoritative on the FIRST read, so **that retry was removed** — a barrier is
|
||||||
|
deterministic where a retry only guessed. The only residual bounded guard is a small
|
||||||
|
re-read of the **pointer** itself (`pointerGuard`, one write-once triple): it can
|
||||||
|
NEVER re-provision or fork an account — at worst it takes a couple extra reads to see
|
||||||
|
a pointer that is still landing.
|
||||||
|
|
||||||
|
**No legacy migration.** A wallet written under the OLD scheme (accounts directly in
|
||||||
|
the store-root graph, no pointer) is NOT recovered: opening it under the current scheme
|
||||||
|
simply provisions a fresh doc-shim, and the pre-indirection store-root records are
|
||||||
|
ignored. This is deliberate — the only such wallets are dev data — so the resolution
|
||||||
|
path carries no legacy-migration step; it always reads the account authoritatively from
|
||||||
|
the doc-shim.
|
||||||
|
|
||||||
### The union is read-only — writes must target one document
|
### The union is read-only — writes must target one document
|
||||||
|
|
||||||
`resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
|
`resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
|
||||||
@@ -259,10 +445,16 @@ Listing must go through a one-shot union `sparql_query` instead — see
|
|||||||
installed version) **does NOT expose**: Group/Dialog store creation; capability
|
installed version) **does NOT expose**: Group/Dialog store creation; capability
|
||||||
sharing (a NURI with rights); permission manipulation; inbox deposit/read.
|
sharing (a NURI with rights); permission manipulation; inbox deposit/read.
|
||||||
|
|
||||||
Available JS methods: `doc_create`, `doc_subscribe`, `sparql_query`,
|
The JS methods this lib USES: `doc_create`, `doc_subscribe`, `sparql_query`,
|
||||||
`sparql_update`, `orm_start_graph`, `orm_start_discrete`, `graph_orm_update`,
|
`sparql_update`, `orm_start_graph`, `orm_start_discrete`, `graph_orm_update`,
|
||||||
`discrete_orm_update`, `file_get`, `app_request_stream`. The docs announce *"An
|
`discrete_orm_update`, `file_get`, `app_request_stream`. That is a working subset,
|
||||||
API will be provided for permission manipulation"* (no date).
|
**not** the surface: `NGModule` exports **77** (`@ng-org/web@0.1.2-alpha.13`,
|
||||||
|
`dist/index.d.ts:140-268`), including `app_request`, `session_stop`,
|
||||||
|
`disconnections_subscribe`, `social_query_start`, `upload_start`/`upload_chunk`/
|
||||||
|
`upload_done`, and the whole `wallet_*` family. Read "not in the list above" as "we do
|
||||||
|
not call it", never as "it does not exist" — several sections of this very file discuss
|
||||||
|
methods absent from that subset. The docs announce *"An API will be provided for
|
||||||
|
permission manipulation"* (no date).
|
||||||
|
|
||||||
## Integration & deployment model
|
## Integration & deployment model
|
||||||
|
|
||||||
@@ -338,30 +530,37 @@ methods: `doc_subscribe`, `orm_start_graph`, `orm_start_discrete`, `file_get`,
|
|||||||
(`pnpm webfilebuild`). First boot is **interactive** (admin-wallet invitation
|
(`pnpm webfilebuild`). First boot is **interactive** (admin-wallet invitation
|
||||||
link). Official Dockerfiles are **broken**.
|
link). Official Dockerfiles are **broken**.
|
||||||
|
|
||||||
## Apps & services: mono-user, no global data
|
## Apps & services: shared app data goes through a hardcoded app store (section rewritten 2026-08-03)
|
||||||
|
|
||||||
NextGraph's app/service execution model — important because it **invalidates**
|
NextGraph's app/service execution model, and the answer to "can an application hold data common to all its users".
|
||||||
the idea of "a service with its own wallet sharing global data".
|
|
||||||
|
|
||||||
- **Apps AND services are mono-user.** They see only **what the user makes
|
> **Provenance — two layers, do not mix them.** What the **engine** contains is verified below and is vocabulary only. What the **model will be** was stated by the NextGraph developer on 2026-08-03 and is **not implemented**: treat it as the target's declared direction, not as something the clone can confirm. Per [`../README.md`](../README.md)'s design principle, never infer the target's shape from the source's current state — an absent implementation says nothing about the intended one. The previous version of this section drew exactly that inference and concluded the opposite of what the developer states; it has been replaced.
|
||||||
available** to them. There is **no global data** natively, and no central
|
|
||||||
service holding shared data.
|
|
||||||
- **Local settings document.** Every app — even a singleton — and every service
|
|
||||||
has a **settings document** the user configures it through.
|
|
||||||
- **Multi-instance apps.** A **non-singleton** app can be **instantiated several
|
|
||||||
times** (e.g. a text editor, once per open file).
|
|
||||||
- **Singleton apps.** Also **mono-user**, but **bound to a particular user (the
|
|
||||||
developer)**. A singleton app **can hold a global document**, administered by
|
|
||||||
that user.
|
|
||||||
|
|
||||||
**Consequence for a "global document" (e.g. a discovery index):** the only path
|
**What the engine actually has — types, no behaviour (verified 2026-08-03):**
|
||||||
glimpsed is a singleton app whose global document is administered by the
|
|
||||||
developer-user — though this is not implemented and not guaranteed (simpler
|
- `AppManifestV0` (`engine/wallet/src/permissions.rs:113`) carries `nuri`, `origin` (webapps), `singleton: bool`, `access_requests`, `installs` (Viewer / Editor / ReadService / WriteService / Model, keyed by PrimaryClass), `dependencies`, and presentation fields.
|
||||||
paths may exist; to explore later). The model that does exist is this
|
- The JS surface exists: `init(callback, singleton, access_requests)` (`sdk/js/web/src/index.ts:51`) relays `singleton` to the wallet origin by `postMessage`. Every example passes `true`.
|
||||||
singleton-app one; a dedicated service with its own wallet sharing a
|
- **Nothing consumes it.** The `permissions` module is declared by `engine/wallet/src/lib.rs:19` and imported by no other crate; `AppManifest` is constructed nowhere; no code reads `singleton`; the surrounding `AccessRequest` / `AccessGrant` machinery is in the same state. There is no app runtime, no app store, no global-document type.
|
||||||
freely-readable index is not a NextGraph shape (a service is mono-user, no global
|
- The field's doc comment reads `/// cannot create Documents?` — with the question mark, in the source. It is an open design note, and the developer's statement below settles it the other way. Do not treat it as the semantics.
|
||||||
data). This is why a global-index package is deferred in this lib (see the top-level
|
- **A second, published gloss of the same flag disagrees with that doc comment, and it is the one that matches the developer's statement.** `sdk/js/web/README.md:90,108` annotates the argument as *"will your app create many docs in the system, or should it be launched as a unique instance"* — i.e. `singleton` is about **instance multiplicity**, not about being forbidden to create documents. That is consistent with "a singleton app can also manage ordinary per-user documents" below, and it is the reading to carry. Both README examples pass `true`.
|
||||||
README).
|
- Unrelated homonyms, so a grep does not mislead: singleton *commits* (`engine/repo/`), the broker singleton (`engine/net/src/broker.rs`), Oxigraph's `empty_singleton` (SPARQL optimiser).
|
||||||
|
|
||||||
|
**The declared model (NextGraph developer, 2026-08-03 — not implemented):**
|
||||||
|
|
||||||
|
- A singleton app can **also** manage ordinary per-user documents, i.e. cover what a non-singleton app does. When both modes are needed, declaring one app as singleton is enough — there is no need for two apps.
|
||||||
|
- Centralized data for an application takes the form of a **document, or a store, shared by all its users and hardcoded in the app**.
|
||||||
|
- The **app's developer holds the write rights** on the app's documents and stores, and can **delegate** them.
|
||||||
|
- Delegation is **never to all users**. User contributions reach app-owned data **through an inbox** — this is NextGraph's general model, in which nothing is freely writable by everyone.
|
||||||
|
|
||||||
|
**Reading of the last point, since it decides the shape of any index:** an app-owned index is not a place users write to. It is a document they can read (its NURI being hardcoded) and **deposit into**, with an authority derived from the developer materializing the deposits — the same deposit-then-materialize shape the inbox already imposes elsewhere, moved up to the app level.
|
||||||
|
|
||||||
|
**Open questions to put to the developer before designing on this:**
|
||||||
|
|
||||||
|
- What exactly is hardcoded — the store's or document's NURI, and does that carry the read capability?
|
||||||
|
- How is write delegation transmitted, and is it revocable?
|
||||||
|
- Who processes the app store's inbox: an instance running with the developer's rights, a service, something else? A deposit nobody materializes is not an index.
|
||||||
|
|
||||||
|
**Bearing on [`decisions/discovery-model.md`](decisions/discovery-model.md):** that ADR's superseding block leans in part on the singleton-app path being "not implemented, uncertain". The path is still not implemented, but it is no longer uncertain in direction — the developer names it as *the* way to hold data common to all users. That does not reinstate discovery: the "you cannot discover, you can only follow links" verdict rests on its own footing (the PO, 2026-07-30). It does mean the *singleton-app* half of that reasoning must be re-put rather than cited as closed.
|
||||||
|
|
||||||
## Third-party wallet auto-import constraint
|
## Third-party wallet auto-import constraint
|
||||||
|
|
||||||
@@ -402,3 +601,244 @@ logout is exposed (`ng.session_stop()`, `ng.user_disconnect()`,
|
|||||||
redirect afterwards. This lib's identity store sidesteps all of it — the identity
|
redirect afterwards. This lib's identity store sidesteps all of it — the identity
|
||||||
id is set at wallet-import time and relayed to the lib, without a separate login;
|
id is set at wallet-import time and relayed to the lib, without a separate login;
|
||||||
see the identity store in [`simulation.md`](./simulation.md).
|
see the identity store in [`simulation.md`](./simulation.md).
|
||||||
|
|
||||||
|
## Authorship, existence, outer overlay, `Ext` (section added 2026-07-27)
|
||||||
|
|
||||||
|
Four capability facts about the current core, verified in `nextgraph-rs`. They bear on
|
||||||
|
what can be BUILT on top (can we deliver a key? can we tell whether a document exists?
|
||||||
|
can we attribute a write?) — they are not a security assessment. Each carries its
|
||||||
|
epistemic status; do not upgrade an INFERRED item without new evidence.
|
||||||
|
|
||||||
|
### Author-signature verification is never called at runtime — VERIFIED
|
||||||
|
|
||||||
|
`Commit::verify` (`engine/repo/src/commit.rs`) chains `verify_sig` → `verify_perm` →
|
||||||
|
`verify_full_object_refs_of_branch_at_commit`. Its only callers in the whole tree are
|
||||||
|
inside `#[cfg(test)] mod test` blocks (`engine/repo/src/commit.rs`,
|
||||||
|
`engine/repo/src/branch.rs`); `verify_sig` and `verify_perm` have no other caller. The
|
||||||
|
verifier's commit path calls a **different** `verify`:
|
||||||
|
`CommitBodyV0::<Body>::verify(commit, self, branch_id, repo_id, store)` in
|
||||||
|
`engine/verifier/src/verifier.rs` — the `CommitVerifier` trait, which APPLIES a body
|
||||||
|
(mutating verifier state); it is not a signature check.
|
||||||
|
|
||||||
|
Even if it were called it could not succeed. `verify_sig` resolves the author through
|
||||||
|
`Repo::member_pubkey` → `Repo.members`, and every `Repo` the verifier builds at runtime
|
||||||
|
sets `members: HashMap::new()` — `engine/verifier/src/user_storage/repo.rs` (with a
|
||||||
|
literal `//TODO: members`) and `engine/verifier/src/commits/mod.rs`. Only
|
||||||
|
`Repo::new_with_member` ever populates a member, and it is called only from tests. An
|
||||||
|
empty table makes `member_pubkey` return `NotFound` →
|
||||||
|
`CommitVerifyError::PermissionDenied`.
|
||||||
|
|
||||||
|
Reading authorship at all presupposes the read cap (VERIFIED): the author field is not a
|
||||||
|
UserId but `CommitContent::author_digest(user, overlay)`, a BLAKE3 keyed hash, and the
|
||||||
|
commit content sits in blocks ChaCha20-encrypted under `Object::convergence_key(store)`
|
||||||
|
(`engine/repo/src/object.rs`), whose key material is the store id **plus the
|
||||||
|
store-overlay-branch ReadCapSecret**. No read cap → the author field is not even
|
||||||
|
visible. *Nuance, VERIFIED:* the digest's own hashing key derives from
|
||||||
|
`overlay_id_for_read_purpose`, which for Public/Protected/Private/Group stores is
|
||||||
|
`OverlayId::outer(store_id)` — public. What is secret is the commit content, not the
|
||||||
|
hash key.
|
||||||
|
|
||||||
|
**Consequence for this lib:** "who wrote this triple" is unanswerable today — neither
|
||||||
|
cryptographically (nothing verifies) nor by identity (the digest is opaque without a
|
||||||
|
member table). Any authorship or provenance the polyfill needs must be carried in the
|
||||||
|
DATA it writes and re-read from there; an "authored by X" claim in the emulation has no
|
||||||
|
core check behind it.
|
||||||
|
|
||||||
|
### No existence probe at SDK level — addressing presupposes the cap — VERIFIED
|
||||||
|
|
||||||
|
`AppRequestCommandV0` (`engine/net/src/app_protocol.rs`) contains no existence command:
|
||||||
|
`Fetch`, `Pin`, `UnPin`, `Delete`, `Create`, `FileGet`, `FilePut`, `Header`, `InboxPost`,
|
||||||
|
`SocialQueryStart`, `SocialQueryCancel`, `QrCodeProfile`, `QrCodeProfileImport`,
|
||||||
|
`OrmStartGraph`, `OrmStartDiscrete`, `OrmGraphUpdate`, `OrmDiscreteUpdate`, `OrmStop`.
|
||||||
|
Nothing answers *"does document D exist?"*.
|
||||||
|
|
||||||
|
The single probe in the tree is internal and cannot answer it either:
|
||||||
|
`Verifier::has_blocks` (`engine/verifier/src/verifier.rs`) sends
|
||||||
|
`BlocksExist { blocks, overlay }`. It is `pub(crate)` (never reaches JS); it takes
|
||||||
|
**`BlockId`s** — content addresses you only hold if you already read the object; it takes
|
||||||
|
a **`&Repo` already loaded**; and it targets
|
||||||
|
`repo.store.overlay_for_read_on_client_protocol()` = the **inner** overlay
|
||||||
|
(`Store::inner_overlay` → `overlay_id_for_write_purpose(store_overlay_branch_readcap.key)`,
|
||||||
|
`engine/repo/src/store.rs`), derived from the read-cap secret.
|
||||||
|
|
||||||
|
**Consequence for this lib:** you cannot prove — nor disprove — the existence of a
|
||||||
|
document whose key you do not hold. **Addressing presupposes the cap.** Every "is it
|
||||||
|
there?" question therefore collapses into "can I read it?", which is why absence is only
|
||||||
|
ever established behind a sync barrier (see § *Findable-without-lookup vs subscribable*)
|
||||||
|
and never by probing.
|
||||||
|
|
||||||
|
### `expose_outer` is hard-coded to `false` — VERIFIED
|
||||||
|
|
||||||
|
Both constructors of `PinRepo` — `PinRepo::for_branch` and `PinRepo::from_repo`
|
||||||
|
(`engine/net/src/actors/client/pin_repo.rs`) — set `expose_outer: false`, and they are
|
||||||
|
the only two `PinRepoV0` constructions in the tree. No parameter carries the flag up:
|
||||||
|
`expose_outer` appears nowhere under `sdk/`. The broker side is fully wired
|
||||||
|
(`RepoInfo.expose_outer: HashSet<UserId>` in `engine/broker/src/server_broker.rs`, the
|
||||||
|
`if expose_outer` branch in `rocksdb_server_storage.rs`, the outer-overlay registration
|
||||||
|
in `server_storage/core/overlay.rs`), and the `PinRepo` responder even validates the flag
|
||||||
|
(refusing `expose_outer` from a peer that publishes no topic) — but no client ever sets
|
||||||
|
it.
|
||||||
|
|
||||||
|
**Consequence for this lib:** a store's **outer** overlay is never registered broker-side,
|
||||||
|
so there is no anonymous / capability-free read surface to build on. Everything is reached
|
||||||
|
through the inner overlay, i.e. through a read cap — the same cap-first addressing as
|
||||||
|
above. The "public store readable by everyone without permission" promise in the official
|
||||||
|
docs has no client-side switch today.
|
||||||
|
|
||||||
|
### The `Ext` protocol serves blocks with no control — VERIFIED
|
||||||
|
|
||||||
|
The `ExtObjectGetV0` responder (`engine/net/src/actors/ext/get.rs`) builds
|
||||||
|
`Store::new_from_overlay_id(&req.overlay, …)` from the OverlayId the **requester
|
||||||
|
declares**, then returns `Object::load_without_header(obj_id, None, &store)` blocks for
|
||||||
|
each requested id. No authentication, no verification that the requester belongs to that
|
||||||
|
overlay. The guards that were planned exist but are dead:
|
||||||
|
|
||||||
|
- `Authorization::ExtMessage` is matched in `Broker::authorize`
|
||||||
|
(`engine/net/src/broker.rs`) and returns `AccessDenied` — but **no caller ever passes
|
||||||
|
it**; the only `authorize` call sites pass `Discover`, `Admin` or `Client`. The
|
||||||
|
server-side `StartProtocol::Ext` arm in `engine/net/src/connection.rs` goes straight to
|
||||||
|
`StepReply::Responder`, never through `authorize`.
|
||||||
|
- the config flag whose comment reads *"are ExtRequest allowed on the server? this
|
||||||
|
requires the core to be ON."* — `allow_read` in `engine/net/src/types.rs` — is declared
|
||||||
|
and defaulted to `false`, and **read nowhere**.
|
||||||
|
- `ExtRequestContentV0::get_actor` handles `WalletGetExport` and `ExtObjectGet` and falls
|
||||||
|
through to `_ => unimplemented!()` for `ExtTopicSyncReq` — a **panic reachable from an
|
||||||
|
anonymous peer**. (The commented-out `// Self::ExtTopicSyncReq(a) => a.get_actor(),` on
|
||||||
|
that arm and the `// TODO inbox requests` in the enum are *direction hints*, labelled as
|
||||||
|
such — not current behaviour.)
|
||||||
|
|
||||||
|
**Consequence for this lib:** `Ext` is not a usable read path in either direction. Blocks
|
||||||
|
come back **encrypted**, and naming them requires ObjectIds you only have once you can
|
||||||
|
already read — so it grants no capability we could build on, and confirms the shape of
|
||||||
|
everything above: confidentiality lives entirely in the keys, and holding no key means
|
||||||
|
holding no partial access, just none.
|
||||||
|
|
||||||
|
## Known open issues (section added 2026-07-18)
|
||||||
|
|
||||||
|
Live limitations observed against the current core/SDK, each with its epistemic
|
||||||
|
status. **None is treated.** The status labels below are load-bearing — do not
|
||||||
|
upgrade an OPEN / UNDETERMINED / HYPOTHESIS item to "confirmed" or "fixed"
|
||||||
|
without new evidence.
|
||||||
|
|
||||||
|
### Write loss on socket death (`SerializationError`) — symptom VERIFIED, mechanism UNSETTLED, OPEN / untreated
|
||||||
|
|
||||||
|
A write made just before an idle period / spontaneous socket death
|
||||||
|
(`SOCKET IS CLOSED Some(Left(SerializationError))`) can be **silently lost**:
|
||||||
|
the entity is absent on reconnection while the account survives. Reconnection is
|
||||||
|
an unimplemented `// TODO` stub in the core (`broker.rs`, ≈ `1051-1076`);
|
||||||
|
`disconnections_subscribe` DOES fire on the failure but nothing — neither this
|
||||||
|
polyfill nor the consumer app — consumes it; and there is **no
|
||||||
|
write-durability-confirmation API** a caller could `await`. Full post-mortem
|
||||||
|
(logs, causal chain, correction leads, none arbitrated):
|
||||||
|
[`incidents/2026-07-14-write-loss-on-disconnect.md`](./incidents/2026-07-14-write-loss-on-disconnect.md).
|
||||||
|
|
||||||
|
### Cold-start read does not rehydrate the owner's own scope from the broker — symptom VERIFIED, root cause UNDETERMINED, OPEN / untreated
|
||||||
|
|
||||||
|
Decisive test (2026-07-14): a genuinely no-local cold reader — fresh
|
||||||
|
non-persistent browser context, SAME wallet + account — reads **0** of the
|
||||||
|
owner's own scope from the broker. The previously "passing" reconnect test was
|
||||||
|
FALSE-GREEN: it read the owner's repos from the persistent profile's LOCAL
|
||||||
|
IndexedDB, so it never proved broker durability. It is UNDETERMINED whether
|
||||||
|
**(i)** the write never durably reached the broker, or **(ii)** the write IS on
|
||||||
|
the broker but a fresh session cannot re-open the owner's own scope docs (a
|
||||||
|
cold-open / rehydration limitation) — both collapse to the same 0-read in this
|
||||||
|
setup. Next step (NOT done): disambiguate (i) vs (ii) with an independent warm /
|
||||||
|
second-identity read of the same doc. The same (i)/(ii) reserve is carried in
|
||||||
|
[`incidents/2026-07-14-write-loss-on-disconnect.md`](./incidents/2026-07-14-write-loss-on-disconnect.md)
|
||||||
|
(§ *Portée & non-reproduit*), whose Firefox case leans (i) — this cold-reader
|
||||||
|
signature is distinct (no socket death) and does not settle it.
|
||||||
|
|
||||||
|
### Reactive subscription may not echo the writer's OWN local commit — HYPOTHESIS (high-confidence), confirmation in progress (2026-07-18), NOT confirmed, NOT fixed
|
||||||
|
|
||||||
|
When a client does a local `sparqlUpdate` on a doc it is itself subscribed to
|
||||||
|
(`subscribeDoc`/`doc_subscribe`), the subscription callback appears NOT to fire
|
||||||
|
for its own local commit in the same session, so the polyfill's reactive re-read
|
||||||
|
chain never runs and consumers keep a stale value until the next connection
|
||||||
|
delivers a fresh initial `State`. REMOTE commits DO push correctly (verified:
|
||||||
|
cross-browser reactive update works). Verdict pending a live instrumented run.
|
||||||
|
Full write-up (suspect link, instrumentation, planned polyfill-side fix):
|
||||||
|
[`../packages/polyfill/docs/polyfill-reference.md`](../packages/polyfill/docs/polyfill-reference.md)
|
||||||
|
§ *Current emulation status*.
|
||||||
|
|
||||||
|
### Cold-start anchored read returns 0 rows instead of an error — symptom VERIFIED, mechanism INFERRED, healed polyfill-side
|
||||||
|
|
||||||
|
On a FRESH session over the SAME persistent wallet (reconnect, new page, re-login), an
|
||||||
|
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
|
||||||
|
reader of the polyfill and healed identically in each (`ensureRepoOpen` before the read,
|
||||||
|
`packages/polyfill/src/emulated-verifier/open-repo.ts`): the user's own documents,
|
||||||
|
the user's store (`shared-wallet/account-registry.ts` `readUserStore`), the by-need doc batch
|
||||||
|
(`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
|
||||||
|
barrier) → THEN the anchored read, and it is verified to return the data.
|
||||||
|
|
||||||
|
The circularity that made it self-inflicted (VERIFIED by the fix working): `doc_subscribe`
|
||||||
|
WOULD open the repo, but the reactive layer only subscribes AFTER a listing produced
|
||||||
|
NURIs, and the listing is itself an anchored read of a not-yet-open index repo → 0 rows →
|
||||||
|
nothing to subscribe → nothing ever opens.
|
||||||
|
|
||||||
|
**Mechanism INFERRED, not established.** `resolve_target_for_sparql(Repo(id))`
|
||||||
|
(`engine/verifier/src/request_processor.rs`) does
|
||||||
|
`self.repos.get(repo_id).ok_or(RepoNotFound)`, so a repo genuinely absent from
|
||||||
|
`self.repos` should ERROR, not return 0 rows. The most plausible reading of the silent 0
|
||||||
|
is that the repo IS in `self.repos` (loaded from local user storage at bootstrap) while
|
||||||
|
its named graph in `graph_dataset` is not yet populated — commits not applied/synced yet
|
||||||
|
— so the query legitimately matches nothing. Not traced end to end; the tension with the
|
||||||
|
`RepoNotFound` path described in § *A repo is only queryable once OPENED/synced into the
|
||||||
|
store* is unresolved.
|
||||||
|
|
||||||
|
**Consequence for this lib:** a cold anchored read is NOT authoritative on its own — 0
|
||||||
|
rows does not mean absent. This is what imposes the open-then-read discipline on every
|
||||||
|
cold reader, and it is why the account trust root had to move behind a first-`State`
|
||||||
|
barrier (see § *The pointer → doc-shim indirection*).
|
||||||
|
|
||||||
|
### Account fork on concurrent provision — symptom VERIFIED, guarded polyfill-side, residue persists in wallets
|
||||||
|
|
||||||
|
On a fresh page, several independent callers hit `ensureAccount(A)` near-simultaneously
|
||||||
|
(the public and protected `watchShape`, container subscriptions, the app's owned-events
|
||||||
|
effect). When the account is genuinely new, each caller sees 0 and each provisions its
|
||||||
|
own set of three scope documents — an **in-session account fork**. The persisted residue
|
||||||
|
is a single account subject carrying MULTIPLE values for one scope predicate (observed:
|
||||||
|
five `shim:docPublic`), after which a writer and a later reader can resolve DIFFERENT
|
||||||
|
scope docs and the reader's anchored read returns 0.
|
||||||
|
|
||||||
|
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
|
||||||
|
resolve-or-provision) prevents new forks; `canonicalDoc` (pick the lexicographically
|
||||||
|
smallest NURI among all distinct values for a scope predicate — NURIs are
|
||||||
|
content-addressed, so the order is total and session-independent) makes resolution
|
||||||
|
deterministic on wallets that already carry fork residue. The earlier account-level
|
||||||
|
`provisionRetry` / `resolveAccountReliably` loop is gone, replaced by the doc-shim
|
||||||
|
barrier.
|
||||||
|
|
||||||
|
**Consequence for this lib:** the underlying enabler is core-side — there is no atomic
|
||||||
|
create-if-absent, and no existence probe to settle "does this account already exist?"
|
||||||
|
(see § *No existence probe at SDK level*), so provisioning is a read-then-create race the
|
||||||
|
polyfill has to serialize itself. The guards are mitigation, not a fix: a wallet already
|
||||||
|
corrupted stays corrupted, and only `canonicalDoc` keeps it readable.
|
||||||
|
|
||||||
|
### Outbox replay aborts on an unknown topic (`REPLAY TOPIC NOT FOUND`) — VERIFIED in core, already documented as an incident
|
||||||
|
|
||||||
|
`Verifier::send_outbox` (`engine/verifier/src/verifier.rs`) walks the queued events and,
|
||||||
|
for each, looks up `self.topics.get(&(overlay, topic_id))`. On a miss it logs
|
||||||
|
`REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay>` and sets `need_replay`, calls
|
||||||
|
`load_from_credentials_and_outbox(&events_to_replay)`, then in the send loop does
|
||||||
|
`self.topics.get(…).ok_or(NgError::TopicNotFound)?` — the `?` **aborts the whole outbox
|
||||||
|
flush**, so the remaining queued events are not sent. There is no per-event isolation and
|
||||||
|
no signal to the caller.
|
||||||
|
|
||||||
|
Already covered — **not duplicated here**: this is the core-side mechanism behind the
|
||||||
|
symptom described in § *Write loss on socket death (`SerializationError`)* above, whose
|
||||||
|
full post-mortem (logs, causal chain, the unarbitrated (i)/(ii) reserve) is
|
||||||
|
[`incidents/2026-07-14-write-loss-on-disconnect.md`](./incidents/2026-07-14-write-loss-on-disconnect.md).
|
||||||
|
The spontaneous socket death (`SOCKET IS CLOSED Some(Left(SerializationError))`) is
|
||||||
|
likewise covered there and in that section — the only fact added here is the abort
|
||||||
|
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,
|
||||||
|
and one unknown topic can take the rest of the queue with it. The polyfill's own
|
||||||
|
`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
|
||||||
|
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
|
||||||
|
is durable".
|
||||||
|
|||||||
+30
-30
@@ -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
|
||||||
@@ -37,31 +37,29 @@ The governing constraints (all verified in `nextgraph-rs`, cited there):
|
|||||||
- No reactive union query, and the reactive ORM hangs if handed a per-entity
|
- No reactive union query, and the reactive ORM hangs if handed a per-entity
|
||||||
/ unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`).
|
/ unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`).
|
||||||
|
|
||||||
## Two read regimes — enumerate vs follow
|
## One read regime — follow, never enumerate
|
||||||
|
|
||||||
There is **no cross-wallet read** in current NextGraph, so nothing is globally
|
There is **no cross-wallet read** in current NextGraph, and there is no discovery
|
||||||
enumerable "for free". The polyfill splits every list into one of two regimes:
|
either: **you cannot discover, you can only follow links**
|
||||||
|
([`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4ter-bis). Nothing is
|
||||||
|
globally enumerable, and nothing is meant to be.
|
||||||
|
|
||||||
### Events (all public) = the global index — the one enumeration hack
|
> An earlier version of this document described a second regime — "all public
|
||||||
|
> events, enumerated through a global index" — presented as the one justified
|
||||||
|
> "hack". It was removed on 2026-07-30 along with `discovery.ts`: a global index
|
||||||
|
> emulates a capability the target will never have, and it pools data across
|
||||||
|
> wallets. A public document is reached because someone circulated its link, never
|
||||||
|
> because it was listed.
|
||||||
|
|
||||||
Public events are the only thing enumerated across accounts, via the emulated
|
### Everything = follow a graph, never enumerate across accounts
|
||||||
discovery index (`discovery.readIndex`, see
|
|
||||||
[`simulation.md`](./simulation.md) § *Emulated discovery index*). This is the one
|
|
||||||
"hack", and it is justified precisely because P2P has no cross-wallet read: without
|
|
||||||
a shared index a client could never learn that another account's public event-doc
|
|
||||||
exists. `readIndex` yields the event-doc NURIs to open/sync; those repos
|
|
||||||
then enter the local union and become union-queryable.
|
|
||||||
|
|
||||||
### Everything else = 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`);
|
- my own docs (always in `self.repos`, and whose caps I hold);
|
||||||
- docs an owner has granted me via a directed per-document read grant
|
- docs whose cap an owner has delivered to my inbox (`inbox.share` — see the
|
||||||
(`grantRead(doc, granteeId)` — see the per-document ReadCap in
|
per-document ReadCap in [`simulation.md`](./simulation.md));
|
||||||
[`simulation.md`](./simulation.md));
|
|
||||||
- my inbox (deposits addressed to me).
|
- my inbox (deposits addressed to me).
|
||||||
|
|
||||||
The rule of thumb: access is not discovery. You only union-query over graphs you
|
The rule of thumb: access is not discovery. You only union-query over graphs you
|
||||||
@@ -71,14 +69,16 @@ Accessing a document without read rights yields an empty result: a reactive / un
|
|||||||
read never decrypts a repo you hold no cap for, so it simply returns nothing (this
|
read never decrypts a repo you hold no cap for, so it simply returns nothing (this
|
||||||
matches NextGraph's union read). A targeted read of a repo you do not hold diverges
|
matches NextGraph's union read). A targeted read of a repo you do not hold diverges
|
||||||
in one way — it raises `RepoNotFound` rather than returning empty — and the read
|
in one way — it raises `RepoNotFound` rather than returning empty — and the read
|
||||||
path tolerates that per-doc (a doc that throws is skipped). The cap-introspection
|
path tolerates that per-doc (a doc that throws is skipped). The held-caps lookup used
|
||||||
used here (`canRead` / `governsRead`) is emulation-only; there is no NextGraph API
|
here (`capFor`) is emulation-only in its *implementation*; its shape is the target's
|
||||||
behind it, so it has no migration target.
|
(possession), so what disappears at migration is the lookup, not the model. Note
|
||||||
|
there is deliberately no "may identity X read doc D?" call: the real model cannot
|
||||||
|
answer that either.
|
||||||
|
|
||||||
## Listing = a bounded set of per-doc anchored reads (never a union-scan, never the ORM fan-out)
|
## Listing = a bounded set of per-doc anchored reads (never a union-scan, never the ORM fan-out)
|
||||||
|
|
||||||
To produce a list, take the bounded, by-need set of doc NURIs (the index-yielded
|
To produce a list, take the bounded, by-need set of doc NURIs (my own docs, and the
|
||||||
event NURIs, my own docs, the NURIs an owner has granted me) and read each one with its
|
NURIs whose cap someone delivered to me) and read each one with its
|
||||||
own anchored `sparql_query` (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that
|
own anchored `sparql_query` (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that
|
||||||
doc NURI, in parallel and tolerant per-doc). The anchor restricts the query to that
|
doc NURI, in parallel and tolerant per-doc). The anchor restricts the query to that
|
||||||
one repo's graph, so each read is O(1) in the doc's own size and independent of how
|
one repo's graph, so each read is O(1) in the doc's own size and independent of how
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
# NextGraph's ReadCap & NURI model — and the polyfill's caps emulation
|
||||||
|
|
||||||
|
**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 `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
|
||||||
|
>
|
||||||
|
> **NextGraph works very differently from what general knowledge of distributed systems suggests.** Assert nothing about it that is not, at minimum, in this repository's docs — and preferably read in `nextgraph-rs` itself, with a `file:line`. Reasoning by analogy with git, with ACL systems, with pub/sub brokers, or with "how this normally works" produces confident, wrong statements. Every correction recorded below started that way.
|
||||||
|
>
|
||||||
|
> **Write down everything you learn about NextGraph, as you learn it** (PO, 2026-07-30) — at least everything that helps move forward or that corrects a direction. Not at the end of an investigation, not only in the brief that happened to need it: a fact read in `nextgraph-rs` and left in a conversation is a fact the next agent will re-derive, and will get wrong.
|
||||||
|
>
|
||||||
|
> It does not all have to land in this file. This is where the **access model** accumulates (caps, NURIs, stores, branches, who can read what); platform behaviour and SDK gaps belong in [`nextgraph-current-state.md`](./nextgraph-current-state.md), and how the polyfill fakes something belongs in [`simulation.md`](./simulation.md). What matters is that it is written down somewhere durable and findable, with a `file:line` — not which file.
|
||||||
|
>
|
||||||
|
> Three traps in particular, all of which have already caught an agent more than once:
|
||||||
|
>
|
||||||
|
> - **A comment describing the CURRENT state is not the intent.** §3's DIRECTION block exists because `RepoLinkV0`'s comment was read as the target model. It is not.
|
||||||
|
> - **A word you recognise probably does not mean what you think.** `branch` is not git's. `wallet` is only a keyring — what we call a virtual user is a **user** (a *site*). Check the type before using the word.
|
||||||
|
> - **The access rule is "whoever has the reference AND the key reads" — never "whoever has the reference reads".** An agent forgets this with remarkable consistency, because client-server reflexes supply a missing half: somewhere a server checks. Here nobody checks (§0 below). Getting it wrong does not fail — it silently grants. Every time you hand out a value, ask which of the two you just handed.
|
||||||
|
> - **NextGraph's naming ALWAYS wins over any rule of ours — including the one that follows.** Where upstream has a word for something, that is the word, full stop. `publisher` is a case in point: it appears 126 times in the engine (`as_publisher`, `publisher_advert`) for a **pub/sub role on a topic**, and it must be used, unchanged, whenever that is what is meant.
|
||||||
|
> - **What is banned is OUR loose "publish a document"**, which has no upstream referent and covers three different acts: *placing a document in a public store*, *making it findable*, and (in this emulation only) *handing out a key*. Every design discussion that used it drifted between them. NextGraph knows only the first — a document IS IN a public store, and brokers serve it accordingly. Say which act you mean. If you catch yourself writing "publish a document", you have not yet decided which one. This never licenses renaming something upstream calls `publisher`.
|
||||||
|
> - **"I looked and it is not there" is not a finding.** §4quinquies once stated that no register existed for received caps, after checking one code path. `AddLink` had been sitting next to `AddRepo` in the same file the whole time. Absence needs at least as much evidence as presence — and an implementation *cache* (like local user storage) is never the model: it is what the model fills.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The security model, and why an agent keeps losing it
|
||||||
|
|
||||||
|
**This is the one thing to hold on to.** Every design mistake this file records — a cap minted where a reference sufficed, a link that granted what it was meant to merely name, an "is it allowed?" call that cannot exist — comes from letting go of it.
|
||||||
|
|
||||||
|
### The rule
|
||||||
|
|
||||||
|
> A document is readable by whoever holds **the reference AND the key**. Not by whoever holds the reference.
|
||||||
|
|
||||||
|
There is no third party in that sentence. No server, no ACL, no check at access time: the engine verifies a permission on WRITE only (`verify_permission` → `PermissionDenied` in `Commit::verify`), never on read. Content is encrypted; the key IS the right. That is what local-first means here — there is nobody to ask, so possession is the whole mechanism.
|
||||||
|
|
||||||
|
### Why this is easy to lose
|
||||||
|
|
||||||
|
Client-server reflexes supply the missing half without asking. In that world a link is an identifier, harmless on its own, and access is decided elsewhere — so handing out a URL feels safe, and "may Bob read this?" is a question with an answer. Both instincts are wrong here, and neither announces itself: code written on them **does not fail**, it silently grants. There is no error to notice, no test that goes red on its own.
|
||||||
|
|
||||||
|
### What the rule buys — composable confidentiality
|
||||||
|
|
||||||
|
Because a reference grants nothing, **a reference is not recursive**: a widely-circulated document may point at a restricted one. The reference says *it exists*; the key says *who reads it*.
|
||||||
|
|
||||||
|
That is what lets confidentiality be composed inside one document:
|
||||||
|
|
||||||
|
- a public summary pointing at restricted chapters,
|
||||||
|
- a public event pointing at its participant list,
|
||||||
|
- a profile pointing at data reserved for a circle.
|
||||||
|
|
||||||
|
The author circulates one document and still decides, per referenced document, who gets the key. **Remove non-recursiveness and this whole construction collapses**: everything a circulated document mentions becomes readable by all its readers, and no level of confidentiality can exist inside a document one shares.
|
||||||
|
|
||||||
|
`test/cross-user-access.test.ts` is this property's test, and it is the suite's centrepiece: Alice's document in a public store REFERENCES her protected one; Bob follows the reference, names the target, reads nothing of it. It writes the reference **bare** — which is exactly what an application must do.
|
||||||
|
|
||||||
|
### The failure mode to watch for
|
||||||
|
|
||||||
|
Any call that returns a key where a reference was asked for. It looks like a convenience ("give me the link to my document"), it type-checks, the tests stay green — and it converts "whoever has the reference and the key" into "whoever has the reference", for that document and every document it mentions. `linkTo` (added and removed 2026-08-06) was exactly this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. A ReadCap = possession of a key, NOT a per-identity ACL
|
||||||
|
|
||||||
|
A **ReadCap is fundamentally a cryptographic key that one holds**, not an ACL entry tied to a wallet. "Whoever holds the key can read."
|
||||||
|
|
||||||
|
- Structure: `ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }` (`engine/repo/src/types.rs:461, 463-471, 557, 565`).
|
||||||
|
- `id: BlockId` = **BLAKE3** digest (address of the encrypted object).
|
||||||
|
- `key: SymKey = ChaCha20Key([u8;32])` = the **decryption key**.
|
||||||
|
Holding the pair → the broker serves the encrypted blocks by `id`, and one decrypts **locally** with `key`.
|
||||||
|
- Granularity: per commit/object the `ObjectRef` **is** the cap; for a branch → its defining commit; for a repo → RootBranch; for a store → the root repo's cap (`types.rs:559-565`). `ReadCapSecret` = the key half (`:567-570`).
|
||||||
|
- **There is NO read-ACL.** A repo's membership/permissions (`RootBranch`, `AddMember`, `AddPermission`) govern **writing/admin**, not reading. Reading is guarded only by key possession.
|
||||||
|
|
||||||
|
## 2. Granting read access = sealing the key to the recipient
|
||||||
|
|
||||||
|
"Grant" = delivering the cap **sealed** (`crypto_box seal`, anonymous public-key encryption) to the recipient's **inbox pubkey** — only they can open it with their private key.
|
||||||
|
|
||||||
|
- Sealed inbox message: `InboxMsgBody.msg` = `crypto_box::seal(... to_inbox ...)`, opened with the inbox secret key (`engine/net/src/types.rs:4272, 4299, 4319`).
|
||||||
|
- The payload can carry a cap: `ContactDetails.read_cap: Option<ReadCap>` ("if user wants to share the content of profile") (`net/types.rs:4232-4233`) → **directed grant** (sealed to one recipient).
|
||||||
|
- **Undirected** variant: `RepoLinkV0.read_cap` = a shareable link that **whoever receives it** can open (`net/types.rs:5061-5078`).
|
||||||
|
|
||||||
|
So "wallet targeting" lives in the **sealing envelope**, not in the cap: the cap remains `{id, key}`, possession-based.
|
||||||
|
|
||||||
|
> **Current state (2026-07-27) — the path is a GAP, not a disagreement.** The `ContactDetails.read_cap` field exists, but the construction of the message is `unimplemented!()` (its only caller passes "without read_cap") and the receiver **discards** the cap it would receive. The *shape* is therefore the right one; the implementation is not there. The polyfill emulates it in the meantime — filed in the bug-inbox.
|
||||||
|
|
||||||
|
## 3. Revocation = re-key (coarse, non-retroactive)
|
||||||
|
|
||||||
|
A delivered key is not "taken back". To revoke = **re-encrypt** with a new key and re-seal it only to the remaining authorized holders.
|
||||||
|
|
||||||
|
- "Capabilities are not durable: they can be refreshed by members and previously shared Caps become obsolete/revoked… if [a member] doesn't subscribe, they lose access after the refresh" (`net/types.rs:5055-5058`).
|
||||||
|
- Mechanism: `RootCapRefresh` / `BranchCapRefresh` (`engine/verifier/src/commits/mod.rs:616,630` — both no-op `Ok(())` stubs today; perms `types.rs:1748-1749`).
|
||||||
|
- Consequences: **coarse** (repo/branch scale), **non-retroactive** (what was read before remains known to the former holder; they only decrypt the versions **prior to** the refresh).
|
||||||
|
- **Durable** delivery of a cap = `PermaCap` — still **TODO** (`repo/types.rs:578`).
|
||||||
|
|
||||||
|
### DIRECTION — rotation does NOT cause access to be lost (confirmed by the PO, 2026-07-27)
|
||||||
|
|
||||||
|
**Do not read the comment above as the intent.** "*if they don't subscribe, they lose access after the refresh*" describes **the current state**, not the target. What NextGraph is aiming for:
|
||||||
|
|
||||||
|
> When a key is rotated, the new one is **sent to the inbox** of the users who retain the access right. That inbox is **processed automatically** as soon as one of the user's clients connects.
|
||||||
|
|
||||||
|
So access is **not lost**, it is **deferred** until the next connection — consistent with local-first. Shape consequences: **no subscription obligation** to expose to the consumer; a re-delivery takes **the same channel** as the initial delivery, so the sharing mechanism covers both with no special case. **Revocation** remains "stop re-delivering", non-retroactive.
|
||||||
|
|
||||||
|
## 4. NURI grammar: cap-less vs cap-bearing (the `r:` segment)
|
||||||
|
|
||||||
|
**Clearing up the confusion first**: `did:ng:` is **not** a "cap-less" marker, it is the **URI scheme prefix** — present everywhere (inbox `did:ng:d:…`, branch `did:ng:b:…`, overlay `did:ng:v:…`, document `did:ng:o:…`). A NURI **is** a `did:ng:…`. So there is no "the did" on one side and "the NURI" on the other: it is **a single object**, with or without the key inside it — a single type upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`.
|
||||||
|
|
||||||
|
The discriminant is the **`r:` segment** (see the correction below — this document said `:k:` until 2026-07-30): present = cap-bearing; **absent = cap-less** (names/locates **without** granting the right to read). This is **first-class** in the type: `NuriV0.target` (ids) and `access`/`objects` (the cap) are **separate fields** — an id-only NURI parses with `access: vec![]` (`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`).
|
||||||
|
|
||||||
|
**Cap-less** (id + optional overlay, no key) — formatters in `app_protocol.rs`, regexes in `net/types.rs`:
|
||||||
|
- `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52)
|
||||||
|
- `did:ng:o:{repo_id}:v:{overlay_id}` (`:263`, `RE_REPO` types.rs:55)
|
||||||
|
- `did:ng:o:{repo_id}:v:{overlay_id}:b:{branch_id}` (`RE_BRANCH` types.rs:58)
|
||||||
|
- `did:ng:o:{repo_id}:c:{commit_id}` (`:355`)
|
||||||
|
- `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`)
|
||||||
|
|
||||||
|
**Cap-bearing — and `:k:` is NOT the ReadCap segment.** CORRECTED 2026-07-30, on a report from NextGraph's developer, verified in the source. There are **two different encodings**, and confusing them was an error in this document:
|
||||||
|
|
||||||
|
| Segment | Shape | What it is |
|
||||||
|
|---|---|---|
|
||||||
|
| `:k:` | `{id}:k:{key}` — id and key as **two segments** | an **object / file / commit** ref: `j:{id}:k:{key}` (`repo/types.rs:510`), `c:{id}:k:{key}` (`:514`) |
|
||||||
|
| `r:` | `r:{base64url(serde_bare(ObjectRef))}` — id and key **serialized together into one** | a **ReadCap** — `BlockRef::readcap_nuri()` (`repo/types.rs:518-521`) |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn readcap_nuri(&self) -> String {
|
||||||
|
let ser = serde_bare::to_vec(self).unwrap();
|
||||||
|
format!("r:{}", base64_url::encode(&ser))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Used to surface a branch's / root branch's read cap (`engine/verifier/src/verifier.rs:278,320`; `rocksdb_user_storage.rs:162,172`).
|
||||||
|
|
||||||
|
So a ReadCap is **not** "a NURI with `:k:{key}` appended". It is an opaque `r:` segment carrying the whole `ObjectRef { id, key }`. Note also that **no regex matches a cap-bearing repo NURI**: `RE_REPO_O` (`did:ng:o:{id}`) and `RE_REPO` (`…:v:{overlay}`) are both cap-less, and `RE_COMMIT`/`RE_FILE_READ_CAP` are about commits and files, not repos (`net/types.rs:48-73`).
|
||||||
|
|
||||||
|
The `:k:` forms, for completeness:
|
||||||
|
- `did:ng:j:{id}:k:{key}` — object/file read cap (`RE_FILE_READ_CAP` types.rs:48)
|
||||||
|
- `did:ng:o:{repo}:c:{commit}:k:{key}` (`RE_COMMIT` types.rs:72)
|
||||||
|
- list `RE_OBJECTS` `…:[cj]:{id}:k:{key}…:l:{locator}` (types.rs:63)
|
||||||
|
|
||||||
|
The `:v:` segment is the **overlay**, which has its own section below — it is the point with the heaviest consequences for anonymous-presence models.
|
||||||
|
|
||||||
|
## 4bis. The overlay is the network space of a STORE — never of a document
|
||||||
|
|
||||||
|
**The overlay is a store's unit of network addressing.** At the broker, blocks are filed under a `(overlay, block_id)` key, and peers synchronize *within* an overlay. Two forms per store:
|
||||||
|
|
||||||
|
| | Derivation | Who can compute it |
|
||||||
|
|---|---|---|
|
||||||
|
| **outer** | `OverlayId::outer(store_id)` = **public** BLAKE3 | everyone (the store_id is enough) |
|
||||||
|
| **inner** | `OverlayId::inner(store_id, readcap_secret)` = **keyed** BLAKE3 | only whoever holds the store's read key |
|
||||||
|
|
||||||
|
Consistent with the rest of the model: no role and no list, only "do you hold the key that lets you derive this identifier". `outer` = the store's public name, `inner` = its private name.
|
||||||
|
|
||||||
|
**The `:v:` of a DOCUMENT NURI carries the overlay of its STORE** (VERIFIED, chain read end to end): `NuriV0::repo_graph_name(repo_id, overlay_id)` formats `o:{repo_id}:v:{overlay_id}`; in `doc_create` the value injected is `store.outer_overlay()` — the **containing** store, never the `repo_id`. A `Repo` carries **no** overlay field (only `store: Arc<Store>`); it is `Store` that carries `overlay_id`. **Mechanical counter-proof**: in `Store`, `get`/`put`/`del`/`has` all pass `&self.overlay_id` to the block storage — every document of a store shares the same block namespace, so a per-document overlay is structurally impossible.
|
||||||
|
|
||||||
|
### The consequence to know about: the `:v:` is a stable pseudonym
|
||||||
|
|
||||||
|
**All of one person's documents in their protected store carry the SAME `:v:`** = `outer(protected_store_id)`. So a cap-less reference — precisely the one used to "name without granting read" — **exposes store membership**, that is to say a **stable and permanent pseudonymous identifier of the person**. The store_id itself does not leak (BLAKE3 is not invertible), so it does not say *who*; but it is a **constant handle**, the same everywhere and forever, correlatable by anyone who collects cap-less references.
|
||||||
|
|
||||||
|
**The coupling that results, and that constrains any anonymous-presence model**: that same `:v:` is *simultaneously* (a) what makes it possible to **deduplicate** references without reading them — two references with the same `:v:` come from the same person — and (b) what makes it possible to **track** that person from one context to another. **It is the same bit of information.** You cannot get the dedup without conceding the tracking, nor remove the tracking without losing the dedup — short of changing how the stores are carved up, which moves the cursor but does not remove the trade-off.
|
||||||
|
|
||||||
|
*Nuances.* The NURI's `:v:` is the **outer** overlay, whereas client↔broker traffic and local storage use the **inner** one — a different value, but derived from the store as well, so the property holds in both cases. A `Dialog` store returns an `Inner`, still store-scoped.
|
||||||
|
|
||||||
|
**CORRECTED on 2026-07-27 — this hypothesis was FALSE.** We had inferred, then believed we had verified, that a holder **without a key** could fetch the encrypted blocks and therefore prove a document's **existence**. An adversarial review showed that the reasoning stopped at *access control* without looking at **addressing**:
|
||||||
|
|
||||||
|
- 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**.
|
||||||
|
- 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 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.
|
||||||
|
|
||||||
|
Transferable lesson: verifying that an access guard **lets you through** does not prove that an operation is reachable — you still have to be able to **name** what you are asking for.
|
||||||
|
|
||||||
|
## 4ter. The public store: readable by URL, and NOT recursive
|
||||||
|
|
||||||
|
Target principle (confirmed by the PO, 2026-07-27):
|
||||||
|
|
||||||
|
> **An element of the public store is public: whoever has the URL reads the content.**
|
||||||
|
> But **not recursively** — public content can *reference* private content, and the reference does **not** give access to the referenced.
|
||||||
|
|
||||||
|
This is a **second mechanism**, alongside key possession (§1) — not a breach of it. And it is the **non-recursiveness** that carries the value: it allows a public object that **points** to private identity, without divulging it. That is exactly the pattern an anonymous-presence model needs.
|
||||||
|
|
||||||
|
*Implementation detail, NOT to be carried by the shape*: NextGraph is moving toward **not encrypting** the content of the public store (the data remaining **signed**). A surface must not depend on it. And if the public store does not behave the way this principle describes, it is **the polyfill** that adapts, not the consumer.
|
||||||
|
|
||||||
|
## 4ter-bis. THERE IS NO DISCOVERY — you only ever follow links
|
||||||
|
|
||||||
|
**Stated by the PO, 2026-07-30, as one of NextGraph's foundations.** It bears on more design decisions than any other point in this document, and it is the easiest to violate without noticing, so it is stated before anything is built on top of it:
|
||||||
|
|
||||||
|
> **You cannot discover. You can only follow links.**
|
||||||
|
|
||||||
|
NextGraph is **local-first**. There is no global index, no registry, no crawler, no "list everything public" — and nothing of the kind is planned. Nothing exists *to be found*; things exist *to be reached*, and reaching them means someone handed you the way in.
|
||||||
|
|
||||||
|
So **publishing is two acts, never one**:
|
||||||
|
|
||||||
|
1. **Place** the data in your public store — that makes it readable *by whoever reaches it*, not visible;
|
||||||
|
2. **Circulate the link** — post it into inboxes, or put it somewhere already reachable by the people concerned (a document they already hold).
|
||||||
|
|
||||||
|
And it is seen **only by those who received the information**, i.e. the link. There is no audience beyond the people you reached, and no way to enumerate one. Private distribution is the same act, plus the ReadCap: place, then circulate — the cap being what turns "reached" into "readable".
|
||||||
|
|
||||||
|
**The consequences, which are not obvious:**
|
||||||
|
|
||||||
|
- **A "global list of everything public" is not constructible**, and a surface that offers one exposes a capability the target will never have — precisely the failure mode this whole chantier exists to prevent. Whatever such a surface is emulated on, it teaches the consumer a model that does not exist.
|
||||||
|
- **Reachability is a graph, not a directory.** The only way in is a link somebody gave you: in an inbox, or inside a document you already hold. Which is why the inbox is not a side feature — it is *the* bootstrap of the whole graph, the only channel through which a link crosses from one wallet to another.
|
||||||
|
- **This is what makes §4ter operational.** "Whoever has the URL reads the content" is not a weaker form of public: the URL *is* the access. Having it means someone gave it to you.
|
||||||
|
- **An audience cannot be counted, only addressed.** No primitive answers "who can see this"; you know who you sent it to.
|
||||||
|
|
||||||
|
### And the second reason, which stands on its own: nothing is COMMON
|
||||||
|
|
||||||
|
Even setting discovery aside, a global index is **data shared between users/wallets**, and that is not acceptable in an emulation whose whole job is to simulate the boundary of a single-user wallet (PO, 2026-07-30):
|
||||||
|
|
||||||
|
> Nothing common — only **indexing mechanisms to make the virtual users work**.
|
||||||
|
|
||||||
|
The distinction is the operative one, and it is sharp:
|
||||||
|
|
||||||
|
| | Verdict | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| The **shim** (pointer → doc-shim → account → its scope documents) | **acceptable** | pure plumbing: it holds no user data, only the table that makes a virtual user resolvable at all. Remove it and no wallet exists. |
|
||||||
|
| A **discovery index** (announcements deposited by users, read by everyone) | **not acceptable** | it is application data pooled across wallets. Remove it and every wallet still works — you simply have to be given links, which is the model. |
|
||||||
|
|
||||||
|
The test to apply to anything shared: *does removing it stop the virtual users from functioning, or does it merely stop users from seeing each other's content?* Only the first justifies existing outside a wallet.
|
||||||
|
|
||||||
|
*Impact on this library, RESOLVED 2026-07-30 — the module was removed*: `discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`) emulates exactly the capability described above as non-existent, **and** holds pooled user data, and `watchShape('public')` folds it into its read set. The ADR that specified it ([`decisions/discovery-model.md`](decisions/discovery-model.md)) already recorded that a freely-readable global index "is not a NextGraph shape" and rested on a singleton-app path that is "not implemented, uncertain". That reservation is now a verdict on both counts. See [`briefs/2026-07-30-virtual-wallet-boundary.md`](briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||||
|
|
||||||
|
## 4quater. Where an owner gets the caps for THEIR OWN documents — the Store branch
|
||||||
|
|
||||||
|
**There is no "keyring" object in NextGraph, and this section used to say there was.** It read *"the store branch **is** the owner's keyring… upstream of that, the keyring is the wallet"*, which is wrong twice: the wallet holds **one** key per user (the private store's read cap, §4quinquies level 1), not every key; and the caps of one's own documents live on a **Store branch**, per store, not in any single trousseau. An agent built a global in-memory "keyring" on that sentence. Corrected 2026-07-30 on the PO's instruction — *use the Store branch logic, not an invented keyring*.
|
||||||
|
|
||||||
|
What is actually true:
|
||||||
|
|
||||||
|
On every document creation, an `AddRepo { read_cap }` is committed to the store's **Store branch** — the store being itself a repo with **typed** branches (the word "branch" has nothing to do with git: it is a compartment with a defined role, its own pub/sub topic, and here `BranchCrdt::None` — service commits, not triples). That branch lists **the store's documents, each with its read cap**, and replaying it is what reloads them (`AddRepo::verify` → `load_repo_from_read_cap`, `engine/verifier/src/commits/mod.rs:644-664`).
|
||||||
|
|
||||||
|
So the answer to *"how does an owner find the cap of a document they created?"* is: **it is on the Store branch of the store that document lives in** — one such branch per store, reached from the root key the wallet does hold.
|
||||||
|
|
||||||
|
**This is NOT the sharing mechanism.** An easy and costly confusion: concluding "we share at the store level" is wrong — delivering a store's cap would give access to **all** of its content, present and future. **The unit of sharing is the document** (§2), and a cap received for someone else's document goes somewhere else entirely (`AddLink` on the User branch, §4quinquies).
|
||||||
|
|
||||||
|
*(VERIFIED for the `AddRepo { read_cap }` mechanism and for `BranchType::Store` / `BranchCrdt::None`; the full enumeration of branch types is in `engine/repo/src/types.rs:1536-1551`.)*
|
||||||
|
|
||||||
|
## 4quinquies. WHERE the caps actually live — three levels, and one of them does not exist yet
|
||||||
|
|
||||||
|
**VERIFIED 2026-07-30** by reading `nextgraph-rs` (`git 213338f6`), answering "where does a received cap get stored?".
|
||||||
|
|
||||||
|
### Nomenclature first — `wallet` in the source is NOT what we call a wallet
|
||||||
|
|
||||||
|
A **wallet is only a keyring**. What we have been calling a "virtual user" is, upstream, a **user** (a *site*): `SensitiveWalletV0.sites: HashMap<String, SiteV0>` (`engine/wallet/src/types.rs:434,457`) — one wallet holds SEVERAL sites. `SiteV0` (`engine/verifier/src/site.rs:23`) is what owns the three stores (`public`, `protected`, `private`), and `UserId = PubKey` (`engine/repo/src/types.rs:453`). **Our vocabulary must follow: virtual user → user.**
|
||||||
|
|
||||||
|
### The three levels
|
||||||
|
|
||||||
|
**1. The wallet (keyring) holds ONE root key per user.** `SiteV0.site_type = SiteType::Individual((priv_key, read_cap))`, read back by `get_individual_site_private_store_read_cap` (`site.rs:52`) — the read cap of the **private store**, and nothing else. Everything else is reached *from* it. Following links applied to your own data.
|
||||||
|
|
||||||
|
**2. The store's own branch carries `AddRepo { read_cap }` — one per document.** `doc_create` performs **four distinct writes**; the two that matter here (`engine/verifier/src/request_processor.rs:697-710`):
|
||||||
|
|
||||||
|
- `send_add_repo_to_store` → a commit `AddRepo { read_cap }` on the **Store branch** of the store (`verifier.rs:2172-2199`) — *the key*;
|
||||||
|
- `INSERT DATA { <store> ldp:contains <doc> }` on the store's **main branch** — *the listing*.
|
||||||
|
|
||||||
|
*(The other two: the class quad on the **Header** branch, `request_processor.rs:719-728`; and `AddSignerCap` on the private store's **User** branch, `verifier.rs:3022-3040`.)*
|
||||||
|
|
||||||
|
**The key and the list are separate, deliberately.** Replaying the Store branch is what reloads the repos with their keys: `AddRepo::verify` calls `load_repo_from_read_cap` then `add_doc` (`engine/verifier/src/commits/mod.rs:644-664`). Our `shim:contains` emulates `ldp:contains` and `shim:readCap` (on a `storeBranch` subject) emulates `AddRepo` — so a created document's cap is stored beside it and read back, not recomputed.
|
||||||
|
|
||||||
|
> **The Store branch holds NO triples.** Its CRDT is `BranchCrdt::None` — *"used by Overlay, Store and User BranchTypes"* (`engine/repo/src/types.rs:1420`; `store.rs:426`). It is a stream of **service commits** (`AddRepo` / `RemoveRepo`), not a graph. Any RDF we use to emulate it is our invention, and should be labelled as such rather than presented as "the same thing".
|
||||||
|
|
||||||
|
**3. Local user storage persists the read cap of EVERY opened repo.** `user_storage/repo.rs` stores `READ_CAP` as a property per repo (`:109,:219,:248,:359`), and a persistent verifier reloads from it at startup (`verifier.rs:542-544`). This is a **local store (RocksDB / IndexedDB), not a NextGraph document** — the verifier's own cache, per user.
|
||||||
|
|
||||||
|
### Giving access is a **Link** — one word, three places, all already named
|
||||||
|
|
||||||
|
**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 |
|
||||||
|
|---|---|---|
|
||||||
|
| 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`) | **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`) | **declared, stubbed** — the same no-op `Ok(())` arm (`commits/mod.rs:695-707`) |
|
||||||
|
| 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`) |
|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|
- **What travels is a cap-BEARING reference.** A bare NURI in a Link grants nothing — it names a document the recipient still cannot open. `AddLink` carries a `read_cap`, not a `RepoId`.
|
||||||
|
- **`ContactDetails` is a different gesture.** It shares a *profile* (with an optional `read_cap` on it), not an arbitrary document. Do not route document sharing through it.
|
||||||
|
|
||||||
|
### A cap received from someone else: the **User branch**, via `AddLink`
|
||||||
|
|
||||||
|
**CORRECTED 2026-07-30 after adversarial review — an earlier version of this section claimed there was no register at all. That was wrong, and it was the kind of wrong this document exists to prevent: concluding "it does not exist" from having looked in one place.**
|
||||||
|
|
||||||
|
There IS a register, and it is a fourth commit type next to `AddRepo`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Adds a link into the user branch, so that a user can share with all its device a new Link they received.
|
||||||
|
/// The repo's `store` field should not match with any store of the user. Only external repos are accepted here.
|
||||||
|
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 — 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 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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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".
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
**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-cap-surface) |
|
||||||
|
|---|---|---|
|
||||||
|
| 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 | `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 |
|
||||||
|
| Revocation | coarse **re-key**, non-retroactive | **not emulated** (P3). Nothing pretends to revoke |
|
||||||
|
| 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 |
|
||||||
|
| 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 (`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 (`inbox.share` once, per document), which is a consumer **re-architecture**, not an API swap.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **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 (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
|
||||||
|
|
||||||
|
- `file:line` references are dated (2026-07) — re-verify by symbol; the core moves.
|
||||||
|
- ~~INFERRED: keyless broker fetch (existence without a key)~~ — **RESOLVED and REFUTED, 2026-07-27**: not constructible. See the CORRECTED block in §4bis. Kept struck through because the hypothesis is intuitive and will otherwise be re-formed.
|
||||||
|
- Not traced: the full execution of `RootCapRefresh` on the verifier side (`verifier/src/commits/mod.rs:616`), wallet storage of `private_store_read_cap` (`repo/types.rs:945,976`).
|
||||||
+344
-196
@@ -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
|
||||||
@@ -32,34 +32,45 @@ application fiction the lib maintains. On top of that one wallet the lib rebuild
|
|||||||
by emulation, the per-user stores + capabilities + inbox the consumer application
|
by emulation, the per-user stores + capabilities + inbox the consumer application
|
||||||
codes against.
|
codes against.
|
||||||
|
|
||||||
## Physical wallet vs virtual wallet — never enumerate the physical one
|
## Physical user vs virtual user — never enumerate the physical one
|
||||||
|
|
||||||
|
**Nomenclature (aligned on NextGraph, 2026-07-30).** A **wallet** upstream is only a
|
||||||
|
**keyring**; what owns three stores is a **user** (a *site*), and one wallet holds
|
||||||
|
several of them (`SensitiveWalletV0.sites`, `engine/wallet/src/types.rs:434,457`).
|
||||||
|
So this document says *user*, not *wallet*, for the thing an identity is — the two
|
||||||
|
words meant the opposite of each other here until this was corrected.
|
||||||
|
|
||||||
Because the emulation runs on ONE shared wallet, distinguish two levels:
|
Because the emulation runs on ONE shared wallet, distinguish two levels:
|
||||||
|
|
||||||
- **Physical wallet** — the real NextGraph wallet everyone opens. Its local store
|
- **The physical user** — the single NextGraph user everybody's session opens. Its
|
||||||
holds every account's documents plus the lib's own internals (the shim index,
|
stores hold every account's documents plus the library's own internals (the
|
||||||
the inbox docs, the discovery index) as named graphs. It accumulates without
|
pointer, the doc-shim, the inbox documents) as named graphs, accumulating without
|
||||||
bound across sessions/runs. Listing or scanning "all documents" of the physical
|
bound across sessions and runs. Listing or scanning "all documents" at this level
|
||||||
wallet is meaningless and O(size) — it mixes every user's data with lib internals,
|
is meaningless and O(size): it mixes every virtual user's data with library
|
||||||
and it is exactly what a `sparql_query` with no anchor (`GRAPH ?g { … }`) does
|
internals, and it is exactly what an anchorless `sparql_query` (`GRAPH ?g { … }`)
|
||||||
(it spans every synced graph). The physical wallet is a substrate,
|
does. The physical user is a substrate, not something to enumerate.
|
||||||
not something to enumerate.
|
|
||||||
|
|
||||||
- **Virtual wallet** — the lib's emulation of one user's wallet: the set of
|
- **A virtual user** — the library's emulation of one identity: the documents the
|
||||||
documents the shim attributes to that account (its per-scope index in
|
shim attributes to that account (its three store documents in
|
||||||
`store-registry.ts`). This is what "the user owns". Over a *virtual* wallet,
|
`shared-wallet/account-registry.ts`). This is what "the user owns", and over it "list my
|
||||||
"list my documents" is meaningful and bounded (only that account's docs).
|
documents" is meaningful and bounded.
|
||||||
|
|
||||||
**Consequence for reads (see `read-model.md`):** to list a user's entities you
|
**Which API you use follows that line, and it is enforced** (see `shared-wallet/physical.ts`):
|
||||||
enumerate the *virtual* wallet — the account's scope index (bounded, O(my docs)),
|
machinery operating on the *index of virtual users* — the store-root pointer, the
|
||||||
not the physical union — then read those specific documents with a per-doc anchored
|
doc-shim, the account records — goes through unguarded primitives that are never
|
||||||
`sparql_query`. A non-empty / bloated physical wallet then costs nothing, because the
|
exported from the package. Everything touching a virtual user's own content goes
|
||||||
physical union is never scanned. Discovery (all public events) is the one bounded
|
through the guarded `docs.*`, even when the library is what calls it. One API is the
|
||||||
enumeration hack and goes through the discovery index, not a physical scan.
|
app's; the other must never be.
|
||||||
|
|
||||||
At migration each virtual wallet becomes a real per-user wallet; the physical/virtual
|
**Consequence for reads (see `read-model.md`):** to list an identity's entities you
|
||||||
distinction — and the "never enumerate the physical wallet" rule — dissolves into
|
enumerate the *virtual* user — that account's store document, bounded to its own
|
||||||
native per-wallet reads.
|
documents — never the physical union. A bloated shared wallet then costs nothing,
|
||||||
|
and nothing is enumerated across users at all: you read your own documents and the
|
||||||
|
ones whose cap you were given.
|
||||||
|
|
||||||
|
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
|
||||||
|
`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)
|
||||||
|
|
||||||
@@ -75,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"
|
||||||
@@ -84,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.
|
||||||
@@ -98,46 +109,56 @@ public/protected/private stores — on top of one shared wallet.
|
|||||||
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
|
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
|
||||||
logical attribute tracked here, not a physical store.
|
logical attribute tracked here, not a physical store.
|
||||||
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
|
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
|
||||||
NURIs`, persisted as RDF in the shared wallet's private store (the anchor,
|
NURIs`. It is persisted as RDF, but **not directly in the store-root graph** — it
|
||||||
always known from the session: `RegistrySession.privateStoreId`). That makes
|
lives in a subscribable **doc-shim** reached through a write-once **pointer** in the
|
||||||
identity resolution cross-device: another device opening the same wallet reads
|
store-root, an indirection forced by a NextGraph fact: "findable-without-lookup"
|
||||||
the same shim and finds the same accounts. It is the account→document trust root,
|
(store-root) and "subscribable / cold-read-authoritative" (`did:ng:o:` repo with a
|
||||||
which is why every untrusted value that reaches its SPARQL is escaped (see
|
first-`State` barrier) are DISJOINT. The pointer (findable) names the doc-shim
|
||||||
SPARQL hardening below).
|
(authoritative); resolution reads the pointer from the store-root, opens the doc-shim
|
||||||
|
through its barrier, and reads the account authoritatively — so a fresh reconnecting
|
||||||
|
session never mistakes sync-lag for "account absent" (which would provision a FORK).
|
||||||
|
Full rationale — including why the old account-level retry (`provisionRetry`) is
|
||||||
|
removed (pre-indirection store-root records are NOT recovered; such wallets are dev
|
||||||
|
data and simply get a fresh doc-shim) — is in
|
||||||
|
[`nextgraph-current-state.md`](./nextgraph-current-state.md) §§ *Findable vs
|
||||||
|
subscribable* / *The pointer → doc-shim indirection*. This map is the
|
||||||
|
account→document trust root, which is why every untrusted value that reaches its
|
||||||
|
SPARQL is escaped (see SPARQL hardening below). It makes identity resolution
|
||||||
|
cross-device: another device opening the same wallet reads the same pointer → the
|
||||||
|
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
|
||||||
account's scope index document — the index doc plays the role of the future
|
account's scope index document — the index doc plays the role of the future
|
||||||
store-container (it lists the entity-document NURIs "in" that scope).
|
store-container (it lists the entity-document NURIs "in" that scope).
|
||||||
`listEntityDocs(scope)` unions the contained NURIs across all accounts. This is a
|
`listMyEntityDocs(id, scope)` reads back ONE user's documents — bounded to that
|
||||||
fallback / test-only path, not the read path: enumerating every account and
|
user, and the only listing there is: the cross-account fan-out
|
||||||
handing the NURIs to `useShape({ graphs })` opens/syncs other accounts' possibly-
|
(`listEntityDocs` / `resolveReadGraphs` / `allAccounts` / `loadShim`) was
|
||||||
unsynced docs and hangs (the ORM fan-out — see
|
**removed on 2026-07-30**, being cross-user enumeration by construction. The real read path is
|
||||||
[`read-model.md`](./read-model.md)). The real read path is
|
`readUnion(docs)`, which reads the by-need doc set with one per-doc
|
||||||
`readModel.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 discovery index (public events) 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
|
||||||
one private store via `docCreate(..., undefined)`).
|
one private store via `docCreate(..., undefined)`).
|
||||||
|
|
||||||
### A virtual wallet's structure — the three emulated stores
|
### A virtual user's structure — the three emulated stores
|
||||||
|
|
||||||
A *virtual wallet* = one account in the shim, keyed by its virtual-wallet id
|
A *virtual user* = one account in the shim, keyed by its virtual-wallet id
|
||||||
(the technical identifier the consumer application sets when the physical wallet is
|
(the technical identifier the consumer application sets when the physical user is
|
||||||
opened; it identifies *which* virtual wallet, and is an id rather than a
|
opened; it identifies *which* virtual user, and is an id rather than a
|
||||||
human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3
|
human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3
|
||||||
native stores":
|
native stores":
|
||||||
|
|
||||||
```
|
```
|
||||||
Virtual wallet (id)
|
Virtual user (id)
|
||||||
├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ]
|
├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ]
|
||||||
├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ]
|
├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ]
|
||||||
└── private store = docPrivate index → [ record doc NURI, … ]
|
└── private store = docPrivate index → [ record doc NURI, … ]
|
||||||
@@ -145,28 +166,28 @@ Virtual wallet (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
|
||||||
document, and the shim anchor itself all live in the shared physical wallet's
|
document, and the shim anchor itself all live in the shared physical user's
|
||||||
private store (`docCreate(..., undefined)`). The 3-store structure is the per-account
|
private store (`docCreate(..., undefined)`). The 3-store structure is the per-account
|
||||||
logical layer the lib maintains on top.
|
logical layer the lib maintains on top.
|
||||||
|
|
||||||
```
|
```
|
||||||
Physical wallet (shared, one) → private_store (physical) holds everything:
|
Physical user (shared, one) → private_store (physical) holds everything:
|
||||||
• the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate }
|
• the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate }
|
||||||
• every account's 3 scope-index docs + all per-entity docs + inbox + discovery index
|
• every account's 3 scope-index docs + all per-entity docs + inboxes
|
||||||
```
|
```
|
||||||
|
|
||||||
At migration each virtual wallet's 3 index documents become the user's 3 **real**
|
At migration each virtual user's 3 index documents become the user's 3 **real**
|
||||||
native stores, the entity documents move into them physically, and the
|
native stores, the entity documents move into them physically, and the
|
||||||
virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)).
|
virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)).
|
||||||
|
|
||||||
### 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:
|
||||||
|
|
||||||
@@ -179,21 +200,45 @@ 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.
|
||||||
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a
|
- **`userInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
|
||||||
dedicated inbox document (a reserved account's public scope document, from
|
first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for
|
||||||
`docCreate` — a real repo NURI, stable across clients), not the shared
|
one of its documents, opened on demand by its **owner only** (ownership read from the
|
||||||
wallet's private-store root. Why dedicated: the shim (the account→document trust
|
Store branches — a received cap is not ownership, and a recipient must not be able to
|
||||||
root) lives in the private-store graph and is scanned on every `loadShim`;
|
redirect the owner's deposits to itself). Both are dedicated documents (real repo
|
||||||
routing every inbox deposit into that same graph bloats it without bound
|
NURIs from `docCreate`), never the private-store root: routing deposits into the shim
|
||||||
(thousands of deposit triples across sessions), turning `loadShim` into a
|
graph would bloat the account→document trust root without bound. `myInboxes()`
|
||||||
multi-second full-graph scan. A separate inbox document keeps the shim graph
|
enumerates both levels — what `emulated-verifier/connect.ts` drains at connection — and `isOwnInbox`
|
||||||
small and the deposits isolated. At migration it becomes the host's native
|
answers from the same record. *(The former `resolveInboxAnchor`, a single inbox COMMON
|
||||||
inbox NURI.
|
to every user, was removed on 2026-07-30: nothing may be common but the mechanisms
|
||||||
|
that make the virtual users work.)*
|
||||||
|
- **`documentInboxAddress(doc)` — the DEPOSIT side, and the one a third party uses.**
|
||||||
|
Reading an inbox and finding where to deposit into it are opposite acts with opposite
|
||||||
|
audiences, and conflating them is what made per-document inboxes unusable at first:
|
||||||
|
resolution answered *"which inbox do I know for this document"*, so a depositor got
|
||||||
|
one of their own and their deposit vanished silently
|
||||||
|
([`briefs/2026-08-03-document-inbox-addressing.md`](./briefs/2026-08-03-document-inbox-addressing.md)).
|
||||||
|
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
|
||||||
|
`read-model` filters the whole `urn:ng-eventually:` namespace out of consumer data
|
||||||
|
(`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.
|
||||||
|
**One inbox belongs to one document** — never several documents behind one inbox, a
|
||||||
|
relation upstream cannot express (the verifier routes by `inboxes: PubKey → RepoId`
|
||||||
|
and unseals with that repo's key, `engine/verifier/src/verifier.rs:1677,1928`), which
|
||||||
|
is also why a deposit carries no target document: the address identifies it. A fresh
|
||||||
|
document therefore has NO inbox and `documentInboxAddress` returns `undefined` — its
|
||||||
|
owner opens one when the document is meant to receive, which is what keeps the cost
|
||||||
|
proportional. At migration the address becomes the repo's native inbox pubkey and the
|
||||||
|
resolution moves; the consumer-facing act is unchanged.
|
||||||
|
- **`inbox.postToDocument(doc, { payload })`** — the one call an app makes to reach a
|
||||||
|
document's owner: it names the DOCUMENT, never an inbox. **Throws** when the document
|
||||||
|
has no inbox, rather than returning quietly: a deposit that vanishes without an error
|
||||||
|
is the exact bug this path shipped with.
|
||||||
|
|
||||||
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.
|
||||||
@@ -220,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).
|
||||||
@@ -238,87 +283,154 @@ 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 ReadCaps as faithfully as a data layer
|
- **`CapRegistry` (`emulated-verifier/caps.ts`)** models a ReadCap as what it is: **the document's
|
||||||
can. The access unit is the document = repo NURI (an item's `@graph`),
|
key**. The access unit is the document = repo NURI (an item's `@graph`), never
|
||||||
never the item — because in `nextgraph-rs` a store is just a container repo
|
the item — because in `nextgraph-rs` a store is just a container repo and holding
|
||||||
and holding its cap does not grant the repos it references (no store-level read
|
its cap does not grant the repos it references (no store-level read inheritance;
|
||||||
inheritance; verified). So the registry is purely per-document:
|
verified). The registry records, **per identity**, the caps it holds — `Map<Nuri, ReadCap>`
|
||||||
`grantRead(doc, granteeId)` issues a directed read grant to one identity,
|
— and answers exactly one question: `capFor(nuri)`, *do I hold this document's
|
||||||
alongside `grantWrite` / `makePublic` / `open(doc, scope, owner)` /
|
cap?* There is deliberately **no** "may principal P read document D": that is an
|
||||||
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`, plus the read-only
|
ACL question, and the real model cannot answer it either.
|
||||||
accessor `protectedDocsOf(owner)` the consumer application uses to pick which
|
- **`nuri.ts`** carries the cap-less / cap-bearing distinction, which upstream is
|
||||||
protected docs to grant. The consumer application performs the *acts* of granting
|
one object (`NuriV0 { target, access }`) discriminated by the `:r:{cap}` segment.
|
||||||
(create-public, grant a specific doc to a specific identity…) exactly as it
|
`Nuri` names, `ReadCap` names *and* reads. Both are plain strings — the real SDK
|
||||||
will in the target; the lib injects no policy.
|
takes `nuri: String` and enforces at runtime through cryptography, so a branded
|
||||||
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
|
type would be a concept NextGraph does not have. The stand-in key value is the
|
||||||
`Proxy`: iteration / `size` / `forEach` are filtered by
|
constant `OK` (see the module header): the only question the emulation answers is
|
||||||
`caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`,
|
*do I hold this cap or not*, so the value says exactly that and pretends nothing
|
||||||
`getById`…) forwards to the target, preserving writes and reactivity. An item
|
more. Cap-enforcement, not cap-surface, is the batch that turns the shape into a protection.
|
||||||
with no `@graph`, or in a document under no cap policy, is kept (the filter only
|
- **`emulated-verifier/read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
|
||||||
restricts documents that *declare* a cap — no regression on ungoverned data).
|
`Proxy`: iteration / `size` / `forEach` keep only items whose `@graph` the
|
||||||
`filterReadable` is the pure variant.
|
current holder holds; everything else (`add`, `delete`, `has`, `getById`…) forwards to
|
||||||
- **`useShape` (`use-shape.ts`)** applies the view only if
|
the target, preserving writes and reactivity. An item with no `@graph` is kept (it
|
||||||
`caps.hasReadPolicy()` — otherwise it passes the real set through unchanged
|
names no document, so there is no cap to hold). `filterReadable` is the pure
|
||||||
(no regression when the consumer application declares no caps).
|
variant. Note the absence of a `user` parameter — that absence *is* the model.
|
||||||
|
- **`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
|
||||||
|
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:
|
||||||
|
that is the isolation.
|
||||||
|
|
||||||
In a mono-store layout (every item in one repo) this is all-or-nothing on that
|
In a mono-store layout (every item in one repo) this is all-or-nothing on that
|
||||||
document — exactly the native behaviour, and why fine-grained isolation requires
|
document — exactly the native behaviour, and why fine-grained isolation requires
|
||||||
one document per entity (axis B).
|
one document per entity (axis B).
|
||||||
|
|
||||||
### Making the ReadCap active — current identity + directed grants
|
### Where caps come from — stored, never derived
|
||||||
|
|
||||||
The filter only discriminates once the consumer application (a) tells the SDK who is
|
`doc_create` returns a **cap-less** NURI, so "no function ever goes from a bare
|
||||||
reading and (b) declares the access policy on the documents. Both are plain SDK
|
reference to a cap" cannot be the whole rule — it would lock a document's own creator
|
||||||
calls; the consumer application never touches the registry internals:
|
out of it. The real mechanism: creating a document commits `AddRepo { read_cap }` to
|
||||||
|
the store's **Store branch**, separately from the `ldp:contains` listing on its Main
|
||||||
|
branch. That is where an owner finds the caps of what it created; a cap RECEIVED for
|
||||||
|
someone else's document goes elsewhere, on the **User branch** (`AddLink`). The wallet
|
||||||
|
itself holds one key per user — the private store's read cap — from which the rest is
|
||||||
|
reached. Hence the invariant:
|
||||||
|
|
||||||
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
|
> **You do not derive a cap from a bare reference. You look it up in what you hold —
|
||||||
`useShape`'s filtered view reads it lazily, so the delivered subset always
|
> or you were given it.**
|
||||||
reflects the identity in effect at read time. Until it is set, the filter has no
|
|
||||||
principal and (per `canRead(doc, null)`) only public documents pass — which is
|
|
||||||
why isolation stays dormant until the consumer application makes this call.
|
|
||||||
- **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the
|
|
||||||
consumer application creates it: `public` → world-readable; `protected`/`private`
|
|
||||||
→ owner reads, owner holds the write cap. `open` also remembers `(scope, owner)`
|
|
||||||
per document so `protectedDocsOf(owner)` can later enumerate the protected ones.
|
|
||||||
- **`grantRead(doc, granteeId)` (`caps.ts`, exposed via `getCaps()`)** — the one
|
|
||||||
relationship-shaped sharing act the lib exposes: a directed per-document read
|
|
||||||
grant issued to a specific identity. Public docs stay world-readable; private
|
|
||||||
docs stay owner-only; a protected doc becomes readable by `granteeId` once the
|
|
||||||
owner grants it. The consumer application passes a document NURI and a grantee id
|
|
||||||
— no store id.
|
|
||||||
|
|
||||||
The relationship concept — who is "connected" to whom, and therefore which of
|
Three ways a cap arrives, and there are no others:
|
||||||
their protected docs to grant — is owned by the consumer application, not the lib.
|
|
||||||
A connection or friendship is not a NextGraph primitive; the only platform-mappable
|
|
||||||
primitive is the directed per-document read grant above. So the consumer application
|
|
||||||
decides a relationship exists and, for each protected doc it wants to share, calls
|
|
||||||
`grantRead(doc, granteeId)` — typically iterating `protectedDocsOf(owner)` to pick
|
|
||||||
the owner's protected docs. The intended target of such a directed grant is a native
|
|
||||||
per-document ReadCap issued to that identity — but that target is itself
|
|
||||||
scaffolding-only in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0
|
|
||||||
{grantee}` is unpersisted and cap-send is `unimplemented!()`, so directing a grant
|
|
||||||
to another identity is not-yet-built at the platform level. There is no bilateral
|
|
||||||
capability exchange to mirror, only (eventually) individual directed grants.
|
|
||||||
|
|
||||||
The result is the target's discrimination reproduced end-to-end: private →
|
- **Creation.** `createEntityDoc(id, scope)` writes the cap on the store's emulated
|
||||||
owner; protected → owner + whoever the owner has directly granted; public → all.
|
Store branch (`shim:readCap`) and the creator holds it. The consumer declares
|
||||||
Proven in `test/isolation-active.test.ts`: an unconnected principal is denied a
|
nothing, and the cap is minted exactly ONCE — the stored value is the held value,
|
||||||
protected document, granted it after the owner issues a directed `grantRead`, and
|
which is what keeps this correct when cap-enforcement makes the key real.
|
||||||
reads the public document throughout.
|
- **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
|
||||||
|
a **fresh session** read its own documents again with nothing re-declared — the
|
||||||
|
durability the old in-memory ACL faked and lost every reload.
|
||||||
|
- **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
|
||||||
|
verifier applies queued messages upstream. **Receiving needs no 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.
|
||||||
|
|
||||||
This discrimination is only observable because each entity is its own document
|
**The caps a holder holds are not the sharing mechanism.** Handing over a *store* cap would give
|
||||||
(the consumer application creates per-entity docs via `createEntityDoc` and `open`s
|
away everything the store contains, present and future. The unit of sharing is the
|
||||||
each) — in a mono-store layout the per-document ReadCap is all-or-nothing.
|
document; the Store branch is a private index.
|
||||||
|
|
||||||
|
Switching identity **switches** records — it never wipes one. If it wiped,
|
||||||
|
durability would be a lie and per-session re-declaration would come back under
|
||||||
|
another name.
|
||||||
|
|
||||||
|
### Sharing, publication, and the recipient
|
||||||
|
|
||||||
|
- **`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
|
||||||
|
reflects the identity in effect at read time.
|
||||||
|
- **`inbox.share(doc, toUser)`** — the one sharing act the lib exposes. Recipients
|
||||||
|
are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
|
||||||
|
there is no `PrincipalId` in this surface, because that notion exists nowhere
|
||||||
|
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).
|
||||||
|
- **A document created in the `public` scope** needs no sharing act at all. The store
|
||||||
|
serves its ReadCap to whoever asks (`emulated-verifier/public-store.ts`, emulating
|
||||||
|
*"the latest ReadCap will be downloaded from the outerOverlay"* — `PublicRepoLinkV0`,
|
||||||
|
`engine/net/src/types.rs:5098`), so what an application circulates is the **bare
|
||||||
|
reference**, exactly as it will after migration. Never recursive: a public document
|
||||||
|
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`
|
||||||
|
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, so this lib emulates it meanwhile.
|
||||||
|
|
||||||
|
**Key rotation needs nothing on this surface.** A rotated key is re-sent to the
|
||||||
|
inbox of whoever keeps access, and that inbox is processed automatically at the next
|
||||||
|
connection — so access is not lost, it is *deferred*, consistent with local-first.
|
||||||
|
Same channel as the initial delivery, so there is **no subscription obligation** to
|
||||||
|
expose and no special case to write. Revocation stays what it is: stop re-delivering,
|
||||||
|
non-retroactive.
|
||||||
|
|
||||||
|
The relationship concept — who is "connected" to whom, and therefore whose documents
|
||||||
|
to share — is owned by the consumer application, not the lib. A connection or
|
||||||
|
friendship is not a NextGraph primitive; the only platform-mappable primitive is the
|
||||||
|
per-document cap delivery above.
|
||||||
|
|
||||||
|
The result is the target's discrimination reproduced end-to-end: you read the
|
||||||
|
documents whose caps you hold, and nothing else. Proven in
|
||||||
|
`test/isolation-active.test.ts` (a document nobody shared is unreadable; a share to
|
||||||
|
one inbox reveals it there and only there; a bare reference reads nothing while the
|
||||||
|
repo link opens the published document; a returning identity keeps its caps) and in
|
||||||
|
`test/cross-user-access.test.ts`, the acceptance test below.
|
||||||
|
|
||||||
|
This discrimination is only observable because each entity is its own document (the
|
||||||
|
consumer application creates per-entity docs via `createEntityDoc`) — in a mono-store
|
||||||
|
layout the per-document ReadCap is all-or-nothing.
|
||||||
|
|
||||||
|
### The acceptance test — no cryptography required
|
||||||
|
|
||||||
|
Alice owns a protected document holding a secret and a public one that carries a
|
||||||
|
**reference** to it. Bob, holding the public document's link, reads it, finds the
|
||||||
|
reference, and can NAME the protected document while reading nothing of it —
|
||||||
|
publication is **not recursive**. Charlie, holding the same link plus the protected
|
||||||
|
document's cap (delivered to his inbox), reads through the very same reference. The
|
||||||
|
only difference between them is each of them holds; nobody was named to any
|
||||||
|
registry. And dynamically: the cap lands in Bob's inbox, his client processes it, and
|
||||||
|
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** —
|
||||||
|
which is what makes the cap-surface (shape) / cap-enforcement (enforcement) split honest rather than
|
||||||
|
cosmetic. Proven in `test/cross-user-access.test.ts`.
|
||||||
|
|
||||||
|
> **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
|
||||||
|
> inbox, `store-registry`, `subscribe`, `open-repo`) consult no cap at all — worse,
|
||||||
|
> any wallet can reach any document. That is the subject of
|
||||||
|
> [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||||
|
> Nothing may be claimed "anonymous" or "private" until it lands.
|
||||||
|
|
||||||
### 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`
|
||||||
@@ -330,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 grants are explicit
|
alone: the access unit is the document (`@graph` = repo), and the only acts are
|
||||||
(`open` / `grantRead` / `makePublic`) — for `protected`, the owner issues a directed
|
possession-shaped (`createEntityDoc` files a cap, `inbox.share` delivers one, a public
|
||||||
`grantRead(doc, granteeId)` per identity it wants to share with. Because the consumer
|
store serves one to whoever asks). Because the consumer application writes
|
||||||
application now writes one document per entity (`createEntityDoc` + `open` per entity),
|
one document per entity, the per-document cap discriminates at entity granularity —
|
||||||
the per-document cap discriminates at entity granularity — the target's behaviour.
|
the target's behaviour.
|
||||||
|
|
||||||
The old item-level application-visibility filter (`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 issues directed grants, 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.
|
||||||
@@ -354,7 +466,7 @@ fork the broker ([`fork-inbox-fallback.md`](./fork-inbox-fallback.md)), the lib
|
|||||||
emulates the inbox on the shared wallet:
|
emulates the inbox on the shared wallet:
|
||||||
|
|
||||||
- **Target vs polyfill.** In the target, `post` seals a reference into the owner's
|
- **Target vs polyfill.** In the target, `post` seals a reference into the owner's
|
||||||
native inbox (`inbox_post_link(...)`, a proposed/future API) and the recipient's
|
native inbox — through a JS call that **does not exist and is not announced** — and the recipient's
|
||||||
own verifier unseals each queued message and applies it inline when it processes
|
own verifier unseals each queued message and applies it inline when it processes
|
||||||
its inbox — there is no separate curator or materialization process. Here,
|
its inbox — there is no separate curator or materialization process. Here,
|
||||||
everything is readable, so the lib emulates the read side in-lib.
|
everything is readable, so the lib emulates the read side in-lib.
|
||||||
@@ -377,71 +489,107 @@ emulates the inbox on the shared wallet:
|
|||||||
polls `read` and fires when the deposit count changes (the polyfill has no
|
polls `read` and fires when the deposit count changes (the polyfill has no
|
||||||
reactive inbox subscription). Fires once immediately; returns an unsubscribe.
|
reactive inbox subscription). Fires once immediately; returns an unsubscribe.
|
||||||
|
|
||||||
|
### An inbox BELONGS to a virtual user (2026-07-30)
|
||||||
|
|
||||||
|
`storeRegistry.userInbox(id)` resolves — creating on first sight — the inbox
|
||||||
|
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).
|
||||||
|
The asymmetry that matters:
|
||||||
|
|
||||||
|
- **Depositing into anyone's inbox is open.** It is the ONLY way a link crosses
|
||||||
|
from one wallet to another, and since you cannot discover, it is the bootstrap of
|
||||||
|
the whole reachability graph. A deposit grants the depositor nothing in return —
|
||||||
|
upstream it is an anonymous sealed box.
|
||||||
|
- **Reading an inbox is confined to its owner** (`isOwnInbox`, enforced in `read` /
|
||||||
|
`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
|
||||||
|
owner — defeating directed sharing. Anonymous owns no inbox and reads none.
|
||||||
|
|
||||||
|
At migration this guard disappears into cryptography: an inbox is sealed to its
|
||||||
|
owner's key.
|
||||||
|
|
||||||
The module knows no domain — the consumer application supplies the inbox document
|
The module knows no domain — the consumer application supplies the inbox document
|
||||||
NURI and interprets `payload`. At migration `post` becomes the native
|
NURI and interprets `payload`. At migration `post` becomes the native sealed
|
||||||
`inbox_post_link` (proposed/future) and the read side is served by the recipient's
|
deposit — **whose JS name and signature are not known**, since none is exposed or
|
||||||
own verifier unsealing queued messages inline (see the deferred global-index note in
|
announced — and the read side is served by the recipient's own verifier unsealing
|
||||||
the top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
|
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 — e.g. a registration/deposit in one consumer app and submission to a
|
purposes — a registration/deposit, a cap delivery (`inbox.share`), a link handed to
|
||||||
discovery index — same `post` API, same watcher.
|
someone — same `post` API, same watcher.
|
||||||
|
|
||||||
## Emulated discovery index + special account (`discovery.ts`)
|
## The virtual user boundary (`emulated-verifier/reach.ts` + `shared-wallet/physical.ts`)
|
||||||
|
|
||||||
Discovery is a surface on top of the inbox, not a new primitive. Access is not the
|
Every access function is confined to the user currently connected: no cross-user
|
||||||
same as discovery: a public entity is world-readable *with its NURI*; the discovery
|
access, so the consumer is coded against a reach that will actually exist.
|
||||||
index is how a client learns that NURI exists without holding a relationship
|
**Two rules, one criterion — possession — implemented in two places**, deliberately
|
||||||
to its creator (see [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
|
redundant so a lapse in either is caught by the other:
|
||||||
The model is: one global index = an owned document (public read), fed via
|
|
||||||
its inbox. Nobody writes the index directly — a creator deposits a reference into
|
|
||||||
the index's inbox, and the index is built up from those deposits. That build-up
|
|
||||||
step is the natural dedup / moderation point.
|
|
||||||
|
|
||||||
- **The special account (polyfill owner).** "Who owns the global index" is
|
- **Rule 1, authorization** (`assertMayReach`, at the passage points `docs.sparqlQuery`
|
||||||
undecided in the target (NextGraph is mono-user with no global data — a
|
/ `sparqlUpdate` / `subscribeDoc`): nothing reaches `ng` unless the connected user
|
||||||
singleton app is the only glimpsed path). So the polyfill parks ownership on a
|
possesses that document's cap. It fires on a request that should never have been
|
||||||
reserved special account in the shim — `INDEX_ACCOUNT = reservedAccount("index")`.
|
made, and makes it fail loudly rather than succeed quietly.
|
||||||
This is NOT the key `"index"` / `"@index"`: `reservedAccount` mints a
|
- **Rule 2, do not even attempt** (`mustNotAttempt`, at the callers — `readUnion`
|
||||||
sentinel-prefixed key in the shim's reserved namespace (e.g. `" reserved:index"`)
|
filters before opening or reading, `ensureRepoOpen` returns): a reader holding no
|
||||||
that `normalizeId` can never produce, so no user id — not even one typed as
|
cap does not issue the operation at all. Upstream you cannot even *address* a repo
|
||||||
"index" or "@index", which normalizes to the disjoint key "index" — can collide
|
you have no cap for, so asking is not "a read that will be refused" — it is a read
|
||||||
with or hijack the index account (asserted in `discovery.test.ts`). It is a
|
with no meaning.
|
||||||
normal shim account (so its 3 scope documents are created on first sight like
|
|
||||||
any other), but never a real user; it only hosts the index document. Its
|
|
||||||
`public` scope document is the index document, and its inbox receives the
|
|
||||||
deposits — a stable NURI: every client opening the same shared wallet
|
|
||||||
resolves the same account, hence the same document, so all clients read/write one
|
|
||||||
shared index.
|
|
||||||
- **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable".
|
|
||||||
Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows
|
|
||||||
the inbox convention (bound to the current identity; anonymous when `null`).
|
|
||||||
`ref` is opaque here — the consumer application serializes whatever locates the
|
|
||||||
entity (e.g. an entity document NURI + discovery metadata). Public-only guard: when
|
|
||||||
`opts.doc` names the document being surfaced, a document under a non-public
|
|
||||||
(protected/private) read policy is refused (`caps.governsRead(doc) &&
|
|
||||||
!caps.canRead(doc, null)`) — the global index is world-readable, so admitting a
|
|
||||||
governed doc's NURI would leak it past its scope. Proven in
|
|
||||||
`test/discovery.test.ts` case (d).
|
|
||||||
- **`readIndex()`** — the emulated read side. Reads every submission, dedups by
|
|
||||||
serialized `ref` (the moderation point: a duplicate submission surfaces
|
|
||||||
once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the
|
|
||||||
emulated watcher (polls `readIndex`).
|
|
||||||
|
|
||||||
This replaces the cross-account fan-out (`store-registry.ts`
|
**Possession decides, never the shape of the reference in hand.** A caller
|
||||||
`listEntityDocs('public')` / `resolveReadGraphs`) as the app-facing discovery
|
legitimately holds a bare NURI while possessing its cap elsewhere — references travel
|
||||||
path: the consumer application submits public entities to the index and reads the
|
bare through content and stores, the cap sits in what the user holds.
|
||||||
index, instead of fanning out over every account's public documents. The fan-out
|
|
||||||
survives only as an internal lib fallback — kept for the per-scope listing it also
|
|
||||||
powers (e.g. `resolveReadGraphs`), never the app's discovery route.
|
|
||||||
|
|
||||||
`discovery.ts` knows no application domain — the consumer application defines the
|
The exception is **depositing** into another user's inbox (`register-write.depositInto`, internal): a
|
||||||
`ref` shape and its meaning. At migration the special account disappears:
|
named primitive rather than a flag, because it is a different act — you hold no cap,
|
||||||
ownership moves to the decided global-index owner, `submitToIndex` becomes the
|
you cannot read back, and you get nothing in return. It is the only channel by which
|
||||||
native `inbox_post_link` (proposed/future) on the index's inbox, and `readIndex`
|
a link crosses between users, hence the bootstrap of the whole reachability graph.
|
||||||
queries the real index document. The consumer surface (`submitToIndex` / `readIndex`)
|
|
||||||
is designed to survive that swap unchanged.
|
|
||||||
|
|
||||||
## Emulated write guard (`ng-proxy.ts`)
|
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
|
||||||
|
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
|
||||||
|
never saw.
|
||||||
|
|
||||||
|
## Connecting a user (`emulated-verifier/connect.ts`)
|
||||||
|
|
||||||
|
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 —
|
||||||
|
forgetting would look like "the share did not work" rather than "nobody consumed the
|
||||||
|
queue". So `setCurrentUser` fires `connectedUser()`, which does two things in order:
|
||||||
|
|
||||||
|
1. **Restore** — read back the caps this user already applied (`readLinks`, the
|
||||||
|
emulated `AddLink` records on its User branch) into what it holds. Durable state,
|
||||||
|
one read, no inbox involved.
|
||||||
|
2. **Drain** — process every inbox it may read (`myInboxes`: its own, plus one per
|
||||||
|
document it opened an inbox on), filing any new Link durably.
|
||||||
|
|
||||||
|
Restore-first is what lets a reconnecting user read its shared documents immediately
|
||||||
|
instead of waiting on a queue round-trip.
|
||||||
|
|
||||||
|
**Fire-and-forget, deliberately.** The setter is synchronous and every consumer calls
|
||||||
|
it from synchronous code; making it async would push the wait back onto the app,
|
||||||
|
which is the obligation this removes. The work announces itself through
|
||||||
|
`CapRegistry.onChange` — which `watchShape` already listens to — so a view that was
|
||||||
|
empty for want of a cap re-reads when the cap lands. `connectedUser()` is exported
|
||||||
|
for a caller that needs to await it (tests, a deterministic startup).
|
||||||
|
|
||||||
|
**It does not provision.** Connecting an identity that does not exist creates
|
||||||
|
nothing (`resolveAccount`, not `ensureAccount`): otherwise connecting would mint a
|
||||||
|
user's stores and their caps as a background side effect, arming the whole emulation
|
||||||
|
at a moment nothing controls.
|
||||||
|
|
||||||
|
*Cost worth knowing*: `setCurrentUser` therefore has observable asynchronous effects
|
||||||
|
— it reads, and it logs. Tests asserting on log output must await `connectedUser()`
|
||||||
|
first.
|
||||||
|
|
||||||
|
## ~~Emulated discovery index + special account~~ — REMOVED 2026-07-30
|
||||||
|
|
||||||
|
**There is no discovery in NextGraph. You cannot discover; you can only follow links** (see [`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4ter-bis). Publishing is two acts — place the data in your public store, **and** circulate its link (into an inbox, or into a document the reader already holds) — and it is seen only by those who received the link.
|
||||||
|
|
||||||
|
`discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`), its tests, and `watchShape`'s public-scope fold were **removed**. The module failed on two independent counts: it emulated a capability the target will never have — teaching consumers a model that does not exist — and it was **data common to several wallets**, where nothing may be common but the indexing mechanisms that make the virtual users work.
|
||||||
|
|
||||||
|
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 (`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
|
||||||
@@ -450,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
|
||||||
@@ -468,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.
|
||||||
@@ -491,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).*
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Vision & principles of `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
A **stand-in faithful in SHAPE** to NextGraph's future primitives. **Single** objective: that consumers (Festipod) be **coded against the CORRECT mental model** — the one of finished NextGraph — and have **NOTHING to rewrite** when NextGraph provides the real primitives.
|
||||||
|
|
||||||
|
## What the polyfill is NOT
|
||||||
|
|
||||||
|
A **security** layer. The **shared wallet** (everyone shares the same keys) plus the absence of real crypto make the emulation **infinitely less secure** than a wallet-per-user — it is a **dev/staging vehicle**, not a goal. **Insecurity is ACCEPTED.** An attacker who bypasses the emulation is not our problem.
|
||||||
|
|
||||||
|
## The only criterion: shape-fidelity, with RIGOR
|
||||||
|
|
||||||
|
The **exposed surfaces** must match the **exact SHAPE** of the future primitives, **even where enforcement is simulated**. The **failure mode to avoid**: exposing the **wrong shape** → the consumer codes against a model that will not exist → rewrite. The **ACL** inversion of ReadCaps was exactly that defect (an ACL where the real thing is **key possession**) — a lack of rigor.
|
||||||
|
|
||||||
|
## Simulating crypto to PREVENT shortcuts
|
||||||
|
|
||||||
|
Without a minimum of crypto simulation, damaging shortcuts get taken (reading the plaintext, falling back on ACLs). The polyfill therefore **simulates** the final mechanism, enough to hold this **invariant**:
|
||||||
|
|
||||||
|
> **A `did` (bare id, WITHOUT a ReadCap) and a NURI (WITH a ReadCap) are treated GENUINELY differently: the former does NOT allow reading the data; the latter is SUFFICIENT and REQUIRED.**
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
- **Everything is keys and URLs.** There is **no** notion of membership, role, or authorization list in the model: only symmetric and asymmetric cryptography, URIs, and who holds which key. Any exposed shape that looks like an ACL, a `member`, a `role`, or a `permission` is a **wrong shape**, whatever scaffolding one may otherwise read in the current state of NextGraph.
|
||||||
|
- **Reading = possession of the read key** (ReadCap = `{id, key}`). A bare id (a `did` without a ReadCap) does not read.
|
||||||
|
- **Writing = possession of the write key** — a key **distinct** from the read key, hence a distinct axis, but **possession too**.
|
||||||
|
- **Sharing a cap = sealing it to a recipient** (**durable** delivery, at share time — NOT an ACL re-declared every session).
|
||||||
|
- **Revocation = re-key** (new key; former holders keep the old state). Non-retroactive.
|
||||||
|
- **Cap-less reference** (naming/pointing without reading) **distinct** from the cap-bearing reference.
|
||||||
|
|
||||||
|
See `readcap-and-nuri-model.md` (the real model, verified in `nextgraph-rs`) and `briefs/2026-07-20-caps-emulation-alignment.md` (the alignment effort).
|
||||||
@@ -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,48 +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 capability helpers (`getCaps`, `grantRead`, `canRead`/`canWrite`). It falls away as NextGraph matures. |
|
|
||||||
|
|
||||||
```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 enforcement — a read filter + write guard over emulated grants
|
|
||||||
attached to documents; the app declares a document's read policy and issues
|
|
||||||
directed read grants.
|
|
||||||
- Anticipated methods (inbox `post`, capability ops) with their future-SDK shapes,
|
|
||||||
emulated for now.
|
|
||||||
|
|
||||||
Generic: no application domain. The consumer application injects its shapes and
|
|
||||||
performs the acts of granting access. The relationship concept ("who is connected
|
|
||||||
to whom") is the consumer application's own — the client exposes only directed
|
|
||||||
per-document read grants.
|
|
||||||
@@ -1,243 +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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@ng-eventually/client",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "SDK-identical client wrapper over @ng-org/web + @ng-org/orm with emulated capabilities and inbox. Drop-in; remove at migration.",
|
|
||||||
"main": "./src/index.ts",
|
|
||||||
"types": "./src/index.ts",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts",
|
|
||||||
"./polyfill": "./src/polyfill.ts"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@ng-org/web": "*",
|
|
||||||
"@ng-org/orm": "*",
|
|
||||||
"@ng-org/shex-orm": "*",
|
|
||||||
"@ng-org/alien-deepsignals": "*"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@ng-org/web": { "optional": true },
|
|
||||||
"@ng-org/orm": { "optional": true },
|
|
||||||
"@ng-org/shex-orm": { "optional": true },
|
|
||||||
"@ng-org/alien-deepsignals": { "optional": true }
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@ng-org/web": "0.1.2-alpha.13",
|
|
||||||
"@ng-org/shex-orm": "0.1.2-alpha.8",
|
|
||||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test",
|
|
||||||
"test:e2e": "bun run e2e/run.ts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* access-log — an OFF-by-default observability probe for document access.
|
|
||||||
*
|
|
||||||
* Diagnostic tool for the shared-wallet isolation footgun: on ONE physical
|
|
||||||
* wallet, several virtual identities coexist, and a read must never surface a
|
|
||||||
* document scoped to another identity. When it does (identity B reading identity
|
|
||||||
* A's doc), the leak is invisible in the data — it looks like a normal read. This
|
|
||||||
* probe makes it VISIBLE: every real read/write is logged, prefixed by the ACTIVE
|
|
||||||
* identity (the discriminating virtual identity, NOT the constant physical wallet
|
|
||||||
* id), so replaying the scenario shows the exact line where a doc is accessed
|
|
||||||
* under the wrong identity.
|
|
||||||
*
|
|
||||||
* OFF by default → zero overhead, zero output. Turned on either by the SDK config
|
|
||||||
* option `debugAccessLog: true` (via {@link setAccessLog}) or, without touching
|
|
||||||
* the calling code, by the env var `NG_EVENTUALLY_ACCESS_LOG=1`. The `enabled()`
|
|
||||||
* gate is a single boolean read on the hot path when off.
|
|
||||||
*
|
|
||||||
* Polyfill-era, like the rest of /polyfill; removed at the real multi-store
|
|
||||||
* migration where the broker/verifier enforces isolation natively.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getCurrentUser } from "./polyfill";
|
|
||||||
|
|
||||||
/** Access kind: a document READ or a document WRITE. */
|
|
||||||
export type AccessOp = "READ" | "WRITE";
|
|
||||||
|
|
||||||
// Config-driven toggle (set by configure() via setAccessLog); default OFF.
|
|
||||||
let configEnabled = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Env override: `NG_EVENTUALLY_ACCESS_LOG=1` (or `true`) turns the log on without
|
|
||||||
* a code change in the caller. Read once, tolerant of env access throwing (e.g.
|
|
||||||
* a locked-down runtime), so it never breaks the hot path.
|
|
||||||
*/
|
|
||||||
function envEnabled(): boolean {
|
|
||||||
try {
|
|
||||||
const v = (globalThis as any)?.process?.env?.NG_EVENTUALLY_ACCESS_LOG;
|
|
||||||
return v === "1" || v === "true";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set the config-driven toggle (called from configure()). */
|
|
||||||
export function setAccessLog(on: boolean): void {
|
|
||||||
configEnabled = on;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether access logging is currently on (config OR env). */
|
|
||||||
export function enabled(): boolean {
|
|
||||||
return configEnabled || envEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The identity to prefix an access line with: the ACTIVE virtual identity
|
|
||||||
* (`getCurrentUser`) — the account/space the operation is scoped under, which is
|
|
||||||
* the discriminating signal for the isolation leak. NOT the physical wallet id
|
|
||||||
* (shared, constant → useless). `(none)` when no identity is set yet (startup).
|
|
||||||
*/
|
|
||||||
function activeIdentity(): string {
|
|
||||||
return getCurrentUser() ?? "(none)";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log one document access — but ONLY when {@link enabled}. Off → returns
|
|
||||||
* immediately, prints nothing. Format:
|
|
||||||
* `[<identity>] READ <nuri> (<label>)` — optionally with `<extra>` appended
|
|
||||||
* (e.g. ` → 3 rows`, a strong signal a doc rendered data under an identity that
|
|
||||||
* should see nothing).
|
|
||||||
*/
|
|
||||||
export function logAccess(
|
|
||||||
op: AccessOp,
|
|
||||||
nuri: string,
|
|
||||||
label: string,
|
|
||||||
extra?: string,
|
|
||||||
): void {
|
|
||||||
if (!enabled()) return;
|
|
||||||
console.log(
|
|
||||||
"[" + activeIdentity() + "] " + op + " " + nuri + " (" + label + ")" + (extra ?? ""),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
/**
|
|
||||||
* Capability emulation — generic, with no domain rules. It models NextGraph
|
|
||||||
* ReadCaps (and write caps) as a data layer can.
|
|
||||||
*
|
|
||||||
* In NextGraph a ReadCap is possession of a document's (repo's) read key: the
|
|
||||||
* broker only delivers documents the wallet holds a cap for. The access unit is
|
|
||||||
* therefore the document = repo, identified here by its NURI — the `@graph` an
|
|
||||||
* item lives in, rather than the item. (A store is just a container repo, and
|
|
||||||
* holding a store's cap does not grant the repos it references — each document
|
|
||||||
* carries its own cap — so this registry is purely per-document, with no
|
|
||||||
* store-level inheritance.)
|
|
||||||
*
|
|
||||||
* Sharing here is DIRECTED: a grant issues one grantee the read cap of one
|
|
||||||
* document (`grantRead(doc, granteeId)`). Whether two identities are "connected"
|
|
||||||
* — and therefore whether such a grant should be issued — is an application
|
|
||||||
* concept the consumer owns; this layer only records the resulting per-document
|
|
||||||
* grants. At migration this whole layer disappears: the broker/verifier enforces
|
|
||||||
* the real caps and `useShape` returns only authorized documents.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Nuri, PrincipalId, Scope } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Who holds the read/write cap of each document. The consumer populates it via
|
|
||||||
* cap operations (make-public, directed grant…) exactly as it will in the
|
|
||||||
* target; this layer enforces possession generically, with no policy of its own.
|
|
||||||
*/
|
|
||||||
export class CapRegistry {
|
|
||||||
/** doc NURI → principals holding its READ cap. */
|
|
||||||
private readers = new Map<Nuri, Set<PrincipalId>>();
|
|
||||||
/** doc NURI → principals holding its WRITE cap. */
|
|
||||||
private writers = new Map<Nuri, Set<PrincipalId>>();
|
|
||||||
/** doc NURIs readable by everyone (public_store repos — no cap needed). */
|
|
||||||
private publicDocs = new Set<Nuri>();
|
|
||||||
/** doc NURI → its declared (scope, owner), as recorded at {@link open}. Lets
|
|
||||||
* the consumer re-derive which documents are `protected` and who owns them
|
|
||||||
* (see {@link protectedDocsOf}) so it can issue directed grants, without
|
|
||||||
* re-supplying that per-document — it already declared it at open. */
|
|
||||||
private policy = new Map<Nuri, { scope: Scope; owner: PrincipalId }>();
|
|
||||||
|
|
||||||
/** Grant `grantee` the READ cap of document `doc` — a directed grant. */
|
|
||||||
grantRead(doc: Nuri, grantee: PrincipalId): void {
|
|
||||||
add(this.readers, doc, grantee);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Grant `principal` the WRITE cap of document `doc`. */
|
|
||||||
grantWrite(doc: Nuri, principal: PrincipalId): void {
|
|
||||||
add(this.writers, doc, principal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark `doc` public (readable without a cap — a public_store repo). */
|
|
||||||
makePublic(doc: Nuri): void {
|
|
||||||
this.publicDocs.add(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the caps a creator attaches to a fresh document, by scope. Public →
|
|
||||||
* world-readable; protected/private → only the owner reads. The owner always
|
|
||||||
* holds the write cap. Further sharing is a separate explicit grant.
|
|
||||||
*/
|
|
||||||
open(doc: Nuri, scope: Scope, owner: PrincipalId): void {
|
|
||||||
if (scope === "public") this.makePublic(doc);
|
|
||||||
else this.grantRead(doc, owner);
|
|
||||||
this.grantWrite(doc, owner);
|
|
||||||
this.policy.set(doc, { scope, owner });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `protected` documents owned by `owner`, as recorded at {@link open}. The
|
|
||||||
* consumer uses this to issue directed read grants: it decides who may read an
|
|
||||||
* owner's protected documents (its own relationship concept) and calls
|
|
||||||
* {@link grantRead} on each of these documents for each such reader. Public
|
|
||||||
* documents are already world-readable and private documents stay owner-only,
|
|
||||||
* so only the protected ones are surfaced here.
|
|
||||||
*
|
|
||||||
* This mirrors a native cap operation: in the target, sharing a protected repo
|
|
||||||
* with another identity issues that identity the repo's ReadCap. Here the
|
|
||||||
* consumer selects the documents via this accessor and grants the emulated read
|
|
||||||
* cap on the same unit.
|
|
||||||
*/
|
|
||||||
protectedDocsOf(owner: PrincipalId): Nuri[] {
|
|
||||||
const out: Nuri[] = [];
|
|
||||||
for (const [doc, { scope, owner: o }] of this.policy) {
|
|
||||||
if (scope === "protected" && o === owner) out.push(doc);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is `doc` under any READ-cap policy? (Undeclared docs are not enforced.) */
|
|
||||||
governsRead(doc: Nuri): boolean {
|
|
||||||
return this.publicDocs.has(doc) || this.readers.has(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Does `principal` hold a READ cap for `doc` (or is `doc` public)? */
|
|
||||||
canRead(doc: Nuri, principal: PrincipalId | null): boolean {
|
|
||||||
if (this.publicDocs.has(doc)) return true;
|
|
||||||
if (principal === null) return false;
|
|
||||||
return this.readers.get(doc)?.has(principal) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is `doc` under any WRITE-cap policy? */
|
|
||||||
governsWrite(doc: Nuri): boolean {
|
|
||||||
return this.writers.has(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(doc)?.has(principal) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** No READ policy declared → the read filter stays inert (passthrough). */
|
|
||||||
hasReadPolicy(): boolean {
|
|
||||||
return this.readers.size > 0 || this.publicDocs.size > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** No WRITE policy declared → the write guard stays inert (passthrough). */
|
|
||||||
hasWritePolicy(): boolean {
|
|
||||||
return this.writers.size > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.readers.clear();
|
|
||||||
this.writers.clear();
|
|
||||||
this.publicDocs.clear();
|
|
||||||
this.policy.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function add(m: Map<Nuri, Set<PrincipalId>>, doc: Nuri, principal: PrincipalId): void {
|
|
||||||
let s = m.get(doc);
|
|
||||||
if (!s) m.set(doc, (s = new Set()));
|
|
||||||
s.add(principal);
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
/**
|
|
||||||
* discovery — a GENERIC discovery-index surface, reusing the ONE deposit +
|
|
||||||
* materialization mechanism (`inbox.ts`). GENERIC by construction: this module
|
|
||||||
* knows no application domain (no event, no meeting-point). The consumer submits
|
|
||||||
* an opaque reference and interprets the entries it reads back.
|
|
||||||
*
|
|
||||||
* ── The mechanism (see docs/decisions/discovery-model.md) ─────────────────
|
|
||||||
* Access and discovery are separate concerns. A public entity is world-readable
|
|
||||||
* with its NURI; the discovery index is how a client learns that NURI exists
|
|
||||||
* without holding a grant to read its creator's other documents. There is one
|
|
||||||
* global index — an owned document (public read), fed via its own inbox. A
|
|
||||||
* creator deposits a reference into the index's inbox; reading the index folds
|
|
||||||
* those deposits into entries, deduplicating identical references along the way.
|
|
||||||
*
|
|
||||||
* ── The special account (polyfill owner) ──────────────────────────────────
|
|
||||||
* Ownership of a truly global index is undecided in the real platform, where an
|
|
||||||
* identity's apps and services see only what that identity shares. The polyfill
|
|
||||||
* therefore parks ownership on a reserved special account in the shim
|
|
||||||
* ({@link INDEX_ACCOUNT}). Its `public` scope document is the index document;
|
|
||||||
* deposits land in that document's inbox (a stable NURI: every client opening the
|
|
||||||
* same shared wallet resolves the same account, so the same document). This is
|
|
||||||
* the app-facing discovery path, in place of a cross-account fan-out
|
|
||||||
* (`store-registry.ts` `listEntityDocs`), which survives only as an internal
|
|
||||||
* fallback (see {@link readIndex}).
|
|
||||||
*
|
|
||||||
* ── Real target vs this emulation ─────────────────────────────────────────
|
|
||||||
* The intended real shape is: `submitToIndex` seals a reference into the index
|
|
||||||
* document's own inbox (a future `inbox_post_link`), and reading the index is a
|
|
||||||
* query on the materialized index document. Here, everything runs in-lib on the
|
|
||||||
* shared wallet (deposit via `inbox.post`, fold via `inbox.read`). Against real
|
|
||||||
* NextGraph the special account gives way to the decided global-index owner and
|
|
||||||
* `readIndex` points at that document; the consumer surface (`submitToIndex` /
|
|
||||||
* `readIndex`) is designed to survive that change unchanged.
|
|
||||||
*
|
|
||||||
* All NextGraph I/O routes through `inbox.ts` (which routes through the `docs`
|
|
||||||
* primitives, the real injected `ng`), so this module imports no `@ng-org`
|
|
||||||
* package.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as inbox from "./inbox";
|
|
||||||
import { subscribeDoc } from "./subscribe";
|
|
||||||
import { ensureAccount, reservedAccount } from "./store-registry";
|
|
||||||
import { getCaps } from "./polyfill";
|
|
||||||
import type { Nuri, PrincipalId } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The reserved special account that owns the global discovery index in the
|
|
||||||
* polyfill. It hosts the index document but is never a real identity. It lives in
|
|
||||||
* the registry's reserved namespace ({@link reservedAccount}), whose key
|
|
||||||
* `normalizeId` can never produce, so an id of "index"/"@index" cannot hijack it
|
|
||||||
* (it normalizes to "index", a disjoint key). Removed against real NextGraph
|
|
||||||
* (see file header).
|
|
||||||
*/
|
|
||||||
export const INDEX_ACCOUNT = reservedAccount("index");
|
|
||||||
|
|
||||||
/** One entry as materialized from the discovery index. */
|
|
||||||
export interface IndexEntry {
|
|
||||||
/** The reference submitted by a creator (opaque — the consumer interprets it). */
|
|
||||||
ref: unknown;
|
|
||||||
/** The submitter, if identified; `null` when the submission was anonymous. */
|
|
||||||
from: PrincipalId | null;
|
|
||||||
/** Submission timestamp (ms epoch). */
|
|
||||||
ts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options for {@link submitToIndex}. */
|
|
||||||
export interface SubmitOptions {
|
|
||||||
/**
|
|
||||||
* Who is submitting. Omit for the current identity, or pass `null` for an
|
|
||||||
* anonymous submission. `from` is bound to the current identity by the inbox
|
|
||||||
* (naming another identity is rejected as a spoof — see {@link inbox.post}).
|
|
||||||
*/
|
|
||||||
from?: PrincipalId | null;
|
|
||||||
/**
|
|
||||||
* The NURI of the document being made discoverable. When given, the index
|
|
||||||
* admits only a public document: one under a non-public (protected/private)
|
|
||||||
* read policy is refused, so the world-readable index never exposes a governed
|
|
||||||
* document's NURI. Omit it only for a ref with no addressable document (rare);
|
|
||||||
* a governed document passes it so the guard can fire.
|
|
||||||
*/
|
|
||||||
doc?: Nuri;
|
|
||||||
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
|
|
||||||
* keeps tests deterministic. */
|
|
||||||
ts?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the NURI of the index document — the stable inbox where discovery
|
|
||||||
* submissions land. The special account owns this document (its `public` scope
|
|
||||||
* document, a real repo NURI from `docCreate`); deposits go into that document's
|
|
||||||
* inbox exactly as host-registration deposits go into a host inbox. Because the
|
|
||||||
* special account lives in the shim (persisted in the shared wallet's private
|
|
||||||
* store), EVERY client opening the same wallet resolves the same account → the
|
|
||||||
* same document NURI → ONE shared index for all clients. Distinct from
|
|
||||||
* host-registration inboxes because it is a distinct document NURI.
|
|
||||||
*/
|
|
||||||
async function indexInboxNuri(): Promise<Nuri> {
|
|
||||||
// Ensure the special account exists (idempotent) so its scope documents are
|
|
||||||
// created and stably resolvable across clients.
|
|
||||||
const record = await ensureAccount(INDEX_ACCOUNT);
|
|
||||||
return record.docPublic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a reference to the global discovery index — the SDK act "make this
|
|
||||||
* discoverable". Deposits `ref` into the index document's inbox via
|
|
||||||
* {@link inbox.post}; reading the index ({@link readIndex}) folds it into an
|
|
||||||
* entry. `ref` is opaque here (the consumer serializes whatever a client needs to
|
|
||||||
* later locate the entity — e.g. an entity document NURI plus discovery metadata).
|
|
||||||
* `from` follows the inbox convention (anonymous when `null`).
|
|
||||||
*
|
|
||||||
* When `opts.doc` names the document being surfaced, a document under a
|
|
||||||
* non-public read policy (protected/private) is refused: the global index is
|
|
||||||
* world-readable, so admitting a governed document's NURI would expose it past
|
|
||||||
* its scope.
|
|
||||||
*/
|
|
||||||
export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise<void> {
|
|
||||||
const doc = opts?.doc;
|
|
||||||
if (doc !== undefined) {
|
|
||||||
const caps = getCaps();
|
|
||||||
// A governed doc is submittable ONLY if it is public (anonymous may read it).
|
|
||||||
if (caps.governsRead(doc) && !caps.canRead(doc, null)) {
|
|
||||||
throw new Error(
|
|
||||||
"[ng-eventually] submitToIndex: only PUBLIC documents may be submitted to " +
|
|
||||||
"the discovery index — a protected/private document must not be surfaced.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const target = await indexInboxNuri();
|
|
||||||
await inbox.post(target, {
|
|
||||||
payload: ref,
|
|
||||||
...(opts && "from" in opts ? { from: opts.from } : {}),
|
|
||||||
...(opts?.ts !== undefined ? { ts: opts.ts } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the global discovery index. Reads every submission from the index inbox,
|
|
||||||
* deduplicates by serialized `ref` (a duplicate submission surfaces once — the
|
|
||||||
* discovery model's moderation point), and returns the entries sorted by `ts`
|
|
||||||
* ascending. Against real NextGraph this becomes a query on the materialized
|
|
||||||
* index document.
|
|
||||||
*/
|
|
||||||
export async function readIndex(): Promise<IndexEntry[]> {
|
|
||||||
const target = await indexInboxNuri();
|
|
||||||
const deposits = await inbox.read(target);
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const entries: IndexEntry[] = [];
|
|
||||||
for (const d of deposits) {
|
|
||||||
// Dedup on the serialized reference — the materialization moderation point.
|
|
||||||
const key = JSON.stringify(d.payload ?? null);
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
entries.push({ ref: d.payload, from: d.from, ts: d.ts });
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watch the discovery index — **event-driven, not polled**. Subscribes to the
|
|
||||||
* index document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
|
|
||||||
* `onEntries` fires once on the initial state push and again on every subsequent
|
|
||||||
* change to the index document — a local submission OR a broker-synced remote one.
|
|
||||||
* Returns an unsubscribe. (Deduplication is applied on each read.)
|
|
||||||
*
|
|
||||||
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
|
|
||||||
* there is no polling. The index is a single document, so this is immune to the
|
|
||||||
* ORM fan-out hang (see {@link subscribeDoc}).
|
|
||||||
*/
|
|
||||||
export function watchIndex(
|
|
||||||
onEntries: (entries: IndexEntry[]) => void,
|
|
||||||
_opts?: { intervalMs?: number },
|
|
||||||
): () => void {
|
|
||||||
let stopped = false;
|
|
||||||
let lastCount = -1;
|
|
||||||
let unsubscribe: (() => void) | null = null;
|
|
||||||
|
|
||||||
const refresh = async (): Promise<void> => {
|
|
||||||
if (stopped) return;
|
|
||||||
try {
|
|
||||||
const entries = await readIndex();
|
|
||||||
if (!stopped && entries.length !== lastCount) {
|
|
||||||
lastCount = entries.length;
|
|
||||||
onEntries(entries);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[discovery] watchIndex read failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// The index document NURI is resolved async (ensureAccount); subscribe once it
|
|
||||||
// is known. The initial State push fires the first read (onEntries fires once),
|
|
||||||
// each later Patch a re-read.
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const anchor = await indexInboxNuri();
|
|
||||||
if (stopped) return;
|
|
||||||
unsubscribe = subscribeDoc(anchor, () => void refresh());
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[discovery] watchIndex subscribe failed:", error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stopped = true;
|
|
||||||
if (unsubscribe) {
|
|
||||||
unsubscribe();
|
|
||||||
unsubscribe = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* Low-level document + SPARQL primitives.
|
|
||||||
*
|
|
||||||
* These call the real injected `ng` (`getConfig().ng`) directly — never the
|
|
||||||
* public `ng` proxy (`makeNg`). This is a validated hard constraint, not a style
|
|
||||||
* choice: the public `ng` is a JS `Proxy` over `@ng-org/web`'s iframe-RPC proxy,
|
|
||||||
* and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling
|
|
||||||
* with **`DataCloneError: function ... could not be cloned`** — the footgun this
|
|
||||||
* rule exists to prevent. Reaching the real `ng` held in the config avoids the
|
|
||||||
* double-proxy. Do not import from `./ng-proxy`.
|
|
||||||
*
|
|
||||||
* Signatures mirror the real `@ng-org/web` `ng` surface (verified against the
|
|
||||||
* app's storeRegistry usage), so this is a drop-in for those raw calls.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getConfig } from "./polyfill";
|
|
||||||
import { logAccess, enabled as accessLogEnabled } from "./access-log";
|
|
||||||
import type { Nuri } from "./types";
|
|
||||||
|
|
||||||
// The low common point for ALL document access: every read in the SDK routes
|
|
||||||
// through `sparqlQuery`, every write through `sparqlUpdate` (+ container creation
|
|
||||||
// through `docCreate`) — each ultimately calling the real injected `ng` here. The
|
|
||||||
// access log is therefore instrumented HERE so no access path escapes it. Callers
|
|
||||||
// pass a semantic `label` (readDoc|readUnion|listMyEntityDocs|writeEntity|deposit
|
|
||||||
// |…); it is a lib-internal probe param, NOT forwarded to the real `ng` (the docs
|
|
||||||
// primitives forward the exact SDK signature — see test/docs.test.ts). When the
|
|
||||||
// log is OFF (default) the extra param is inert and costs one boolean read.
|
|
||||||
|
|
||||||
/** Count rows in a raw SPARQL SELECT result, tolerant of the possible shapes. */
|
|
||||||
function rowCount(result: unknown): number {
|
|
||||||
if (!result) return 0;
|
|
||||||
if (Array.isArray(result)) return result.length;
|
|
||||||
const anyRes = result as { results?: { bindings?: unknown[] } };
|
|
||||||
return anyRes.results?.bindings?.length ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create one document → its NURI.
|
|
||||||
*
|
|
||||||
* Mirrors `ng.doc_create(session_id, crdt, cls, dest, store_repo?)`. For a graph
|
|
||||||
* document in the (shared) private store: `docCreate(sid, "Graph", "data:graph",
|
|
||||||
* "store")` (store_repo left undefined → private store).
|
|
||||||
*/
|
|
||||||
export async function docCreate(
|
|
||||||
sessionId: string,
|
|
||||||
crdt: string,
|
|
||||||
cls: string,
|
|
||||||
dest: string,
|
|
||||||
store?: unknown,
|
|
||||||
): Promise<Nuri> {
|
|
||||||
const { ng } = getConfig();
|
|
||||||
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
|
|
||||||
// A container creation is a WRITE; the NURI only exists after the call.
|
|
||||||
logAccess("WRITE", nuri, "docCreate");
|
|
||||||
return nuri;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.).
|
|
||||||
*
|
|
||||||
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
|
|
||||||
* document NURI the update is scoped/base'd to (optional).
|
|
||||||
*/
|
|
||||||
export async function sparqlUpdate(
|
|
||||||
sessionId: string,
|
|
||||||
query: string,
|
|
||||||
anchor?: Nuri,
|
|
||||||
label = "sparqlUpdate",
|
|
||||||
): Promise<void> {
|
|
||||||
const { ng } = getConfig();
|
|
||||||
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
|
|
||||||
logAccess("WRITE", anchor ?? "(no anchor)", label);
|
|
||||||
return ng.sparql_update(sessionId, query, anchor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
|
|
||||||
*
|
|
||||||
* Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the
|
|
||||||
* query base IRI (usually `undefined`); `anchor` is the document NURI to query.
|
|
||||||
*/
|
|
||||||
export async function sparqlQuery(
|
|
||||||
sessionId: string,
|
|
||||||
query: string,
|
|
||||||
base?: string,
|
|
||||||
anchor?: Nuri,
|
|
||||||
label = "sparqlQuery",
|
|
||||||
): Promise<unknown> {
|
|
||||||
const { ng } = getConfig();
|
|
||||||
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
|
|
||||||
const result = await ng.sparql_query(sessionId, query, base, anchor);
|
|
||||||
// Log AFTER the read so the row count (a strong leak signal: a doc rendering
|
|
||||||
// rows under an identity that should see nothing) can be appended. Skip the
|
|
||||||
// rowCount work entirely when the log is off.
|
|
||||||
if (accessLogEnabled()) {
|
|
||||||
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " rows");
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,236 +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. A future `inbox_post_link` is the intended way to seal a link into an
|
|
||||||
* inbox from the sender side; it is not exposed yet.
|
|
||||||
*
|
|
||||||
* 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 the sealed-inbox path (`inbox_post_link`) is available.
|
|
||||||
*
|
|
||||||
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
|
|
||||||
* never `makeNg`), so this module imports no `@ng-org` package.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { sparqlUpdate, sparqlQuery } from "./docs";
|
|
||||||
import { subscribeDoc } from "./subscribe";
|
|
||||||
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
|
||||||
import { escapeLiteral } from "./sparql";
|
|
||||||
import type { Nuri, PrincipalId } 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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} .
|
|
||||||
}`;
|
|
||||||
await sparqlUpdate(sid, update, targetInbox, "deposit");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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`.
|
|
||||||
*/
|
|
||||||
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
|
||||||
const sid = await sessionId();
|
|
||||||
// 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);
|
|
||||||
return deposits;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Alias for {@link read} — the name that reads as "process the inbox now". */
|
|
||||||
export const materialize = read;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
if (!stopped && deposits.length !== lastCount) {
|
|
||||||
lastCount = deposits.length;
|
|
||||||
onDeposits(deposits);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[inbox] 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.
|
|
||||||
const unsubscribe = subscribeDoc(targetInbox, () => void refresh());
|
|
||||||
return () => {
|
|
||||||
stopped = true;
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* @ng-eventually/client — **SDK-identical** surface.
|
|
||||||
*
|
|
||||||
* This entry exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose (current +
|
|
||||||
* anticipated: `inbox`). Import `ng` / `useShape` from here instead of the SDK
|
|
||||||
* during the polyfill period; at migration the build alias is removed and these
|
|
||||||
* resolve to the real SDK with **no code change**.
|
|
||||||
*
|
|
||||||
* The one non-SDK piece — the polyfill bootstrap (`configure`, capability
|
|
||||||
* helpers, current user) — lives at `@ng-eventually/client/polyfill`, and is the
|
|
||||||
* only thing removed at migration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./types";
|
|
||||||
export { useShape } from "./use-shape";
|
|
||||||
export { init, initNg } from "./lifecycle";
|
|
||||||
export * as inbox from "./inbox";
|
|
||||||
export * as discovery from "./discovery";
|
|
||||||
export type { IndexEntry, SubmitOptions } from "./discovery";
|
|
||||||
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";
|
|
||||||
export type { AccountRecord, RegistrySession } from "./store-registry";
|
|
||||||
export * as accounts from "./accounts";
|
|
||||||
export type { AccountStorage } from "./accounts";
|
|
||||||
|
|
||||||
// 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";
|
|
||||||
|
|
||||||
// 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): inbox_post_link + 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,152 +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, PrincipalId } from "./types";
|
|
||||||
import type { RegistrySession } from "./store-registry";
|
|
||||||
import { CapRegistry } from "./caps";
|
|
||||||
import { setAccessLog } from "./access-log";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
/**
|
|
||||||
* ANTI-FORK budget for account resolution (polyfill-era). Before provisioning
|
|
||||||
* a "missing" account, the registry re-reads its shim record a bounded number
|
|
||||||
* of times with backoff, so a record merely lagging by broker sync (fresh
|
|
||||||
* session over a persistent wallet) is FOUND and REUSED instead of triggering
|
|
||||||
* a second set of scope docs (the account fork). See the note in
|
|
||||||
* store-registry.ts. Only if every attempt still reads 0 do we provision.
|
|
||||||
*
|
|
||||||
* Default (production): a real budget (~8 attempts / ≲8.5s). Set `attempts: 1`
|
|
||||||
* to disable the retry entirely — appropriate for a SYNCHRONOUS in-memory
|
|
||||||
* store (the unit fake `ng`) where a 0-row read is authoritative and the
|
|
||||||
* backoff would only add dead time; there is no sync lag to wait out there.
|
|
||||||
*/
|
|
||||||
provisionRetry?: { 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;
|
|
||||||
let registryDeps: Required<StoreRegistryDeps> | null = null;
|
|
||||||
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
|
|
||||||
* while it has no read policy the read filter passes through (no regression). */
|
|
||||||
let caps = new CapRegistry();
|
|
||||||
|
|
||||||
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 {
|
|
||||||
registryDeps = {
|
|
||||||
getSession: deps.getSession,
|
|
||||||
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
|
|
||||||
// Production default: a real anti-fork budget. Consumers over a synchronous
|
|
||||||
// store pass `{ attempts: 1 }` to opt out (see StoreRegistryDeps).
|
|
||||||
provisionRetry: {
|
|
||||||
attempts: deps.provisionRetry?.attempts ?? 8,
|
|
||||||
baseMs: deps.provisionRetry?.baseMs ?? 300,
|
|
||||||
maxStepMs: deps.provisionRetry?.maxStepMs ?? 1500,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal — used by the storeRegistry to reach its injected dependencies. */
|
|
||||||
export function getStoreRegistryDeps(): Required<StoreRegistryDeps> {
|
|
||||||
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 {
|
|
||||||
currentUser = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCurrentUser(): PrincipalId | null {
|
|
||||||
return currentUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The emulated cap registry — the app opens a document's read policy and issues
|
|
||||||
* directed read grants on it (as it will via real cap operations in the target).
|
|
||||||
* The read filter consults it. */
|
|
||||||
export function getCaps(): CapRegistry {
|
|
||||||
return caps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset all emulated caps (mainly for tests / fresh sessions). */
|
|
||||||
export function resetCaps(): void {
|
|
||||||
caps = new CapRegistry();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap surface — polyfill-era (caps are emulated now; native at migration).
|
|
||||||
// Re-exported here so the whole polyfill API lives under /polyfill.
|
|
||||||
export { CapRegistry } from "./caps";
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* Read filter — the polyfill of capability-based read access.
|
|
||||||
*
|
|
||||||
* In the target, the broker only delivers documents the user holds a **ReadCap**
|
|
||||||
* for, 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) the current user may read, 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CapRegistry } from "./caps";
|
|
||||||
import type { PrincipalId } from "./types";
|
|
||||||
|
|
||||||
/** The document (repo NURI) an item lives in — its `@graph`. */
|
|
||||||
function docOf(item: unknown): string | null {
|
|
||||||
const g = (item as Record<string, unknown> | null)?.["@graph"];
|
|
||||||
return typeof g === "string" ? g : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* May `user` read this item? An item with no `@graph`, or in a document under no
|
|
||||||
* cap policy, is KEPT (the filter only restricts documents that DECLARE a cap —
|
|
||||||
* mirrors the prior behavior and keeps ungoverned data flowing).
|
|
||||||
*/
|
|
||||||
function readable(item: unknown, caps: CapRegistry, user: PrincipalId | null): boolean {
|
|
||||||
const doc = docOf(item);
|
|
||||||
if (doc === null) return true;
|
|
||||||
if (!caps.governsRead(doc)) return true;
|
|
||||||
return caps.canRead(doc, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pure: keep only the items the user may read. */
|
|
||||||
export function filterReadable<T>(
|
|
||||||
items: Iterable<T>,
|
|
||||||
caps: CapRegistry,
|
|
||||||
user: PrincipalId | null,
|
|
||||||
): T[] {
|
|
||||||
const out: T[] = [];
|
|
||||||
for (const item of items) if (readable(item, caps, user)) 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. The current user is read lazily (via
|
|
||||||
* `getUser`) so the view reflects the user in effect at read time.
|
|
||||||
*/
|
|
||||||
export function makeReadFilteredView<S extends object>(
|
|
||||||
set: S,
|
|
||||||
caps: CapRegistry,
|
|
||||||
getUser: () => PrincipalId | null,
|
|
||||||
): S {
|
|
||||||
const keep = (item: unknown): boolean => readable(item, caps, getUser());
|
|
||||||
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,588 +0,0 @@
|
|||||||
/**
|
|
||||||
* storeRegistry — resolves (account, scope) → document NURI.
|
|
||||||
*
|
|
||||||
* Stopgap / polyfill-era. Emulates the target infrastructure — where each
|
|
||||||
* user owns their own public/protected/private stores — on top of one shared
|
|
||||||
* wallet. It creates one document per (account × scope) inside that shared
|
|
||||||
* wallet (via the `docs.docCreate` primitive), so the `scope`
|
|
||||||
* (`public|protected|private`) is a logical attribute tracked here, not a
|
|
||||||
* physical NextGraph store. Isolation is enforced by the app layer + the
|
|
||||||
* emulated cap registry, not by crypto.
|
|
||||||
*
|
|
||||||
* The mapping (account → its 3 document NURIs) is the **sharedWalletShim**,
|
|
||||||
* persisted as RDF in the shared wallet's private store (the anchor, always
|
|
||||||
* known from the session). That makes login cross-device: another device
|
|
||||||
* opening the same wallet reads the same shim and finds the same accounts.
|
|
||||||
*
|
|
||||||
* ── Generic by construction ──────────────────────────────────────────────
|
|
||||||
* This module knows only the three native scopes; it knows no application
|
|
||||||
* entity kind. The consumer maps its entities to a scope and calls
|
|
||||||
* `createEntityDoc(scope)` / `listEntityDocs(scope)` with the resulting native
|
|
||||||
* scope. No application domain here.
|
|
||||||
*
|
|
||||||
* ── What disappears at migration ─────────────────────────────────────────
|
|
||||||
* At the real multi-store migration the shim vanishes entirely: `(account,
|
|
||||||
* scope)` maps to the user's REAL store NURI instead of a document in the
|
|
||||||
* shared wallet, `docCreate` targets the real per-user store, and the
|
|
||||||
* per-scope index document (the store-container emulation) is replaced by the
|
|
||||||
* store itself. The consumer-facing surface (`createEntityDoc`,
|
|
||||||
* `listEntityDocs`, resolvers) is designed to survive that swap unchanged.
|
|
||||||
*
|
|
||||||
* All NextGraph I/O routes through the T01.a `docs` primitive (real injected
|
|
||||||
* `ng`), so this module imports **no** `@ng-org` package.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
|
||||||
import { getStoreRegistryDeps } from "./polyfill";
|
|
||||||
import { ensureRepoOpen } from "./open-repo";
|
|
||||||
|
|
||||||
// --- provisioning anti-fork guard: barrier-first (polyfill-era) ------------
|
|
||||||
//
|
|
||||||
// The shim (account→docs trust root) lives in the shared wallet's PRIVATE store.
|
|
||||||
// On a fresh session over a persistent wallet, that repo may not have synced yet
|
|
||||||
// from the broker: a targeted account resolve would then read 0 rows even though
|
|
||||||
// the account WAS persisted in an earlier session. If ensureAccount took that 0
|
|
||||||
// at face value it would RE-PROVISION a second set of scope documents — an account
|
|
||||||
// FORK: one session writes/reads one set, another (or the same after a cache drop)
|
|
||||||
// the other, EMPTY set → the user "loses" their data on reconnect.
|
|
||||||
//
|
|
||||||
// Guard (barrier-first): BEFORE reading the shim, open/subscribe the private-store
|
|
||||||
// repo and AWAIT its first `State` push — the deterministic sync barrier (CONTRACT 3
|
|
||||||
// in e2e/). After the barrier, presence is GUARANTEED and absence DEFINITIVE. Then
|
|
||||||
// read the shim ONCE: 0 rows = account genuinely absent → provision exactly once;
|
|
||||||
// rows present = account found → reuse it (NO-FORK). No retry loop, no poll.
|
|
||||||
//
|
|
||||||
// Timed-out barrier (conservative): if `ensureRepoOpen` exits via the 8-second
|
|
||||||
// fallback timeout (getSyncState → "timed-out") the sync is UNCONFIRMED — we do NOT
|
|
||||||
// provision blindly (that would re-fork). Instead we surface a clear error so the
|
|
||||||
// caller can retry the full flow rather than silently creating a duplicate account.
|
|
||||||
// The timeout is rare (pathological broker) and a hard error there is far safer than
|
|
||||||
// a silent fork. Disappears at the real multi-store migration (per-user store NURIs
|
|
||||||
// make this moot — barrier becomes the native store-open which is always confirmed).
|
|
||||||
import { getSyncState } from "./open-repo";
|
|
||||||
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
|
||||||
import type { Nuri, Scope } from "./types";
|
|
||||||
|
|
||||||
// --- sharedWalletShim model ----------------------------------------------
|
|
||||||
|
|
||||||
/** One account's three scope-document NURIs, as recorded in the shim. */
|
|
||||||
export interface AccountRecord {
|
|
||||||
id: string;
|
|
||||||
docPublic: Nuri;
|
|
||||||
docProtected: Nuri;
|
|
||||||
docPrivate: Nuri;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHIM = "urn:ng-eventually:shim";
|
|
||||||
const P = {
|
|
||||||
type: `${SHIM}:Account`,
|
|
||||||
id: `${SHIM}:id`,
|
|
||||||
docPublic: `${SHIM}:docPublic`,
|
|
||||||
docProtected: `${SHIM}:docProtected`,
|
|
||||||
docPrivate: `${SHIM}:docPrivate`,
|
|
||||||
contains: `${SHIM}:contains`, // scope-index → entity document NURI
|
|
||||||
} as const;
|
|
||||||
// Fixed subject of the per-(account×scope) index document. The index doc plays
|
|
||||||
// the role of the future store-container: it lists the NURIs of the entity
|
|
||||||
// documents (one per entity) that live "in" that scope.
|
|
||||||
const INDEX_SUBJECT = `${SHIM}:index`;
|
|
||||||
|
|
||||||
function accountSubject(id: string): string {
|
|
||||||
// The id is UNTRUSTED and lands in an IRI position. Percent-encode it
|
|
||||||
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
|
|
||||||
// the `<...>` and inject triples into the shim graph (the account→doc trust
|
|
||||||
// root). accountKey() runs first so the subject stays stable per shim key.
|
|
||||||
return `${SHIM}:account:${escapeIri(accountKey(id))}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- reserved accounts -----------------------------------------------------
|
|
||||||
//
|
|
||||||
// Some accounts are internal to the lib (e.g. the discovery index owner) and
|
|
||||||
// must NOT collide with any user-chosen id. A reserved account is created
|
|
||||||
// via {@link reservedAccount}, which marks the name with a sentinel PREFIX that
|
|
||||||
// `normalizeId` (consumer-injected) can never produce: it strips a leading
|
|
||||||
// `@`, trims, and lowercases, so a NUL prefix is unreachable. Reserved
|
|
||||||
// keys therefore live in a disjoint namespace from every normalized id —
|
|
||||||
// a real user named "index"/"@index" can never resolve to the reserved
|
|
||||||
// `reservedAccount("index")` account.
|
|
||||||
const RESERVED_PREFIX = "\u0000reserved:";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap an internal account name so it occupies a key that no user input can
|
|
||||||
* produce (see {@link RESERVED_PREFIX}). Pass the result to {@link ensureAccount}
|
|
||||||
* (and the other registry calls) instead of a bare id.
|
|
||||||
*/
|
|
||||||
export function reservedAccount(name: string): string {
|
|
||||||
return `${RESERVED_PREFIX}${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */
|
|
||||||
function isReserved(id: string): boolean {
|
|
||||||
return id.startsWith(RESERVED_PREFIX);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The shim/cache key for an account. Reserved accounts bypass `normalizeId`
|
|
||||||
* entirely and key on their sentinel-prefixed name, so they cannot collide with
|
|
||||||
* a normalized id; everyone else normalizes as usual.
|
|
||||||
*/
|
|
||||||
function accountKey(id: string): string {
|
|
||||||
return isReserved(id) ? id : normalize(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- session / normalization access (injected by the consumer) ------------
|
|
||||||
|
|
||||||
/** Minimal session shape the registry needs — provided by the consumer. */
|
|
||||||
export interface RegistrySession {
|
|
||||||
sessionId: string;
|
|
||||||
/** The shared wallet's private store id — the shim anchor. */
|
|
||||||
privateStoreId: string;
|
|
||||||
/** The shared wallet's protected store id (native store). Optional: only the
|
|
||||||
* scope resolvers need it; the shim only needs the private anchor. */
|
|
||||||
protectedStoreId?: string;
|
|
||||||
/** The shared wallet's public store id (native store). Optional. */
|
|
||||||
publicStoreId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(id: string): string {
|
|
||||||
return getStoreRegistryDeps().normalizeId(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function session(): Promise<RegistrySession> {
|
|
||||||
return getStoreRegistryDeps().getSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The shim lives in the shared wallet's private store (always-known anchor). */
|
|
||||||
async function anchorNuri(): Promise<Nuri> {
|
|
||||||
const s = await session();
|
|
||||||
return `did:ng:${s.privateStoreId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- cache ----------------------------------------------------------------
|
|
||||||
|
|
||||||
// In-memory cache of the FULL shim (all accounts), keyed by account key. Set
|
|
||||||
// only once loadShim() has read every account — used by the all-accounts paths.
|
|
||||||
let cache: Map<string, AccountRecord> | null = null;
|
|
||||||
|
|
||||||
// Per-account cache, keyed by account key. Populated by the TARGETED resolver
|
|
||||||
// (resolveAccount) and by loadShim(). Independent of `cache` so a single
|
|
||||||
// targeted resolve never forces a full shim scan. Both are cleared together.
|
|
||||||
const accountCache = new Map<string, AccountRecord>();
|
|
||||||
|
|
||||||
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
|
|
||||||
export function resetRegistryCache(): void {
|
|
||||||
cache = null;
|
|
||||||
accountCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SPARQL result helpers ------------------------------------------------
|
|
||||||
|
|
||||||
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
|
||||||
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
|
|
||||||
if (!result) return [];
|
|
||||||
const anyRes = result as {
|
|
||||||
results?: { bindings?: Array<Record<string, { value: string }>> };
|
|
||||||
};
|
|
||||||
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
|
|
||||||
if (anyRes.results?.bindings) return anyRes.results.bindings;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindingValue(row: Record<string, { value: string }>, key: string): string {
|
|
||||||
return row[key]?.value ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- shim load / account bootstrap ----------------------------------------
|
|
||||||
|
|
||||||
/** Load all accounts from the shim into the cache. */
|
|
||||||
export async function loadShim(): Promise<Map<string, AccountRecord>> {
|
|
||||||
if (cache) return cache;
|
|
||||||
const s = await session();
|
|
||||||
const anchor = await anchorNuri();
|
|
||||||
const query = `
|
|
||||||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
|
||||||
GRAPH <${assertNuri(anchor)}> {
|
|
||||||
?acc a <${P.type}> ;
|
|
||||||
<${P.id}> ?id ;
|
|
||||||
<${P.docPublic}> ?docPublic ;
|
|
||||||
<${P.docProtected}> ?docProtected ;
|
|
||||||
<${P.docPrivate}> ?docPrivate .
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
const map = new Map<string, AccountRecord>();
|
|
||||||
try {
|
|
||||||
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "loadShim");
|
|
||||||
for (const row of readBindings(result)) {
|
|
||||||
const id = bindingValue(row, "id");
|
|
||||||
if (!id) continue;
|
|
||||||
const key = accountKey(id);
|
|
||||||
const record: AccountRecord = {
|
|
||||||
id,
|
|
||||||
docPublic: bindingValue(row, "docPublic"),
|
|
||||||
docProtected: bindingValue(row, "docProtected"),
|
|
||||||
docPrivate: bindingValue(row, "docPrivate"),
|
|
||||||
};
|
|
||||||
map.set(key, record);
|
|
||||||
// Feed the per-account cache too, so a subsequent targeted resolve is free.
|
|
||||||
accountCache.set(key, record);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[storeRegistry] loadShim failed:", error);
|
|
||||||
}
|
|
||||||
cache = map;
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
|
|
||||||
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
|
|
||||||
* the account record at its known subject (`accountSubject(id)`) directly,
|
|
||||||
* instead of scanning EVERY account like {@link loadShim}. Returns the account's
|
|
||||||
* record or `null` if it does not exist yet.
|
|
||||||
*
|
|
||||||
* Cached per account (in `accountCache`); a hit skips the query entirely, so
|
|
||||||
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
|
|
||||||
*/
|
|
||||||
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
|
|
||||||
const key = accountKey(id);
|
|
||||||
const cached = accountCache.get(key);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const s = await session();
|
|
||||||
const anchor = await anchorNuri();
|
|
||||||
// `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a
|
|
||||||
// trusted-shaped NURI → assertNuri. The query is bounded to this one subject.
|
|
||||||
const subj = accountSubject(id);
|
|
||||||
const query = `
|
|
||||||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
|
||||||
GRAPH <${assertNuri(anchor)}> {
|
|
||||||
<${subj}> a <${P.type}> ;
|
|
||||||
<${P.id}> ?id ;
|
|
||||||
<${P.docPublic}> ?docPublic ;
|
|
||||||
<${P.docProtected}> ?docProtected ;
|
|
||||||
<${P.docPrivate}> ?docPrivate .
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
try {
|
|
||||||
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "resolveAccount");
|
|
||||||
const rows = readBindings(result);
|
|
||||||
if (rows.length === 0) return null;
|
|
||||||
const row = rows[0]!;
|
|
||||||
const record: AccountRecord = {
|
|
||||||
id: bindingValue(row, "id") || id,
|
|
||||||
docPublic: bindingValue(row, "docPublic"),
|
|
||||||
docProtected: bindingValue(row, "docProtected"),
|
|
||||||
docPrivate: bindingValue(row, "docPrivate"),
|
|
||||||
};
|
|
||||||
accountCache.set(key, record);
|
|
||||||
return record;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[storeRegistry] resolveAccount failed:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve ONE account after waiting for the private-store sync barrier, so a
|
|
||||||
* 0-row result is DEFINITIVE (account genuinely absent) rather than ambiguous
|
|
||||||
* (broker lag). This is the ANTI-FORK guard for the polyfill-era shim.
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. Cache hit → return immediately (session-idempotent, no I/O).
|
|
||||||
* 2. Open/subscribe the private-store repo (`did:ng:${privateStoreId}`) and
|
|
||||||
* await the first `State` push — the deterministic sync barrier (CONTRACT 3).
|
|
||||||
* After the barrier, presence is guaranteed and absence definitive.
|
|
||||||
* 3. If the barrier TIMED-OUT (getSyncState → "timed-out"), the sync is
|
|
||||||
* unconfirmed — provisioning here would risk a fork. Throw a clear error
|
|
||||||
* instead so the caller can retry the full login flow. This is the
|
|
||||||
* conservative-safe choice: a hard error is recoverable; a silent fork is not.
|
|
||||||
* 4. Read the shim ONCE. 0 rows = genuinely absent → caller provisions exactly
|
|
||||||
* once. Rows present → reuse (NO-FORK preserved).
|
|
||||||
*
|
|
||||||
* With the unit fake `ng` (no `doc_subscribe`), `ensureRepoOpen` is a no-op
|
|
||||||
* (getSyncState → "unknown") and the single read is immediate — synchronous
|
|
||||||
* behaviour preserved, no lag to wait out.
|
|
||||||
*/
|
|
||||||
async function resolveAccountReliably(id: string): Promise<AccountRecord | null> {
|
|
||||||
// Cache hit → session-idempotent, no query, no fork risk.
|
|
||||||
const cached = accountCache.get(accountKey(id));
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
// The shim lives in the private store. Open/subscribe it and await the first
|
|
||||||
// `State` push (the sync barrier). NURI: `did:ng:${session.privateStoreId}`.
|
|
||||||
const nuri = await anchorNuri();
|
|
||||||
await ensureRepoOpen(nuri);
|
|
||||||
|
|
||||||
// Conservative timed-out guard: if the barrier did not confirm sync,
|
|
||||||
// provisioning blind risks creating a fork. Raise a clear error — the caller
|
|
||||||
// (or the app's retry-login flow) should re-attempt when the broker is reachable.
|
|
||||||
// "unknown" means the fake-ng no-op path: no barrier semantics → safe to read.
|
|
||||||
const syncResult = getSyncState(nuri);
|
|
||||||
if (syncResult === "timed-out") {
|
|
||||||
throw new Error(
|
|
||||||
"[storeRegistry] sync barrier timed out for the private store — " +
|
|
||||||
"shim state unconfirmed, refusing to provision to avoid account fork. " +
|
|
||||||
"Retry when the broker is reachable.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Barrier reached (or fake-ng "unknown" path): single read, result is definitive.
|
|
||||||
return resolveAccount(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All known accounts (from the shim). */
|
|
||||||
export async function allAccounts(): Promise<AccountRecord[]> {
|
|
||||||
return [...(await loadShim()).values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create one graph document in the shared wallet's private store (→ a NURI). */
|
|
||||||
async function createDoc(): Promise<Nuri> {
|
|
||||||
const s = await session();
|
|
||||||
// crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store",
|
|
||||||
// store_repo=undefined → shared wallet's private store.
|
|
||||||
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure an account exists in the shim, creating its 3 scope documents on
|
|
||||||
* first sight. Idempotent — returns the existing record if already present.
|
|
||||||
*/
|
|
||||||
export async function ensureAccount(id: string): Promise<AccountRecord> {
|
|
||||||
const key = accountKey(id);
|
|
||||||
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
|
|
||||||
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
|
|
||||||
//
|
|
||||||
// ANTI-FORK (polyfill-era): open the private-store repo and await its first
|
|
||||||
// `State` push (the sync barrier) BEFORE reading the shim, so a 0-row result
|
|
||||||
// is DEFINITIVE rather than a lag artifact. Only after the barrier do we treat
|
|
||||||
// 0 rows as "genuinely new" and provision. A cache hit inside
|
|
||||||
// resolveAccountReliably keeps same-session resolves free and deterministic
|
|
||||||
// (the cached record wins over any re-provision). Throws if the barrier timed
|
|
||||||
// out — safer than provisioning blind and creating a fork.
|
|
||||||
const existing = await resolveAccountReliably(id);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
|
||||||
createDoc(),
|
|
||||||
createDoc(),
|
|
||||||
createDoc(),
|
|
||||||
]);
|
|
||||||
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
|
|
||||||
|
|
||||||
const s = await session();
|
|
||||||
const anchor = await anchorNuri();
|
|
||||||
const subj = accountSubject(id);
|
|
||||||
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
|
|
||||||
// trusted-shaped NURI → assertNuri. `id` is UNTRUSTED text in a LITERAL
|
|
||||||
// position → escapeLiteral. The doc NURIs come from `ng` but are stored as
|
|
||||||
// literals here, so they are escaped as literals too (defence in depth).
|
|
||||||
const update = `
|
|
||||||
INSERT DATA {
|
|
||||||
GRAPH <${assertNuri(anchor)}> {
|
|
||||||
<${subj}> a <${P.type}> ;
|
|
||||||
<${P.id}> "${escapeLiteral(id)}" ;
|
|
||||||
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
|
|
||||||
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
|
|
||||||
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
try {
|
|
||||||
await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[storeRegistry] ensureAccount persist failed:", error);
|
|
||||||
}
|
|
||||||
// Feed the per-account cache, and the full-shim cache if it is already loaded
|
|
||||||
// (so allAccounts / the fan-out see the freshly-created account too).
|
|
||||||
accountCache.set(key, record);
|
|
||||||
cache?.set(key, record);
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- resolvers ------------------------------------------------------------
|
|
||||||
|
|
||||||
/** The index document NURI of an account for a scope (the store-container). */
|
|
||||||
function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
|
|
||||||
return scope === "public"
|
|
||||||
? record.docPublic
|
|
||||||
: scope === "protected"
|
|
||||||
? record.docProtected
|
|
||||||
: record.docPrivate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NURI of the document where `id` writes GROUPED entities of `scope` (a single
|
|
||||||
* per-scope index document, for entities that need no per-entity document / no
|
|
||||||
* inbox). For per-entity scopes use {@link createEntityDoc} instead.
|
|
||||||
*/
|
|
||||||
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
|
|
||||||
const record = await ensureAccount(id);
|
|
||||||
return indexDocOf(record, scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** NURIs of every account's document for `scope` (read fan-out). */
|
|
||||||
export async function resolveReadGraphs(scope: Scope): Promise<Nuri[]> {
|
|
||||||
const accounts = await allAccounts();
|
|
||||||
return accounts.map((a) => indexDocOf(a, scope));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ----------
|
|
||||||
//
|
|
||||||
// The consumer asks by SCOPE ("give me the graph to write/read entities of
|
|
||||||
// scope X", "give me the inbox anchor") and NEVER constructs a `did:ng:${…}`
|
|
||||||
// store NURI itself. The lib owns the physical placement — which is the whole
|
|
||||||
// point of the SDK boundary. In THIS polyfill the placement is the shared
|
|
||||||
// wallet's native stores (Axis A, per the two-axes doctrine in
|
|
||||||
// docs/simulation.md): a scope maps to a native store NURI resolved from the
|
|
||||||
// injected session. `public` currently co-locates with `protected` because
|
|
||||||
// `doc_create`/ORM cannot target a non-private/protected native store today
|
|
||||||
// (the SDK blocker recorded in migration-guide.md); at migration each scope
|
|
||||||
// resolves to the user's REAL per-scope store and this mapping changes here,
|
|
||||||
// in the lib, with no consumer change.
|
|
||||||
|
|
||||||
/** The native store NURI backing `scope`, resolved from the injected session.
|
|
||||||
* Requires `protectedStoreId` on the session for the non-private scopes. */
|
|
||||||
async function scopeStoreNuri(scope: Scope): Promise<Nuri> {
|
|
||||||
const s = await session();
|
|
||||||
if (scope === "private") return `did:ng:${s.privateStoreId}`;
|
|
||||||
// public + protected → the protected native store (see note above). Falls
|
|
||||||
// back to the private store if the session didn't carry a protected id.
|
|
||||||
const store = s.protectedStoreId ?? s.privateStoreId;
|
|
||||||
return `did:ng:${store}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The graph NURI where the current session WRITES entities of `scope`, and
|
|
||||||
* whose repo `useShape` must subscribe to read them back. SDK-shaped: the
|
|
||||||
* consumer passes a logical scope and gets an opaque graph NURI — it holds no
|
|
||||||
* store-id and builds no NURI. Use the returned value as both the read scope
|
|
||||||
* (`useShape(shape, nuri)`) and the `@graph` write target.
|
|
||||||
*/
|
|
||||||
export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
|
|
||||||
return scopeStoreNuri(scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The reserved account that OWNS the shared registration-inbox document. Like the
|
|
||||||
* discovery index's special account, it lives in the reserved namespace (no user
|
|
||||||
* can produce this key) and only HOSTS a document — its `public` scope document is
|
|
||||||
* the inbox anchor. Disappears at migration (native per-document inboxes).
|
|
||||||
*/
|
|
||||||
const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The inbox anchor NURI for the current session (where emulated inbox deposits
|
|
||||||
* physically land). SDK-shaped: the consumer never resolves a store itself.
|
|
||||||
*
|
|
||||||
* This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document —
|
|
||||||
* a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
|
|
||||||
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the
|
|
||||||
* account→document trust root) lives in the private-store graph and is scanned on
|
|
||||||
* every `loadShim`; routing every inbox deposit into that SAME graph bloats it
|
|
||||||
* without bound (thousands of deposit triples across sessions), turning `loadShim`
|
|
||||||
* into a multi-second full-graph scan. A separate inbox document keeps the shim
|
|
||||||
* graph small and the deposits isolated. At migration this becomes the host's
|
|
||||||
* native per-document inbox and the resolution moves here.
|
|
||||||
*/
|
|
||||||
export async function resolveInboxAnchor(): Promise<Nuri> {
|
|
||||||
const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
|
|
||||||
return record.docPublic;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- per-entity documents + per-scope index -------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a dedicated document for ONE entity — mirrors the target, where each
|
|
||||||
* such entity is its own document/repo (addressable, future inbox). The new
|
|
||||||
* document's NURI is appended to the account's scope index document (the
|
|
||||||
* store-container). Returns the entity document NURI (use it as `@graph`).
|
|
||||||
*/
|
|
||||||
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
|
||||||
const record = await ensureAccount(id);
|
|
||||||
const indexDoc = indexDocOf(record, scope);
|
|
||||||
const entityNuri = await createDoc();
|
|
||||||
const s = await session();
|
|
||||||
try {
|
|
||||||
await sparqlUpdate(
|
|
||||||
s.sessionId,
|
|
||||||
// NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the
|
|
||||||
// `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the
|
|
||||||
// anchored default-graph read queries (readScopeIndex below, same as
|
|
||||||
// read-model.ts). Not a round-trip necessity on the current broker: the e2e
|
|
||||||
// harness (`packages/client/e2e/`) verified an anchored `GRAPH <plainNuri>`
|
|
||||||
// write ALSO round-trips here (same repo graph, no phantom graph); no-GRAPH
|
|
||||||
// is kept as a simplicity/safety convention. entityNuri is a NURI stored as
|
|
||||||
// a literal → escapeLiteral.
|
|
||||||
`INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
|
|
||||||
indexDoc,
|
|
||||||
"createEntityDoc",
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[storeRegistry] createEntityDoc index append failed:", error);
|
|
||||||
}
|
|
||||||
return entityNuri;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the entity-document NURIs contained in ONE scope index document. */
|
|
||||||
async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
|
|
||||||
const s = await session();
|
|
||||||
const out: Nuri[] = [];
|
|
||||||
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
|
|
||||||
// scope-index repo (public OR protected — the protected one carries participations
|
|
||||||
// and is the one that most often reads empty) is not yet in `self.repos`, so this
|
|
||||||
// anchored read would return 0 NURIs → nothing gets listed → nothing gets
|
|
||||||
// subscribed (the self-inflicted circularity). Open/subscribe the index repo ONCE
|
|
||||||
// before reading it. Idempotent per session; no-op with the unit fake ng. See
|
|
||||||
// open-repo.ts.
|
|
||||||
await ensureRepoOpen(indexDoc);
|
|
||||||
try {
|
|
||||||
const res = await sparqlQuery(
|
|
||||||
s.sessionId,
|
|
||||||
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see
|
|
||||||
// the note in createEntityDoc). The `indexDoc` anchor scopes the query.
|
|
||||||
`SELECT ?e WHERE { <${INDEX_SUBJECT}> <${P.contains}> ?e }`,
|
|
||||||
undefined,
|
|
||||||
indexDoc,
|
|
||||||
"readScopeIndex",
|
|
||||||
);
|
|
||||||
for (const row of readBindings(res)) {
|
|
||||||
const v = bindingValue(row, "e");
|
|
||||||
if (v) out.push(v);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[storeRegistry] readScopeIndex read failed:", error);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every entity document NURI of `scope`, across all accounts — the read
|
|
||||||
* fan-out for per-entity scopes. Reads each account's scope index document and
|
|
||||||
* unions the contained NURIs. Use as `useShape(shape, { graphs })`.
|
|
||||||
*
|
|
||||||
* NOTE (read-by-need): this ALL-ACCOUNTS fan-out contradicts the read-by-need
|
|
||||||
* model (docs/read-model.md) — it opens/syncs other accounts' possibly-unsynced
|
|
||||||
* docs, which HANGS. Prefer {@link listMyEntityDocs} (my own account's scope
|
|
||||||
* docs) for "my entities", and the discovery index for "all public events".
|
|
||||||
* Retained for callers that legitimately need every account (tests).
|
|
||||||
*/
|
|
||||||
export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
|
|
||||||
const accounts = await allAccounts();
|
|
||||||
const out: Nuri[] = [];
|
|
||||||
for (const a of accounts) {
|
|
||||||
out.push(...(await readScopeIndex(indexDocOf(a, scope))));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The entity-document NURIs of `scope` belonging to ONE account (`id`) —
|
|
||||||
* the read-by-need path for one account's own entities. Bounded to a SINGLE
|
|
||||||
* account: it resolves only that account's scope index doc (via `ensureAccount`)
|
|
||||||
* and reads the contained NURIs — NO cross-account fan-out, so it never touches
|
|
||||||
* another account's unsynced docs. This is the helper a consumer application uses
|
|
||||||
* for its own my-entities path, instead of the all-accounts `listEntityDocs`.
|
|
||||||
*/
|
|
||||||
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
|
|
||||||
const record = await ensureAccount(id);
|
|
||||||
return readScopeIndex(indexDocOf(record, scope));
|
|
||||||
}
|
|
||||||
@@ -1,187 +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 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 {
|
|
||||||
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,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generic, NextGraph-shaped types. ZERO application domain.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** A NextGraph URI (document / store / inbox). */
|
|
||||||
export type Nuri = 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,17 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wrapped `useShape`: same signature as `@ng-org/orm`. When a read-cap policy is
|
|
||||||
* declared, the returned set is a read-filtered VIEW (only items in documents the
|
|
||||||
* current user holds a ReadCap for); otherwise it passes the real set through
|
|
||||||
* unchanged. At migration the filtering disappears — the broker only delivers
|
|
||||||
* authorized documents.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getConfig, getCurrentUser, getCaps } from "./polyfill";
|
|
||||||
import { makeReadFilteredView } from "./read-filter";
|
|
||||||
|
|
||||||
export function useShape(shapeType: unknown, scope: unknown): unknown {
|
|
||||||
const set = getConfig().useShape(shapeType, scope) as object;
|
|
||||||
const caps = getCaps();
|
|
||||||
if (!caps.hasReadPolicy()) return set; // no policy configured → passthrough
|
|
||||||
return makeReadFilteredView(set, caps, getCurrentUser);
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
/**
|
|
||||||
* anti-fork.test.ts — behavioral tests for the ANTI-FORK guard in ensureAccount /
|
|
||||||
* resolveAccountReliably (src/store-registry.ts).
|
|
||||||
*
|
|
||||||
* The guard is: BEFORE reading the shim, open the private-store repo and await
|
|
||||||
* the first `State` push (the deterministic sync barrier, CONTRACT 3). After the
|
|
||||||
* barrier, 0 rows is DEFINITIVE (account genuinely absent) → provision exactly
|
|
||||||
* once; rows present → reuse (NO-FORK). No retry loop, no polling.
|
|
||||||
*
|
|
||||||
* With the unit fake `ng` (no `doc_subscribe`), `ensureRepoOpen` is a no-op
|
|
||||||
* (getSyncState → "unknown") and the single shim read is immediate — no lag to
|
|
||||||
* wait out, synchronous behaviour preserved.
|
|
||||||
*
|
|
||||||
* Timed-out barrier: if the barrier expires without a `State`, we refuse to
|
|
||||||
* provision (throwing a clear error) rather than risk a fork.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
|
||||||
import {
|
|
||||||
ensureAccount,
|
|
||||||
resetRegistryCache,
|
|
||||||
} from "../src/store-registry";
|
|
||||||
import type { RegistrySession } from "../src/store-registry";
|
|
||||||
import {
|
|
||||||
configure,
|
|
||||||
configureStoreRegistry,
|
|
||||||
resetStoreRegistry,
|
|
||||||
resetConfig,
|
|
||||||
} from "../src/polyfill";
|
|
||||||
import { resetOpenedRepos, _forceOpenedSyncState } from "../src/open-repo";
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
resetConfig();
|
|
||||||
resetStoreRegistry();
|
|
||||||
resetRegistryCache();
|
|
||||||
resetOpenedRepos();
|
|
||||||
});
|
|
||||||
|
|
||||||
const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" };
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fake ng — simple in-memory store (no doc_subscribe → fake-ng no-op path)
|
|
||||||
//
|
|
||||||
// Queries return data from the `quads` array immediately. No lag simulation:
|
|
||||||
// the barrier mechanism (ensureRepoOpen) is a no-op in the fake-ng path, so
|
|
||||||
// the single shim read after "the barrier" is already authoritative.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface Quad { g: string; s: string; p: string; o: string }
|
|
||||||
|
|
||||||
function unescapeLiteral(s: string): string {
|
|
||||||
let out = "";
|
|
||||||
for (let i = 0; i < s.length; i++) {
|
|
||||||
if (s[i] === "\\" && i + 1 < s.length) {
|
|
||||||
const next = s[++i];
|
|
||||||
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
|
|
||||||
} else {
|
|
||||||
out += s[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFakeNg() {
|
|
||||||
const quads: Quad[] = [];
|
|
||||||
let docCounter = 0;
|
|
||||||
let accountQueryCount = 0;
|
|
||||||
|
|
||||||
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
|
||||||
|
|
||||||
const sparql_update = mock(async (...a: unknown[]) => {
|
|
||||||
const query = a[1] as string;
|
|
||||||
const anchor = a[2] as string | undefined;
|
|
||||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
|
||||||
let g: string;
|
|
||||||
let body: string;
|
|
||||||
if (gm) {
|
|
||||||
g = gm[1]!;
|
|
||||||
body = gm[2]!;
|
|
||||||
} else {
|
|
||||||
if (!anchor) return undefined;
|
|
||||||
g = anchor;
|
|
||||||
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
|
||||||
}
|
|
||||||
const sm = body.match(/<([^>]+)>/);
|
|
||||||
if (!sm) return undefined;
|
|
||||||
const s = sm[1]!;
|
|
||||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
|
||||||
while ((m = pairRe.exec(after)) !== null) {
|
|
||||||
const p = m[1] ?? "urn:ng-eventually:shim:Account";
|
|
||||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
|
||||||
quads.push({ g, s, p, o });
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sparql_query = mock(async (...a: unknown[]) => {
|
|
||||||
const query = a[1] as string;
|
|
||||||
const anchor = a[3] as string | undefined;
|
|
||||||
|
|
||||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
|
||||||
// Account SELECT (resolveAccount / loadShim)
|
|
||||||
accountQueryCount++;
|
|
||||||
|
|
||||||
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
|
||||||
const onlySubject = subjM ? subjM[1]! : null;
|
|
||||||
const bySubject = new Map<string, Record<string, string>>();
|
|
||||||
for (const q of quads) {
|
|
||||||
if (q.g !== anchor) continue;
|
|
||||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
|
||||||
const rec = bySubject.get(q.s) ?? {};
|
|
||||||
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
|
||||||
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
|
||||||
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
|
||||||
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
|
||||||
bySubject.set(q.s, rec);
|
|
||||||
}
|
|
||||||
const bindings = [...bySubject.values()]
|
|
||||||
.filter((r) => r.id)
|
|
||||||
.map((r) => ({
|
|
||||||
id: { value: r.id! },
|
|
||||||
docPublic: { value: r.docPublic ?? "" },
|
|
||||||
docProtected: { value: r.docProtected ?? "" },
|
|
||||||
docPrivate: { value: r.docPrivate ?? "" },
|
|
||||||
}));
|
|
||||||
return { results: { bindings } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entity-index SELECT
|
|
||||||
const bindings = quads
|
|
||||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
|
||||||
.map((q) => ({ e: { value: q.o } }));
|
|
||||||
return { results: { bindings } };
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
doc_create,
|
|
||||||
sparql_update,
|
|
||||||
sparql_query,
|
|
||||||
_quads: quads,
|
|
||||||
getAccountQueryCount: () => accountQueryCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function inject(fakeNg: ReturnType<typeof makeFakeNg>) {
|
|
||||||
// No doc_subscribe → ensureRepoOpen is a no-op (fake-ng path).
|
|
||||||
configure({ ng: fakeNg as any, useShape: (() => {}) as any });
|
|
||||||
configureStoreRegistry({
|
|
||||||
getSession: async () => SESSION,
|
|
||||||
normalizeId: (u) => u.trim().toLowerCase(),
|
|
||||||
// provisionRetry is now unused by the barrier mechanism; kept for API compat.
|
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
|
||||||
resetRegistryCache();
|
|
||||||
resetOpenedRepos();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("anti-fork: barrier-first resolveAccountReliably / ensureAccount", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
resetRegistryCache();
|
|
||||||
resetOpenedRepos();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(a) NO-FORK: account already in shim → reused, 0 new doc_create", async () => {
|
|
||||||
// Provision an account in an initial "session" (quads are written).
|
|
||||||
const fakeNg = makeFakeNg();
|
|
||||||
inject(fakeNg);
|
|
||||||
const first = await ensureAccount("LauraBarrier");
|
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
|
||||||
|
|
||||||
// Simulate a fresh session: clear caches but keep quads intact (same fakeNg).
|
|
||||||
// In the barrier model the single post-barrier read finds the account immediately.
|
|
||||||
resetRegistryCache();
|
|
||||||
resetOpenedRepos();
|
|
||||||
|
|
||||||
const second = await ensureAccount("LauraBarrier");
|
|
||||||
|
|
||||||
// ANTI-FORK: same scope docs returned, no new provisioning
|
|
||||||
expect(second.docPublic).toBe(first.docPublic);
|
|
||||||
expect(second.docProtected).toBe(first.docProtected);
|
|
||||||
expect(second.docPrivate).toBe(first.docPrivate);
|
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); // still exactly 3, not 6
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(b) genuinely new account (fake returns 0 rows) → provisioned exactly once (3 doc_create)", async () => {
|
|
||||||
const fakeNg = makeFakeNg(); // empty quads → 0 rows on any account query
|
|
||||||
inject(fakeNg);
|
|
||||||
|
|
||||||
const rec = await ensureAccount("BrandNewUser");
|
|
||||||
|
|
||||||
// Exactly 3 doc_create calls (1 set of scope docs, no fork)
|
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
|
||||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
|
||||||
expect(rec.docProtected).not.toBe(rec.docPublic);
|
|
||||||
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
|
||||||
// Single read — no retry loop
|
|
||||||
expect(fakeNg.getAccountQueryCount()).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(c) idempotence within a session: calling ensureAccount twice never creates 2 sets", async () => {
|
|
||||||
const fakeNg = makeFakeNg();
|
|
||||||
inject(fakeNg);
|
|
||||||
|
|
||||||
const a = await ensureAccount("SameUser");
|
|
||||||
const b = await ensureAccount("SameUser");
|
|
||||||
|
|
||||||
expect(b).toEqual(a);
|
|
||||||
// Must still be exactly 3 (not 6)
|
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(d) fake-ng no-op barrier path: single shim read, no doc_subscribe calls", async () => {
|
|
||||||
// The fake ng has no doc_subscribe → ensureRepoOpen is a no-op (getSyncState → "unknown").
|
|
||||||
// resolveAccountReliably must proceed to the single read without waiting or throwing.
|
|
||||||
const fakeNg = makeFakeNg(); // no doc_subscribe
|
|
||||||
inject(fakeNg);
|
|
||||||
|
|
||||||
// Provision once, then verify a second resolve (fresh cache) finds it immediately.
|
|
||||||
const first = await ensureAccount("FakeNgUser");
|
|
||||||
resetRegistryCache();
|
|
||||||
resetOpenedRepos();
|
|
||||||
|
|
||||||
const second = await ensureAccount("FakeNgUser");
|
|
||||||
|
|
||||||
// Same docs reused (no fork), exactly 3 total doc_create across both calls
|
|
||||||
expect(second.docPublic).toBe(first.docPublic);
|
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
|
||||||
// The account query was called exactly twice (once per ensureAccount, no retries)
|
|
||||||
expect(fakeNg.getAccountQueryCount()).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(e) timed-out barrier: resolveAccountReliably throws rather than provisioning", async () => {
|
|
||||||
// Simulate: the private store nuri is already in the `opened` set but with
|
|
||||||
// sync state "timed-out" (forced via _forceOpenedSyncState so the test
|
|
||||||
// does not have to wait 8s for the real OPEN_TIMEOUT_MS to fire).
|
|
||||||
const fakeNg = makeFakeNg(); // empty quads → 0 rows
|
|
||||||
inject(fakeNg);
|
|
||||||
|
|
||||||
// Force the private-store nuri ("did:ng:PRIV-AF") into timed-out state.
|
|
||||||
// resolveAccountReliably calls anchorNuri() → `did:ng:${privateStoreId}`.
|
|
||||||
_forceOpenedSyncState("did:ng:PRIV-AF", "timed-out");
|
|
||||||
|
|
||||||
// ensureAccount must throw (conservative anti-fork guard: do not provision blind)
|
|
||||||
await expect(ensureAccount("TimedOutUser")).rejects.toThrow(
|
|
||||||
/sync barrier timed out/,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Must NOT have created any scope docs (refusing to provision)
|
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { test, expect } from "bun:test";
|
|
||||||
import { CapRegistry } from "../src/caps";
|
|
||||||
|
|
||||||
test("public documents are readable by anyone, even anonymous", () => {
|
|
||||||
const caps = new CapRegistry();
|
|
||||||
caps.open("did:ng:o:pub", "public", "alice");
|
|
||||||
expect(caps.canRead("did:ng:o:pub", null)).toBe(true);
|
|
||||||
expect(caps.canRead("did:ng:o:pub", "bob")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("protected documents: owner + explicitly granted principals only", () => {
|
|
||||||
const caps = new CapRegistry();
|
|
||||||
caps.open("did:ng:o:prot", "protected", "alice");
|
|
||||||
expect(caps.canRead("did:ng:o:prot", "alice")).toBe(true);
|
|
||||||
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(false);
|
|
||||||
caps.grantRead("did:ng:o:prot", "bob"); // a directed grant issues bob the read cap
|
|
||||||
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("private documents: owner only", () => {
|
|
||||||
const caps = new CapRegistry();
|
|
||||||
caps.open("did:ng:o:priv", "private", "alice");
|
|
||||||
expect(caps.canRead("did:ng:o:priv", "alice")).toBe(true);
|
|
||||||
expect(caps.canRead("did:ng:o:priv", "bob")).toBe(false);
|
|
||||||
expect(caps.canRead("did:ng:o:priv", null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("protectedDocsOf surfaces an owner's protected documents for directed grants", () => {
|
|
||||||
const caps = new CapRegistry();
|
|
||||||
caps.open("did:ng:o:prot1", "protected", "alice");
|
|
||||||
caps.open("did:ng:o:prot2", "protected", "alice");
|
|
||||||
caps.open("did:ng:o:pub", "public", "alice"); // not protected → excluded
|
|
||||||
caps.open("did:ng:o:priv", "private", "alice"); // not protected → excluded
|
|
||||||
caps.open("did:ng:o:bob", "protected", "bob"); // other owner → excluded
|
|
||||||
expect(caps.protectedDocsOf("alice").sort()).toEqual([
|
|
||||||
"did:ng:o:prot1",
|
|
||||||
"did:ng:o:prot2",
|
|
||||||
]);
|
|
||||||
expect(caps.protectedDocsOf("bob")).toEqual(["did:ng:o:bob"]);
|
|
||||||
expect(caps.protectedDocsOf("carol")).toEqual([]);
|
|
||||||
// A directed grant on one of them makes the reader read that doc only.
|
|
||||||
caps.grantRead("did:ng:o:prot1", "carol");
|
|
||||||
expect(caps.canRead("did:ng:o:prot1", "carol")).toBe(true);
|
|
||||||
expect(caps.canRead("did:ng:o:prot2", "carol")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("write is restricted to write-cap holders; the creator always holds it", () => {
|
|
||||||
const caps = new CapRegistry();
|
|
||||||
caps.open("did:ng:o:pub", "public", "alice");
|
|
||||||
expect(caps.canWrite("did:ng:o:pub", "alice")).toBe(true);
|
|
||||||
expect(caps.canWrite("did:ng:o:pub", "bob")).toBe(false);
|
|
||||||
expect(caps.canWrite("did:ng:o:pub", null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("holding a document's cap does NOT grant another document (no inheritance)", () => {
|
|
||||||
const caps = new CapRegistry();
|
|
||||||
caps.grantRead("did:ng:o:doc1", "alice");
|
|
||||||
expect(caps.canRead("did:ng:o:doc1", "alice")).toBe(true);
|
|
||||||
expect(caps.canRead("did:ng:o:doc2", "alice")).toBe(false); // separate repo, separate cap
|
|
||||||
});
|
|
||||||
|
|
||||||
test("governsRead / hasReadPolicy distinguish governed from ungoverned documents", () => {
|
|
||||||
const caps = new CapRegistry();
|
|
||||||
expect(caps.hasReadPolicy()).toBe(false);
|
|
||||||
caps.grantRead("did:ng:o:doc1", "alice");
|
|
||||||
expect(caps.hasReadPolicy()).toBe(true);
|
|
||||||
expect(caps.governsRead("did:ng:o:doc1")).toBe(true);
|
|
||||||
expect(caps.governsRead("did:ng:o:unknown")).toBe(false); // not declared → not enforced
|
|
||||||
});
|
|
||||||
|
|
||||||
test("governsWrite / hasWritePolicy distinguish governed from ungoverned documents", () => {
|
|
||||||
const caps = new CapRegistry();
|
|
||||||
expect(caps.hasWritePolicy()).toBe(false);
|
|
||||||
caps.open("did:ng:o:doc1", "private", "alice"); // owner gets the write cap
|
|
||||||
expect(caps.hasWritePolicy()).toBe(true);
|
|
||||||
expect(caps.governsWrite("did:ng:o:doc1")).toBe(true);
|
|
||||||
expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced
|
|
||||||
// A public doc grants read to all but its write cap is still owner-only.
|
|
||||||
const pub = new CapRegistry();
|
|
||||||
pub.open("did:ng:o:pub", "public", "alice");
|
|
||||||
expect(pub.hasWritePolicy()).toBe(true);
|
|
||||||
expect(pub.governsWrite("did:ng:o:pub")).toBe(true);
|
|
||||||
});
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
|
||||||
import { submitToIndex, readIndex, watchIndex, INDEX_ACCOUNT } from "../src/discovery";
|
|
||||||
import type { IndexEntry } from "../src/discovery";
|
|
||||||
import {
|
|
||||||
configure,
|
|
||||||
configureStoreRegistry,
|
|
||||||
resetStoreRegistry,
|
|
||||||
resetConfig,
|
|
||||||
setCurrentUser,
|
|
||||||
getCaps,
|
|
||||||
resetCaps,
|
|
||||||
} from "../src/polyfill";
|
|
||||||
import { resetRegistryCache, ensureAccount } from "../src/store-registry";
|
|
||||||
import type { RegistrySession } from "../src/store-registry";
|
|
||||||
|
|
||||||
// discovery.ts submits to / reads from a global index owned by a RESERVED
|
|
||||||
// SPECIAL ACCOUNT (@index) in the shim. This suite injects one fake `ng` that
|
|
||||||
// emulates BOTH the shim SPARQL (ensureAccount('@index') → doc_create ×3 +
|
|
||||||
// shim INSERT/SELECT) AND the inbox SPARQL (deposit INSERT + read SELECT), over
|
|
||||||
// a single in-memory quad store. Restore un-configured state at the end.
|
|
||||||
afterAll(() => {
|
|
||||||
resetConfig();
|
|
||||||
resetStoreRegistry();
|
|
||||||
setCurrentUser(null);
|
|
||||||
resetCaps();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws a clear error when configureStoreRegistry() was not called", async () => {
|
|
||||||
resetStoreRegistry();
|
|
||||||
resetRegistryCache();
|
|
||||||
await expect(submitToIndex({ ref: 1 })).rejects.toThrow(
|
|
||||||
/configureStoreRegistry\(\) must be called before use/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Quad { g: string; s: string; p: string; o: string }
|
|
||||||
|
|
||||||
const SHIM = "urn:ng-eventually:shim";
|
|
||||||
const INBOX = "urn:ng-eventually:inbox";
|
|
||||||
|
|
||||||
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
|
|
||||||
function unescapeLiteral(s: string): string {
|
|
||||||
let out = "";
|
|
||||||
for (let i = 0; i < s.length; i++) {
|
|
||||||
if (s[i] === "\\" && i + 1 < s.length) {
|
|
||||||
const next = s[++i];
|
|
||||||
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
|
|
||||||
} else {
|
|
||||||
out += s[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stateful fake `ng` serving BOTH the shim and the inbox SPARQL.
|
|
||||||
function makeFakeNg() {
|
|
||||||
const quads: Quad[] = [];
|
|
||||||
let docCounter = 0;
|
|
||||||
|
|
||||||
// Reactive subscriptions (see inbox.test.ts): doc_subscribe registers a
|
|
||||||
// callback per anchor + fires an initial push; sparql_update pushes a Patch to
|
|
||||||
// that anchor's subscribers, so discovery.watchIndex (now event-driven) works
|
|
||||||
// without a timer.
|
|
||||||
const subs = new Map<string, Set<(r: unknown) => void>>();
|
|
||||||
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
|
|
||||||
let set = subs.get(nuri);
|
|
||||||
if (!set) {
|
|
||||||
set = new Set();
|
|
||||||
subs.set(nuri, set);
|
|
||||||
}
|
|
||||||
set.add(cb);
|
|
||||||
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } }));
|
|
||||||
return () => set!.delete(cb);
|
|
||||||
});
|
|
||||||
const pushTo = (anchor: string): void => {
|
|
||||||
for (const cb of subs.get(anchor) ?? []) cb({ V0: { Patch: { doc: anchor } } });
|
|
||||||
};
|
|
||||||
|
|
||||||
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
|
||||||
|
|
||||||
const sparql_update = mock(async (...a: unknown[]) => {
|
|
||||||
const query = a[1] as string;
|
|
||||||
const anchor = a[2] as string | undefined;
|
|
||||||
// TWO shapes coexist: the shim account write STILL uses `GRAPH <${priv}>`
|
|
||||||
// (the private-store repo's graph name equals the plain store NURI → it
|
|
||||||
// round-trips; key by that GRAPH IRI). The inbox deposit write has NO
|
|
||||||
// explicit GRAPH — the real broker keys it by the ANCHORED repo's default
|
|
||||||
// graph (repo_graph_name(id, overlay)); key it by the ANCHOR arg (a[2]).
|
|
||||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
|
||||||
let g: string;
|
|
||||||
let body: string;
|
|
||||||
if (gm) {
|
|
||||||
g = gm[1]!;
|
|
||||||
body = gm[2]!;
|
|
||||||
} else {
|
|
||||||
if (!anchor) return undefined;
|
|
||||||
g = anchor;
|
|
||||||
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
|
||||||
}
|
|
||||||
const sm = body.match(/<([^>]+)>/);
|
|
||||||
if (!sm) return undefined;
|
|
||||||
const s = sm[1]!;
|
|
||||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
|
||||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = pairRe.exec(after)) !== null) {
|
|
||||||
// `a` → an rdf:type marker; the two type IRIs the modules use differ, so
|
|
||||||
// pick by which body we're in (deposit vs account) — harmless if wrong,
|
|
||||||
// the SELECT filters by the real predicates below.
|
|
||||||
const isDeposit = query.includes(`${INBOX}:Deposit`);
|
|
||||||
const p = m[1] ?? (isDeposit ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
|
||||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
|
||||||
quads.push({ g, s, p, o });
|
|
||||||
}
|
|
||||||
pushTo(g); // local-push to the written graph's subscribers
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sparql_query = mock(async (...a: unknown[]) => {
|
|
||||||
const query = a[1] as string;
|
|
||||||
const anchor = a[3] as string | undefined;
|
|
||||||
// Shim account SELECT. Two shapes: the full scan (`?acc a <Account>`) and
|
|
||||||
// the TARGETED bounded resolve (`<subj> a <Account>`), which binds one
|
|
||||||
// subject — honour that subject filter so the bounded query is O(1)/exact.
|
|
||||||
if (query.includes(`<${SHIM}:id>`)) {
|
|
||||||
const subjM = query.match(new RegExp(`GRAPH <[^>]+>\\s*\\{\\s*<([^>]+)>\\s+a\\s+<${SHIM}:Account>`));
|
|
||||||
const onlySubject = subjM ? subjM[1]! : null;
|
|
||||||
const bySubject = new Map<string, Record<string, string>>();
|
|
||||||
for (const q of quads) {
|
|
||||||
if (q.g !== anchor) continue;
|
|
||||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
|
||||||
const rec = bySubject.get(q.s) ?? {};
|
|
||||||
if (q.p === `${SHIM}:id`) rec.id = q.o;
|
|
||||||
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
|
||||||
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
|
|
||||||
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
|
|
||||||
bySubject.set(q.s, rec);
|
|
||||||
}
|
|
||||||
const bindings = [...bySubject.values()]
|
|
||||||
.filter((r) => r.id)
|
|
||||||
.map((r) => ({
|
|
||||||
id: { value: r.id! },
|
|
||||||
docPublic: { value: r.docPublic ?? "" },
|
|
||||||
docProtected: { value: r.docProtected ?? "" },
|
|
||||||
docPrivate: { value: r.docPrivate ?? "" },
|
|
||||||
}));
|
|
||||||
return { results: { bindings } };
|
|
||||||
}
|
|
||||||
// Inbox deposit SELECT (?payload ?ts ?from).
|
|
||||||
if (query.includes(`<${INBOX}:payload>`)) {
|
|
||||||
const bySubject = new Map<string, Record<string, string>>();
|
|
||||||
for (const q of quads) {
|
|
||||||
if (q.g !== anchor) continue;
|
|
||||||
if (q.p === `${INBOX}:Deposit`) {
|
|
||||||
if (!bySubject.has(q.s)) bySubject.set(q.s, {});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const rec = bySubject.get(q.s) ?? {};
|
|
||||||
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
|
|
||||||
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
|
|
||||||
if (q.p === `${INBOX}:from`) rec.from = q.o;
|
|
||||||
bySubject.set(q.s, rec);
|
|
||||||
}
|
|
||||||
const bindings = [...bySubject.values()]
|
|
||||||
.filter((r) => r.payload !== undefined && r.ts !== undefined)
|
|
||||||
.map((r) => {
|
|
||||||
const row: Record<string, { value: string }> = {
|
|
||||||
payload: { value: r.payload! },
|
|
||||||
ts: { value: r.ts! },
|
|
||||||
};
|
|
||||||
if (r.from !== undefined) row.from = { value: r.from };
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
return { results: { bindings } };
|
|
||||||
}
|
|
||||||
// Entity-index SELECT (shim contains) — unused here.
|
|
||||||
return { results: { bindings: [] } };
|
|
||||||
});
|
|
||||||
|
|
||||||
return { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads };
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
|
|
||||||
|
|
||||||
function inject() {
|
|
||||||
const ng = makeFakeNg();
|
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
||||||
configureStoreRegistry({
|
|
||||||
getSession: async () => SESSION,
|
|
||||||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
|
||||||
resetRegistryCache();
|
|
||||||
setCurrentUser(null);
|
|
||||||
return ng;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fake: ReturnType<typeof makeFakeNg>;
|
|
||||||
beforeEach(() => {
|
|
||||||
fake = inject();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
|
|
||||||
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
|
|
||||||
// ensureAccount('@index') created its 3 scope docs.
|
|
||||||
expect(fake.doc_create).toHaveBeenCalledTimes(3);
|
|
||||||
// The deposit landed in the @index public document (its inbox).
|
|
||||||
const depositCall = fake.sparql_update.mock.calls.find((c) =>
|
|
||||||
(c[1] as string).includes(`${INBOX}:Deposit`),
|
|
||||||
)!;
|
|
||||||
expect(depositCall, "a deposit INSERT was issued").not.toBeUndefined();
|
|
||||||
expect(depositCall[2]).toMatch(/^did:ng:o:doc/); // the index document NURI
|
|
||||||
});
|
|
||||||
|
|
||||||
test("submit → read round-trips the reference as an index entry", async () => {
|
|
||||||
setCurrentUser("alice"); // `from` is bound to the current identity
|
|
||||||
const ref = { nuri: "did:ng:o:event1", title: "Concert au parc" };
|
|
||||||
await submitToIndex(ref, { from: "alice", ts: 100 });
|
|
||||||
const entries = await readIndex();
|
|
||||||
expect(entries).toHaveLength(1);
|
|
||||||
expect(entries[0]).toEqual({ ref, from: "alice", ts: 100 } as IndexEntry);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a reference submitted by A is discovered by a NON-connected reader via the index", async () => {
|
|
||||||
// A submits (identified). No connection is ever declared. A separate reader
|
|
||||||
// materializes the SAME index (same special account → same document) and sees
|
|
||||||
// the reference — discovery is via the index, not any direct fan-out/link.
|
|
||||||
setCurrentUser("alice");
|
|
||||||
const ref = { nuri: "did:ng:o:evA", title: "Public event by A" };
|
|
||||||
await submitToIndex(ref, { ts: 100 });
|
|
||||||
|
|
||||||
// Reader B: a fresh cache, never connected to A, reads the index.
|
|
||||||
resetRegistryCache();
|
|
||||||
setCurrentUser("bob");
|
|
||||||
const entries = await readIndex();
|
|
||||||
const refs = entries.map((e) => e.ref);
|
|
||||||
expect(refs).toContainEqual(ref);
|
|
||||||
expect(entries.find((e) => JSON.stringify(e.ref) === JSON.stringify(ref))!.from).toBe("alice");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("readIndex deduplicates identical references (materialization moderation point)", async () => {
|
|
||||||
const ref = { nuri: "did:ng:o:dup", title: "Twice" };
|
|
||||||
// Anonymous submissions (dedup keys on the ref, not the submitter).
|
|
||||||
await submitToIndex(ref, { from: null, ts: 100 });
|
|
||||||
await submitToIndex(ref, { from: null, ts: 200 }); // duplicate reference
|
|
||||||
const entries = await readIndex();
|
|
||||||
expect(entries).toHaveLength(1); // surfaced once
|
|
||||||
});
|
|
||||||
|
|
||||||
test("from: null makes an anonymous submission", async () => {
|
|
||||||
await submitToIndex({ nuri: "did:ng:o:anon" }, { from: null, ts: 100 });
|
|
||||||
const entries = await readIndex();
|
|
||||||
expect(entries[0]!.from).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
// (d) PUBLIC-ONLY: a protected/private document must NOT be submittable to the
|
|
||||||
// world-readable discovery index; a public (or ungoverned) document is fine.
|
|
||||||
test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", async () => {
|
|
||||||
resetCaps();
|
|
||||||
// A PROTECTED and a PRIVATE governed document, and a PUBLIC one.
|
|
||||||
getCaps().open("did:ng:o:prot", "protected", "alice");
|
|
||||||
getCaps().open("did:ng:o:priv", "private", "alice");
|
|
||||||
getCaps().open("did:ng:o:pub", "public", "alice");
|
|
||||||
|
|
||||||
// Submitting the protected doc's NURI is REJECTED.
|
|
||||||
await expect(
|
|
||||||
submitToIndex({ nuri: "did:ng:o:prot" }, { from: null, doc: "did:ng:o:prot" }),
|
|
||||||
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
|
|
||||||
// Private too.
|
|
||||||
await expect(
|
|
||||||
submitToIndex({ nuri: "did:ng:o:priv" }, { from: null, doc: "did:ng:o:priv" }),
|
|
||||||
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
|
|
||||||
// The PUBLIC document passes.
|
|
||||||
await submitToIndex({ nuri: "did:ng:o:pub" }, { from: null, doc: "did:ng:o:pub", ts: 1 });
|
|
||||||
const entries = await readIndex();
|
|
||||||
expect(entries.map((e) => (e.ref as { nuri: string }).nuri)).toEqual(["did:ng:o:pub"]);
|
|
||||||
resetCaps();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("INDEX_ACCOUNT lives in the reserved namespace (no typed id can equal it)", () => {
|
|
||||||
// The index account occupies a key no consumer input can produce: it is prefixed
|
|
||||||
// with a NUL control char, which a user cannot type into an id field and
|
|
||||||
// which no `normalizeId` output (a typeable value) contains. So it is
|
|
||||||
// disjoint from the keys "index" / "@index" a hostile user would submit.
|
|
||||||
expect(INDEX_ACCOUNT.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel
|
|
||||||
expect(INDEX_ACCOUNT).not.toBe("index");
|
|
||||||
expect(INDEX_ACCOUNT).not.toBe("@index");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a user named 'index'/'@index' does NOT resolve to the index account's document", async () => {
|
|
||||||
// The discovery index lives on INDEX_ACCOUNT. A hostile (or unlucky) user who
|
|
||||||
// registers as "index" or "@index" normalizes to key "index" — which must be
|
|
||||||
// a DISJOINT key from the reserved index account, so they get their own
|
|
||||||
// documents and cannot hijack / read-write the global index document.
|
|
||||||
const indexRecord = await ensureAccount(INDEX_ACCOUNT);
|
|
||||||
|
|
||||||
// A real user "index" — same normalized form as "@index".
|
|
||||||
const userIndex = await ensureAccount("index");
|
|
||||||
expect(userIndex.docPublic).not.toBe(indexRecord.docPublic);
|
|
||||||
expect(userIndex.docProtected).not.toBe(indexRecord.docProtected);
|
|
||||||
expect(userIndex.docPrivate).not.toBe(indexRecord.docPrivate);
|
|
||||||
|
|
||||||
// "@index" must land on the SAME account as "index" (both normalize to
|
|
||||||
// "index") — and still NOT on the reserved index account.
|
|
||||||
const userAtIndex = await ensureAccount("@index");
|
|
||||||
expect(userAtIndex.docPublic).toBe(userIndex.docPublic);
|
|
||||||
expect(userAtIndex.docPublic).not.toBe(indexRecord.docPublic);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("watchIndex fires immediately then when a submission arrives", async () => {
|
|
||||||
const seen: IndexEntry[][] = [];
|
|
||||||
const stop = watchIndex((e) => seen.push(e), { intervalMs: 5 });
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
expect(seen.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(seen[seen.length - 1]).toEqual([]);
|
|
||||||
|
|
||||||
await submitToIndex({ nuri: "did:ng:o:watched" }, { from: null, ts: 1 });
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
const last = seen[seen.length - 1]!;
|
|
||||||
expect(last.map((e) => (e.ref as any).nuri)).toContain("did:ng:o:watched");
|
|
||||||
|
|
||||||
stop();
|
|
||||||
const countAfterStop = seen.length;
|
|
||||||
await submitToIndex({ nuri: "did:ng:o:after" }, { from: null, ts: 2 });
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
expect(seen.length).toBe(countAfterStop);
|
|
||||||
});
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/**
|
|
||||||
* ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
|
|
||||||
* isolation, driven by per-entity documents + DIRECTED read grants.
|
|
||||||
*
|
|
||||||
* Mirrors what the app does: create an entity document through the REAL registry
|
|
||||||
* (`createEntityDoc`), declare its cap policy via `getCaps().open(doc, scope,
|
|
||||||
* owner)`, set the current identity, and — when the app decides two identities
|
|
||||||
* are related — issue a DIRECTED read grant on each of the owner's protected
|
|
||||||
* documents (`getCaps().grantRead(doc, granteeId)`). Whether identities are
|
|
||||||
* "connected" is the application's own concept: this test plays that role
|
|
||||||
* directly. The read filter then discriminates:
|
|
||||||
* (a) an ungranted principal is denied a PROTECTED doc; granted once the owner
|
|
||||||
* issues a directed grant; PUBLIC readable throughout — via the ACTIVE
|
|
||||||
* ReadCap.
|
|
||||||
* (b) no grant → no protected read (a reader cannot grant itself).
|
|
||||||
*/
|
|
||||||
import { test, expect, mock, afterAll } from "bun:test";
|
|
||||||
import { createEntityDoc, resetRegistryCache } from "../src/store-registry";
|
|
||||||
import type { RegistrySession } from "../src/store-registry";
|
|
||||||
import {
|
|
||||||
configure,
|
|
||||||
configureStoreRegistry,
|
|
||||||
resetStoreRegistry,
|
|
||||||
resetConfig,
|
|
||||||
getCaps,
|
|
||||||
resetCaps,
|
|
||||||
setCurrentUser,
|
|
||||||
} from "../src/polyfill";
|
|
||||||
import { filterReadable } from "../src/read-filter";
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
resetConfig();
|
|
||||||
resetStoreRegistry();
|
|
||||||
resetCaps();
|
|
||||||
setCurrentUser(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
|
|
||||||
|
|
||||||
function inject() {
|
|
||||||
let n = 0;
|
|
||||||
const ng = {
|
|
||||||
doc_create: mock(async () => `did:ng:o:doc${++n}`),
|
|
||||||
sparql_update: mock(async () => undefined),
|
|
||||||
sparql_query: mock(async () => ({ results: { bindings: [] } })),
|
|
||||||
};
|
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
||||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim(), provisionRetry: { attempts: 1 } });
|
|
||||||
resetRegistryCache();
|
|
||||||
resetCaps();
|
|
||||||
setCurrentUser(null);
|
|
||||||
return ng;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The app's relationship concept, played inline: grant `reader` the read cap of
|
|
||||||
* every protected document owned by `owner`. */
|
|
||||||
function grantOwnerProtectedTo(owner: string, reader: string) {
|
|
||||||
for (const doc of getCaps().protectedDocsOf(owner)) getCaps().grantRead(doc, reader);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => {
|
|
||||||
inject();
|
|
||||||
|
|
||||||
const aliceDoc = await createEntityDoc("alice", "private");
|
|
||||||
getCaps().open(aliceDoc, "private", "alice");
|
|
||||||
|
|
||||||
const bobDoc = await createEntityDoc("bob", "public");
|
|
||||||
getCaps().open(bobDoc, "public", "bob");
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{ "@graph": aliceDoc, "@id": "a1", label: "alice-private" },
|
|
||||||
{ "@graph": bobDoc, "@id": "b1", label: "bob-public" },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(filterReadable(items, getCaps(), "bob").map((i) => i["@id"])).toEqual(["b1"]);
|
|
||||||
expect(filterReadable(items, getCaps(), "alice").map((i) => i["@id"]).sort()).toEqual(["a1", "b1"]);
|
|
||||||
expect(filterReadable(items, getCaps(), null).map((i) => i["@id"])).toEqual(["b1"]);
|
|
||||||
expect(getCaps().hasReadPolicy()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// (a) protected hidden while ungranted → revealed after a DIRECTED grant; public
|
|
||||||
// readable regardless — all through the ACTIVE ReadCap.
|
|
||||||
test("(a) PROTECTED doc: hidden ungranted, revealed after a DIRECTED grant, PUBLIC always readable", async () => {
|
|
||||||
inject();
|
|
||||||
|
|
||||||
const aliceProtected = await createEntityDoc("alice", "protected");
|
|
||||||
getCaps().open(aliceProtected, "protected", "alice");
|
|
||||||
const alicePublic = await createEntityDoc("alice", "public");
|
|
||||||
getCaps().open(alicePublic, "public", "alice");
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{ "@graph": aliceProtected, "@id": "p1" },
|
|
||||||
{ "@graph": alicePublic, "@id": "u1" },
|
|
||||||
];
|
|
||||||
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]).sort();
|
|
||||||
|
|
||||||
// BEFORE any grant: bob sees only the public item.
|
|
||||||
expect(view("bob")).toEqual(["u1"]);
|
|
||||||
expect(view("alice")).toEqual(["p1", "u1"]);
|
|
||||||
|
|
||||||
// The app decides alice↔bob are related and grants bob the read cap of alice's
|
|
||||||
// protected documents.
|
|
||||||
grantOwnerProtectedTo("alice", "bob");
|
|
||||||
|
|
||||||
expect(view("bob")).toEqual(["p1", "u1"]);
|
|
||||||
// A third, ungranted principal still sees only the public one.
|
|
||||||
expect(view("carol")).toEqual(["u1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// (b) An identity gets no protected read until the OWNER issues the grant — a
|
|
||||||
// reader cannot grant itself.
|
|
||||||
test("(b) no directed grant → no protected read", async () => {
|
|
||||||
inject();
|
|
||||||
|
|
||||||
const aliceProtected = await createEntityDoc("alice", "protected");
|
|
||||||
getCaps().open(aliceProtected, "protected", "alice");
|
|
||||||
const items = [{ "@graph": aliceProtected, "@id": "p1" }];
|
|
||||||
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]);
|
|
||||||
|
|
||||||
// mallory holds no grant on alice's protected doc → denied.
|
|
||||||
expect(view("mallory")).toEqual([]);
|
|
||||||
|
|
||||||
// Granting bob (a different, legitimate reader) leaves mallory denied.
|
|
||||||
grantOwnerProtectedTo("alice", "bob");
|
|
||||||
expect(view("mallory")).toEqual([]);
|
|
||||||
expect(view("bob")).toEqual(["p1"]);
|
|
||||||
});
|
|
||||||
@@ -1,59 +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`; caps are granted per document.
|
|
||||||
interface Item { id: string; "@graph"?: string }
|
|
||||||
|
|
||||||
const PRIV: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc
|
|
||||||
const PUB: Item = { id: "p", "@graph": "did:ng:o:public" }; // public doc
|
|
||||||
const UNGOV: Item = { id: "n", "@graph": "did:ng:o:other" }; // doc under no policy
|
|
||||||
const NOGRAPH: Item = { id: "x" }; // no document → kept
|
|
||||||
|
|
||||||
function caps(): CapRegistry {
|
|
||||||
const c = new CapRegistry();
|
|
||||||
c.grantRead("did:ng:o:alice", "alice");
|
|
||||||
c.makePublic("did:ng:o:public");
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("filterReadable keeps public, cap-held, ungoverned and graphless items", () => {
|
|
||||||
const items = [PRIV, PUB, UNGOV, NOGRAPH];
|
|
||||||
expect(filterReadable(items, caps(), "alice").map(i => (i as Item).id)).toEqual(["a", "p", "n", "x"]);
|
|
||||||
expect(filterReadable(items, caps(), "bob").map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
|
|
||||||
expect(filterReadable(items, caps(), null).map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("makeReadFilteredView filters iteration/size, reflects the current user", () => {
|
|
||||||
const set = new Set<Item>([PRIV, PUB, UNGOV, NOGRAPH]);
|
|
||||||
let user: string | null = "bob";
|
|
||||||
const view = makeReadFilteredView(set, caps(), () => user);
|
|
||||||
|
|
||||||
expect([...view].map(i => i.id)).toEqual(["p", "n", "x"]);
|
|
||||||
expect(view.size).toBe(3);
|
|
||||||
|
|
||||||
user = "alice"; // read lazily → view updates without rewrapping
|
|
||||||
expect([...view].map(i => i.id)).toEqual(["a", "p", "n", "x"]);
|
|
||||||
expect(view.size).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("makeReadFilteredView forwards mutations and membership to the target", () => {
|
|
||||||
const set = new Set<Item>([PUB]);
|
|
||||||
const view = makeReadFilteredView(set, caps(), () => "bob");
|
|
||||||
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>([PRIV, PUB]);
|
|
||||||
const seen: string[] = [];
|
|
||||||
makeReadFilteredView(set, caps(), () => "bob").forEach((i) => seen.push((i as Item).id));
|
|
||||||
expect(seen).toEqual(["p"]);
|
|
||||||
});
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { test, expect, mock } from "bun:test";
|
|
||||||
import { readUnion } from "../src/read-model";
|
|
||||||
import { configure, configureStoreRegistry } from "../src/polyfill";
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
||||||
configureStoreRegistry({
|
|
||||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
|
||||||
normalizeId: (u: string) => u,
|
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
|
||||||
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,
|
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
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"]);
|
|
||||||
});
|
|
||||||
@@ -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, provisionRetry: { attempts: 1 } });
|
|
||||||
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
|
||||||
|
```
|
||||||
+85
-24
@@ -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() {
|
||||||
@@ -92,6 +92,9 @@ useShape<T extends BaseType>(
|
|||||||
- `shape` — the ORM shape type (generated from a SHEX shape). Names the entity type
|
- `shape` — the ORM shape type (generated from a SHEX shape). Names the entity type
|
||||||
and the properties to materialize.
|
and the properties to materialize.
|
||||||
- `scope` — where to read: a `{ graphs, subjects }` scope object or a NURI string.
|
- `scope` — where to read: a `{ graphs, subjects }` scope object or a NURI string.
|
||||||
|
**Not to be confused with this library's `Scope`** (`types.ts`), which is the
|
||||||
|
literal union `public | protected | private` naming a store. Same word, two
|
||||||
|
meanings: the ORM's is a read target, ours is a placement.
|
||||||
`undefined` yields an empty read.
|
`undefined` yields an empty read.
|
||||||
- **Returns** a `DeepSignalSet<T>` — a **live reactive set**. Iterate it like a set;
|
- **Returns** a `DeepSignalSet<T>` — a **live reactive set**. Iterate it like a set;
|
||||||
the component re-renders whenever the set changes.
|
the component re-renders whenever the set changes.
|
||||||
@@ -105,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
|
||||||
|
|
||||||
@@ -158,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)).
|
||||||
|
|
||||||
@@ -182,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
|
||||||
@@ -202,11 +205,18 @@ Data is isolated **per document (repo)**, and each document lives in a **scope**
|
|||||||
| Scope | Read | Write |
|
| Scope | Read | Write |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Private** | Owner only | Owner only |
|
| **Private** | Owner only | Owner only |
|
||||||
| **Protected** | Owner + explicit grant holders | Owner + permissioned collaborators |
|
| **Protected** | Owner + whoever the owner delivered the cap to | Owner + permissioned collaborators |
|
||||||
| **Public** | Everyone (no capability needed) | **Owner only** |
|
| **Public** | Whoever has the URL (the repo link) | **Owner only** |
|
||||||
|
|
||||||
Consequences a consumer must internalize:
|
Consequences a consumer must internalize:
|
||||||
|
|
||||||
|
- **Reading is key possession, never an authorization list.** You hold a document's
|
||||||
|
`ReadCap` (`…:r:{cap}`) or you do not read it — there is no "may X read Y?" to ask,
|
||||||
|
here or upstream. A cap-less `did:ng:o:…` **names** a document without granting
|
||||||
|
anything, which is what lets public content point at private content without
|
||||||
|
disclosing it. Caps reach you two ways: creating a document files its own, and
|
||||||
|
someone delivering one to your inbox (`shareCap`). Nothing derives a cap from a
|
||||||
|
bare reference.
|
||||||
- **Isolation is per-document, not per-store.** Holding a store's cap does **not**
|
- **Isolation is per-document, not per-store.** Holding a store's cap does **not**
|
||||||
grant read on the documents it contains — each document has its own ReadCap. Fine-
|
grant read on the documents it contains — each document has its own ReadCap. Fine-
|
||||||
grained isolation therefore means **one document per entity**
|
grained isolation therefore means **one document per entity**
|
||||||
@@ -224,9 +234,28 @@ Consequences a consumer must internalize:
|
|||||||
permission enum (`engine/repo/src/types.rs:1729`, `PermissionV0`) has `WriteAsync`/
|
permission enum (`engine/repo/src/types.rs:1729`, `PermissionV0`) has `WriteAsync`/
|
||||||
`WriteSync` but **no** add-only/append permission and **no** public-writable grant.
|
`WriteSync` but **no** add-only/append permission and **no** public-writable grant.
|
||||||
To surface data to others without a shared write, use the **inbox** (any identity —
|
To surface data to others without a shared write, use the **inbox** (any identity —
|
||||||
even anonymous — can deposit into a document's native inbox; the owner materializes
|
even anonymous — can deposit; only the owner reads back) or make the document
|
||||||
deposits) or make the document **public-readable** and let each identity own its own
|
**public-readable** and let each identity own its own document. *Per-document inboxes
|
||||||
document.
|
are this library's, not the engine's: upstream only the public and protected store
|
||||||
|
repos carry one (`engine/verifier/src/site.rs:128,149`).*
|
||||||
|
|
||||||
|
Depositing into a document you do not own is **one** call, and it names the document:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await inbox.postToDocument(doc, { payload: { signingUp: true } });
|
||||||
|
```
|
||||||
|
|
||||||
|
You need the **document** (its cap), nothing else — the address rides on it. It
|
||||||
|
**throws** if the document has no inbox: its owner opens one with
|
||||||
|
`storeRegistry.openDocumentInbox(doc)` for documents meant to receive, so a fresh
|
||||||
|
document has none. When "no inbox" is an expected case, check first with
|
||||||
|
`storeRegistry.documentInboxAddress(doc)` (→ `Nuri | undefined`).
|
||||||
|
|
||||||
|
A deposit carries no target document, deliberately — one inbox belongs to one
|
||||||
|
document, so the address already identifies it, exactly as upstream (`inboxes:
|
||||||
|
PubKey → RepoId`). Do not encode the document in your payload; you would have to
|
||||||
|
unlearn it. Reading that inbox is a different right, and it stays the owner's
|
||||||
|
(`inbox.read` refuses otherwise).
|
||||||
|
|
||||||
The consumer asks the SDK for what it needs and trusts the result; it does not
|
The consumer asks the SDK for what it needs and trusts the result; it does not
|
||||||
construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list
|
construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list
|
||||||
@@ -244,7 +273,7 @@ helpers live in the consumer app; the SDK exposes the generic reactive/by-need r
|
|||||||
> [`read-model.md`](../../../docs/read-model.md),
|
> [`read-model.md`](../../../docs/read-model.md),
|
||||||
> [`simulation.md`](../../../docs/simulation.md).
|
> [`simulation.md`](../../../docs/simulation.md).
|
||||||
|
|
||||||
Today, on a single shared wallet emulating the mature platform, three gaps diverge
|
Today, on a single shared wallet emulating the mature platform, four gaps diverge
|
||||||
from the reactive contract:
|
from the reactive contract:
|
||||||
|
|
||||||
1. **Entity-list reads are one-shot, not reactive.** The reactive ORM cannot be used
|
1. **Entity-list reads are one-shot, not reactive.** The reactive ORM cannot be used
|
||||||
@@ -255,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
|
||||||
@@ -263,13 +292,12 @@ from the reactive contract:
|
|||||||
for a **single already-opened document**; it is the per-entity **fan-out** that is
|
for a **single already-opened document**; it is the per-entity **fan-out** that is
|
||||||
unfit today.
|
unfit today.
|
||||||
|
|
||||||
2. **Inbox and discovery index use polling watchers.** 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)) and
|
deposit), so `inbox.watch` ([`../src/surface/inbox.ts`](../src/surface/inbox.ts)) **polls** via
|
||||||
`discovery.watchIndex` ([`../src/discovery.ts`](../src/discovery.ts)) **poll** 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 (`inbox_post_link`) lands.
|
sealed-inbox path is exposed to JS (no such method exists today).
|
||||||
|
|
||||||
3. **No cross-wallet / on-demand repo open.** There is no JS primitive to sync an
|
3. **No cross-wallet / on-demand repo open.** There is no JS primitive to sync an
|
||||||
*unknown* repo by NURI+ReadCap today (`load_repo_from_read_cap` is `pub(crate)`,
|
*unknown* repo by NURI+ReadCap today (`load_repo_from_read_cap` is `pub(crate)`,
|
||||||
@@ -279,5 +307,38 @@ from the reactive contract:
|
|||||||
queryable. At the multi-store migration, opening a repo by cap becomes a native
|
queryable. At the multi-store migration, opening a repo by cap becomes a native
|
||||||
broker sync and the anchored read is unchanged.
|
broker sync and the anchored read is unchanged.
|
||||||
|
|
||||||
|
4. **The subscription may not echo the writer's OWN local commit — HYPOTHESIS
|
||||||
|
(high-confidence), confirmation in progress (2026-07-18); NOT confirmed, NOT
|
||||||
|
fixed.** Unlike gaps 1–3 (designed emulation stopgaps), this is a suspected
|
||||||
|
defect in the polyfill's own reactive assembly. When a client does a local
|
||||||
|
`sparqlUpdate` on a doc it is itself subscribed to (`subscribeDoc` /
|
||||||
|
`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
|
||||||
|
([`../src/surface/watch-shape.ts`](../src/surface/watch-shape.ts) `watchShape` → `reread` →
|
||||||
|
[`../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
|
||||||
|
initial `State`. **Remote** commits DO push correctly (verified: cross-browser
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
also sits in tension with § *The reactivity model* above, which documents the
|
||||||
|
target contract — one commit, every subscriber pushed, local or remote.) The
|
||||||
|
requirement at stake is multi-user: a value change (e.g. a participant count)
|
||||||
|
must propagate reactively to ALL viewers — other viewers (remote push, which
|
||||||
|
works) AND the writer's own view (this suspect link). **Treatment (PLANNED,
|
||||||
|
not done):** confirm first via the temporary instrumentation just added
|
||||||
|
([`../src/surface/subscribe.ts`](../src/surface/subscribe.ts) ≈`:119` logs
|
||||||
|
`doc_subscribe FIRE <nuri> (State|Patch)`;
|
||||||
|
[`../src/surface/watch-shape.ts`](../src/surface/watch-shape.ts) ≈`:341` logs
|
||||||
|
`reread TRIGGER by <nuri>` — line numbers volatile, grep the log strings);
|
||||||
|
then, IF confirmed, fix **polyfill-side** — a
|
||||||
|
local commit should notify the doc's active `subscribeDoc` callbacks.
|
||||||
|
Consumers must not compensate. Short entry:
|
||||||
|
[`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) §
|
||||||
|
*Known open issues*.
|
||||||
|
|
||||||
When these gaps close, the read path collapses to the reference above: `useShape`
|
When these gaps close, the read path collapses to the reference above: `useShape`
|
||||||
everywhere, push everywhere, no polling and no re-query-on-signal assembly.
|
everywhere, push everywhere, no polling and no re-query-on-signal assembly.
|
||||||
@@ -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
|
||||||
@@ -15,20 +15,59 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ng as realNg, init as realInit } from "@ng-org/web";
|
import { ng as realNg, init as realInit } from "@ng-org/web";
|
||||||
import { configure, configureStoreRegistry, setCurrentUser, getCaps, resetCaps } from "@ng-eventually/client/polyfill";
|
|
||||||
import {
|
import {
|
||||||
|
configure,
|
||||||
docs,
|
docs,
|
||||||
|
init,
|
||||||
subscribeDoc,
|
subscribeDoc,
|
||||||
subscribeDocs,
|
subscribeDocs,
|
||||||
readModel,
|
readUnion,
|
||||||
inbox,
|
inbox,
|
||||||
discovery,
|
|
||||||
storeRegistry,
|
storeRegistry,
|
||||||
useShape as libUseShape,
|
useShape as libUseShape,
|
||||||
accounts,
|
watchShape,
|
||||||
} from "@ng-eventually/client";
|
} from "@ng-eventually/polyfill";
|
||||||
|
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
|
||||||
|
// application must not — but through the internal path, never the published entry.
|
||||||
|
// `storeRegistry` above is the app-facing slice; these are the shim internals.
|
||||||
|
import * as registryInternals from "../src/shared-wallet/account-registry";
|
||||||
|
// The harness plays SEVERAL identities on one page — something no application does, and
|
||||||
|
// the reason `setCurrentUser` / `configureStoreRegistry` are no longer published. It
|
||||||
|
// 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
|
||||||
|
* a plain `string` (Playwright serializes arguments), so it arrives untyped even
|
||||||
|
* though the library's `Nuri` is a template literal type. Narrow it here, loudly:
|
||||||
|
* a test that passes something which is not a NextGraph reference should fail with
|
||||||
|
* that message, not with a confusing downstream error. Never cast — a cast would
|
||||||
|
* re-open exactly the confusion the types exist to close.
|
||||||
|
*/
|
||||||
|
function asNuri(s: string): Nuri {
|
||||||
|
if (!isNuri(s)) throw new Error(`[e2e] not a NextGraph reference: ${JSON.stringify(s)}`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
/** Same, for an optional anchor. */
|
||||||
|
function asAnchor(s?: string): Nuri | undefined {
|
||||||
|
return s === undefined ? undefined : asNuri(s);
|
||||||
|
}
|
||||||
|
|
||||||
// ── The broker session, resolved once the iframe connects ──────────────────
|
// ── The broker session, resolved once the iframe connects ──────────────────
|
||||||
interface BrokerSession {
|
interface BrokerSession {
|
||||||
@@ -64,10 +103,39 @@ 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/discovery/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.
|
||||||
getSession: async () => {
|
getSession: async () => {
|
||||||
// Read the CURRENT session (mutable): a fresh session (session_stop+session_start
|
// Read the CURRENT session (mutable): a fresh session (session_stop+session_start
|
||||||
@@ -84,6 +152,13 @@ configureStoreRegistry({
|
|||||||
},
|
},
|
||||||
// Identity normalization used as the shim key (lowercase, strip leading `@`).
|
// Identity normalization used as the shim key (lowercase, strip leading `@`).
|
||||||
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
||||||
|
// REAL broker: enable the POINTER micro-guard. The account records live in a
|
||||||
|
// subscribable doc-shim reached via a write-once pointer triple in the store-root;
|
||||||
|
// the account read is barrier-authoritative (no account retry). The only residual
|
||||||
|
// store-root sync-lag is the pointer read — this bounded guard re-reads JUST that
|
||||||
|
// one write-once triple on a cold reconnect (CONTRACT 2 non-fork). It can never
|
||||||
|
// provision or fork an account.
|
||||||
|
pointerGuard: { attempts: 8, baseMs: 150, maxStepMs: 2000 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bridge marshaling note ─────────────────────────────────────────────────
|
// ── Bridge marshaling note ─────────────────────────────────────────────────
|
||||||
@@ -92,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,
|
||||||
);
|
);
|
||||||
@@ -128,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;
|
||||||
@@ -135,11 +269,11 @@ const identity = new IdentityStore(
|
|||||||
},
|
},
|
||||||
async sparqlUpdate(query: string, anchor?: string) {
|
async sparqlUpdate(query: string, anchor?: string) {
|
||||||
const s = await sessionReady;
|
const s = await sessionReady;
|
||||||
return docs.sparqlUpdate(s.session_id, query, anchor);
|
return docs.sparqlUpdate(s.session_id, query, asAnchor(anchor));
|
||||||
},
|
},
|
||||||
async sparqlQuery(query: string, anchor?: string) {
|
async sparqlQuery(query: string, anchor?: string) {
|
||||||
const s = await sessionReady;
|
const s = await sessionReady;
|
||||||
return docs.sparqlQuery(s.session_id, query, undefined, anchor);
|
return docs.sparqlQuery(s.session_id, query, undefined, asAnchor(anchor));
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* The load-bearing graph-behavior characterization against the REAL broker.
|
* The load-bearing graph-behavior characterization against the REAL broker.
|
||||||
@@ -243,7 +377,7 @@ const identity = new IdentityStore(
|
|||||||
*/
|
*/
|
||||||
async readUnionOverDocs(n: number, includeBad: boolean) {
|
async readUnionOverDocs(n: number, includeBad: boolean) {
|
||||||
const s = await sessionReady;
|
const s = await sessionReady;
|
||||||
const docNuris: string[] = [];
|
const docNuris: Nuri[] = [];
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const d = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
const d = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||||
await docs.sparqlUpdate(
|
await docs.sparqlUpdate(
|
||||||
@@ -253,24 +387,44 @@ const identity = new IdentityStore(
|
|||||||
);
|
);
|
||||||
docNuris.push(d);
|
docNuris.push(d);
|
||||||
}
|
}
|
||||||
const toRead = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
|
// Two SUBJECTS in the LAST document, so the probe can tell "one entry per document"
|
||||||
const subjects = await readModel.readUnion(toRead);
|
// from "one entry per subject". Until 2026-08-10 `readUnion` folded every triple of a
|
||||||
return { docNuris, subjectCount: subjects.length, subjects };
|
// 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 subjects = await readUnion(toRead);
|
||||||
|
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 cap gate: create a doc, mark it protected for owner O, set the
|
* readUnion possession gate: create a doc as owner O (whose keyring gets its
|
||||||
* current user to a DIFFERENT identity, and readUnion → the doc is dropped.
|
* cap), then read it as a DIFFERENT identity, which holds nothing → dropped.
|
||||||
|
* The stranger has the document's NURI in hand throughout: naming is not reading.
|
||||||
*/
|
*/
|
||||||
async readUnionCapGate() {
|
async readUnionCapGate() {
|
||||||
const s = await sessionReady;
|
const s = await sessionReady;
|
||||||
resetCaps();
|
resetCaps();
|
||||||
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||||
await docs.sparqlUpdate(s.session_id, `INSERT DATA { <urn:e2e:cg> <urn:e2e:p> "x" }`, doc);
|
await docs.sparqlUpdate(s.session_id, `INSERT DATA { <urn:e2e:cg> <urn:e2e:p> "x" }`, doc);
|
||||||
getCaps().open(doc, "protected", "owner-O");
|
|
||||||
setCurrentUser("someone-else");
|
|
||||||
const asStranger = await readModel.readUnion([doc]);
|
|
||||||
setCurrentUser("owner-O");
|
setCurrentUser("owner-O");
|
||||||
const asOwner = await readModel.readUnion([doc]);
|
getCaps().open(doc, "protected");
|
||||||
|
setCurrentUser("someone-else");
|
||||||
|
const asStranger = await readUnion([doc]);
|
||||||
|
setCurrentUser("owner-O");
|
||||||
|
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 };
|
||||||
@@ -299,7 +453,7 @@ const identity = new IdentityStore(
|
|||||||
await docs.sparqlUpdate(
|
await docs.sparqlUpdate(
|
||||||
s.session_id,
|
s.session_id,
|
||||||
`INSERT DATA { <urn:e2e:sub:${marker}> <urn:e2e:m> "${marker}" }`,
|
`INSERT DATA { <urn:e2e:sub:${marker}> <urn:e2e:m> "${marker}" }`,
|
||||||
doc,
|
asNuri(doc),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
subscribeStop(handle: string) {
|
subscribeStop(handle: string) {
|
||||||
@@ -332,10 +486,18 @@ const identity = new IdentityStore(
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── inbox ────────────────────────────────────────────────────────────────
|
// ── inbox ────────────────────────────────────────────────────────────────
|
||||||
async inboxPostRead(payloadA: unknown, payloadB: unknown) {
|
/**
|
||||||
const s = await sessionReady;
|
* `id` must be FRESH per run (run.ts stamps it). A user's inbox is stable over time —
|
||||||
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
* that is the point of it — so re-running against a reused id accumulates the previous
|
||||||
setCurrentUser("inbox-user");
|
* runs' deposits on a persistent wallet, and the exact-count assertion drifts. The
|
||||||
|
* thing to make disposable is the user, not the inbox.
|
||||||
|
*/
|
||||||
|
async inboxPostRead(id: string, payloadA: unknown, payloadB: unknown) {
|
||||||
|
// 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
|
||||||
|
// FIRST is what makes `userInbox` resolve (and file) that user's inbox.
|
||||||
|
setCurrentUser(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);
|
||||||
@@ -344,9 +506,12 @@ const identity = new IdentityStore(
|
|||||||
},
|
},
|
||||||
// watch (doc_subscribe-based) fires when a deposit lands.
|
// watch (doc_subscribe-based) fires when a deposit lands.
|
||||||
_inboxWatch: { fires: 0, lastLen: -1, unsub: () => {}, target: "" },
|
_inboxWatch: { fires: 0, lastLen: -1, unsub: () => {}, target: "" },
|
||||||
async inboxWatchStart() {
|
/** `id` fresh per run, for the same reason as {@link inboxPostRead}. */
|
||||||
const s = await sessionReady;
|
async inboxWatchStart(id: string) {
|
||||||
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
// Watching an inbox is READING it continuously, so the watcher stays connected
|
||||||
|
// for the whole probe — including across `inboxWatchDeposit`.
|
||||||
|
setCurrentUser(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) => {
|
||||||
@@ -357,9 +522,7 @@ const identity = new IdentityStore(
|
|||||||
},
|
},
|
||||||
async inboxWatchDeposit(payload: unknown) {
|
async inboxWatchDeposit(payload: unknown) {
|
||||||
const rec = (window as any).__sdk._inboxWatch;
|
const rec = (window as any).__sdk._inboxWatch;
|
||||||
setCurrentUser("watcher");
|
|
||||||
await inbox.post(rec.target, { payload, from: null });
|
await inbox.post(rec.target, { payload, from: null });
|
||||||
setCurrentUser(null);
|
|
||||||
},
|
},
|
||||||
inboxWatchState() {
|
inboxWatchState() {
|
||||||
const r = (window as any).__sdk._inboxWatch;
|
const r = (window as any).__sdk._inboxWatch;
|
||||||
@@ -367,12 +530,16 @@ const identity = new IdentityStore(
|
|||||||
},
|
},
|
||||||
inboxWatchStop() {
|
inboxWatchStop() {
|
||||||
(window as any).__sdk._inboxWatch.unsub();
|
(window as any).__sdk._inboxWatch.unsub();
|
||||||
|
setCurrentUser(null);
|
||||||
},
|
},
|
||||||
// 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" });
|
||||||
@@ -387,53 +554,12 @@ const identity = new IdentityStore(
|
|||||||
return { spoofRejected: threw, selfOk, anonOk };
|
return { spoofRejected: threw, selfOk, anonOk };
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── discovery index ──────────────────────────────────────────────────────
|
|
||||||
async discoverySubmitRead(ref: unknown) {
|
|
||||||
setCurrentUser("publisher");
|
|
||||||
await discovery.submitToIndex(ref);
|
|
||||||
setCurrentUser(null);
|
|
||||||
const entries = await discovery.readIndex();
|
|
||||||
return { entries };
|
|
||||||
},
|
|
||||||
_discWatch: { fires: 0, lastLen: -1, unsub: () => {} },
|
|
||||||
discoveryWatchStart() {
|
|
||||||
const rec = { fires: 0, lastLen: -1, unsub: () => {} };
|
|
||||||
(window as any).__sdk._discWatch = rec;
|
|
||||||
rec.unsub = discovery.watchIndex((entries) => {
|
|
||||||
rec.fires += 1;
|
|
||||||
rec.lastLen = entries.length;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async discoverySubmit(ref: unknown) {
|
|
||||||
setCurrentUser("publisher2");
|
|
||||||
await discovery.submitToIndex(ref);
|
|
||||||
setCurrentUser(null);
|
|
||||||
},
|
|
||||||
discoveryWatchState() {
|
|
||||||
const r = (window as any).__sdk._discWatch;
|
|
||||||
return { fires: r.fires, lastLen: r.lastLen };
|
|
||||||
},
|
|
||||||
discoveryWatchStop() {
|
|
||||||
(window as any).__sdk._discWatch.unsub();
|
|
||||||
},
|
|
||||||
// reserved @index account isolation: a real user named "index"/"@index" resolves
|
|
||||||
// to a DIFFERENT account than the reserved index owner.
|
|
||||||
async discoveryIndexIsolation() {
|
|
||||||
const userIndex = await storeRegistry.ensureAccount("@index");
|
|
||||||
const reserved = await storeRegistry.ensureAccount(discovery.INDEX_ACCOUNT);
|
|
||||||
return {
|
|
||||||
userIndexDoc: userIndex.docPublic,
|
|
||||||
reservedDoc: reserved.docPublic,
|
|
||||||
disjoint: userIndex.docPublic !== reserved.docPublic,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── store-registry ───────────────────────────────────────────────────────
|
// ── store-registry ───────────────────────────────────────────────────────
|
||||||
async ensureAccountIdempotent(id: string) {
|
async ensureAccountIdempotent(id: string) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const first = await storeRegistry.ensureAccount(id);
|
const first = await registryInternals.ensureAccount(id);
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const second = await storeRegistry.ensureAccount(id);
|
const second = await registryInternals.ensureAccount(id);
|
||||||
return {
|
return {
|
||||||
firstDocs: [first.docPublic, first.docProtected, first.docPrivate],
|
firstDocs: [first.docPublic, first.docProtected, first.docPrivate],
|
||||||
secondDocs: [second.docPublic, second.docProtected, second.docPrivate],
|
secondDocs: [second.docPublic, second.docProtected, second.docPrivate],
|
||||||
@@ -444,18 +570,25 @@ const identity = new IdentityStore(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
async entityDocsBounded(idA: string, idB: string) {
|
async entityDocsBounded(idA: string, idB: string) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const dA1 = await storeRegistry.createEntityDoc(idA, "public");
|
// Each user creates its OWN documents: you act as one virtual user at a time,
|
||||||
const dA2 = await storeRegistry.createEntityDoc(idA, "public");
|
// and the caps of what you create are filed under the identity you were acting
|
||||||
const dB1 = await storeRegistry.createEntityDoc(idB, "public");
|
// as. Creating B's document while connected as A is not a thing the model has.
|
||||||
|
setCurrentUser(idA);
|
||||||
|
const dA1 = await storeRegistry.createEntityDoc("public");
|
||||||
|
const dA2 = await storeRegistry.createEntityDoc("public");
|
||||||
|
setCurrentUser(idB);
|
||||||
|
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);
|
||||||
let listA: string[] = [];
|
let listA: string[] = [];
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
storeRegistry.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));
|
||||||
}
|
}
|
||||||
|
setCurrentUser(null);
|
||||||
return {
|
return {
|
||||||
dA1, dA2, dB1,
|
dA1, dA2, dB1,
|
||||||
listA,
|
listA,
|
||||||
@@ -477,9 +610,13 @@ const identity = new IdentityStore(
|
|||||||
* expected values to assert against.
|
* expected values to assert against.
|
||||||
*/
|
*/
|
||||||
async reconnectSeed(id: string, scope: "public" | "protected" | "private") {
|
async reconnectSeed(id: string, scope: "public" | "protected" | "private") {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const s = await sessionReady;
|
const s = await sessionReady;
|
||||||
const entityNuri = await storeRegistry.createEntityDoc(id, scope);
|
// Seed AS the user whose document this is — otherwise the cap of the created
|
||||||
|
// document is filed under nobody and the very session that created it is
|
||||||
|
// refused the write below.
|
||||||
|
setCurrentUser(id);
|
||||||
|
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,
|
||||||
@@ -491,8 +628,8 @@ const identity = new IdentityStore(
|
|||||||
// data is persisted before the fresh session tries to read it back.
|
// data is persisted before the fresh session tries to read it back.
|
||||||
let listed: string[] = [];
|
let listed: string[] = [];
|
||||||
for (let i = 0; i < 15; i++) {
|
for (let i = 0; i < 15; i++) {
|
||||||
storeRegistry.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));
|
||||||
}
|
}
|
||||||
@@ -501,7 +638,7 @@ const identity = new IdentityStore(
|
|||||||
/**
|
/**
|
||||||
* RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a
|
* RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a
|
||||||
* DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the
|
* DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the
|
||||||
* account's entity docs of `scope` (listMyEntityDocs → readScopeIndex) and readUnion
|
* account's entity docs of `scope` (listMyEntityDocs → readUserStore) and readUnion
|
||||||
* them, purely from the persistent wallet — nothing from phase 1's session state
|
* them, purely from the persistent wallet — nothing from phase 1's session state
|
||||||
* carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
|
* carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
|
||||||
* doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here
|
* doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here
|
||||||
@@ -510,22 +647,32 @@ const identity = new IdentityStore(
|
|||||||
* fail-without-the-fix proof (see run.ts's reconnection step comment).
|
* fail-without-the-fix proof (see run.ts's reconnection step comment).
|
||||||
*/
|
*/
|
||||||
async reconnectRead(id: string, scope: "public" | "protected" | "private", entityNuri: string, marker: string) {
|
async reconnectRead(id: string, scope: "public" | "protected" | "private", entityNuri: string, marker: string) {
|
||||||
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open at all, first
|
|
||||||
// thing in the fresh session — reports how 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 readUnion's open step.
|
|
||||||
const s = session ?? (await sessionReady);
|
const s = session ?? (await sessionReady);
|
||||||
|
// A fresh session holds nothing in memory: connect AS the user so the caps are
|
||||||
|
// restored from the durable registers (own documents from the Store branches,
|
||||||
|
// received ones from the Links) before anything is read back.
|
||||||
|
setCurrentUser(id);
|
||||||
|
await connectedUser();
|
||||||
|
|
||||||
|
registryInternals.resetRegistryCache();
|
||||||
|
const listed = await storeRegistry.listMyEntityDocs(scope);
|
||||||
|
// 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
|
||||||
|
// premise: 0 until opened). Uses the low-level docs primitive directly, bypassing
|
||||||
|
// readUnion's open step.
|
||||||
|
//
|
||||||
|
// Placed AFTER `listMyEntityDocs`, which is what restores the caps of the user's
|
||||||
|
// own documents from the Store branch. Before it, the boundary refuses the read
|
||||||
|
// and the probe would measure the guard rather than the open — a number that
|
||||||
|
// looks like the premise holding while proving nothing about it.
|
||||||
let rawRowCount = -1;
|
let rawRowCount = -1;
|
||||||
try {
|
try {
|
||||||
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, entityNuri);
|
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, asNuri(entityNuri));
|
||||||
rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0);
|
rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
|
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
|
||||||
}
|
}
|
||||||
|
const subjects = await readUnion(listed.length ? listed : [asNuri(entityNuri)]);
|
||||||
storeRegistry.resetRegistryCache();
|
|
||||||
const listed = await storeRegistry.listMyEntityDocs(id, scope);
|
|
||||||
const subjects = await readModel.readUnion(listed.length ? listed : [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)) {
|
||||||
@@ -536,7 +683,7 @@ const identity = new IdentityStore(
|
|||||||
rawRowCount,
|
rawRowCount,
|
||||||
listed,
|
listed,
|
||||||
listedCount: listed.length,
|
listedCount: listed.length,
|
||||||
foundEntity: listed.includes(entityNuri),
|
foundEntity: listed.includes(asNuri(entityNuri)),
|
||||||
subjectCount: subjects.length,
|
subjectCount: subjects.length,
|
||||||
markerPresent: markers.includes(marker),
|
markerPresent: markers.includes(marker),
|
||||||
markers,
|
markers,
|
||||||
@@ -552,8 +699,8 @@ const identity = new IdentityStore(
|
|||||||
* first so the resolve goes to the shim, not a same-session in-memory hit.
|
* first so the resolve goes to the shim, not a same-session in-memory hit.
|
||||||
*/
|
*/
|
||||||
async accountDocs(id: string) {
|
async accountDocs(id: string) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const rec = await storeRegistry.ensureAccount(id);
|
const rec = await registryInternals.ensureAccount(id);
|
||||||
return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
||||||
},
|
},
|
||||||
async scopeResolvers() {
|
async scopeResolvers() {
|
||||||
@@ -563,44 +710,222 @@ const identity = new IdentityStore(
|
|||||||
return { priv, prot, pub };
|
return { priv, prot, pub };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COLD-START anchor probe. Runs the EXACT shim SELECT the registry issues,
|
||||||
|
* anchored to `did:ng:${private_store_id}` (the shim anchor), as the very first
|
||||||
|
* thing in a fresh session — BEFORE anything opens that repo. Reports whether the
|
||||||
|
* raw anchored query threw `RepoNotFound` (the cold-start bug: the private-store
|
||||||
|
* repo not yet in `self.repos`) or returned rows. Uses the low-level docs
|
||||||
|
* primitive directly so nothing (open-repo, ensureAccount) opens the repo first.
|
||||||
|
*/
|
||||||
|
async shimAnchorProbe() {
|
||||||
|
const s = session ?? (await sessionReady);
|
||||||
|
const anchor = `did:ng:${s.private_store_id}`;
|
||||||
|
const query =
|
||||||
|
"SELECT ?acc WHERE { GRAPH <" +
|
||||||
|
anchor +
|
||||||
|
"> { ?acc a <urn:ng-eventually:shim:Account> } }";
|
||||||
|
try {
|
||||||
|
const res: any = await docs.sparqlQuery(s.session_id, query, undefined, asNuri(anchor));
|
||||||
|
const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0);
|
||||||
|
return { threw: false, error: null, rows, anchor };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { threw: true, error: String(e?.message ?? e), rows: -1, anchor };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* COLD-START account provision. resetRegistryCache then ensureAccount(id) — the
|
||||||
|
* real bootstrap the app runs on first login. On a fresh wallet, if the anchor
|
||||||
|
* repo isn't open, resolveAccount's read AND ensureAccount's provision write both
|
||||||
|
* hit RepoNotFound; the account never persists. Returns the 3 scope docs (all
|
||||||
|
* truthy iff provisioning succeeded) so the runner can gate on real persistence.
|
||||||
|
*/
|
||||||
|
async coldEnsureAccount(id: string) {
|
||||||
|
registryInternals.resetRegistryCache();
|
||||||
|
try {
|
||||||
|
const rec = await registryInternals.ensureAccount(id);
|
||||||
|
return {
|
||||||
|
threw: false,
|
||||||
|
error: null,
|
||||||
|
docPublic: rec.docPublic,
|
||||||
|
docProtected: rec.docProtected,
|
||||||
|
docPrivate: rec.docPrivate,
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* VERIFY the provisioned account actually PERSISTED to the shim: resetRegistryCache
|
||||||
|
* then re-resolve the SAME id via a fresh anchored read. Returns whether the read
|
||||||
|
* threw + the resolved docs. After the fix, on a fresh wallet this returns the SAME
|
||||||
|
* docs coldEnsureAccount minted (real persistence, no RepoNotFound).
|
||||||
|
*/
|
||||||
|
async verifyShimPersisted(id: string) {
|
||||||
|
registryInternals.resetRegistryCache();
|
||||||
|
try {
|
||||||
|
const rec = await registryInternals.ensureAccount(id);
|
||||||
|
return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── watchShape (reactive useQuery-shaped read) ───────────────────────────
|
||||||
|
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. watchShape
|
||||||
|
// reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals`
|
||||||
|
// to know which @type to keep — generic, no application domain.
|
||||||
|
_wsProbes: {} as Record<
|
||||||
|
string,
|
||||||
|
{ obs: ShapeObservable; initial: ShapeQuery; unsub: () => void }
|
||||||
|
>,
|
||||||
|
/**
|
||||||
|
* REAL-BROKER cycle proof. Under a fresh identity, create ONE protected entity
|
||||||
|
* document carrying an rdf:type=<e2e class> triple, then open a `watchShape` over
|
||||||
|
* (that class shape, "protected"). Capture the FIRST snapshot right after subscribe
|
||||||
|
* (must be isPending) so the caller can then wait event-driven for isSuccess with
|
||||||
|
* the seeded datum present. Returns the handle + the initial snapshot + the seed
|
||||||
|
* doc/type so the runner can assert the data landed.
|
||||||
|
*/
|
||||||
|
async watchShapeSeedAndSubscribe(handle: string, cls: string) {
|
||||||
|
registryInternals.resetRegistryCache();
|
||||||
|
const id = "@ws-" + handle;
|
||||||
|
setCurrentUser(id);
|
||||||
|
const doc = await storeRegistry.createEntityDoc("protected");
|
||||||
|
const s = await sessionReady;
|
||||||
|
// Seed the entity doc with the shape's type + a title (anchored default graph).
|
||||||
|
await docs.sparqlUpdate(
|
||||||
|
s.session_id,
|
||||||
|
`INSERT DATA { <${doc}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "seeded" }`,
|
||||||
|
doc,
|
||||||
|
);
|
||||||
|
// Wait until this session sees the index append (data persisted on the broker).
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
registryInternals.resetRegistryCache();
|
||||||
|
const listed = await storeRegistry.listMyEntityDocs("protected");
|
||||||
|
if (listed.includes(doc)) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
const shape = {
|
||||||
|
shape: "urn:e2e:ws:Shape",
|
||||||
|
schema: {
|
||||||
|
"urn:e2e:ws:Shape": {
|
||||||
|
iri: "urn:e2e:ws:Shape",
|
||||||
|
predicates: [
|
||||||
|
{
|
||||||
|
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||||
|
readablePredicate: "type",
|
||||||
|
maxCardinality: 1,
|
||||||
|
minCardinality: 1,
|
||||||
|
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const obs = watchShape(shape, "protected") as ShapeObservable;
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
// First snapshot immediately after subscribe — the barrier is not yet crossed
|
||||||
|
// (docs opening via doc_subscribe), so this MUST be pending.
|
||||||
|
const initial = obs.getSnapshot();
|
||||||
|
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
|
||||||
|
// NB: leave the current user SET for the probe's lifetime — watchShape resolves
|
||||||
|
// the scope from getCurrentUser() on every (reactive) refresh, exactly as the app
|
||||||
|
// keeps a stable identity. watchShapeStop clears it.
|
||||||
|
return { doc, cls, initial: { isPending: initial.isPending, isSuccess: initial.isSuccess, dataLen: initial.data.length } };
|
||||||
|
},
|
||||||
|
/** Current snapshot of a watchShape probe (event-driven poll target). */
|
||||||
|
watchShapeSnapshot(handle: string) {
|
||||||
|
const rec = (window as any).__sdk._wsProbes[handle];
|
||||||
|
if (!rec) return null;
|
||||||
|
const snap: ShapeQuery = rec.obs.getSnapshot();
|
||||||
|
return {
|
||||||
|
isPending: snap.isPending,
|
||||||
|
isSuccess: snap.isSuccess,
|
||||||
|
isError: snap.isError,
|
||||||
|
dataLen: snap.data.length,
|
||||||
|
// The seeded title, if the datum is present (proves the real data landed).
|
||||||
|
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watchShapeStop(handle: string) {
|
||||||
|
const rec = (window as any).__sdk._wsProbes[handle];
|
||||||
|
if (rec) rec.unsub();
|
||||||
|
setCurrentUser(null);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* EMPTY-scope proof: a brand-new identity with NO entity docs of `scope`. watchShape
|
||||||
|
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
|
||||||
|
* the handle; poll watchShapeSnapshot for the transition.
|
||||||
|
*/
|
||||||
|
watchShapeEmptyStart(handle: string, cls: string) {
|
||||||
|
registryInternals.resetRegistryCache();
|
||||||
|
const id = "@ws-empty-" + handle;
|
||||||
|
setCurrentUser(id);
|
||||||
|
const shape = {
|
||||||
|
shape: "urn:e2e:ws:Shape",
|
||||||
|
schema: {
|
||||||
|
"urn:e2e:ws:Shape": {
|
||||||
|
iri: "urn:e2e:ws:Shape",
|
||||||
|
predicates: [
|
||||||
|
{
|
||||||
|
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||||
|
readablePredicate: "type",
|
||||||
|
maxCardinality: 1,
|
||||||
|
minCardinality: 1,
|
||||||
|
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const obs = watchShape(shape, "protected") as ShapeObservable;
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
const initial = obs.getSnapshot();
|
||||||
|
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
|
||||||
|
// Keep the identity set for the probe's lifetime (watchShapeStop clears it): a
|
||||||
|
// faithful "empty scope for a real identity", not "no identity at all".
|
||||||
|
return { initial: { isPending: initial.isPending, isSuccess: initial.isSuccess } };
|
||||||
|
},
|
||||||
|
|
||||||
// ── caps / read-filter (in-memory cap model) ─────────────────────────────
|
// ── caps / read-filter (in-memory cap model) ─────────────────────────────
|
||||||
// The read-filter over the injected useShape Set-like. Boundary note: the
|
// The read-filter over the injected useShape Set-like. Boundary note: the
|
||||||
// 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.
|
// We test what the SDK enforces: the in-memory read-filtered VIEW, which after
|
||||||
|
// cap-surface is KEY POSSESSION — you read what your keyring holds, nothing else.
|
||||||
capsReadFilter() {
|
capsReadFilter() {
|
||||||
resetCaps();
|
resetCaps();
|
||||||
injectedSetItems = [
|
injectedSetItems = [
|
||||||
{ "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" },
|
{ "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" },
|
||||||
{ "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" },
|
{ "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" },
|
||||||
{ "@graph": "did:ng:o:ungoverned", "@id": "3", v: "ungoverned-item" },
|
{ "@graph": "did:ng:o:unheld", "@id": "3", v: "unheld-item" },
|
||||||
];
|
];
|
||||||
getCaps().open("did:ng:o:protdoc", "protected", "owner-O");
|
|
||||||
getCaps().makePublic("did:ng:o:pubdoc");
|
|
||||||
// as owner-O
|
|
||||||
setCurrentUser("owner-O");
|
setCurrentUser("owner-O");
|
||||||
|
getCaps().open("did:ng:o:protdoc", "protected");
|
||||||
|
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);
|
||||||
// as a stranger
|
// A stranger holds nothing, and this VIEW asks nobody: it is pure possession, with
|
||||||
|
// 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);
|
||||||
|
const strangerWithLinkView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||||
resetCaps();
|
resetCaps();
|
||||||
injectedSetItems = [];
|
injectedSetItems = [];
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
return { ownerView, strangerView };
|
return { ownerView, strangerView, strangerWithLinkView };
|
||||||
},
|
|
||||||
capsDirectedGrant() {
|
|
||||||
resetCaps();
|
|
||||||
injectedSetItems = [{ "@graph": "did:ng:o:sharedoc", "@id": "1", v: "shared-item" }];
|
|
||||||
getCaps().open("did:ng:o:sharedoc", "protected", "owner-O");
|
|
||||||
setCurrentUser("friend");
|
|
||||||
const before = [...(libUseShape(null, null) as Iterable<any>)].length;
|
|
||||||
getCaps().grantRead("did:ng:o:sharedoc", "friend");
|
|
||||||
const after = [...(libUseShape(null, null) as Iterable<any>)].length;
|
|
||||||
resetCaps();
|
|
||||||
injectedSetItems = [];
|
|
||||||
setCurrentUser(null);
|
|
||||||
return { before, after };
|
|
||||||
},
|
},
|
||||||
|
// MOVED to the applicative suite, `e2e/notebook.ts` (2026-08-07):
|
||||||
|
// - `documentInboxDeposit` → "Bob leaves a message on Alice's note, and only Alice
|
||||||
|
// reads it". This one is WHY that suite exists: it was green here while the
|
||||||
|
// feature was unusable, because a harness can hand an inbox address across an
|
||||||
|
// identity boundary through a variable and an application cannot.
|
||||||
|
// - `capsShareCap` → "Alice's protected note stays shut until she gives Bob the key".
|
||||||
|
//
|
||||||
|
// What stays here is what an application does not do: primitives, characterisation,
|
||||||
|
// and the cold-start regressions.
|
||||||
|
|
||||||
// ── accounts (IdentityStore) ─────────────────────────────────────────────
|
// ── accounts (IdentityStore) ─────────────────────────────────────────────
|
||||||
identitySet(id: string) { return identity.set(id); },
|
identitySet(id: string) { return identity.set(id); },
|
||||||
@@ -662,7 +987,7 @@ const identity = new IdentityStore(
|
|||||||
unsub: () => {},
|
unsub: () => {},
|
||||||
};
|
};
|
||||||
(window as any).__sdk._stateProbe = probe;
|
(window as any).__sdk._stateProbe = probe;
|
||||||
probe.unsub = subscribeDoc(doc, (resp: any) => {
|
probe.unsub = subscribeDoc(asNuri(doc), (resp: any) => {
|
||||||
const elapsedMs = Date.now() - probe.startMs;
|
const elapsedMs = Date.now() - probe.startMs;
|
||||||
// AppResponse shape: { V0: { State: … } } | { V0: { Patch: … } } | { V0: { TabInfo: … } } | …
|
// AppResponse shape: { V0: { State: … } } | { V0: { Patch: … } } | { V0: { TabInfo: … } } | …
|
||||||
let typeKey = "unknown";
|
let typeKey = "unknown";
|
||||||
@@ -745,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);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* DECISIVE real-broker determination: does `doc_subscribe` actually PUSH when a
|
||||||
|
* subscribed document is written?
|
||||||
|
*
|
||||||
|
* This is the reactive-layer coverage whose ABSENCE let a reactivity bug ship: the
|
||||||
|
* app's whole read-model reactivity rests on `subscribeDoc(nuri, cb)` (the polyfill
|
||||||
|
* wrapper over `ng.doc_subscribe`, `src/subscribe.ts`) firing `cb` again on every
|
||||||
|
* commit to the doc. Two pushes are load-bearing in production and were reported as
|
||||||
|
* NOT firing:
|
||||||
|
* (SELF) a session's own `sparqlUpdate` to a doc it subscribes to.
|
||||||
|
* (CROSS) another session writes to a doc the first session subscribes to.
|
||||||
|
*
|
||||||
|
* This runner exercises BOTH against the REAL broker, through the SAME public
|
||||||
|
* surface the app uses — `subscribeDoc` (via the harness's `stateProbe*` bridge,
|
||||||
|
* which passes the raw `AppResponse` straight through the polyfill wrapper),
|
||||||
|
* `docs.docCreate`, and `docs.sparqlUpdate` (`writeTo`). It records EVERY push as a
|
||||||
|
* typed event (`{ typeKey: "State" | "Patch" | "TabInfo" | …, elapsedMs }`) so the
|
||||||
|
* verdict is the ground truth "did the subscription callback fire again", not a
|
||||||
|
* re-read of the document. Each wait is a single event-driven promise+timeout on the
|
||||||
|
* push (NO re-read loop) — a timeout is a DEFINITE "did-not-fire", not a flaky miss.
|
||||||
|
*
|
||||||
|
* Standalone (NOT `bun test`). Run:
|
||||||
|
* bun run e2e/reactivity-doc-subscribe.ts
|
||||||
|
* (or `bun run test:e2e:reactivity` from packages/polyfill)
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* the SAME persistent wallet context — a second concurrent verifier session on one
|
||||||
|
* shared wallet (as faithfulReconnect does) — and writes from it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { launchWatchedContext, setupBrokerPage, 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 };
|
||||||
|
const results: Check[] = [];
|
||||||
|
function record(name: string, ok: boolean, detail?: string): void {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event = { typeKey: string; elapsedMs: number };
|
||||||
|
|
||||||
|
// Call a bridge method inside a given iframe.
|
||||||
|
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||||
|
return frame.evaluate(
|
||||||
|
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
||||||
|
[method, args] as const,
|
||||||
|
) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decisive wait: resolve TRUE as soon as the probe's recorded push count grows
|
||||||
|
* past `base` (the subscription callback fired again), or FALSE on timeout. This is
|
||||||
|
* a promise+timeout on the PUSH itself — it polls only the in-memory event counter
|
||||||
|
* the `subscribeDoc` callback writes, NEVER re-reads the document. A FALSE here is a
|
||||||
|
* definite non-delivery within the window, not a missed re-read.
|
||||||
|
*/
|
||||||
|
async function waitForPush(frame: Frame, base: number, timeoutMs: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await frame.waitForFunction(
|
||||||
|
(b) => (window as any).__sdk.stateProbeEvents().length > (b as number),
|
||||||
|
base,
|
||||||
|
{ timeout: timeoutMs },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false; // timed out → the callback did NOT fire again within the window
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = (events: Event[]): string =>
|
||||||
|
events.length ? events.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ") : "(none)";
|
||||||
|
|
||||||
|
async function openSession(
|
||||||
|
ctx: BrowserContext,
|
||||||
|
url: string,
|
||||||
|
tag: string,
|
||||||
|
): Promise<{ page: Page; frame: Frame; sessionId: string }> {
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on("pageerror", (e) => console.error(`[iframe error:${tag}]`, e.message));
|
||||||
|
page.on("console", (m) => {
|
||||||
|
const t = m.text();
|
||||||
|
// Surface the polyfill's own "doc_subscribe FIRE" diagnostic (subscribe.ts) if
|
||||||
|
// access logging happens to be on — an independent confirmation of a push.
|
||||||
|
if (m.type() === "error") console.error(`[iframe console:${tag}]`, t);
|
||||||
|
else if (t.includes("doc_subscribe FIRE")) console.log(`[${tag}] ${t}`);
|
||||||
|
});
|
||||||
|
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.status() === "connected", {
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
const info = await sdk<{ session_id: string } | null>(frame, "sessionInfo");
|
||||||
|
const sessionId = info?.session_id ?? "(none)";
|
||||||
|
console.log(`[session:${tag}] connected — session_id=${sessionId}`);
|
||||||
|
return { page, frame, sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELF_TIMEOUT_MS = 10000;
|
||||||
|
const CROSS_TIMEOUT_MS = 15000;
|
||||||
|
const STATE_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log("[reactivity] building SDK page bundle...");
|
||||||
|
buildBundle();
|
||||||
|
console.log("[reactivity] minting this run's wallet...");
|
||||||
|
const wallet: RunProfile = await mintBatchWallet("the reactivity suite (e2e/reactivity-doc-subscribe.ts)");
|
||||||
|
const { url, close: closeServer } = await serveHarness();
|
||||||
|
console.log(`[reactivity] harness served at ${url}`);
|
||||||
|
|
||||||
|
let ctx: BrowserContext | null = null;
|
||||||
|
try {
|
||||||
|
ctx = await launchWatchedContext("reactivity", wallet.dir);
|
||||||
|
|
||||||
|
// ── Session A (the subscriber for both cases) ────────────────────────────
|
||||||
|
const A = await openSession(ctx, url, "A");
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// CASE 1 — SELF: A subscribes to D, then A itself writes to D.
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log("\n── CASE 1: SELF (single session — own write to own subscribed doc) ──");
|
||||||
|
{
|
||||||
|
const doc = await sdk<string>(A.frame, "docCreate");
|
||||||
|
console.log(` [SELF] created doc D = ${doc}`);
|
||||||
|
await sdk(A.frame, "stateProbeSubscribe", doc);
|
||||||
|
|
||||||
|
// Wait for the initial State (the sync barrier). TabInfo may precede it.
|
||||||
|
const gotState = await (async () => {
|
||||||
|
try {
|
||||||
|
await A.frame.waitForFunction(
|
||||||
|
() => (window as any).__sdk.stateProbeStateCount() >= 1,
|
||||||
|
{ timeout: STATE_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const afterSubscribe = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||||
|
console.log(` [SELF] pushes after subscribe: ${seq(afterSubscribe)}`);
|
||||||
|
record(
|
||||||
|
"SELF: initial State push arrives on subscribe (baseline sanity)",
|
||||||
|
gotState && afterSubscribe.some((e) => e.typeKey === "State"),
|
||||||
|
`sequence=${seq(afterSubscribe)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now the decisive write: A's OWN sparqlUpdate to D.
|
||||||
|
const preWrite = afterSubscribe.length;
|
||||||
|
console.log(` [SELF] A writes to D (own sparqlUpdate); waiting ≤${SELF_TIMEOUT_MS}ms for a push…`);
|
||||||
|
await sdk(A.frame, "writeTo", doc, "self-1");
|
||||||
|
const fired = await waitForPush(A.frame, preWrite, SELF_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const afterWrite = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||||
|
const newEvents = afterWrite.slice(preWrite);
|
||||||
|
console.log(` [SELF] pushes AFTER own write: ${seq(newEvents)}`);
|
||||||
|
console.log(` [SELF] VERDICT: callback ${fired ? "FIRED" : "did NOT fire"} within ${SELF_TIMEOUT_MS}ms`);
|
||||||
|
record(
|
||||||
|
`SELF: subscription callback fires on the session's OWN write (≤${SELF_TIMEOUT_MS}ms)`,
|
||||||
|
fired,
|
||||||
|
`newPushes=${seq(newEvents)}`,
|
||||||
|
);
|
||||||
|
await sdk(A.frame, "stateProbeStop");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// CASE 2 — CROSS-SESSION: A subscribes to D2; a SECOND session B (same shared
|
||||||
|
// wallet, own concurrent verifier session) writes to D2.
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log("\n── CASE 2: CROSS-SESSION (session B writes to a doc session A subscribes to) ──");
|
||||||
|
let B: { page: Page; frame: Frame; sessionId: string } | null = null;
|
||||||
|
try {
|
||||||
|
B = await openSession(ctx, url, "B");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(` [CROSS] COULD-NOT-TEST: second concurrent session on the shared wallet failed to open: ${String(e?.message ?? e)}`);
|
||||||
|
record(
|
||||||
|
"CROSS: second concurrent session opened on the shared wallet",
|
||||||
|
false,
|
||||||
|
`open failed: ${String(e?.message ?? e)} — see Festipod multibrowser harness as the alternative venue`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (B) {
|
||||||
|
// NB: `session_id` is a PER-PAGE local verifier counter (each fresh iframe
|
||||||
|
// numbers its first session "1"), so it is NOT a global identifier and cannot
|
||||||
|
// be used to prove distinctness. The REAL proof that A and B are two separate
|
||||||
|
// verifier sessions is behavioural: B's write reaches A only after a broker
|
||||||
|
// round-trip (a delayed Patch), not as an instant same-session echo.
|
||||||
|
console.log(
|
||||||
|
` [CROSS] both pages connected — A.session=${A.sessionId} B.session=${B.sessionId} (per-page local counter; distinctness shown by the cross-broker propagation below)`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
"CROSS: a second concurrent page/session is open on the same shared wallet",
|
||||||
|
true,
|
||||||
|
`A=${A.sessionId} B=${B.sessionId} (session_id is a per-page counter, not a global id)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A creates D2 and subscribes.
|
||||||
|
const doc2 = await sdk<string>(A.frame, "docCreate");
|
||||||
|
console.log(` [CROSS] A created doc D2 = ${doc2}`);
|
||||||
|
await sdk(A.frame, "stateProbeSubscribe", doc2);
|
||||||
|
const gotState2 = await (async () => {
|
||||||
|
try {
|
||||||
|
await A.frame.waitForFunction(
|
||||||
|
() => (window as any).__sdk.stateProbeStateCount() >= 1,
|
||||||
|
{ timeout: STATE_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const afterSub2 = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||||
|
console.log(` [CROSS] A pushes after subscribe: ${seq(afterSub2)}`);
|
||||||
|
record(
|
||||||
|
"CROSS: A receives its initial State on D2 (baseline sanity)",
|
||||||
|
gotState2 && afterSub2.some((e) => e.typeKey === "State"),
|
||||||
|
`sequence=${seq(afterSub2)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// B writes to D2. Capture a write failure (e.g. RepoNotFound) explicitly —
|
||||||
|
// it would mean B cannot reach A's doc, which is itself a determination.
|
||||||
|
const preCross = afterSub2.length;
|
||||||
|
let writeThrew: string | null = null;
|
||||||
|
// Cross-session writes to a doc created by ANOTHER session can be slow: B must
|
||||||
|
// sync/open D2's repo before it can commit. Time it separately so the push
|
||||||
|
// latency is reported relative to when B's write actually LANDED, not to
|
||||||
|
// subscribe time.
|
||||||
|
console.log(` [CROSS] B writes to D2 from its own session…`);
|
||||||
|
const tWriteStart = Date.now();
|
||||||
|
try {
|
||||||
|
await sdk(B.frame, "writeTo", doc2, "cross-1");
|
||||||
|
} catch (e: any) {
|
||||||
|
writeThrew = String(e?.message ?? e);
|
||||||
|
console.log(` [CROSS] B's write THREW: ${writeThrew}`);
|
||||||
|
}
|
||||||
|
const writeMs = Date.now() - tWriteStart;
|
||||||
|
record("CROSS: session B's write to D2 did not throw", writeThrew === null, writeThrew ? writeThrew : `landed in ${writeMs}ms`);
|
||||||
|
|
||||||
|
console.log(` [CROSS] B's write returned in ${writeMs}ms; now waiting ≤${CROSS_TIMEOUT_MS}ms for A's push…`);
|
||||||
|
const tWaitStart = Date.now();
|
||||||
|
const crossFired = writeThrew ? false : await waitForPush(A.frame, preCross, CROSS_TIMEOUT_MS);
|
||||||
|
const pushAfterWriteMs = Date.now() - tWaitStart;
|
||||||
|
const afterCross = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||||
|
const crossNew = afterCross.slice(preCross);
|
||||||
|
console.log(` [CROSS] A pushes AFTER B's write: ${seq(crossNew)}`);
|
||||||
|
console.log(
|
||||||
|
` [CROSS] VERDICT: A's callback ${crossFired ? `FIRED (${pushAfterWriteMs}ms after B's write landed)` : "did NOT fire"} within ${CROSS_TIMEOUT_MS}ms${writeThrew ? " (B's write threw first)" : ""}`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
`CROSS: A's subscription callback fires on B's write (≤${CROSS_TIMEOUT_MS}ms after B's write landed)`,
|
||||||
|
crossFired,
|
||||||
|
`newPushes=${seq(crossNew)} (B write took ${writeMs}ms; push ${crossFired ? pushAfterWriteMs + "ms after" : "not seen"})${writeThrew ? ` — B write threw: ${writeThrew}` : ""}`,
|
||||||
|
);
|
||||||
|
await sdk(A.frame, "stateProbeStop");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
||||||
|
closeServer();
|
||||||
|
wallet.discard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Determination summary (not a pass/fail gate — this is a probe) ──────────
|
||||||
|
console.log("\n══ doc_subscribe delivery determination ══");
|
||||||
|
for (const r of results) console.log(` [${r.ok ? "PASS" : "FAIL"}] ${r.name}${r.detail ? " — " + r.detail : ""}`);
|
||||||
|
const self = results.find((r) => r.name.startsWith("SELF: subscription callback fires"));
|
||||||
|
const cross = results.find((r) => r.name.startsWith("CROSS: A's subscription callback fires"));
|
||||||
|
console.log("\n SELF →", self ? (self.ok ? "FIRES" : "DOES-NOT-FIRE") : "could-not-test");
|
||||||
|
console.log(" CROSS →", cross ? (cross.ok ? "FIRES" : "DOES-NOT-FIRE") : "could-not-test");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("[reactivity] fatal:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* COLD-START repro — a genuinely FRESH wallet (never ran the app) driving the
|
||||||
|
* shim's first account resolution/provision.
|
||||||
|
*
|
||||||
|
* Unlike `run.ts`, which reuses ONE dedicated wallet (always "hot" — its
|
||||||
|
* private-store repo is already in the verifier's `self.repos`), this mints a
|
||||||
|
* BRAND-NEW wallet + fresh profile per run and opens the SDK page over it as the
|
||||||
|
* very first session. It then, in order:
|
||||||
|
* 1) probes the RAW anchored shim SELECT on `did:ng:${private_store_id}` — the
|
||||||
|
* cold-start bug surfaces here as `RepoNotFound` (the private-store repo not
|
||||||
|
* yet open) rather than a silent 0 rows;
|
||||||
|
* 2) runs `ensureAccount` (resetRegistryCache first) — the app's first-login
|
||||||
|
* bootstrap — and asserts it provisions 3 scope docs WITHOUT throwing;
|
||||||
|
* 3) re-resolves the SAME id from a fresh anchored read and asserts it returns
|
||||||
|
* the SAME docs (real persistence in the shim).
|
||||||
|
*
|
||||||
|
* Expected BEFORE the fix: step 1 throws RepoNotFound; step 2/3 fail to persist.
|
||||||
|
* Expected AFTER the fix: step 1 may still throw (raw, no open), but step 2/3
|
||||||
|
* succeed because ensureAccount opens the anchor repo before read/write.
|
||||||
|
*
|
||||||
|
* Run: `bun run e2e/repro-fresh-wallet.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mintWalletProfileKeepingContext, setupBrokerPage, type RunProfile, type Frame, type Page, type BrowserContext } from "ng-e2e-helpers";
|
||||||
|
import { WALLET, buildBundle, serveHarness } from "./harness-page";
|
||||||
|
|
||||||
|
type Check = { name: string; ok: boolean; detail?: string };
|
||||||
|
const results: Check[] = [];
|
||||||
|
function check(name: string, ok: boolean, detail?: string): void {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||||
|
return frame.evaluate(
|
||||||
|
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
||||||
|
[method, args] as const,
|
||||||
|
) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log("[repro] building SDK page bundle...");
|
||||||
|
buildBundle();
|
||||||
|
const { url, close: closeServer } = await serveHarness();
|
||||||
|
console.log(`[repro] harness served at ${url}`);
|
||||||
|
|
||||||
|
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
|
||||||
|
let ctx: BrowserContext | null = null;
|
||||||
|
let profile: RunProfile | null = null;
|
||||||
|
let page: Page | null = null;
|
||||||
|
try {
|
||||||
|
// 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;
|
||||||
|
profile = fresh.profile;
|
||||||
|
console.log(`[repro] fresh wallet: ${credentials.name}`);
|
||||||
|
|
||||||
|
page = await ctx.newPage();
|
||||||
|
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
|
||||||
|
page.on("console", (m) => {
|
||||||
|
if (m.type() === "error") console.error("[iframe console]", m.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)...");
|
||||||
|
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.status() === "connected", {
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
console.log("[repro] connected. Driving the cold-start shim resolution...");
|
||||||
|
|
||||||
|
// 1) RAW anchored shim probe — the diagnostic. Reports RepoNotFound if the
|
||||||
|
// private-store repo is not yet open in this fresh session.
|
||||||
|
const probe = await sdk<any>(frame, "shimAnchorProbe");
|
||||||
|
console.log(
|
||||||
|
` [DIAG] raw anchored shim read: threw=${probe.threw} error=${probe.error} rows=${probe.rows}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2) ensureAccount — the real bootstrap. This MUST provision cleanly on a fresh
|
||||||
|
// wallet (all 3 docs truthy, no throw). This is the load-bearing assertion.
|
||||||
|
const ensured = await sdk<any>(frame, "coldEnsureAccount", "@cold-user-1");
|
||||||
|
check(
|
||||||
|
"fresh wallet: ensureAccount provisions the account without throwing",
|
||||||
|
!ensured.threw && !!ensured.docPublic && !!ensured.docProtected && !!ensured.docPrivate,
|
||||||
|
ensured.threw
|
||||||
|
? `THREW: ${ensured.error}`
|
||||||
|
: `pub=${String(ensured.docPublic).slice(0, 22)}… prot=${String(ensured.docProtected).slice(0, 22)}…`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3) Re-resolve from a FRESH anchored read — proves the shim actually persisted.
|
||||||
|
const verified = await sdk<any>(frame, "verifyShimPersisted", "@cold-user-1");
|
||||||
|
check(
|
||||||
|
"fresh wallet: the provisioned account persists (re-resolves the SAME docs, no RepoNotFound)",
|
||||||
|
!verified.threw &&
|
||||||
|
verified.docPublic === ensured.docPublic &&
|
||||||
|
verified.docProtected === ensured.docProtected &&
|
||||||
|
verified.docPrivate === ensured.docPrivate,
|
||||||
|
verified.threw
|
||||||
|
? `THREW: ${verified.error}`
|
||||||
|
: `same=${verified.docPublic === ensured.docPublic && verified.docProtected === ensured.docProtected}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
try { if (page) await page.close(); } catch { /* ignore */ }
|
||||||
|
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
||||||
|
profile?.discard();
|
||||||
|
closeServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = results.filter((r) => r.ok).length;
|
||||||
|
const failed = results.length - passed;
|
||||||
|
console.log(`\n══ cold-start repro: ${passed} passed, ${failed} failed ══`);
|
||||||
|
if (failed > 0) {
|
||||||
|
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("[repro] fatal:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
@@ -224,7 +336,9 @@ async function main(): Promise<void> {
|
|||||||
// ── inbox ───────────────────────────────────────────────────────────────
|
// ── inbox ───────────────────────────────────────────────────────────────
|
||||||
console.log("\n── inbox ──");
|
console.log("\n── inbox ──");
|
||||||
await step("inbox post → read round-trip", async () => {
|
await step("inbox post → read round-trip", async () => {
|
||||||
const r = await sdk<any>(frame, "inboxPostRead", { k: "a" }, { k: "b" });
|
// Fresh user per run: an inbox is stable for its owner, so a reused id would
|
||||||
|
// read back the previous runs' deposits too (the wallet persists).
|
||||||
|
const r = await sdk<any>(frame, "inboxPostRead", "@inbox-user-" + Date.now(), { k: "a" }, { k: "b" });
|
||||||
const payloads = (r.deposits || []).map((d: any) => JSON.stringify(d.payload));
|
const payloads = (r.deposits || []).map((d: any) => JSON.stringify(d.payload));
|
||||||
check(
|
check(
|
||||||
"post then read returns both deposits (sorted)",
|
"post then read returns both deposits (sorted)",
|
||||||
@@ -233,7 +347,7 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await step("inbox watch fires on deposit", async () => {
|
await step("inbox watch fires on deposit", async () => {
|
||||||
await sdk(frame, "inboxWatchStart");
|
await sdk(frame, "inboxWatchStart", "@watcher-" + Date.now());
|
||||||
await frame.waitForFunction(() => (window as any).__sdk.inboxWatchState().fires >= 1, { timeout: 20000 });
|
await frame.waitForFunction(() => (window as any).__sdk.inboxWatchState().fires >= 1, { timeout: 20000 });
|
||||||
const base = await sdkGet<any>(frame, "inboxWatchState");
|
const base = await sdkGet<any>(frame, "inboxWatchState");
|
||||||
await sdk(frame, "inboxWatchDeposit", { landed: true });
|
await sdk(frame, "inboxWatchDeposit", { landed: true });
|
||||||
@@ -246,38 +360,16 @@ 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");
|
||||||
});
|
});
|
||||||
|
// MOVED to the applicative suite (`e2e/notebook.ts`, "Bob leaves a message on Alice's
|
||||||
|
// note, and only Alice reads it"). This is the step that motivated that suite: it was
|
||||||
|
// green here while the feature was unusable, because the harness could hand the inbox
|
||||||
|
// address across an identity boundary through a variable — a channel no application
|
||||||
|
// has. Driven through two screens, the address has to be FOUND or the journey fails.
|
||||||
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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── discovery index ─────────────────────────────────────────────────────
|
|
||||||
console.log("\n── discovery index ──");
|
|
||||||
await step("discovery submit → read", async () => {
|
|
||||||
const ref = { doc: "did:ng:o:some-public-doc", title: "t" };
|
|
||||||
const r = await sdk<any>(frame, "discoverySubmitRead", ref);
|
|
||||||
const refs = (r.entries || []).map((e: any) => JSON.stringify(e.ref));
|
|
||||||
check("submitToIndex then readIndex returns the entry", refs.includes(JSON.stringify(ref)), `entries=${r.entries.length}`);
|
|
||||||
});
|
|
||||||
await step("discovery watchIndex fires reactively", async () => {
|
|
||||||
await sdk(frame, "discoveryWatchStart");
|
|
||||||
await frame.waitForFunction(() => (window as any).__sdk.discoveryWatchState().fires >= 1, { timeout: 20000 });
|
|
||||||
const base = await sdkGet<any>(frame, "discoveryWatchState");
|
|
||||||
await sdk(frame, "discoverySubmit", { doc: "did:ng:o:doc2", title: "t2", n: Date.now() });
|
|
||||||
await frame.waitForFunction(
|
|
||||||
(b) => (window as any).__sdk.discoveryWatchState().fires > (b as number),
|
|
||||||
base.fires,
|
|
||||||
{ timeout: 20000 },
|
|
||||||
);
|
|
||||||
const after = await sdkGet<any>(frame, "discoveryWatchState");
|
|
||||||
check("watchIndex fires on a new submission", after.fires > base.fires, `fires=${after.fires}`);
|
|
||||||
await sdk(frame, "discoveryWatchStop");
|
|
||||||
});
|
|
||||||
await step("reserved @index account isolation", async () => {
|
|
||||||
const r = await sdk<any>(frame, "discoveryIndexIsolation");
|
|
||||||
check("user '@index' resolves disjoint from the reserved index owner", r.disjoint === true, `disjoint=${r.disjoint}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── store-registry ──────────────────────────────────────────────────────
|
// ── store-registry ──────────────────────────────────────────────────────
|
||||||
console.log("\n── store-registry ──");
|
console.log("\n── store-registry ──");
|
||||||
await step("ensureAccount idempotent", async () => {
|
await step("ensureAccount idempotent", async () => {
|
||||||
@@ -294,20 +386,70 @@ async function main(): Promise<void> {
|
|||||||
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}…`);
|
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}…`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── watchShape (reactive useQuery-shaped read) ──────────────────────────
|
||||||
|
// The real cycle: first subscription reads isPending (barrier not yet crossed),
|
||||||
|
// then after the broker sync it reaches isSuccess with the seeded datum present.
|
||||||
|
// A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the
|
||||||
|
// distinction useShape's upgrade will make native, surfaced here from getSyncState).
|
||||||
|
console.log("\n── watchShape (reactive useQuery-shaped read) ──");
|
||||||
|
await step("watchShape: first subscribe isPending → isSuccess with data present", async () => {
|
||||||
|
const h = "cyc" + Date.now();
|
||||||
|
const CLS = "urn:e2e:ws:Event";
|
||||||
|
const seed = await sdk<any>(frame, "watchShapeSeedAndSubscribe", h, CLS);
|
||||||
|
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
|
||||||
|
// Event-driven: wait for the barrier to cross + the datum to land.
|
||||||
|
await frame.waitForFunction(
|
||||||
|
(hh) => {
|
||||||
|
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
|
||||||
|
return s && s.isSuccess && s.dataLen >= 1;
|
||||||
|
},
|
||||||
|
h,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
|
||||||
|
check("reaches isSuccess with the seeded datum (titles include 'seeded')", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen >= 1 && snap.titles.includes("seeded"), `snap=${JSON.stringify(snap)}`);
|
||||||
|
await sdk(frame, "watchShapeStop", h);
|
||||||
|
});
|
||||||
|
await step("watchShape: empty scope reaches isSuccess with data:[]", async () => {
|
||||||
|
const h = "empty" + Date.now();
|
||||||
|
const CLS = "urn:e2e:ws:Event";
|
||||||
|
await sdk<any>(frame, "watchShapeEmptyStart", h, CLS);
|
||||||
|
await frame.waitForFunction(
|
||||||
|
(hh) => {
|
||||||
|
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
|
||||||
|
return s && s.isSuccess;
|
||||||
|
},
|
||||||
|
h,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
|
||||||
|
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
|
||||||
|
await sdk(frame, "watchShapeStop", h);
|
||||||
|
});
|
||||||
|
|
||||||
// ── caps / read-filter (in-memory cap model) ────────────────────────────
|
// ── caps / read-filter (in-memory cap model) ────────────────────────────
|
||||||
console.log("\n── caps / read-filter (in-memory cap model) ──");
|
console.log("\n── caps / read-filter (in-memory cap model) ──");
|
||||||
await step("read-filter: protected hidden from stranger", async () => {
|
await step("read-filter: you read what your keyring holds, nothing else", async () => {
|
||||||
const r = await sdk<any>(frame, "capsReadFilter");
|
const r = await sdk<any>(frame, "capsReadFilter");
|
||||||
const ownerSeesProt = r.ownerView.includes("protected-item");
|
// The owner reads the documents whose caps their keyring holds — and NOT the
|
||||||
const strangerHiddenProt = !r.strangerView.includes("protected-item");
|
// one it does not, even though its NURI is right there in the set.
|
||||||
const bothSeePublic = r.ownerView.includes("public-item") && r.strangerView.includes("public-item");
|
const ownerReadsHeld =
|
||||||
const bothSeeUngoverned = r.ownerView.includes("ungoverned-item") && r.strangerView.includes("ungoverned-item");
|
r.ownerView.includes("protected-item") && r.ownerView.includes("public-item");
|
||||||
check("owner reads protected; stranger does not; public+ungoverned visible to both", ownerSeesProt && strangerHiddenProt && bothSeePublic && bothSeeUngoverned, `owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)}`);
|
const ownerMissesUnheld = !r.ownerView.includes("unheld-item");
|
||||||
});
|
// A stranger holds nothing at all — a bare reference names without reading.
|
||||||
await step("read-filter: directed grant reveals the doc", async () => {
|
const strangerReadsNothing = r.strangerView.length === 0;
|
||||||
const r = await sdk<any>(frame, "capsDirectedGrant");
|
// …until the repo link of the PUBLISHED document reaches them.
|
||||||
check("grantRead reveals the protected doc to the grantee", r.before === 0 && r.after === 1, `before=${r.before} after=${r.after}`);
|
const linkOpensPublic =
|
||||||
|
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
|
||||||
|
check(
|
||||||
|
"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,
|
||||||
|
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
// 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) ──");
|
||||||
@@ -355,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");
|
||||||
@@ -385,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 */ }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -465,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());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -527,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());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -679,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 ?? ""}`);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@ng-eventually/polyfill",
|
||||||
|
"version": "1.0.0-dev.3",
|
||||||
|
"type": "module",
|
||||||
|
"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",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@ng-org/web": "*",
|
||||||
|
"@ng-org/orm": "*",
|
||||||
|
"@ng-org/shex-orm": "*",
|
||||||
|
"@ng-org/alien-deepsignals": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@ng-org/web": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@ng-org/orm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@ng-org/shex-orm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@ng-org/alien-deepsignals": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ng-org/web": "0.1.2-alpha.13",
|
||||||
|
"@ng-org/shex-orm": "0.1.2-alpha.8",
|
||||||
|
"@ng-org/alien-deepsignals": "0.1.2-alpha.11"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user