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:
+21
-17
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.) */
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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(/[<>" ]/);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user