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:
Sylvain Duchesne
2026-07-06 14:02:16 +02:00
parent d39b12885a
commit 63ecfeeff8
31 changed files with 1059 additions and 1396 deletions
+21 -17
View File
@@ -1,32 +1,36 @@
# @ng-eventually/client
Two entry points — the data-plane is **SDK-identical**, the polyfill bootstrap is
Two entry points — the data-plane is SDK-identical, the polyfill bootstrap is
separate:
| Import | Surface | At migration |
|---|---|---|
| `@ng-eventually/client` | **Same signature as the SDK**`ng`, `useShape`, `inbox` (+ types). Drop-in for `@ng-org/web` / `@ng-org/orm`. | Resolves to the real SDK (build alias removed) no code change. |
| `@ng-eventually/client/polyfill` | The **only non-SDK** surface — `configure`, `setCurrentUser`, capability helpers (`canRead`/`canWrite`/`defaultGrant`/`grantRead`). | Removed. |
| 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
// 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, pure SDK surface:
// 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 user may read
const set = useShape(MyShape, scope); // filtered to what the identity may read
await inbox.post(targetInbox, ref); // deposit (anticipated SDK API)
```
What the polyfill adds on top of the real SDK (all removed/native at migration):
- **Shared-wallet login** (one wallet for everyone).
- **Capability enforcement** — read filter + write guard, on emulated grants
attached to documents.
- **Anticipated methods** (inbox `post`, capability ops) with their future-SDK
shapes, emulated for now.
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 injects shapes and performs the
*acts* of granting access. The client **must not** contain the global-index
curator (a separate package, deferred — see the repo README).
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.
+26 -39
View File
@@ -1,25 +1,21 @@
/**
* accounts — the framework-agnostic core of the *simulated* app-level login.
* accounts — a framework-agnostic store for the current identity id.
*
* STOPGAP — part of the shared-wallet shim. The real NextGraph login (redirect
* to the broker, opening the single SHARED wallet) is perceived as a technical
* access barrier, not as a login. THIS layer is the perceived login: 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.
* The identity a session acts as is established when its wallet is imported; the
* SDK is told who that is via the current-identity call. This small store just
* persists that id (in an injected storage) so it survives reloads and a second
* device, re-opening the same wallet, lands on the same identity. It carries no
* notion of a login step, a password, or a username — only an opaque identity id.
*
* `login()` / `logout()` here 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.
*
* FRAMEWORK-AGNOSTIC on purpose: NO React, no DOM assumption beyond an optional
* storage. The React `Context`/`Provider` is a thin wrapper that stays in the
* consumer app (it wires `useState` around {@link login}/{@link logout}). The
* lib must not force a React dependency. Removed at migration.
* Framework-agnostic on purpose: no React, no DOM assumption beyond an optional
* storage. A consumer's React `Context`/`Provider` wraps `useState` around
* {@link IdentityStore.set}/{@link IdentityStore.clear}. The lib does not force a
* React dependency. Removed against real NextGraph, where the wallet session is
* the source of the identity id.
*/
/** localStorage key holding the perceived-login username. */
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.username";
/** localStorage key holding the current identity id. */
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id";
/**
* Minimal storage contract (a subset of the Web `Storage` interface). The
@@ -34,20 +30,11 @@ export interface AccountStorage {
}
/**
* Normalize a username for matching: case-insensitive, optional leading `@`
* stripped, trimmed. Lets "marie", "@marie", "Marie" match interchangeably.
* Pure — safe to use as the shim key normalizer too.
* The persisted current identity id. A tiny store around an injected
* {@link AccountStorage}. It holds no framework state; the consumer's Provider
* mirrors `get()` into framework state and re-reads after `set`/`clear`.
*/
export function normalizeUsername(username: string | null | undefined): string {
return (username ?? "").trim().replace(/^@+/, "").toLowerCase();
}
/**
* The persisted app-level account. A tiny store around an injected
* {@link AccountStorage}. Holds no React state; the consumer's Provider mirrors
* `get()` into framework state and re-reads after `login`/`logout`.
*/
export class AccountStore {
export class IdentityStore {
private readonly storage: AccountStorage | null;
private readonly key: string;
@@ -56,7 +43,7 @@ export class AccountStore {
this.key = key;
}
/** The current app-level username (the perceived "login"). null = not connected. */
/** The current identity id. null = no identity set yet. */
get(): string | null {
if (!this.storage) return null;
try {
@@ -67,11 +54,11 @@ export class AccountStore {
}
/**
* Faux login — persist the (trimmed) username. Empty/blank is ignored and the
* previous value is kept (returns the resulting username, or null). No NG call.
* Persist the (trimmed) identity id. An empty/blank value is ignored and the
* previous id is kept (returns the resulting id, or null). No NextGraph call.
*/
login(username: string): string | null {
const clean = username.trim();
set(id: string): string | null {
const clean = id.trim();
if (!clean) return this.get();
if (this.storage) {
try {
@@ -83,8 +70,8 @@ export class AccountStore {
return clean;
}
/** Faux logout — clear the username only. No NG call. */
logout(): void {
/** Clear the persisted identity id. No NextGraph call. */
clear(): void {
if (this.storage) {
try {
this.storage.removeItem(this.key);
@@ -99,11 +86,11 @@ export class AccountStore {
* Convenience factory using `globalThis.localStorage` when present, else a
* null (non-persisting) store — so the same call is safe in browser and SSR.
*/
export function browserAccountStore(key: string = ACCOUNT_STORAGE_KEY): AccountStore {
export function browserIdentityStore(key: string = ACCOUNT_STORAGE_KEY): IdentityStore {
const ls =
typeof globalThis !== "undefined" &&
(globalThis as { localStorage?: AccountStorage }).localStorage
? (globalThis as { localStorage: AccountStorage }).localStorage
: null;
return new AccountStore(ls, key);
return new IdentityStore(ls, key);
}
+38 -33
View File
@@ -1,25 +1,29 @@
/**
* Capability emulation — generic, no domain rules. Models NextGraph **ReadCaps**
* (and write caps) as faithfully as a data layer can.
* 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 ever 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 — **never the item**. (Verified in nextgraph-rs:
* a store is just a container repo; holding a store's cap does NOT grant the
* repos it references — each document needs its own cap. So this registry is
* purely per-document, with NO store-level inheritance.)
* 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.)
*
* At migration this whole layer disappears: the broker/verifier enforces the
* real caps and `useShape` already returns only authorized documents.
* 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 (create-public, grant-to-a-connection…) exactly as it will in
* the target; this layer enforces possession generically — it knows no policy.
* 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. */
@@ -29,14 +33,14 @@ export class CapRegistry {
/** 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
* a later, connection-aware sharing act (see {@link grantReadToConnections})
* re-derive which documents are `protected` and who owns them, without the
* consumer re-supplying that per-document — it already declared it at open. */
* 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 `principal` the READ cap of document `doc`. */
grantRead(doc: Nuri, principal: PrincipalId): void {
add(this.readers, doc, principal);
/** 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`. */
@@ -62,23 +66,24 @@ export class CapRegistry {
}
/**
* Extend the read caps of every `protected` document so its owner's direct
* connections may read it — the sharing act "protected = owner + connections".
* `neighborsOf(owner)` yields the principals connected to `owner` (the consumer
* supplies its social graph; this layer invents no relationship). Public docs
* are already world-readable; private docs are untouched (owner only). Additive
* and idempotent: re-running after the connection graph changes only ever adds
* read caps for the current connections.
* 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 is the per-document ReadCap image of a native cap operation: in the
* target, sharing a protected repo with a connection issues that connection the
* repo's ReadCap. Here it grants the emulated read cap on the same unit.
* 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.
*/
grantReadToConnections(neighborsOf: (owner: PrincipalId) => Iterable<PrincipalId>): void {
for (const [doc, { scope, owner }] of this.policy) {
if (scope !== "protected") continue;
for (const connection of neighborsOf(owner)) this.grantRead(doc, connection);
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.) */
-90
View File
@@ -1,90 +0,0 @@
/**
* connections — a BILATERAL (two-sided, authenticated) connection registry.
*
* STOPGAP / polyfill-era. In the target, "connected" means the two wallets have
* *each* issued the other a capability (a mutual, cryptographically-authenticated
* link). A single side cannot manufacture the relationship. Here — one shared
* wallet, everything physically readable — the registry reproduces that property
* as data: a connection between `a` and `b` is materialized ONLY when BOTH sides
* have asserted it.
*
* ── Why bilateral (the adversarial finding this defends) ──────────────────────
* "protected = owner + connections" must not be bypassable by a reader who simply
* self-declares a connection to the owner. If a single directed assertion granted
* access, any principal could read any owner's protected documents by unilaterally
* claiming a link. So the registry keeps DIRECTED assertions and exposes as
* `neighbors(p)` only the principals `q` for which BOTH `assert(p → q)` AND
* `assert(q → p)` are present — the materialized two-sided link.
*
* ── Generic by construction ───────────────────────────────────────────────────
* Knows no application domain. The consumer maps its own relationship (accepted
* friendships, follows-back, …) onto directed assertions: each side asserts the
* other. Only when both assertions exist is the link live and does it drive a
* protected read grant (via {@link CapRegistry.grantReadToConnections}). Removed
* at migration, where a real mutual capability replaces the materialized link.
*/
import type { Connections } from "./isolation";
import type { PrincipalId } from "./types";
/**
* One directed connection assertion: `from` asserts a connection to `to`. A link
* is live (and grants protected read) only when the reverse assertion also
* exists. The consumer's own social graph is fed as these directed assertions.
*/
export interface ConnectionAssertion {
from: PrincipalId;
to: PrincipalId;
}
/**
* Accumulates directed assertions and exposes the BILATERAL neighbourhood. Both
* `assert(a → b)` and `assert(b → a)` must be present for `a`/`b` to be neighbours.
*/
export class ConnectionRegistry {
/** principal → the set of principals it has asserted a connection TO. */
private asserted = new Map<PrincipalId, Set<PrincipalId>>();
/** Record that `from` asserts a connection to `to` (one direction only). */
assert(from: PrincipalId, to: PrincipalId): void {
if (!from || !to || from === to) return;
let s = this.asserted.get(from);
if (!s) this.asserted.set(from, (s = new Set()));
s.add(to);
}
/** Record a batch of directed assertions. */
assertAll(assertions: Iterable<ConnectionAssertion>): void {
for (const { from, to } of assertions) this.assert(from, to);
}
/** Has `from` asserted a connection to `to` (one direction)? */
hasAsserted(from: PrincipalId, to: PrincipalId): boolean {
return this.asserted.get(from)?.has(to) ?? false;
}
/**
* The BILATERAL neighbours of `principal`: every `q` such that `principal` and
* `q` have each asserted the other. A unilateral (one-sided) assertion yields
* NO neighbour — the defence against self-declared connections.
*/
neighbors(principal: PrincipalId): Set<PrincipalId> {
const out = new Set<PrincipalId>();
const outgoing = this.asserted.get(principal);
if (!outgoing) return out;
for (const to of outgoing) if (this.hasAsserted(to, principal)) out.add(to);
return out;
}
clear(): void {
this.asserted.clear();
}
}
/**
* Adapt a {@link ConnectionRegistry} to the {@link Connections} interface consumed
* by {@link CapRegistry.grantReadToConnections}. Only bilateral links surface.
*/
export function bilateralConnections(registry: ConnectionRegistry): Connections {
return { neighbors: (principal) => registry.neighbors(principal) };
}
+54 -55
View File
@@ -5,38 +5,36 @@
* an opaque reference and interprets the entries it reads back.
*
* ── The mechanism (see docs/decisions/discovery-model.md) ─────────────────
* 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 to its creator. There is ONE global index — an OWNED document
* (public read), fed via ITS OWN inbox, and MATERIALIZED by a curator. Nobody
* writes the index directly: a creator DEPOSITS a reference into the index's
* inbox; the (emulated) curator ingests deposits into entries. Materialization
* is the natural dedup / moderation point.
* 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) ──────────────────────────────────
* "Who owns the global index" is undecided in the target (NextGraph is
* mono-user with no global data — see docs/nextgraph-current-state.md § Apps &
* services; a singleton app is the only glimpsed path). So the polyfill 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 → same document). This replaces the cross-account
* fan-out (`store-registry.ts` `listEntityDocs`) as the app-facing discovery
* path — the fan-out survives only as an internal fallback (see {@link readIndex}).
* 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}).
*
* ── TARGET vs POLYFILL ────────────────────────────────────────────────────
* Target: `submitToIndex` seals a reference into the index's native inbox
* (`inbox_post_link`) and a SEPARATE curator package materializes deposits into
* the owned index document; `readIndex` is a query on the materialized index.
* Here, everything is emulated in-lib on the shared wallet (deposit via
* `inbox.post`, materialize via `inbox.read`). At migration the special account
* disappears; ownership moves to the decided global-index owner and this module
* points `readIndex` at the real materialized document. The consumer surface
* (`submitToIndex` / `readIndex`) is designed to survive that swap unchanged.
* ── 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 T01.a
* `docs` primitives, the REAL injected `ng`), so this module imports NO
* `@ng-org` package.
* 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";
@@ -45,12 +43,12 @@ 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 user. It lives in
* the registry's RESERVED namespace ({@link reservedAccount}), whose key
* `normalizeUser` can never produce so a user named "index"/"@index" can NOT
* hijack it (they would normalize to "index", a disjoint key). Disappears at
* migration (see file header).
* 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");
@@ -68,16 +66,16 @@ export interface IndexEntry {
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 principal is rejected as a spoof — see {@link inbox.post}).
* 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
* enforces PUBLIC-ONLY: a document under a non-public (protected/private) read
* policy is REFUSED — the public index must never leak a governed document's
* NURI. Omit it only for a ref with no addressable document (rare); a governed
* doc always passes it so the guard can fire.
* 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
@@ -105,14 +103,15 @@ async function indexInboxNuri(): Promise<Nuri> {
/**
* 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}; the curator ({@link readIndex}) materializes it into an
* entry. GENERIC: `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 if `null`).
* {@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`).
*
* PUBLIC-ONLY: 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 doc's NURI would leak it past its scope.
* 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;
@@ -135,11 +134,11 @@ export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise
}
/**
* Read (materialize) the global discovery index — the EMULATED CURATOR. Reads
* every submission from the index inbox, DEDUPLICATES by serialized `ref` (the
* materialization dedup / moderation point of the discovery model: a duplicate
* submission surfaces once), and returns the entries sorted by `ts` ascending.
* At migration this becomes a query on the real materialized index document.
* 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();
@@ -157,9 +156,9 @@ export async function readIndex(): Promise<IndexEntry[]> {
}
/**
* Watch the discovery index — the emulated curator's watcher. Polls
* {@link readIndex} and fires `onEntries` whenever the index changes. Returns an
* unsubscribe. Fires once immediately. (Deduplication is applied on each read.)
* Watch the discovery index. Polls {@link readIndex} and fires `onEntries`
* whenever the index changes. Returns an unsubscribe. Fires once immediately.
* (Deduplication is applied on each read.)
*/
export function watchIndex(
onEntries: (entries: IndexEntry[]) => void,
+5 -4
View File
@@ -1,12 +1,13 @@
/**
* 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
* 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
* (`DataCloneError: function ... could not be cloned`). Reaching the real `ng`
* held in the config avoids the double-proxy. Do NOT import from `./ng-proxy`.
* 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.
+38 -35
View File
@@ -1,25 +1,27 @@
/**
* Inbox — the ONE deposit + materialization mechanism, reused for BOTH meeting-
* point registration AND submission to the discovery index (see the discovery-
* model decision: same `inbox.post` API, same watcher). GENERIC by construction:
* this module knows no application domain (no meeting-point, no notification).
* The consumer supplies the inbox document NURI and interprets the `payload`.
* 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.)
*
* ── TARGET vs POLYFILL ────────────────────────────────────────────────────
* Target: `post` seals a reference into the inbox owner's native inbox
* (`ng.inbox_post_link(...)`), and a SEPARATE curator process (the deferred
* `@ng-eventually/service` package) MATERIALIZES the deposits into the owned
* document. Here, in the shared-wallet polyfill (everything is readable),
* both sides are emulated in-lib:
* ── 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` play the CURATOR: they read the deposits back via
* `docs.sparqlQuery` and expose them. This in-client emulation is enough
* for the polyfill — at migration the real materialization moves to the
* separate curator and this read side goes away.
* 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 T01.a `docs` primitives (the REAL
* injected `ng`, never `makeNg`), so this module imports NO `@ng-org` package.
* 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";
@@ -92,13 +94,13 @@ function readBindings(result: unknown): Array<Record<string, { value: string }>>
* (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 user; pass `null` to deposit
* ANONYMOUSLY (a legitimate choice — "identified if known, anonymous otherwise").
* A `from` naming ANOTHER principal is a SPOOF and is REJECTED: in the target the
* `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. (At migration this check is redundant the seal enforces
* it — but until then it closes the spoof the shared wallet would otherwise allow.)
* 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();
@@ -140,13 +142,14 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
await sparqlUpdate(sid, update, targetInbox);
}
// --- read / materialize (emulated curator) --------------------------------
// --- read --------------------------------------------------------------
/**
* Read (materialize) every deposit currently in `targetInbox`, sorted by `ts`
* ascending. This is the EMULATED CURATOR: at migration a separate curator
* process materializes deposits and this in-client read goes away. The consumer
* interprets each deposit's `payload`.
* 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();
@@ -179,15 +182,15 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
return deposits;
}
/** Alias for {@link read} — the name that reads as "run the curator now". */
/** Alias for {@link read} — the name that reads as "process the inbox now". */
export const materialize = read;
/**
* Subscription over an inbox — the emulated watcher. Polls {@link read} on an
* interval and invokes `onDeposits` with the full current deposit list whenever
* it changes (grows). Returns an unsubscribe function. The polyfill has no
* native reactive inbox subscription, so this emulates one; at migration it is
* replaced by the real curator's watch. `onDeposits` fires once immediately.
* Subscription over an inbox. Polls {@link read} on an interval and invokes
* `onDeposits` with the full current deposit list whenever it changes (grows).
* Returns an unsubscribe function. The polyfill has no reactive inbox
* subscription, so this polls; against real NextGraph it follows the recipient's
* own inbox processing. `onDeposits` fires once immediately.
*/
export function watch(
targetInbox: Nuri,
-2
View File
@@ -22,8 +22,6 @@ 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 isolation from "./isolation";
export type { Connections, IsolationAccessors } from "./isolation";
export * as accounts from "./accounts";
export type { AccountStorage } from "./accounts";
-138
View File
@@ -1,138 +0,0 @@
/**
* isolation — APPLICATION-LAYER visibility filter, per **account + connections**.
*
* STOPGAP (shared-wallet shim): with one shared wallet everything is physically
* readable. To make staging *behave* like the target infra, the consumer HONORS
* a visibility matrix by filtering reads relative to the current principal and
* its social connections:
*
* - public → visible to everyone
* - protected → visible to the owner + its direct connections
* - private → visible to the owner only
*
* Pure functions — no NextGraph, no React, ZERO application domain. The consumer
* supplies (a) the connection graph (who is connected to whom — the lib does NOT
* invent it) and (b) accessors telling, for each item, its owner and scope.
*
* ── isolation vs ReadCap (read-filter.ts / caps.ts) — they COEXIST ──────────
* Two DIFFERENT axes, deliberately kept separate:
*
* ReadCap ({@link ./caps} + {@link ./read-filter})
* - Unit : the DOCUMENT (an item's `@graph` = the repo it lives in).
* - Question: does the current principal HOLD this document's read cap?
* - Models : NextGraph's native capability delivery (broker-enforced).
* - Grants : explicit, per-document (grantRead / makePublic).
*
* isolation (this file)
* - Unit : the ITEM / record (per owner + scope).
* - Question: given WHO is connected to WHOM, may this principal see it?
* - Models : an application social-visibility policy, above the doc layer.
* - Grants : implicit, derived from the connection graph + the item's scope.
*
* They are NOT redundant and do NOT merge: ReadCap answers "can the wallet even
* fetch this document"; isolation answers "should the app show this record to
* this account given its relationships". The connection-derived `protected`
* visibility of isolation has no equivalent in the per-document cap model. Both
* are removable scaffolds; each disappears against a different piece of the real
* infra (caps → native ReadCaps; isolation → real per-account social graph +
* per-account wallets). See T01.c ## Result for the recorded decision.
*/
import type { PrincipalId, Scope } from "./types";
/**
* The connection graph, consumer-injected. An undirected "is connected to"
* relation between principals (e.g. accepted friendships). The lib never builds
* this — the consumer maps its own domain (friendships, follows, …) onto it.
*/
export interface Connections {
/** All principals directly connected to `principal` (excluding itself). */
neighbors(principal: PrincipalId): Iterable<PrincipalId>;
}
/**
* Build a {@link Connections} from a flat list of undirected links. A link
* `{ a, b }` connects `a` and `b` both ways. Convenience for the common case;
* the consumer may implement {@link Connections} directly instead.
*/
export function connectionsFromLinks(
links: Iterable<{ a: PrincipalId; b: PrincipalId }>,
): Connections {
const adj = new Map<PrincipalId, Set<PrincipalId>>();
const link = (x: PrincipalId, y: PrincipalId) => {
let s = adj.get(x);
if (!s) adj.set(x, (s = new Set()));
s.add(y);
};
for (const { a, b } of links) {
link(a, b);
link(b, a);
}
return {
neighbors: (principal) => adj.get(principal) ?? new Set<PrincipalId>(),
};
}
/**
* The set the current principal may see PROTECTED data for: itself + its direct
* connections. Pure over the injected {@link Connections}.
*/
export function visibleSet(current: PrincipalId, connections: Connections): Set<PrincipalId> {
const set = new Set<PrincipalId>([current]);
for (const n of connections.neighbors(current)) set.add(n);
return set;
}
/**
* May `current` see an item owned by `owner` at visibility `scope`, given the
* `visible` set (self + connections, precomputed via {@link visibleSet})?
*
* - public → always
* - protected → owner ∈ visible
* - private → owner === current
*/
export function isVisible(
scope: Scope,
owner: PrincipalId,
current: PrincipalId,
visible: Set<PrincipalId>,
): boolean {
switch (scope) {
case "public":
return true;
case "protected":
return visible.has(owner);
case "private":
return owner === current;
}
}
/** How to read an item's owner + scope. Consumer-supplied — keeps this generic. */
export interface IsolationAccessors<T> {
/** The principal that owns / authored the item. */
ownerOf: (item: T) => PrincipalId;
/** The item's visibility scope. */
scopeOf: (item: T) => Scope;
}
/**
* Pure: keep only the items `current` is allowed to see, per the visibility
* matrix and the injected connection graph.
*
* Empty `current` (no identity yet, e.g. during hydration) → pass everything
* through, so the app doesn't blank out mid-load (matches the app's prior
* `if (!currentUserId) return data` guard).
*/
export function applyIsolation<T>(
items: Iterable<T>,
current: PrincipalId,
connections: Connections,
accessors: IsolationAccessors<T>,
): T[] {
const all = [...items];
if (!current) return all;
const visible = visibleSet(current, connections);
return all.filter((item) =>
isVisible(accessors.scopeOf(item), accessors.ownerOf(item), current, visible),
);
}
+14 -48
View File
@@ -11,19 +11,18 @@
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
import type { RegistrySession } from "./store-registry";
import { CapRegistry } from "./caps";
import { ConnectionRegistry, bilateralConnections } from "./connections";
/**
* 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 a username
* 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 a username for shim keying. Default: trim (identity-ish). */
normalizeUser?: (username: string) => string;
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
normalizeId?: (id: string) => string;
}
export interface EventuallyConfig {
@@ -47,9 +46,6 @@ 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();
/** The emulated BILATERAL connection registry. Accumulates directed assertions
* (each authored by the asserting identity); only two-sided links materialize. */
let connectionRegistry = new ConnectionRegistry();
export function configure(c: EventuallyConfig): void {
cfg = c;
@@ -70,7 +66,7 @@ export function resetConfig(): void {
}
/**
* Wire the storeRegistry's consumer-injected dependencies (session + username
* 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.
@@ -78,7 +74,7 @@ export function resetConfig(): void {
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
registryDeps = {
getSession: deps.getSession,
normalizeUser: deps.normalizeUser ?? ((u: string) => u.trim()),
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
};
}
@@ -95,7 +91,12 @@ export function resetStoreRegistry(): void {
registryDeps = null;
}
/** Set the current app-level user (the polyfill's notion of "who am I"). */
/**
* 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;
}
@@ -104,51 +105,16 @@ export function getCurrentUser(): PrincipalId | null {
return currentUser;
}
/** The emulated cap registry — the app grants/opens caps on it (as it will via
* real cap operations in the target). The read filter consults it. */
/** 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;
}
/**
* Declare the CURRENT identity's own connections to the SDK — the domain sharing
* act "a protected document is readable by its owner AND that owner's connections".
*
* AUTHENTICATED / BILATERAL. Each entry in `peers` is a principal the CURRENT user
* (`getCurrentUser()`, or an explicit `as`) asserts a connection to. The lib
* records that assertion as authored BY the current identity — a session can only
* ever assert its OWN side. A protected read is granted between two principals only
* when BOTH have asserted the other (a materialized two-sided link). So a reader
* who unilaterally self-declares a connection to an owner gets NOTHING: the owner
* never asserted them back. Public stays world-readable; private stays owner-only.
* Re-callable whenever the connection graph changes (additive + idempotent).
*
* SDK-shaped: the consumer passes principals only — never a document NURI, a store
* id, or the cap registry internals. `as` names the asserting identity explicitly
* (defaults to the current user); the consumer normally omits it.
*/
export function declareConnections(peers: Iterable<PrincipalId>, as?: PrincipalId): void {
const self = as ?? currentUser;
if (self) for (const peer of peers) connectionRegistry.assert(self, peer);
// Re-derive protected grants from the CURRENT bilateral view (only two-sided
// links surface as neighbours). Idempotent: grants only ever accumulate.
caps.grantReadToConnections((owner) => connectionRegistry.neighbors(owner));
}
/** @internal — the bilateral connection registry (mainly for tests / adapters). */
export function getConnectionRegistry(): ConnectionRegistry {
return connectionRegistry;
}
/** The current bilateral connection view (only two-sided links surface). */
export function getConnections() {
return bilateralConnections(connectionRegistry);
}
/** Reset all emulated caps (mainly for tests / fresh sessions). */
export function resetCaps(): void {
caps = new CapRegistry();
connectionRegistry = new ConnectionRegistry();
}
// Cap surface — polyfill-era (caps are emulated now; native at migration).
+36 -36
View File
@@ -1,39 +1,39 @@
/**
* read-model — the LISTING primitive of the polyfill: read a BOUNDED, by-need set
* of documents, each with its OWN anchored `sparql_query`, and return the triples
* read-model — the listing primitive of the polyfill: read a bounded, by-need set
* of documents, each with its own anchored `sparql_query`, and return the triples
* grouped per subject. This is the mechanism documented in docs/read-model.md.
*
* ── WHY per-doc ANCHORED, never an anchorless union-scan ───────────────────
* An ANCHORED `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)`
* ── Why per-doc anchored, rather than an anchorless union-scan ─────────────
* An anchored `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)`
* is restricted to the anchor repo's graph: `resolve_target_for_sparql(Repo)` →
* `Some(repo_graph_name)`, which becomes the query's DEFAULT graph. A body with NO
* `GRAPH` wrapper reads only that default graph → ONLY that doc's triples, O(1) per
* doc, INDEPENDENT of how many other graphs the local store holds.
* `Some(repo_graph_name)`, which becomes the query's default graph. A body with no
* `GRAPH` wrapper reads only that default graph → only that doc's triples, O(1) per
* doc, independent of how many other graphs the local store holds.
*
* An ANCHORLESS query (`anchor` undefined → `UserSite` → `set_default_graph_as_union`)
* spans EVERY named graph currently in the session store. On a SHARED / bloated
* wallet that accumulates across runs, that is O(wallet size) → the observed ~90s
* timeouts. So the read path must NEVER union-scan all graphs: it reads exactly the
* bounded by-need set, one anchored query per doc.
* The footgun this avoids: an anchorless query (`anchor` undefined → `UserSite` →
* `set_default_graph_as_union`) spans EVERY named graph currently in the session
* store. On a shared / bloated wallet that accumulates across runs, that is
* O(wallet size) → the observed ~90s timeouts. So the read path never union-scans
* all graphs — it reads exactly the bounded by-need set, one anchored query per doc.
*
* NB (verified, docs/read-model.md § probe step 4): an explicit `GRAPH ?g { … }`
* body iterates the NAMED graphs regardless of the default graph, so an anchor does
* NOT bound such a body. The per-doc read therefore uses a DEFAULT-GRAPH body (no
* body iterates the named graphs regardless of the default graph, so an anchor does
* not bound such a body. The per-doc read therefore uses a default-graph body (no
* `GRAPH` wrapper) so the anchor's one-repo restriction actually applies.
*
* ── WHY not the reactive ORM fan-out ──────────────────────────────────────
* ── Why not the reactive ORM fan-out ──────────────────────────────────────
* `useShape({ graphs: […manyDocs] })` drives `orm_start_graph` over a fan-out of
* per-entity graphs; a freshly-created / not-yet-synced doc in that fan-out makes
* `RepoNotFound` abort the whole subscription → the readyPromise never resolves →
* the ~75s hang (docs/nextgraph-current-state.md § The ORM fan-out hang). Listing
* MUST instead be a set of one-shot anchored `sparql_query`s. There is no reactive
* union query, so reactivity is assembled by RE-QUERYING on a change signal.
* is instead a set of one-shot anchored `sparql_query`s. There is no reactive
* union query, so reactivity is assembled by re-querying on a change signal.
*
* ── GENERIC by construction ───────────────────────────────────────────────
* Zero application domain here: the consumer passes the doc NURIs to read (from
* ── Generic by construction ───────────────────────────────────────────────
* No application domain here: the consumer passes the doc NURIs to read (from
* the discovery index for public events, or its own scope docs for my-entities)
* and interprets the returned per-subject property bags. All NextGraph I/O routes
* through the T01.a `docs` primitives (the REAL injected `ng`), so this module
* through the T01.a `docs` primitives (the real injected `ng`), so this module
* imports no `@ng-org` package.
*
* At the real multi-store migration the per-doc anchored read is unchanged (native
@@ -80,18 +80,18 @@ async function sessionId(): Promise<string> {
}
/**
* Read ONE doc with an ANCHORED default-graph query, tolerant per-doc.
* Read one doc with an anchored default-graph query, tolerant per-doc.
*
* The anchor (`doc` NURI) restricts the query to that repo's graph as the DEFAULT
* The anchor (`doc` NURI) restricts the query to that repo's graph as the default
* graph (`resolve_target_for_sparql(Repo)` → `Some(repo_graph_name)`); a body with
* NO `GRAPH` wrapper reads exactly that default graph → ONLY this doc's triples.
* This is O(1) in the doc's own size and INDEPENDENT of the rest of the (possibly
* no `GRAPH` wrapper reads exactly that default graph → only this doc's triples.
* This is O(1) in the doc's own size and independent of the rest of the (possibly
* bloated / shared) session store — it never iterates other graphs.
*
* All same-session repos (every doc `doc_create`d this session — in the mono-wallet
* polyfill, ALL of them) are already in `self.repos`, so the anchored query resolves
* polyfill, all of them) are already in `self.repos`, so the anchored query resolves
* the repo directly with no separate open. A genuinely-absent repo throws
* `RepoNotFound` HERE, in isolation, and the doc is skipped — never aborting the
* `RepoNotFound` here, in isolation, and the doc is skipped — never aborting the
* others (unlike the ORM fan-out). Returns the doc's rows, or `[]` on failure.
*
* At the real multi-store migration this becomes a real sync: opening a per-user
@@ -103,7 +103,7 @@ async function readDoc(
): Promise<Array<Record<string, { value: string } | undefined>>> {
try {
const nuri = assertNuri(doc);
// Anchored to `nuri` → default graph = this repo. NO `GRAPH ?g` wrapper, so
// Anchored to `nuri` → default graph = this repo. No `GRAPH ?g` wrapper, so
// the anchor's one-repo restriction applies (an explicit `GRAPH ?g` body would
// iterate all named graphs regardless of the anchor — see docs § probe step 4).
const res = await sparqlQuery(
@@ -126,10 +126,10 @@ async function readDoc(
* Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the
* batch.
*
* NEVER an anchorless union-scan over all graphs (which is O(wallet size) and wrong
* on a shared / bloated wallet). Each doc is read with an anchored default-graph
* query, O(1) per doc, independent of wallet size — a non-empty wallet no longer
* matters. Reads run in parallel via `Promise.all`.
* Never an anchorless union-scan over all graphs (which is O(wallet size) and wrong
* on a shared / bloated wallet — the footgun this path exists to avoid). Each doc is
* read with an anchored default-graph query, O(1) per doc, independent of wallet
* size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`.
*/
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
const sid = await sessionId();
@@ -142,17 +142,17 @@ export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
);
// Cap gate (defence-in-depth). A doc whose read policy the current user may not
// satisfy is dropped. Isolation holds BY CONSTRUCTION (the app only resolves docs
// it is entitled to) AND BY FILTER here. Generic: the lib owns the cap registry;
// a doc under no policy (`!governsRead`) flows through unchanged. In this polyfill
// each subject IRI IS its own document NURI, so the cap key is the doc NURI.
// satisfy is dropped. Isolation holds both by construction (the app only resolves
// docs it is entitled to) and by filter here. Generic: the lib owns the cap
// registry; a doc under no policy (`!governsRead`) flows through unchanged. In this
// polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI.
const caps = getCaps();
const user = getCurrentUser();
const bySubject = new Map<string, UnionSubject>();
for (const { doc, rows } of perDoc) {
if (caps.governsRead(doc) && !caps.canRead(doc, user)) continue;
// Anchored to `doc`, so every row belongs to `doc`; the subject IS the doc NURI
// Anchored to `doc`, so every row belongs to `doc`; the subject is the doc NURI
// (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which
// is stable regardless of the repo_graph_name overlay suffix the store carries.
for (const row of rows) {
+20 -20
View File
@@ -2,26 +2,26 @@
* SPARQL string-building safety helpers — shared by every module that builds
* SPARQL by interpolation (inbox, store-registry).
*
* WHY THIS EXISTS: SPARQL injection. When an untrusted value (a username, a
* payload) is spliced verbatim into a query, a `"` closes a literal and a `>`
* closes an IRI, letting the value inject arbitrary triples (or wreck the shim
* graph, which is the trust root mapping accounts → document NURIs). Every
* value that reaches a query MUST pass through one of these helpers first.
* These exist because of SPARQL injection. When an untrusted value (an identity
* id, a payload) is spliced verbatim into a query, a `"` closes a literal and a
* `>` closes an IRI, letting the value inject arbitrary triples (or corrupt the
* shim graph, the trust root mapping accounts → document NURIs). Every value that
* reaches a query passes through one of these helpers first.
*
* TWO POSITIONS, TWO STRATEGIES:
* - LITERAL position (`"..."`): {@link escapeLiteral}. We *escape* rather than
* reject because literals legitimately carry arbitrary text (JSON payloads,
* display names). Escaping is lossless and reversible.
* Two positions, two strategies:
* - Literal position (`"..."`): {@link escapeLiteral}. Escape rather than reject,
* because literals legitimately carry arbitrary text (JSON payloads, display
* names). Escaping is lossless and reversible.
* - IRI position (`<...>`): two cases.
* · Trusted-SHAPED NURIs coming back from `ng` (`did:ng:...`): validate
* with {@link assertNuri} — they should never contain IRI-breaking
* chars; if one does, something upstream is wrong, so we throw rather
* than silently build a broken/injected query.
* · UNTRUSTED values embedded into an IRI (a username used to mint an
* account-subject IRI): {@link escapeIri} percent-encodes every
* IRI-hostile character. We *encode* rather than reject so any username
* (spaces, unicode, punctuation) stays usable, while `<`, `>`, `"`,
* whitespace and control chars can never break out of the IRI.
* · Trusted-shaped NURIs coming back from `ng` (`did:ng:...`): validate with
* {@link assertNuri} — they should never contain IRI-breaking chars; if one
* does, something upstream is wrong, so it throws rather than silently
* building a broken/injected query.
* · Untrusted values embedded into an IRI (an identity id used to mint an
* account-subject IRI): {@link escapeIri} percent-encodes every IRI-hostile
* character. Encode rather than reject so any id (spaces, unicode,
* punctuation) stays usable, while `<`, `>`, `"`, whitespace and control
* chars can never break out of the IRI.
*/
/**
@@ -56,8 +56,8 @@ function isIriForbidden(ch: string): boolean {
/**
* Percent-encode every IRI-hostile character in `value` so it is safe to embed
* inside a SPARQL IRI ref (`<PREFIX:${escapeIri(value)}>`). Use this for
* UNTRUSTED values (e.g. a username minted into an account-subject IRI):
* encoding keeps every username usable while making breakout impossible.
* untrusted values (e.g. an identity id minted into an account-subject IRI):
* encoding keeps every id usable while making breakout impossible.
*
* NOTE: this encodes only the delimiter/whitespace/control set, so ordinary
* printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass
+61 -61
View File
@@ -1,11 +1,11 @@
/**
* 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
* 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
* (`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.
*
@@ -14,13 +14,13 @@
* 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 does NOT know any
* application entity kind (event, meeting-point, profile, …). The consumer
* maps its entities to a scope and calls `createEntityDoc(scope)` /
* `listEntityDocs(scope)` with the resulting native scope. Zero domain here.
* ── 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 ─────────────────────────────────────────
* ── 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
@@ -41,7 +41,7 @@ import type { Nuri, Scope } from "./types";
/** One account's three scope-document NURIs, as recorded in the shim. */
export interface AccountRecord {
username: string;
id: string;
docPublic: Nuri;
docProtected: Nuri;
docPrivate: Nuri;
@@ -50,7 +50,7 @@ export interface AccountRecord {
const SHIM = "urn:ng-eventually:shim";
const P = {
type: `${SHIM}:Account`,
username: `${SHIM}:username`,
id: `${SHIM}:id`,
docPublic: `${SHIM}:docPublic`,
docProtected: `${SHIM}:docProtected`,
docPrivate: `${SHIM}:docPrivate`,
@@ -61,22 +61,22 @@ const P = {
// documents (one per entity) that live "in" that scope.
const INDEX_SUBJECT = `${SHIM}:index`;
function accountSubject(username: string): string {
// The username is UNTRUSTED and lands in an IRI position. Percent-encode it
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(username))}`;
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 username. A reserved account is created
// 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
// `normalizeUser` (consumer-injected) can never produce: it strips a leading
// `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 username
// 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:";
@@ -84,24 +84,24 @@ 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 username.
* (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(username: string): boolean {
return username.startsWith(RESERVED_PREFIX);
function isReserved(id: string): boolean {
return id.startsWith(RESERVED_PREFIX);
}
/**
* The shim/cache key for an account. Reserved accounts bypass `normalizeUser`
* 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 username; everyone else normalizes as usual.
* a normalized id; everyone else normalizes as usual.
*/
function accountKey(username: string): string {
return isReserved(username) ? username : normalize(username);
function accountKey(id: string): string {
return isReserved(id) ? id : normalize(id);
}
// --- session / normalization access (injected by the consumer) ------------
@@ -118,8 +118,8 @@ export interface RegistrySession {
publicStoreId?: string;
}
function normalize(username: string): string {
return getStoreRegistryDeps().normalizeUser(username);
function normalize(id: string): string {
return getStoreRegistryDeps().normalizeId(id);
}
async function session(): Promise<RegistrySession> {
@@ -174,10 +174,10 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
const s = await session();
const anchor = await anchorNuri();
const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE {
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${assertNuri(anchor)}> {
?acc a <${P.type}> ;
<${P.username}> ?username ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
@@ -187,11 +187,11 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
try {
const result = await sparqlQuery(s.sessionId, query, undefined, anchor);
for (const row of readBindings(result)) {
const username = bindingValue(row, "username");
if (!username) continue;
const key = accountKey(username);
const id = bindingValue(row, "id");
if (!id) continue;
const key = accountKey(id);
const record: AccountRecord = {
username,
id,
docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"),
@@ -210,15 +210,15 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
/**
* 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(username)`) directly,
* 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(username: string): Promise<AccountRecord | null> {
const key = accountKey(username);
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
const key = accountKey(id);
const cached = accountCache.get(key);
if (cached) return cached;
@@ -226,12 +226,12 @@ export async function resolveAccount(username: string): Promise<AccountRecord |
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(username);
const subj = accountSubject(id);
const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE {
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${assertNuri(anchor)}> {
<${subj}> a <${P.type}> ;
<${P.username}> ?username ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
@@ -243,7 +243,7 @@ export async function resolveAccount(username: string): Promise<AccountRecord |
if (rows.length === 0) return null;
const row = rows[0]!;
const record: AccountRecord = {
username: bindingValue(row, "username") || username,
id: bindingValue(row, "id") || id,
docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"),
@@ -273,11 +273,11 @@ async function createDoc(): Promise<Nuri> {
* 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(username: string): Promise<AccountRecord> {
const key = accountKey(username);
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.
const existing = await resolveAccount(username);
const existing = await resolveAccount(id);
if (existing) return existing;
const [docPublic, docProtected, docPrivate] = await Promise.all([
@@ -285,20 +285,20 @@ export async function ensureAccount(username: string): Promise<AccountRecord> {
createDoc(),
createDoc(),
]);
const record: AccountRecord = { username, docPublic, docProtected, docPrivate };
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
const s = await session();
const anchor = await anchorNuri();
const subj = accountSubject(username);
const subj = accountSubject(id);
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
// trusted-shaped NURI → assertNuri. `username` is UNTRUSTED text in a LITERAL
// 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.username}> "${escapeLiteral(username)}" ;
<${P.id}> "${escapeLiteral(id)}" ;
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
@@ -328,12 +328,12 @@ function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
}
/**
* NURI of the document where `username` writes GROUPED entities of `scope`
* (e.g. participations, profile — no per-entity document / no inbox needed).
* For per-entity scopes use {@link createEntityDoc} instead.
* 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(username: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(username);
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
return indexDocOf(record, scope);
}
@@ -414,8 +414,8 @@ export async function resolveInboxAnchor(): Promise<Nuri> {
* 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(username: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(username);
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();
@@ -480,14 +480,14 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
}
/**
* The entity-document NURIs of `scope` belonging to ONE account (`username`) —
* the read-by-need path for "my own entities" (my profile, my participations).
* Bounded to a SINGLE account: it resolves only that account's scope index doc
* (via `ensureAccount`) and reads the contained NURIs — NO cross-account
* enumeration, so it never touches another account's unsynced docs. This is the
* helper FestipodDataContext uses instead of the all-accounts `listEntityDocs`.
* 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(username: string, scope: Scope): Promise<Nuri[]> {
const record = await ensureAccount(username);
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
const record = await ensureAccount(id);
return readScopeIndex(indexDocOf(record, scope));
}
+2 -2
View File
@@ -9,8 +9,8 @@ export type Nuri = string;
* consumer's concern; this layer only knows the three scopes exist. */
export type Scope = "public" | "protected" | "private";
/** App-level current identity. Target: the wallet user (`session.user`).
* Polyfill: a chosen id (e.g. a username), because everyone shares one wallet. */
/** The current identity id. Target: the wallet user (`session.user`). Polyfill:
* a chosen id, because everyone shares one wallet. */
export type PrincipalId = string;
/**
+28 -37
View File
@@ -1,8 +1,7 @@
import { test, expect } from "bun:test";
import {
AccountStore,
browserAccountStore,
normalizeUsername,
IdentityStore,
browserIdentityStore,
ACCOUNT_STORAGE_KEY,
type AccountStorage,
} from "../src/accounts";
@@ -18,47 +17,39 @@ function fakeStorage(): AccountStorage & { map: Map<string, string> } {
};
}
test("normalizeUsername: strips leading @, trims, lowercases", () => {
expect(normalizeUsername("marie")).toBe("marie");
expect(normalizeUsername("@Marie")).toBe("marie");
expect(normalizeUsername(" @@MARIE ")).toBe("marie");
expect(normalizeUsername(null)).toBe("");
expect(normalizeUsername(undefined)).toBe("");
});
test("AccountStore: login persists a trimmed username, get reads it back", () => {
test("IdentityStore: set persists a trimmed id, get reads it back", () => {
const s = fakeStorage();
const store = new AccountStore(s);
const store = new IdentityStore(s);
expect(store.get()).toBeNull();
expect(store.login(" Marie ")).toBe("Marie"); // trimmed, NOT normalized (display form)
expect(store.get()).toBe("Marie");
expect(s.map.get(ACCOUNT_STORAGE_KEY)).toBe("Marie");
expect(store.set(" marie ")).toBe("marie"); // trimmed
expect(store.get()).toBe("marie");
expect(s.map.get(ACCOUNT_STORAGE_KEY)).toBe("marie");
});
test("AccountStore: blank login is ignored, keeps the previous value", () => {
const store = new AccountStore(fakeStorage());
store.login("bob");
expect(store.login(" ")).toBe("bob");
test("IdentityStore: a blank id is ignored, keeps the previous value", () => {
const store = new IdentityStore(fakeStorage());
store.set("bob");
expect(store.set(" ")).toBe("bob");
expect(store.get()).toBe("bob");
});
test("AccountStore: logout clears the username (no throw)", () => {
const store = new AccountStore(fakeStorage());
store.login("bob");
store.logout();
test("IdentityStore: clear removes the id (no throw)", () => {
const store = new IdentityStore(fakeStorage());
store.set("bob");
store.clear();
expect(store.get()).toBeNull();
});
test("AccountStore: null storage degrades to non-persisting (SSR-safe)", () => {
const store = new AccountStore(null);
test("IdentityStore: null storage degrades to non-persisting (SSR-safe)", () => {
const store = new IdentityStore(null);
expect(store.get()).toBeNull();
expect(store.login("bob")).toBe("bob"); // returns the value, just doesn't persist
expect(store.set("bob")).toBe("bob"); // returns the value, just doesn't persist
expect(store.get()).toBeNull();
store.logout(); // no throw
store.clear(); // no throw
});
test("AccountStore: swallows storage errors on read and write", () => {
test("IdentityStore: swallows storage errors on read and write", () => {
const throwing: AccountStorage = {
getItem: () => {
throw new Error("boom");
@@ -70,15 +61,15 @@ test("AccountStore: swallows storage errors on read and write", () => {
throw new Error("boom");
},
};
const store = new AccountStore(throwing);
const store = new IdentityStore(throwing);
expect(store.get()).toBeNull(); // read error swallowed → null
expect(() => store.login("bob")).not.toThrow();
expect(() => store.logout()).not.toThrow();
expect(() => store.set("bob")).not.toThrow();
expect(() => store.clear()).not.toThrow();
});
test("browserAccountStore returns a working store (uses global localStorage if present)", () => {
const store = browserAccountStore("ng-eventually.test.account");
expect(store).toBeInstanceOf(AccountStore);
// Behaves regardless of environment: login returns the value.
expect(store.login("zoe")).toBe("zoe");
test("browserIdentityStore returns a working store (uses global localStorage if present)", () => {
const store = browserIdentityStore("ng-eventually.test.account");
expect(store).toBeInstanceOf(IdentityStore);
// Behaves regardless of environment: set returns the value.
expect(store.set("zoe")).toBe("zoe");
});
+20 -1
View File
@@ -13,7 +13,7 @@ test("protected documents: owner + explicitly granted principals only", () => {
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"); // bob becomes a connection of alice
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);
});
@@ -25,6 +25,25 @@ test("private documents: owner only", () => {
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");
-60
View File
@@ -1,60 +0,0 @@
/**
* ConnectionRegistry — BILATERAL connection materialization (T03.h).
*
* A connection is live only when BOTH sides have asserted the other. A unilateral
* (self-declared) assertion yields no neighbour — the defence against a reader who
* fakes a connection to an owner to read that owner's protected documents.
*/
import { test, expect } from "bun:test";
import { ConnectionRegistry, bilateralConnections } from "../src/connections";
test("a UNILATERAL assertion yields NO neighbour", () => {
const reg = new ConnectionRegistry();
reg.assert("mallory", "alice"); // mallory self-declares; alice never asserts back
expect([...reg.neighbors("mallory")]).toEqual([]);
expect([...reg.neighbors("alice")]).toEqual([]);
});
test("a BILATERAL assertion (both sides) materializes the link", () => {
const reg = new ConnectionRegistry();
reg.assert("alice", "bob");
reg.assert("bob", "alice");
expect([...reg.neighbors("alice")]).toEqual(["bob"]);
expect([...reg.neighbors("bob")]).toEqual(["alice"]);
});
test("mixed: only the reciprocated peers surface", () => {
const reg = new ConnectionRegistry();
reg.assert("alice", "bob"); // reciprocated below
reg.assert("bob", "alice");
reg.assert("alice", "carol"); // NOT reciprocated by carol
reg.assert("dave", "alice"); // dave asserts alice, alice never asserts dave
expect([...reg.neighbors("alice")].sort()).toEqual(["bob"]);
});
test("self-assertion and empty are ignored", () => {
const reg = new ConnectionRegistry();
reg.assert("alice", "alice");
reg.assert("", "bob");
reg.assert("alice", "");
expect([...reg.neighbors("alice")]).toEqual([]);
});
test("bilateralConnections adapts to the Connections interface", () => {
const reg = new ConnectionRegistry();
reg.assertAll([
{ from: "alice", to: "bob" },
{ from: "bob", to: "alice" },
]);
const conns = bilateralConnections(reg);
expect([...conns.neighbors("alice")]).toEqual(["bob"]);
expect([...conns.neighbors("carol")]).toEqual([]);
});
test("clear() removes all assertions", () => {
const reg = new ConnectionRegistry();
reg.assert("alice", "bob");
reg.assert("bob", "alice");
reg.clear();
expect([...reg.neighbors("alice")]).toEqual([]);
});
+9 -9
View File
@@ -102,7 +102,7 @@ function makeFakeNg() {
// 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}:username>`)) {
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>>();
@@ -110,16 +110,16 @@ function makeFakeNg() {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:username`) rec.username = q.o;
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.username)
.filter((r) => r.id)
.map((r) => ({
username: { value: r.username! },
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
@@ -167,7 +167,7 @@ function inject() {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
setCurrentUser(null);
@@ -256,10 +256,10 @@ test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", asy
resetCaps();
});
test("INDEX_ACCOUNT lives in the reserved namespace (no typed username can equal it)", () => {
// The index account occupies a key no user input can produce: it is prefixed
// with a NUL control char, which a user cannot type into a username field and
// which no `normalizeUser` output (a typeable value) contains. So it is
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");
+35 -31
View File
@@ -1,15 +1,18 @@
/**
* ReadCap ACTIVE (T03.h) — end-to-end proof that the emulated SDK enforces
* per-DOCUMENT isolation, driven by per-entity documents + BILATERAL connections.
* ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
* isolation, driven by per-entity documents + DIRECTED read grants.
*
* Mirrors exactly 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 declare
* connections as the CURRENT identity's own peers (authenticated, bilateral). The
* read filter then discriminates:
* (a) unconnected principal denied a PROTECTED doc; granted after a BILATERAL
* connection; PUBLIC readable throughout — via the ACTIVE ReadCap.
* (b) a UNILATERAL / self-declared connection grants NOTHING.
* 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";
@@ -22,7 +25,6 @@ import {
getCaps,
resetCaps,
setCurrentUser,
declareConnections,
} from "../src/polyfill";
import { filterReadable } from "../src/read-filter";
@@ -43,13 +45,19 @@ function inject() {
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeUser: (u) => u.trim() });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
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();
@@ -70,9 +78,9 @@ test("ReadCap active: a private entity doc created via the real registry is hidd
expect(getCaps().hasReadPolicy()).toBe(true);
});
// (a) protected hidden while unconnected → revealed after a BILATERAL connection;
// public readable regardless — all through the ACTIVE ReadCap.
test("(a) PROTECTED doc: hidden unconnected, revealed after BILATERAL connection, PUBLIC always readable", async () => {
// (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");
@@ -86,22 +94,22 @@ test("(a) PROTECTED doc: hidden unconnected, revealed after BILATERAL connection
];
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]).sort();
// BEFORE any connection: bob sees only the public item.
// BEFORE any grant: bob sees only the public item.
expect(view("bob")).toEqual(["u1"]);
expect(view("alice")).toEqual(["p1", "u1"]);
// BILATERAL: alice asserts bob AND bob asserts alice → the link materializes and
// the SDK issues the protected doc's read cap to bob.
declareConnections(["bob"], "alice");
declareConnections(["alice"], "bob");
// 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, unconnected principal still sees only the public one.
// A third, ungranted principal still sees only the public one.
expect(view("carol")).toEqual(["u1"]);
});
// (b) A UNILATERAL / self-declared connection must NOT grant protected read.
test("(b) a UNILATERAL / self-declared connection grants NO protected read", async () => {
// (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");
@@ -109,15 +117,11 @@ test("(b) a UNILATERAL / self-declared connection grants NO protected read", asy
const items = [{ "@graph": aliceProtected, "@id": "p1" }];
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]);
// The ATTACKER (mallory) self-declares a connection to alice — a UNILATERAL
// assertion authored by mallory. Alice NEVER asserts mallory back.
declareConnections(["alice"], "mallory");
expect(view("mallory")).toEqual([]); // still denied — no bilateral link
// mallory holds no grant on alice's protected doc → denied.
expect(view("mallory")).toEqual([]);
// Even if alice connects to bob (a different, legitimate bilateral link),
// mallory's one-sided assertion still grants nothing.
declareConnections(["bob"], "alice");
declareConnections(["alice"], "bob");
// Granting bob (a different, legitimate reader) leaves mallory denied.
grantOwnerProtectedTo("alice", "bob");
expect(view("mallory")).toEqual([]);
expect(view("bob")).toEqual(["p1"]);
});
-68
View File
@@ -1,68 +0,0 @@
import { test, expect } from "bun:test";
import {
applyIsolation,
connectionsFromLinks,
visibleSet,
isVisible,
type IsolationAccessors,
} from "../src/isolation";
import type { Scope } from "../src/types";
// Generic item: owner + scope. ZERO domain — the accessors read these fields.
interface Item {
id: string;
owner: string;
scope: Scope;
}
const acc: IsolationAccessors<Item> = { ownerOf: (i) => i.owner, scopeOf: (i) => i.scope };
// alice —— bob (carol is unconnected)
const links = [{ a: "alice", b: "bob" }];
const conns = connectionsFromLinks(links);
test("connectionsFromLinks / visibleSet: self + direct connections, both directions", () => {
expect([...conns.neighbors("alice")].sort()).toEqual(["bob"]);
expect([...conns.neighbors("bob")].sort()).toEqual(["alice"]);
expect([...conns.neighbors("carol")]).toEqual([]);
expect([...visibleSet("alice", conns)].sort()).toEqual(["alice", "bob"]);
expect([...visibleSet("carol", conns)].sort()).toEqual(["carol"]);
});
test("isVisible matrix: public=all, protected=owner+connections, private=owner", () => {
const vis = visibleSet("alice", conns); // { alice, bob }
// public → everyone, regardless of owner
expect(isVisible("public", "carol", "alice", vis)).toBe(true);
// protected → owner must be in the visible set
expect(isVisible("protected", "bob", "alice", vis)).toBe(true);
expect(isVisible("protected", "carol", "alice", vis)).toBe(false);
// private → owner must be the current principal
expect(isVisible("private", "alice", "alice", vis)).toBe(true);
expect(isVisible("private", "bob", "alice", vis)).toBe(false);
});
test("applyIsolation narrows a mixed collection for the current principal", () => {
const items: Item[] = [
{ id: "e", owner: "carol", scope: "public" }, // public → kept for all
{ id: "p1", owner: "alice", scope: "protected" }, // own protected → kept
{ id: "p2", owner: "bob", scope: "protected" }, // connection's protected → kept
{ id: "p3", owner: "carol", scope: "protected" }, // stranger's protected → dropped
{ id: "s1", owner: "alice", scope: "private" }, // own private → kept
{ id: "s2", owner: "bob", scope: "private" }, // connection's private → dropped
];
const forAlice = applyIsolation(items, "alice", conns, acc).map((i) => i.id);
expect(forAlice).toEqual(["e", "p1", "p2", "s1"]);
const forCarol = applyIsolation(items, "carol", conns, acc).map((i) => i.id);
// carol sees: public + her own protected + her own private
expect(forCarol).toEqual(["e", "p3"]);
});
test("applyIsolation with empty principal passes everything through (hydration guard)", () => {
const items: Item[] = [
{ id: "s1", owner: "alice", scope: "private" },
{ id: "p1", owner: "bob", scope: "protected" },
];
expect(applyIsolation(items, "", conns, acc).map((i) => i.id)).toEqual(["s1", "p1"]);
});
+2 -2
View File
@@ -34,7 +34,7 @@ function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeUser: (u: string) => u,
normalizeId: (u: string) => u,
});
return ng;
}
@@ -96,7 +96,7 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeUser: (u: string) => u,
normalizeId: (u: string) => u,
});
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
+1 -1
View File
@@ -43,7 +43,7 @@ test("escapeIri percent-encodes every IRI-breaking character", () => {
test("escapeIri neutralises a full breakout attempt", () => {
const attack = 'x> <urn:evil> "pwn';
const encoded = escapeIri(attack);
// The encoded username cannot contain a raw `>`, `<`, `"` or space, so it
// The encoded id cannot contain a raw `>`, `<`, `"` or space, so it
// cannot escape the surrounding <PREFIX:...> IRI.
expect(encoded).not.toMatch(/[<>" ]/);
});
+22 -22
View File
@@ -112,7 +112,7 @@ function makeFakeNg() {
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:username>")) {
if (query.includes("<urn:ng-eventually:shim:id>")) {
// Account SELECT, scoped to the anchor graph. Two shapes: the full scan
// (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
// <Account>`) which binds one subject — honour that subject so the bounded
@@ -124,16 +124,16 @@ function makeFakeNg() {
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:username") rec.username = q.o;
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.username)
.filter((r) => r.id)
.map((r) => ({
username: { value: r.username! },
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
@@ -157,7 +157,7 @@ function inject() {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
return ng;
@@ -170,7 +170,7 @@ beforeEach(() => {
test("ensureAccount creates 3 scope docs and persists them to the shim", async () => {
const rec = await ensureAccount("Alice");
expect(rec.username).toBe("Alice");
expect(rec.id).toBe("Alice");
expect(fake.doc_create).toHaveBeenCalledTimes(3);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
expect(rec.docProtected).not.toBe(rec.docPublic);
@@ -191,7 +191,7 @@ test("loadShim round-trips a persisted account across a cache reset", async () =
resetRegistryCache(); // force a re-read from the fake store
const map = await loadShim();
const rec = map.get("bob");
expect(rec?.username).toBe("Bob");
expect(rec?.id).toBe("Bob");
expect(rec?.docPublic).toMatch(/^did:ng:o:doc/);
});
@@ -260,19 +260,19 @@ test("listEntityDocs fans out across multiple accounts", async () => {
test("allAccounts reflects every ensured account", async () => {
await ensureAccount("Gina");
await ensureAccount("Hank");
const names = (await allAccounts()).map((a) => a.username).sort();
const names = (await allAccounts()).map((a) => a.id).sort();
expect(names).toEqual(["Gina", "Hank"]);
});
// --- SPARQL injection hardening (F1) --------------------------------------
//
// A malicious username must NOT be able to break out of the literal / IRI it
// A malicious id must NOT be able to break out of the literal / IRI it
// lands in and inject arbitrary triples into the shim (the account→doc trust
// root). We inspect the exact SPARQL string the registry hands to sparql_update.
/** The raw INSERT DATA string produced by ensureAccount for `username`. */
async function insertFor(username: string): Promise<string> {
await ensureAccount(username);
/** The raw INSERT DATA string produced by ensureAccount for `id`. */
async function insertFor(id: string): Promise<string> {
await ensureAccount(id);
const calls = fake.sparql_update.mock.calls;
return calls[calls.length - 1]![1] as string;
}
@@ -284,31 +284,31 @@ function rawQuoteCount(s: string): number {
return (withoutEscapes.match(/"/g) ?? []).length;
}
test("injection: username with a quote cannot open extra literals", async () => {
test("injection: id with a quote cannot open extra literals", async () => {
const evil = 'x" ; <urn:evil> "pwn';
const update = await insertFor(evil);
// A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw
// quotes (the delimiters). The injected `"` must have been escaped, so the
// count stays 8 — no extra literal was opened.
expect(rawQuoteCount(update)).toBe(8);
// The escaped username is present as a single literal value — the injected
// The escaped id is present as a single literal value — the injected
// `<urn:evil>` survives only as INERT text inside that literal (its
// surrounding quotes are escaped `\"`), never as query syntax.
expect(update).toContain('"x\\" ; <urn:evil> \\"pwn"');
});
test("injection: username with '>' cannot break out of the account-subject IRI", async () => {
test("injection: id with '>' cannot break out of the account-subject IRI", async () => {
const evil = "x> <urn:evil";
const update = await insertFor(evil);
// The account subject is `<urn:ng-eventually:shim:account:...>` — the encoded
// username must NOT contain a raw `>` that would close the IRI early.
// id must NOT contain a raw `>` that would close the IRI early.
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
expect(subjMatch).not.toBeNull();
expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded
expect(subjMatch[1]).toContain("%3E"); // the `>` became %3E
});
test("injection: newline / control chars in username are neutralised", async () => {
test("injection: newline / control chars in id are neutralised", async () => {
const evil = "a\nb\tc";
const update = await insertFor(evil);
// In the literal: escaped to \n / \t (no raw control char).
@@ -342,20 +342,20 @@ function escapeLiteralRef(v: string): string {
.replace(/\t/g, "\\t")}"`;
}
test("injection: a malicious username still round-trips through the shim", async () => {
test("injection: a malicious id still round-trips through the shim", async () => {
const evil = 'eve" ; <urn:evil> "x';
const rec = await ensureAccount(evil);
expect(rec.username).toBe(evil);
expect(rec.id).toBe(evil);
resetRegistryCache();
const map = await loadShim();
// The stored username came back verbatim (escaping is lossless) under its
// The stored id came back verbatim (escaping is lossless) under its
// normalized key, and exactly ONE account exists (no injected extra subject).
const key = evil.trim().replace(/^@+/, "").toLowerCase();
expect(map.get(key)?.username).toBe(evil);
expect(map.get(key)?.id).toBe(evil);
expect(map.size).toBe(1);
});
test("normalizeUser defaults to trim when not provided", async () => {
test("normalizeId defaults to trim when not provided", async () => {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION });