docs+refactor(client): fidelity pass — id identity, drop connections, no faux-login, accurate NextGraph framing
Align the polyfill's surface and docs with the verified NextGraph reality and
remove application-level concepts:
- Identity is an ID, not a username: AccountRecord.id, shim predicate shim:id,
normalizeId; accounts core becomes IdentityStore (set/clear/get) — the faux
login/logout framing is gone (identity is set at wallet-import time).
- Relationship/connection is an application concept, not a platform primitive
(NextGraph has no bilateral-connection primitive: grantee is unpersisted
scaffolding, cap-send is unimplemented). Remove connections.ts; caps exposes
only a directed grantRead(doc, granteeId) + a read-only protectedDocsOf(owner).
Delete the now-dead isolation.ts social-visibility axis.
- Inbox docs: NextGraph has no separate curator — the recipient's own verifier
unseals and applies each queued sealed message inline (process_inbox);
inbox_post_link is a proposed/future API. Stop attributing the emulated
curator to the platform.
- Read isolation reframed around the outcome: no cap -> empty union read;
targeted read of an unheld repo -> RepoNotFound; cap introspection
(canRead/governsRead) is emulation-only with no NextGraph API behind it.
- read-model.md corrected: the listing path is per-doc ANCHORED default-graph
queries, never the anchorless GRAPH ?g union (that is O(wallet)); the probe
section no longer claims the opposite.
- README recap table restructured (target | current NextGraph status | current
emulation); INDEX_ACCOUNT documented as reservedAccount("index") in the
sentinel namespace; de-domained generic-layer comments; softened tone.
Consumer application (Festipod) rewired separately to own the relationship
concept and feed the lib an id. Lib gates: bun test 83 pass / 0 fail, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+242
-229
@@ -1,61 +1,61 @@
|
||||
# How this library emulates mature NextGraph on ONE shared wallet
|
||||
# How this library emulates mature NextGraph on one shared wallet
|
||||
|
||||
> **EVERYTHING in this file is EMULATION.** Not one behaviour described here is a
|
||||
> real NextGraph feature: each is a **stopgap** the lib fabricates on top of the
|
||||
> *current, immature* NextGraph (the exact gaps it compensates for are in
|
||||
> Everything in this file is emulation. None of the behaviours described here is a
|
||||
> real NextGraph feature: each is a stopgap the lib fabricates on top of the
|
||||
> current, immature NextGraph (the exact gaps it compensates for are in
|
||||
> [`nextgraph-current-state.md`](./nextgraph-current-state.md)). Every piece has a
|
||||
> **real target** and goes away when NextGraph matures — the swap is **lib-only**,
|
||||
> the consumer's code is unchanged. The per-behaviour recap table lives in the
|
||||
> real target and goes away when NextGraph matures — the swap is lib-only, and the
|
||||
> consumer application's code is unchanged. The per-behaviour recap table lives in the
|
||||
> top-level [`README.md`](../README.md) (*What is emulated (and how it goes away)*);
|
||||
> the removal checklist is [`migration-guide.md`](./migration-guide.md). Read this
|
||||
> file for *how* each emulation works; read those two for *what is fake* and *what
|
||||
> replaces it*.
|
||||
|
||||
The consumer writes against `@ng-eventually/client` **as if** NextGraph 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)).
|
||||
The consumer application writes against `@ng-eventually/client` as if NextGraph
|
||||
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)).
|
||||
This file is the lib's own engineering doctrine on how it fabricates that mature
|
||||
face on top of **one single shared wallet / broker**. Everything here is
|
||||
face on top of one single shared wallet / broker. Everything here is
|
||||
polyfill-era and disappears at migration ([`migration-guide.md`](./migration-guide.md)).
|
||||
|
||||
## The premise: one shared wallet, everything readable
|
||||
|
||||
Current NextGraph has **no cross-wallet read** (`OpenRepo` is a TODO at
|
||||
Current NextGraph has no cross-wallet read (`OpenRepo` is a TODO at
|
||||
`engine/verifier/src/verifier.rs:1423`; a foreign NURI raises `RepoNotFound`; a
|
||||
session only holds its own 3 stores in `self.repos`). So "each user their own
|
||||
wallet" is blocked at the root — no data ever crosses the boundary between two
|
||||
wallets.
|
||||
|
||||
The lib's answer: **everyone opens the same wallet**. NextGraph sees a single
|
||||
identity → **everything is physically readable**. "Multi-user" becomes an
|
||||
The lib's answer: everyone opens the same wallet. NextGraph sees a single
|
||||
identity, so everything is physically readable. "Multi-user" becomes an
|
||||
application fiction the lib maintains. On top of that one wallet the lib rebuilds,
|
||||
by emulation, the per-user stores + capabilities + inbox the consumer codes
|
||||
against.
|
||||
by emulation, the per-user stores + capabilities + inbox the consumer application
|
||||
codes against.
|
||||
|
||||
## Physical wallet vs virtual wallet — never enumerate the physical one
|
||||
|
||||
Because the emulation runs on ONE shared wallet, distinguish two levels:
|
||||
|
||||
- **Physical wallet** — the real NextGraph wallet everyone opens. Its local store
|
||||
holds **every account's documents PLUS the lib's own internals** (the shim index,
|
||||
the inbox docs, the discovery index) as named graphs. It **accumulates without
|
||||
bound** across sessions/runs. **Listing / scanning "all documents" of the physical
|
||||
wallet is meaningless AND O(size)** — it mixes every user's data with lib internals,
|
||||
and it is exactly what a `sparql_query` with **no anchor** (`GRAPH ?g { … }`) does
|
||||
(it spans every synced graph). **Never do it.** The physical wallet is a substrate,
|
||||
holds every account's documents plus the lib's own internals (the shim index,
|
||||
the inbox docs, the discovery index) as named graphs. It accumulates without
|
||||
bound across sessions/runs. Listing or scanning "all documents" of the physical
|
||||
wallet is meaningless and O(size) — it mixes every user's data with lib internals,
|
||||
and it is exactly what a `sparql_query` with no anchor (`GRAPH ?g { … }`) does
|
||||
(it spans every synced graph). The physical wallet is a substrate,
|
||||
not something to enumerate.
|
||||
|
||||
- **Virtual wallet** — the lib's emulation of **one user's** wallet: the set of
|
||||
- **Virtual wallet** — the lib's emulation of one user's wallet: the set of
|
||||
documents the shim attributes to that account (its per-scope index in
|
||||
`store-registry.ts`). This is what "the user owns". Over a *virtual* wallet,
|
||||
"**list my documents**" is meaningful and **bounded** (only that account's docs).
|
||||
"list my documents" is meaningful and bounded (only that account's docs).
|
||||
|
||||
**Consequence for reads (see `read-model.md`):** to list a user's entities you
|
||||
enumerate the **virtual** wallet — the account's scope index (bounded, O(my docs)),
|
||||
NOT the physical union — then read those specific documents with a **per-doc anchored**
|
||||
enumerate the *virtual* wallet — the account's scope index (bounded, O(my docs)),
|
||||
not the physical union — then read those specific documents with a per-doc anchored
|
||||
`sparql_query`. A non-empty / bloated physical wallet then costs nothing, because the
|
||||
physical union is never scanned. Discovery (all public events) is the one bounded
|
||||
enumeration hack and goes through the discovery **index**, not a physical scan.
|
||||
enumeration hack and goes through the discovery index, not a physical scan.
|
||||
|
||||
At migration each virtual wallet becomes a real per-user wallet; the physical/virtual
|
||||
distinction — and the "never enumerate the physical wallet" rule — dissolves into
|
||||
@@ -66,20 +66,20 @@ native per-wallet reads.
|
||||
The single most load-bearing distinction. Two **orthogonal** axes the
|
||||
terminology historically fused:
|
||||
|
||||
- **Axis A — which native STORE?** A wallet has 3: `private_store_id`,
|
||||
- **Axis A — which native store?** A wallet has 3: `private_store_id`,
|
||||
`protected_store_id`, `public_store_id`. Historic origin of "mono-store /
|
||||
multi-store" (use 1 store vs the 3).
|
||||
- **Axis B — how many DOCUMENTS in a store?** A store contains documents; the
|
||||
**document (= repo = `@graph`) is the sharing + rights boundary**. The ReadCap
|
||||
— hence **isolation** — is **PER-DOCUMENT**.
|
||||
- **Axis B — how many documents in a store?** A store contains documents; the
|
||||
document (= repo = `@graph`) is the sharing + rights boundary. The ReadCap —
|
||||
hence isolation — is per-document.
|
||||
|
||||
**`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` → the shared
|
||||
wallet's PRIVATE store.** The trailing `store` arg left `undefined` targets the
|
||||
`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` targets the shared
|
||||
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
|
||||
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
|
||||
shim — **not** a NextGraph store. Therefore what a consumer's "multi-store" flag
|
||||
switches on is really **multi-DOCUMENT with logical scope labels**, never
|
||||
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
|
||||
shim — not a NextGraph store. Therefore what a consumer application's "multi-store"
|
||||
flag switches on is really multi-document with logical scope labels, never
|
||||
multi-store. Do not read `Scope` (`types.ts`) as a physical store — it is the
|
||||
logical label the registry attaches.
|
||||
|
||||
@@ -98,32 +98,31 @@ public/protected/private stores — on top of one shared wallet.
|
||||
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
|
||||
logical attribute tracked here, not a physical store.
|
||||
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
|
||||
NURIs`, persisted as RDF in the shared wallet's **private store** (the anchor,
|
||||
NURIs`, persisted as RDF in the shared wallet's private store (the anchor,
|
||||
always known from the session: `RegistrySession.privateStoreId`). That makes
|
||||
login **cross-device**: another device opening the same wallet reads the same
|
||||
shim and finds the same accounts. It is the account→document **trust root** —
|
||||
identity resolution cross-device: another device opening the same wallet reads
|
||||
the same shim and finds the same accounts. It is the account→document trust root,
|
||||
which is why every untrusted value that reaches its SPARQL is escaped (see
|
||||
SPARQL hardening below).
|
||||
- **Per-entity documents + per-scope index.** `createEntityDoc(username, scope)`
|
||||
makes a dedicated document for ONE entity (mirrors the target, where each entity
|
||||
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
|
||||
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
|
||||
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).
|
||||
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).
|
||||
`listEntityDocs(scope)` unions the contained NURIs across all accounts. This is a
|
||||
**fallback / test-only** path, NOT the read path: enumerating every account and
|
||||
fallback / test-only path, not the read path: enumerating every account and
|
||||
handing the NURIs to `useShape({ graphs })` opens/syncs other accounts' possibly-
|
||||
unsynced docs and HANGS (the ORM fan-out, ~75s — see
|
||||
[`read-model.md`](./read-model.md)). The real READ path is
|
||||
`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
|
||||
wallet (that is O(wallet size) and timed out ~90s; see
|
||||
[`read-model.md`](./read-model.md)). The app resolves the by-need doc set from the
|
||||
discovery index (public events) and `listMyEntityDocs(username, scope)` (my own
|
||||
account, bounded — no cross-account fan-out).
|
||||
- **GENERIC by construction.** The registry knows only the three native scopes,
|
||||
**zero** application entity kind. The consumer maps its entities to a scope and
|
||||
injects the session + username normalization via `configureStoreRegistry({
|
||||
getSession, normalizeUser })` (`polyfill.ts`).
|
||||
unsynced docs and hangs (the ORM fan-out — see
|
||||
[`read-model.md`](./read-model.md)). The real read path is
|
||||
`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
|
||||
wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves
|
||||
the by-need doc set from the discovery index (public events) and
|
||||
`listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out).
|
||||
- **Generic by construction.** The registry knows only the three native scopes,
|
||||
zero application entity kind. The consumer application maps its entities to a scope
|
||||
and injects the session + identity-id normalization via `configureStoreRegistry({
|
||||
getSession, normalizeId })` (`polyfill.ts`).
|
||||
|
||||
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
|
||||
@@ -131,30 +130,31 @@ one private store via `docCreate(..., undefined)`).
|
||||
|
||||
### A virtual wallet's structure — the three emulated stores
|
||||
|
||||
A **virtual wallet** = one account in the shim, keyed by its **virtual-wallet id**
|
||||
(the technical identifier the consumer sets when the physical wallet is opened; it
|
||||
identifies *which* virtual wallet, and is NOT the consumer's application username).
|
||||
Its structure mirrors the target "1 user = 1 wallet with 3 native stores":
|
||||
A *virtual wallet* = one account in the shim, keyed by its virtual-wallet id
|
||||
(the technical identifier the consumer application sets when the physical wallet is
|
||||
opened; it identifies *which* virtual wallet, and is an id rather than a
|
||||
human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3
|
||||
native stores":
|
||||
|
||||
```
|
||||
Virtual wallet (id)
|
||||
├── public store = docPublic index → [ event doc NURI, PdR doc NURI, … ]
|
||||
├── protected store = docProtected index → [ profile doc NURI, participation doc NURI, … ]
|
||||
└── private store = docPrivate index → [ settings doc NURI, … ]
|
||||
├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ]
|
||||
├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ]
|
||||
└── private store = docPrivate index → [ record doc NURI, … ]
|
||||
```
|
||||
|
||||
So **yes, the 3 native stores (public/protected/private) are present** — but
|
||||
**emulated**: each "store" is an **index document**
|
||||
(`AccountRecord.{docPublic,docProtected,docPrivate}`) that LISTS the NURIs of the
|
||||
So the 3 native stores (public/protected/private) are present, but emulated: each
|
||||
"store" is an index document
|
||||
(`AccountRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the
|
||||
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
|
||||
document, and the shim anchor itself all live in the shared **physical** wallet's
|
||||
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
|
||||
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 wallet (shared, one) → private_store (physical) holds everything:
|
||||
• the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate }
|
||||
• every account's 3 scope-index docs + all per-entity docs + inbox + discovery index
|
||||
```
|
||||
@@ -163,11 +163,11 @@ At migration each virtual wallet's 3 index documents become the user's 3 **real*
|
||||
native stores, the entity documents move into them physically, and the
|
||||
virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)).
|
||||
|
||||
### SDK-shaped scope resolvers — the consumer holds NO store-id
|
||||
### SDK-shaped scope resolvers — the consumer application holds no store-id
|
||||
|
||||
The consumer 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 resolvers
|
||||
turn a **logical scope** into an **opaque graph NURI** without exposing any
|
||||
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
|
||||
resolvers turn a logical scope into an opaque graph NURI without exposing any
|
||||
store-id:
|
||||
|
||||
- **`resolveScopeGraph(scope)`** — the graph where the current session writes
|
||||
@@ -177,26 +177,26 @@ store-id:
|
||||
native store; `public` + `protected` → the **protected** native store, because
|
||||
`doc_create`/ORM cannot target a non-private/protected native store today (SDK
|
||||
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,
|
||||
the consumer is unchanged.
|
||||
resolves to the user's real per-scope store — the change is in this function,
|
||||
and the consumer application is unchanged.
|
||||
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a
|
||||
**DEDICATED inbox document** (a reserved account's public scope document, from
|
||||
`docCreate` — a real repo NURI, stable across clients), **not** the shared
|
||||
dedicated inbox document (a reserved account's public scope document, from
|
||||
`docCreate` — a real repo NURI, stable across clients), not the shared
|
||||
wallet's private-store root. Why dedicated: 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
|
||||
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 it becomes the host's native
|
||||
inbox NURI.
|
||||
|
||||
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
|
||||
`privateStoreId` anchor). The consumer hands the whole session to the lib at the
|
||||
ONE injection point (`configureStoreRegistry({ getSession })`) — that is wiring,
|
||||
not placement logic; everything else in the consumer speaks only in scopes. If
|
||||
the session omits `protectedStoreId`, the non-private scopes fall back to the
|
||||
private store rather than emit a broken NURI.
|
||||
`privateStoreId` anchor). The consumer application hands the whole session to the
|
||||
lib at the one injection point (`configureStoreRegistry({ getSession })`) — that is
|
||||
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
|
||||
to the private store rather than emit a broken NURI.
|
||||
|
||||
## `RepoNotFound` and the `orm_start_graph` scope rule
|
||||
|
||||
@@ -206,11 +206,11 @@ the ORM, the store's repo must be **explicitly opened** in the verifier's
|
||||
without it, `orm_frontend_update` fails with `RepoNotFound`.
|
||||
|
||||
- **Scope** for `useShape`: the store NURI, e.g. `did:ng:${privateStoreId}` (or,
|
||||
in the consumer, a per-user store once that migration happens).
|
||||
in the consumer application, a per-user store once that migration happens).
|
||||
- **`@graph`** (write target): the same store NURI.
|
||||
- **Never use `did:ng:i` as a scope.** It subscribes to the user's whole site via
|
||||
a special code path (`NuriTargetV0::UserSite`) that **does not open individual
|
||||
repos** → breaks every write with `RepoNotFound`.
|
||||
- Never use `did:ng:i` as a scope: it subscribes to the user's whole site via
|
||||
a special code path (`NuriTargetV0::UserSite`) that does not open individual
|
||||
repos, breaking every write with `RepoNotFound`.
|
||||
|
||||
Both the private and the protected native stores were verified to open the same
|
||||
way for ORM+SPARQL (round-trip probe, no `RepoNotFound`). The original arbitration
|
||||
@@ -218,17 +218,17 @@ is preserved in [`decisions/private-store-nuri-scope.md`](./decisions/private-st
|
||||
|
||||
## The `@ng-org` double-proxy `DataCloneError` constraint
|
||||
|
||||
**Validated hard constraint, not a style choice.** `docs.ts` calls the **real
|
||||
injected `ng`** (`getConfig().ng`) DIRECTLY — never the public `ng` proxy
|
||||
A validated hard constraint, not a style choice: `docs.ts` calls the real
|
||||
injected `ng` (`getConfig().ng`) directly, never the public `ng` proxy
|
||||
(`makeNg` in `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).
|
||||
Wrapping it in the lib's own JS `Proxy` (double proxy) breaks `doc_create`'s
|
||||
postMessage marshaling → `DataCloneError: function ... could not be cloned`.
|
||||
postMessage marshaling with `DataCloneError: function ... could not be cloned`.
|
||||
Reaching the real `ng` held in the config avoids the double-proxy. This was
|
||||
verified: routing the shim's `doc_create`/SPARQL through the public proxy turned
|
||||
4 multistore scenarios red; it was reverted. The integration boundary is:
|
||||
4 multistore scenarios red, so it was reverted. The integration boundary is:
|
||||
|
||||
- **Through the lib's public proxy** (validated): `useShape` (ORM + ReadCap
|
||||
filter), `init`/`initNg`, `login`.
|
||||
@@ -240,223 +240,236 @@ verified: routing the shim's `doc_create`/SPARQL through the public proxy turned
|
||||
|
||||
## Emulated ReadCap — per document (`caps.ts` + `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
|
||||
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
|
||||
can. The access UNIT is the **document = repo NURI** (an item's `@graph`),
|
||||
**never the item** — because in `nextgraph-rs` a store is just a container repo
|
||||
and holding its cap does NOT grant the repos it references (no store-level read
|
||||
inheritance; verified). So the registry is **purely per-document**:
|
||||
`grantRead` / `grantWrite` / `makePublic` / `open(doc, scope, owner)` /
|
||||
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`. The consumer performs
|
||||
the *acts* of granting (create-public, grant-to-a-connection…) exactly as it
|
||||
can. The access unit is the document = repo NURI (an item's `@graph`),
|
||||
never the item — because in `nextgraph-rs` a store is just a container repo
|
||||
and holding its cap does not grant the repos it references (no store-level read
|
||||
inheritance; verified). So the registry is purely per-document:
|
||||
`grantRead(doc, granteeId)` issues a directed read grant to one identity,
|
||||
alongside `grantWrite` / `makePublic` / `open(doc, scope, owner)` /
|
||||
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`, plus the read-only
|
||||
accessor `protectedDocsOf(owner)` the consumer application uses to pick which
|
||||
protected docs to grant. The consumer application performs the *acts* of granting
|
||||
(create-public, grant a specific doc to a specific identity…) exactly as it
|
||||
will in the target; the lib injects no policy.
|
||||
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
|
||||
`Proxy`: iteration / `size` / `forEach` are filtered by
|
||||
`caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`,
|
||||
`getById`…) forwards to the target, preserving writes and reactivity. An item
|
||||
with no `@graph`, or in a document under no cap policy, is KEPT (the filter only
|
||||
with no `@graph`, or in a document under no cap policy, is kept (the filter only
|
||||
restricts documents that *declare* a cap — no regression on ungoverned data).
|
||||
`filterReadable` is the pure variant.
|
||||
- **`useShape` (`use-shape.ts`)** applies the view **only if
|
||||
`caps.hasReadPolicy()`** — otherwise it passes the real set through unchanged
|
||||
(no regression when the consumer declares no caps).
|
||||
- **`useShape` (`use-shape.ts`)** applies the view only if
|
||||
`caps.hasReadPolicy()` — otherwise it passes the real set through unchanged
|
||||
(no regression when the consumer application declares no caps).
|
||||
|
||||
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
|
||||
one document per entity (axis B).
|
||||
|
||||
### Making the ReadCap ACTIVE — current user + connection-driven grants
|
||||
### Making the ReadCap active — current identity + directed grants
|
||||
|
||||
The filter only discriminates once the consumer (a) tells the SDK **who is
|
||||
reading** and (b) declares the access policy on the documents. Both are plain SDK
|
||||
calls; the consumer never touches the registry internals:
|
||||
The filter only discriminates once the consumer application (a) tells the SDK who is
|
||||
reading and (b) declares the access policy on the documents. Both are plain SDK
|
||||
calls; the consumer application never touches the registry internals:
|
||||
|
||||
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
|
||||
`useShape`'s filtered view reads it lazily, so the delivered subset always
|
||||
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 stayed **dormant** while the consumer never made this call.
|
||||
why isolation stays dormant until the consumer application makes this call.
|
||||
- **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the
|
||||
consumer creates it: `public` → world-readable; `protected`/`private` → owner
|
||||
reads, owner holds the write cap. `open` now also **remembers** `(scope, owner)`
|
||||
per document so a later connection-driven grant can find the protected ones.
|
||||
- **`declareConnections(peers, as?)` (`polyfill.ts`)** — the SDK-shaped
|
||||
**protected sharing act**, now **AUTHENTICATED / BILATERAL** (`connections.ts`).
|
||||
Each call declares the CURRENT identity's OWN peers (`as` defaults to
|
||||
`getCurrentUser()`); the lib records that as a **directed assertion authored by
|
||||
the current identity** — a session can only ever assert its own side. A protected
|
||||
read cap is issued between two principals only when **both have asserted the
|
||||
other** (a materialized two-sided link, `ConnectionRegistry.neighbors` →
|
||||
`CapRegistry.grantReadToConnections`). Public docs stay world-readable; private
|
||||
docs stay owner-only. Re-callable; additive + idempotent. The consumer passes
|
||||
only principals — no document NURI, no store id.
|
||||
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.
|
||||
|
||||
**Why bilateral (adversarial finding).** If a single directed assertion granted
|
||||
access, any reader could read any owner's protected documents by unilaterally
|
||||
self-declaring a connection. The two-sided requirement is the emulation of the
|
||||
target's mutual capability exchange: only a reciprocated link grants the cap. A
|
||||
unilateral / self-declared connection grants **nothing** (proven in
|
||||
`test/connections.test.ts` and `test/isolation-active.test.ts` case (b)).
|
||||
The relationship concept — who is "connected" to whom, and therefore which of
|
||||
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** →
|
||||
owner; **protected** → owner + BILATERAL connections; **public** → all. Proven in
|
||||
`test/isolation-active.test.ts`: (a) an unconnected principal is denied a protected
|
||||
document, granted it after a two-sided `declareConnections`, and reads the public
|
||||
document throughout; (b) a unilateral/self-declared connection is denied.
|
||||
The result is the target's discrimination reproduced end-to-end: private →
|
||||
owner; protected → owner + whoever the owner has directly granted; public → all.
|
||||
Proven in `test/isolation-active.test.ts`: an unconnected principal is denied a
|
||||
protected document, granted it after the owner issues a directed `grantRead`, and
|
||||
reads the public document throughout.
|
||||
|
||||
This discrimination is only observable because each entity is **its own document**
|
||||
(the consumer creates per-entity docs via `createEntityDoc` and `open`s each) — in
|
||||
a mono-store layout the per-document ReadCap is all-or-nothing.
|
||||
This discrimination is only observable because each entity is its own document
|
||||
(the consumer application creates per-entity docs via `createEntityDoc` and `open`s
|
||||
each) — in a mono-store layout the per-document ReadCap is all-or-nothing.
|
||||
|
||||
### Write-guard coverage (honest scope)
|
||||
|
||||
The emulated write guard (`ng-proxy.ts`, `sparql_update` override) enforces the
|
||||
per-document write cap **on the public `ng` proxy only**. In practice the
|
||||
consumer's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the **real injected
|
||||
`ng` directly** — never the public proxy — for the validated `DataCloneError`
|
||||
reason above. So the guard is **best-effort**: it fires for any write routed
|
||||
through the public proxy, but the consumer's real write paths bypass it and are
|
||||
**not** guarded today. This is a deliberate, recorded limitation of the emulation
|
||||
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
|
||||
injected `ng` directly — never the public proxy — for the validated `DataCloneError`
|
||||
reason above. So the guard is best-effort: it fires for any write routed
|
||||
through the public proxy, but the consumer application's real write paths bypass it
|
||||
and are not guarded today. This is a deliberate, recorded limitation of the emulation
|
||||
(the write guard becomes effective only when the broker/verifier enforces caps
|
||||
natively at migration); the READ side is what makes isolation observably active.
|
||||
natively at migration); the read side is what makes isolation observably active.
|
||||
|
||||
### The per-document ReadCap is now 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`)
|
||||
alone: the access unit is the DOCUMENT (`@graph` = repo), grants are explicit
|
||||
(`open` / `grantRead` / `makePublic`) and, for `protected`, driven by the
|
||||
**bilateral connection registry** (`connections.ts`). Because the consumer now
|
||||
writes **one document per entity** (`createEntityDoc` + `open` per entity), the
|
||||
per-document cap discriminates at entity granularity — the target's behaviour.
|
||||
Isolation is enforced by the per-document ReadCap (`caps.ts` + `read-filter.ts`)
|
||||
alone: the access unit is the document (`@graph` = repo), and grants are explicit
|
||||
(`open` / `grantRead` / `makePublic`) — for `protected`, the owner issues a directed
|
||||
`grantRead(doc, granteeId)` per identity it wants to share with. Because the consumer
|
||||
application now writes one document per entity (`createEntityDoc` + `open` per entity),
|
||||
the per-document cap discriminates at entity granularity — the target's behaviour.
|
||||
|
||||
The old **item-level application-visibility filter** (`isolation.ts`
|
||||
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is **retired**
|
||||
from the consumer path: the app carries **no** access logic — it declares its
|
||||
identity and its bilateral connections and trusts the SDK. `isolation.ts` survives
|
||||
only as the home of the generic `Connections` interface (consumed by
|
||||
`connections.ts` / `caps.grantReadToConnections`) plus its own unit tests; its
|
||||
matrix functions are 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 per-document cap, exactly as in the target.
|
||||
The old item-level application-visibility filter (`isolation.ts`
|
||||
`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
|
||||
identity and issues directed grants, and trusts the SDK. Its matrix functions are
|
||||
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
|
||||
per-document cap, exactly as in the target.
|
||||
|
||||
## Emulated inbox + curator (`inbox.ts`)
|
||||
## Emulated inbox (`inbox.ts`)
|
||||
|
||||
Current NextGraph does not expose the inbox to the JS SDK (verifier has no
|
||||
`InboxPost` arm; no wasm sealing helper — see
|
||||
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Rather than
|
||||
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.** Target: `post` seals a reference into the owner's native
|
||||
inbox (`ng.inbox_post_link(...)`) and a **separate curator** materializes
|
||||
deposits into the owned document. Here, everything is readable, so both sides are
|
||||
emulated in-lib.
|
||||
- **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
|
||||
own verifier unseals each queued message and applies it inline when it processes
|
||||
its inbox — there is no separate curator or materialization process. Here,
|
||||
everything is readable, so the lib emulates the read side in-lib.
|
||||
- **`post(targetInbox, opts)`** appends a deposit `{ from, payload, ts }` as RDF
|
||||
into the inbox DOCUMENT (in the shared wallet) via `docs.sparqlUpdate`. Each
|
||||
deposit is a unique RDF subject → concurrent deposits don't collide. **`from` is
|
||||
BOUND to the current identity** (`getCurrentUser`) — it is authenticated, not
|
||||
into the inbox document (in the shared wallet) via `docs.sparqlUpdate`. Each
|
||||
deposit is a unique RDF subject, so concurrent deposits don't collide. `from` is
|
||||
bound to the current identity (`getCurrentUser`) — it is authenticated, not
|
||||
caller-supplied: omit it to stamp the current user, pass `null` to deposit
|
||||
ANONYMOUSLY, and a `from` naming ANOTHER principal is **rejected as a spoof**.
|
||||
This reproduces the protocol's "identified if known, anonymous otherwise" AND
|
||||
anonymously, and a `from` naming another principal is rejected as a spoof.
|
||||
This reproduces the protocol's "identified if known, anonymous otherwise" and
|
||||
the target's guarantee that a client cannot forge another's sender identity (in
|
||||
the target the broker seals `from` from the wallet's own key; here the check
|
||||
closes the spoof the shared wallet would otherwise allow). The emulation stores
|
||||
`from = null` as *absence of a triple*, so it does not provide the target's
|
||||
**crypto** anonymity (`from = None` sealed), which only a native inbox would.
|
||||
crypto anonymity (`from = None` sealed), which only a native inbox would.
|
||||
Proven in `test/inbox.test.ts` case (c).
|
||||
- **`read` / `materialize` (alias)** play the **emulated CURATOR**: they read the
|
||||
- **`read` / `materialize` (alias)** emulate the recipient-side read: they read the
|
||||
deposits back via `docs.sparqlQuery`, JSON-parse each payload, sort by `ts`.
|
||||
- **`watch(targetInbox, onDeposits, { intervalMs })`** is the emulated watcher: it
|
||||
polls `read` and fires when the deposit count changes (the polyfill has no
|
||||
reactive inbox subscription). Fires once immediately; returns an unsubscribe.
|
||||
|
||||
GENERIC: the module knows no domain — the consumer 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
|
||||
`inbox_post_link` and the read side moves to a **separate curator package** (see
|
||||
the deferred global-index note in the top-level README and
|
||||
[`decisions/discovery-model.md`](./decisions/discovery-model.md)). The inbox +
|
||||
watcher is the ONE deposit/materialization mechanism reused for BOTH meeting-point
|
||||
registration AND submission to a discovery index — same `post` API, same watcher.
|
||||
`inbox_post_link` (proposed/future) and the read side is served by the recipient's
|
||||
own verifier unsealing queued messages inline (see the deferred global-index note in
|
||||
the top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
|
||||
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
|
||||
discovery index — same `post` API, same watcher.
|
||||
|
||||
## Emulated discovery index + special account (`discovery.ts`)
|
||||
|
||||
Discovery is a **surface on top of the inbox**, not a new primitive. **Access ≠
|
||||
discovery**: a public entity is world-readable *with its NURI*; the discovery
|
||||
index is how a client learns that NURI **exists** without holding a connection
|
||||
Discovery is a surface on top of the inbox, not a new primitive. Access is not the
|
||||
same as discovery: a public entity is world-readable *with its NURI*; the discovery
|
||||
index is how a client learns that NURI exists without holding a relationship
|
||||
to its creator (see [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
|
||||
The model is: ONE global index = an **owned document** (public read), **fed via
|
||||
ITS inbox**, **materialized by a curator**. Nobody writes the index directly — a
|
||||
creator DEPOSITS a reference into the index's inbox; the curator ingests deposits
|
||||
into entries. Materialization is the natural **dedup / moderation point**.
|
||||
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
|
||||
undecided in the target (NextGraph is mono-user with no global data — a
|
||||
singleton app is the only glimpsed path). So the polyfill parks ownership on a
|
||||
**RESERVED SPECIAL ACCOUNT** in the shim — `INDEX_ACCOUNT = "@index"`. It is a
|
||||
reserved special account in the shim — `INDEX_ACCOUNT = reservedAccount("index")`.
|
||||
This is NOT the key `"index"` / `"@index"`: `reservedAccount` mints a
|
||||
sentinel-prefixed key in the shim's reserved namespace (e.g. `" reserved:index"`)
|
||||
that `normalizeId` can never produce, so no user id — not even one typed as
|
||||
"index" or "@index", which normalizes to the disjoint key "index" — can collide
|
||||
with or hijack the index account (asserted in `discovery.test.ts`). It is a
|
||||
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 → same document, so all clients read/write ONE
|
||||
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 serializes whatever locates the entity
|
||||
(e.g. an entity document NURI + discovery metadata). **PUBLIC-ONLY guard:** when
|
||||
`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) &&
|
||||
(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 CURATOR. Reads every submission, **dedups by
|
||||
serialized `ref`** (the moderation point: a duplicate submission surfaces
|
||||
- **`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`
|
||||
This replaces the cross-account fan-out (`store-registry.ts`
|
||||
`listEntityDocs('public')` / `resolveReadGraphs`) as the app-facing discovery
|
||||
path: the app submits public entities to the index and reads the 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.
|
||||
path: the consumer application submits public entities to the index and reads the
|
||||
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.
|
||||
|
||||
GENERIC: `discovery.ts` knows no application domain — the consumer defines the
|
||||
`discovery.ts` knows no application domain — the consumer application defines the
|
||||
`ref` shape and its meaning. At migration the special account disappears:
|
||||
ownership moves to the decided global-index owner, `submitToIndex` becomes the
|
||||
native `inbox_post_link` on the index's inbox, and `readIndex` queries the real
|
||||
materialized index document. The consumer surface (`submitToIndex` / `readIndex`)
|
||||
native `inbox_post_link` (proposed/future) on the index's inbox, and `readIndex`
|
||||
queries the real index document. The consumer surface (`submitToIndex` / `readIndex`)
|
||||
is designed to survive that swap unchanged.
|
||||
|
||||
## Emulated write guard (`ng-proxy.ts`)
|
||||
|
||||
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
|
||||
WRITE cap. Passthrough (no regression) unless a WRITE policy exists AND that
|
||||
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
|
||||
write cap. It passes through (no regression) unless a write policy exists and that
|
||||
specific document (the `anchor` arg) is governed by it — ungoverned docs (the
|
||||
mono-store default, no cap declared) flow through unchanged. 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.
|
||||
|
||||
## Faux login (`accounts.ts`)
|
||||
## Identity store (`accounts.ts`)
|
||||
|
||||
The real NextGraph login (redirect to the broker, opening the single SHARED
|
||||
wallet) is perceived as a **technical access barrier**, not a login (see login
|
||||
The real NextGraph login (redirect to the broker, opening the single shared
|
||||
wallet) is perceived as a technical access barrier (see the login
|
||||
flow in [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
|
||||
THIS layer is the **perceived** login:
|
||||
This layer is not a login: it is an `IdentityStore` that holds the current
|
||||
identity id the consumer application relays to it:
|
||||
|
||||
- The user picks a **username** (no password — declarative), persisted in
|
||||
`localStorage` so the "session" survives reloads and lands on the same account
|
||||
when the shared wallet re-opens.
|
||||
- `login()` / `logout()` are **FAUX**: they only read/write the username in
|
||||
storage. They must **NEVER** call NextGraph (no `session_stop` / `wallet_close`)
|
||||
— the shared wallet stays open underneath. The real logout lives elsewhere
|
||||
(hidden in the consumer's settings/debug), because it forces a new redirect.
|
||||
- **Framework-agnostic**: no React, no DOM beyond an optional injected
|
||||
- The identity id is set at wallet-import time by the consumer application and
|
||||
relayed to the lib via its current-identity call. It is persisted in
|
||||
`localStorage` so the id survives reloads and lands on the same account
|
||||
when the shared wallet re-opens. In practice the id is often a human-friendly
|
||||
handle the consumer application chose, but the lib's surface speaks only of an id.
|
||||
- `set(id)` / `clear()` / `get()` only read/write the id in storage. They never
|
||||
call NextGraph (no `session_stop` / `wallet_close`) — the shared wallet stays
|
||||
open underneath. The real logout lives elsewhere (hidden in the consumer
|
||||
application's settings/debug), because it forces a new redirect.
|
||||
- Framework-agnostic: no React, no DOM beyond an optional injected
|
||||
`AccountStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The
|
||||
React `Context`/`Provider` stays in the consumer. `normalizeUsername`
|
||||
React `Context`/`Provider` stays in the consumer application. `normalizeId`
|
||||
(case-insensitive, optional leading `@` stripped, trimmed) is the pure
|
||||
normalizer, reusable as the shim key normalizer.
|
||||
|
||||
@@ -470,13 +483,13 @@ document trust root):
|
||||
- **`escapeLiteral`** — for LITERAL position (`"..."`): escapes backslash,
|
||||
double-quote, C0 whitespace. Lossless (literals legitimately carry arbitrary
|
||||
text — JSON payloads, display names).
|
||||
- **`escapeIri`** — for UNTRUSTED values embedded into an IRI (`<PREFIX:${…}>`,
|
||||
e.g. a username minted into an account-subject IRI): percent-encodes every
|
||||
IRI-hostile character so any username (spaces, unicode, punctuation) stays
|
||||
- **`escapeIri`** — for untrusted values embedded into an IRI (`<PREFIX:${…}>`,
|
||||
e.g. an identity id minted into an account-subject IRI): percent-encodes every
|
||||
IRI-hostile character so any id (spaces, unicode, punctuation) stays
|
||||
usable while breakout is impossible.
|
||||
- **`assertNuri`** — for trusted-SHAPED NURIs coming back from `ng`
|
||||
- **`assertNuri`** — for trusted-shaped NURIs coming back from `ng`
|
||||
(`did:ng:...`): validates and throws on IRI-breaking chars rather than emitting
|
||||
a malformed/injected query.
|
||||
|
||||
These are re-exported from `@ng-eventually/client` so the consumer reuses the same
|
||||
escaping when it builds SPARQL.
|
||||
These are re-exported from `@ng-eventually/client` so the consumer application
|
||||
reuses the same escaping when it builds SPARQL.
|
||||
|
||||
Reference in New Issue
Block a user