From 654cb90d996ac0386d935248ec88fea49cf335be Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 3 Jul 2026 10:12:55 +0200 Subject: [PATCH] feat(client): generic shared-wallet shim surface (docs/storeRegistry/isolation/accounts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the shared-wallet shim mechanics from the Festipod app into the generic @ng-eventually/client library — zero app-specific knowledge, the consumer injects the domain (entity->scope mapping, connections, storage). New namespaces exposed from src/index.ts: - docs docCreate/sparqlUpdate/sparqlQuery via the REAL injected `ng` (getConfig().ng), never the public makeNg proxy — the JS-over- iframe double proxy breaks doc_create postMessage marshaling (DataCloneError). Validated hard constraint. - storeRegistry generic (account,scope)->NURI resolver, createEntityDoc/ listEntityDocs + per-scope index, sharedWalletShim in the private_store, cache. Consumer wiring injected via configureStoreRegistry({ getSession, normalizeUser }). - isolation pure applyIsolation (public=all / protected=owner+connections / private=owner); accessors + connection graph injected. - accounts AccountStore (localStorage-backed faux login, storage injected) + normalizeUsername. React wrapper intentionally NOT ported. polyfill.ts gains configureStoreRegistry/getStoreRegistryDeps + resetConfig. 36/36 bun test, tsc --noEmit rc=0. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/src/accounts.ts | 109 ++++++++ packages/client/src/docs.ts | 65 +++++ packages/client/src/index.ts | 7 + packages/client/src/isolation.ts | 138 ++++++++++ packages/client/src/polyfill.ts | 48 ++++ packages/client/src/store-registry.ts | 285 ++++++++++++++++++++ packages/client/test/accounts.test.ts | 84 ++++++ packages/client/test/docs.test.ts | 91 +++++++ packages/client/test/isolation.test.ts | 68 +++++ packages/client/test/store-registry.test.ts | 202 ++++++++++++++ 10 files changed, 1097 insertions(+) create mode 100644 packages/client/src/accounts.ts create mode 100644 packages/client/src/docs.ts create mode 100644 packages/client/src/isolation.ts create mode 100644 packages/client/src/store-registry.ts create mode 100644 packages/client/test/accounts.test.ts create mode 100644 packages/client/test/docs.test.ts create mode 100644 packages/client/test/isolation.test.ts create mode 100644 packages/client/test/store-registry.test.ts diff --git a/packages/client/src/accounts.ts b/packages/client/src/accounts.ts new file mode 100644 index 0000000..ea76944 --- /dev/null +++ b/packages/client/src/accounts.ts @@ -0,0 +1,109 @@ +/** + * accounts — the framework-agnostic core of the *simulated* app-level login. + * + * 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. + * + * `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. + */ + +/** localStorage key holding the perceived-login username. */ +export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.username"; + +/** + * Minimal storage contract (a subset of the Web `Storage` interface). The + * consumer injects one — `window.localStorage` in a browser, a fake in tests — + * so this stays framework/DOM-agnostic. When none is available (SSR, no + * `window`), pass `null` and the store degrades to in-memory-null (no persist). + */ +export interface AccountStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** + * 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. + */ +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 { + private readonly storage: AccountStorage | null; + private readonly key: string; + + constructor(storage: AccountStorage | null, key: string = ACCOUNT_STORAGE_KEY) { + this.storage = storage; + this.key = key; + } + + /** The current app-level username (the perceived "login"). null = not connected. */ + get(): string | null { + if (!this.storage) return null; + try { + return this.storage.getItem(this.key); + } catch { + return null; + } + } + + /** + * 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. + */ + login(username: string): string | null { + const clean = username.trim(); + if (!clean) return this.get(); + if (this.storage) { + try { + this.storage.setItem(this.key, clean); + } catch { + /* ignore — staging, no security */ + } + } + return clean; + } + + /** Faux logout — clear the username only. No NG call. */ + logout(): void { + if (this.storage) { + try { + this.storage.removeItem(this.key); + } catch { + /* ignore */ + } + } + } +} + +/** + * 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 { + const ls = + typeof globalThis !== "undefined" && + (globalThis as { localStorage?: AccountStorage }).localStorage + ? (globalThis as { localStorage: AccountStorage }).localStorage + : null; + return new AccountStore(ls, key); +} diff --git a/packages/client/src/docs.ts b/packages/client/src/docs.ts new file mode 100644 index 0000000..008df7e --- /dev/null +++ b/packages/client/src/docs.ts @@ -0,0 +1,65 @@ +/** + * Low-level document + SPARQL primitives. + * + * These call the **REAL injected `ng`** (`getConfig().ng`) DIRECTLY — never the + * public `ng` proxy (`makeNg`). This is a validated hard constraint, NOT a style + * choice: the public `ng` is a JS `Proxy` over `@ng-org/web`'s iframe-RPC proxy, + * and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling + * (`DataCloneError: function ... could not be cloned`). Reaching the real `ng` + * held in the config avoids the double-proxy. Do NOT import from `./ng-proxy`. + * + * Signatures mirror the real `@ng-org/web` `ng` surface (verified against the + * app's storeRegistry usage), so this is a drop-in for those raw calls. + */ + +import { getConfig } from "./polyfill"; +import type { Nuri } from "./types"; + +/** + * Create one document → its NURI. + * + * Mirrors `ng.doc_create(session_id, crdt, cls, dest, store_repo?)`. For a graph + * document in the (shared) private store: `docCreate(sid, "Graph", "data:graph", + * "store")` (store_repo left undefined → private store). + */ +export async function docCreate( + sessionId: string, + crdt: string, + cls: string, + dest: string, + store?: unknown, +): Promise { + const { ng } = getConfig(); + return ng.doc_create(sessionId, crdt, cls, dest, store); +} + +/** + * Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.). + * + * Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the + * document NURI the update is scoped/base'd to (optional). + */ +export async function sparqlUpdate( + sessionId: string, + query: string, + anchor?: Nuri, +): Promise { + const { ng } = getConfig(); + return ng.sparql_update(sessionId, query, anchor); +} + +/** + * Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result. + * + * Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the + * query base IRI (usually `undefined`); `anchor` is the document NURI to query. + */ +export async function sparqlQuery( + sessionId: string, + query: string, + base?: string, + anchor?: Nuri, +): Promise { + const { ng } = getConfig(); + return ng.sparql_query(sessionId, query, base, anchor); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 81d75cd..ec7b45e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -15,6 +15,13 @@ export * from "./types"; export { useShape } from "./use-shape"; export { init, initNg } from "./lifecycle"; export * as inbox from "./inbox"; +export * as docs from "./docs"; +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"; // SDK type re-exports — so the app imports these from @ng-eventually/client too, // not from @ng-org. `export type` is ERASED at build, so this adds NO runtime diff --git a/packages/client/src/isolation.ts b/packages/client/src/isolation.ts new file mode 100644 index 0000000..40ad5d5 --- /dev/null +++ b/packages/client/src/isolation.ts @@ -0,0 +1,138 @@ +/** + * 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; +} + +/** + * 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>(); + 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(), + }; +} + +/** + * 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 { + const set = new Set([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, +): 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 { + /** 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( + items: Iterable, + current: PrincipalId, + connections: Connections, + accessors: IsolationAccessors, +): 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), + ); +} diff --git a/packages/client/src/polyfill.ts b/packages/client/src/polyfill.ts index b8e86dd..77294c3 100644 --- a/packages/client/src/polyfill.ts +++ b/packages/client/src/polyfill.ts @@ -9,8 +9,22 @@ */ import type { NgLike, UseShapeLike, PrincipalId } from "./types"; +import type { RegistrySession } from "./store-registry"; import { CapRegistry } from "./caps"; +/** + * 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 + * 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; + /** Normalize a username for shim keying. Default: trim (identity-ish). */ + normalizeUser?: (username: string) => string; +} + export interface EventuallyConfig { /** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */ ng: NgLike; @@ -28,6 +42,7 @@ export interface EventuallyConfig { let cfg: EventuallyConfig | null = null; let currentUser: PrincipalId | null = null; +let registryDeps: Required | 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(); @@ -43,6 +58,39 @@ export function getConfig(): EventuallyConfig { return cfg; } +/** Reset the injected config back to un-configured (mainly for tests, so a + * suite that calls configure() can restore the not-configured guard state). */ +export function resetConfig(): void { + cfg = null; + currentUser = null; +} + +/** + * Wire the storeRegistry's consumer-injected dependencies (session + username + * normalization). Must be called before any storeRegistry.* use. Separate from + * {@link configure} because it's storeRegistry-specific and, like the shim, + * disappears at migration. + */ +export function configureStoreRegistry(deps: StoreRegistryDeps): void { + registryDeps = { + getSession: deps.getSession, + normalizeUser: deps.normalizeUser ?? ((u: string) => u.trim()), + }; +} + +/** @internal — used by the storeRegistry to reach its injected dependencies. */ +export function getStoreRegistryDeps(): Required { + if (!registryDeps) { + throw new Error("[ng-eventually] configureStoreRegistry() must be called before use"); + } + return registryDeps; +} + +/** Reset storeRegistry deps (mainly for tests). */ +export function resetStoreRegistry(): void { + registryDeps = null; +} + /** Set the current app-level user (the polyfill's notion of "who am I"). */ export function setCurrentUser(id: PrincipalId | null): void { currentUser = id; diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts new file mode 100644 index 0000000..59acc46 --- /dev/null +++ b/packages/client/src/store-registry.ts @@ -0,0 +1,285 @@ +/** + * storeRegistry — resolves (account, scope) → document NURI. + * + * **STOPGAP / polyfill-era.** Emulates the target infrastructure — where each + * user owns their own public/protected/private stores — on top of ONE shared + * wallet. It creates one document per (account × scope) inside that shared + * wallet (via the `docs.docCreate` primitive), so the `scope` + * (`public|protected|private`) is a LOGICAL attribute tracked here, NOT a + * physical NextGraph store. Isolation is enforced by the app layer + the + * emulated cap registry, not by crypto. + * + * The mapping (account → its 3 document NURIs) is the **sharedWalletShim**, + * persisted as RDF in the shared wallet's private store (the anchor, always + * known from the session). That makes login cross-device: another device + * opening the same wallet reads the same shim and finds the same accounts. + * + * ── GENERIC by construction ────────────────────────────────────────────── + * This module knows ONLY the three native scopes. It 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. + * + * ── WHAT DISAPPEARS AT MIGRATION ───────────────────────────────────────── + * At the real multi-store migration the shim vanishes entirely: `(account, + * scope)` maps to the user's REAL store NURI instead of a document in the + * shared wallet, `docCreate` targets the real per-user store, and the + * per-scope index document (the store-container emulation) is replaced by the + * store itself. The consumer-facing surface (`createEntityDoc`, + * `listEntityDocs`, resolvers) is designed to survive that swap unchanged. + * + * All NextGraph I/O routes through the T01.a `docs` primitive (real injected + * `ng`), so this module imports **no** `@ng-org` package. + */ + +import { docCreate, sparqlUpdate, sparqlQuery } from "./docs"; +import { getStoreRegistryDeps } from "./polyfill"; +import type { Nuri, Scope } from "./types"; + +// --- sharedWalletShim model ---------------------------------------------- + +/** One account's three scope-document NURIs, as recorded in the shim. */ +export interface AccountRecord { + username: string; + docPublic: Nuri; + docProtected: Nuri; + docPrivate: Nuri; +} + +const SHIM = "urn:ng-eventually:shim"; +const P = { + type: `${SHIM}:Account`, + username: `${SHIM}:username`, + docPublic: `${SHIM}:docPublic`, + docProtected: `${SHIM}:docProtected`, + docPrivate: `${SHIM}:docPrivate`, + contains: `${SHIM}:contains`, // scope-index → entity document NURI +} as const; +// Fixed subject of the per-(account×scope) index document. The index doc plays +// the role of the future store-container: it lists the NURIs of the entity +// documents (one per entity) that live "in" that scope. +const INDEX_SUBJECT = `${SHIM}:index`; + +function accountSubject(username: string): string { + return `${SHIM}:account:${normalize(username)}`; +} + +// --- session / normalization access (injected by the consumer) ------------ + +/** Minimal session shape the registry needs — provided by the consumer. */ +export interface RegistrySession { + sessionId: string; + /** The shared wallet's private store id — the shim anchor. */ + privateStoreId: string; +} + +function normalize(username: string): string { + return getStoreRegistryDeps().normalizeUser(username); +} + +async function session(): Promise { + return getStoreRegistryDeps().getSession(); +} + +/** The shim lives in the shared wallet's private store (always-known anchor). */ +async function anchorNuri(): Promise { + const s = await session(); + return `did:ng:${s.privateStoreId}`; +} + +// --- cache ---------------------------------------------------------------- + +// In-memory cache of the shim, keyed by normalized username. +let cache: Map | null = null; + +/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */ +export function resetRegistryCache(): void { + cache = null; +} + +// --- SPARQL result helpers ------------------------------------------------ + +/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */ +function readBindings(result: unknown): Array> { + if (!result) return []; + const anyRes = result as { + results?: { bindings?: Array> }; + }; + if (Array.isArray(result)) return result as Array>; + if (anyRes.results?.bindings) return anyRes.results.bindings; + return []; +} + +function bindingValue(row: Record, key: string): string { + return row[key]?.value ?? ""; +} + +// --- shim load / account bootstrap ---------------------------------------- + +/** Load all accounts from the shim into the cache. */ +export async function loadShim(): Promise> { + if (cache) return cache; + const s = await session(); + const anchor = await anchorNuri(); + const query = ` + SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE { + GRAPH <${anchor}> { + ?acc a <${P.type}> ; + <${P.username}> ?username ; + <${P.docPublic}> ?docPublic ; + <${P.docProtected}> ?docProtected ; + <${P.docPrivate}> ?docPrivate . + } + }`; + const map = new Map(); + try { + const result = await sparqlQuery(s.sessionId, query, undefined, anchor); + for (const row of readBindings(result)) { + const username = bindingValue(row, "username"); + if (!username) continue; + map.set(normalize(username), { + username, + docPublic: bindingValue(row, "docPublic"), + docProtected: bindingValue(row, "docProtected"), + docPrivate: bindingValue(row, "docPrivate"), + }); + } + } catch (error) { + console.error("[storeRegistry] loadShim failed:", error); + } + cache = map; + return map; +} + +/** All known accounts (from the shim). */ +export async function allAccounts(): Promise { + return [...(await loadShim()).values()]; +} + +/** Create one graph document in the shared wallet's private store (→ a NURI). */ +async function createDoc(): Promise { + const s = await session(); + // crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store", + // store_repo=undefined → shared wallet's private store. + return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined); +} + +/** + * Ensure an account exists in the shim, creating its 3 scope documents on + * first sight. Idempotent — returns the existing record if already present. + */ +export async function ensureAccount(username: string): Promise { + const map = await loadShim(); + const key = normalize(username); + const existing = map.get(key); + if (existing) return existing; + + const [docPublic, docProtected, docPrivate] = await Promise.all([ + createDoc(), + createDoc(), + createDoc(), + ]); + const record: AccountRecord = { username, docPublic, docProtected, docPrivate }; + + const s = await session(); + const anchor = await anchorNuri(); + const subj = accountSubject(username); + const update = ` + INSERT DATA { + GRAPH <${anchor}> { + <${subj}> a <${P.type}> ; + <${P.username}> "${username}" ; + <${P.docPublic}> "${docPublic}" ; + <${P.docProtected}> "${docProtected}" ; + <${P.docPrivate}> "${docPrivate}" . + } + }`; + try { + await sparqlUpdate(s.sessionId, update, anchor); + } catch (error) { + console.error("[storeRegistry] ensureAccount persist failed:", error); + } + map.set(key, record); + return record; +} + +// --- resolvers ------------------------------------------------------------ + +/** The index document NURI of an account for a scope (the store-container). */ +function indexDocOf(record: AccountRecord, scope: Scope): Nuri { + return scope === "public" + ? record.docPublic + : scope === "protected" + ? record.docProtected + : record.docPrivate; +} + +/** + * NURI of the document where `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. + */ +export async function resolveWriteGraph(username: string, scope: Scope): Promise { + const record = await ensureAccount(username); + return indexDocOf(record, scope); +} + +/** NURIs of every account's document for `scope` (read fan-out). */ +export async function resolveReadGraphs(scope: Scope): Promise { + const accounts = await allAccounts(); + return accounts.map((a) => indexDocOf(a, scope)); +} + +// --- per-entity documents + per-scope index ------------------------------- + +/** + * Create a dedicated document for ONE entity — mirrors the target, where each + * such entity is its own document/repo (addressable, future inbox). The new + * document's NURI is appended to the account's scope index document (the + * store-container). Returns the entity document NURI (use it as `@graph`). + */ +export async function createEntityDoc(username: string, scope: Scope): Promise { + const record = await ensureAccount(username); + const indexDoc = indexDocOf(record, scope); + const entityNuri = await createDoc(); + const s = await session(); + try { + await sparqlUpdate( + s.sessionId, + `INSERT DATA { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> "${entityNuri}" } }`, + indexDoc, + ); + } catch (error) { + console.error("[storeRegistry] createEntityDoc index append failed:", error); + } + return entityNuri; +} + +/** + * Every entity document NURI of `scope`, across all accounts — the read + * fan-out for per-entity scopes. Reads each account's scope index document and + * unions the contained NURIs. Use as `useShape(shape, { graphs })`. + */ +export async function listEntityDocs(scope: Scope): Promise { + const accounts = await allAccounts(); + const s = await session(); + const out: Nuri[] = []; + for (const a of accounts) { + const indexDoc = indexDocOf(a, scope); + try { + const res = await sparqlQuery( + s.sessionId, + `SELECT ?e WHERE { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`, + undefined, + indexDoc, + ); + for (const row of readBindings(res)) { + const v = bindingValue(row, "e"); + if (v) out.push(v); + } + } catch (error) { + console.error("[storeRegistry] listEntityDocs read failed:", error); + } + } + return out; +} diff --git a/packages/client/test/accounts.test.ts b/packages/client/test/accounts.test.ts new file mode 100644 index 0000000..04effc1 --- /dev/null +++ b/packages/client/test/accounts.test.ts @@ -0,0 +1,84 @@ +import { test, expect } from "bun:test"; +import { + AccountStore, + browserAccountStore, + normalizeUsername, + ACCOUNT_STORAGE_KEY, + type AccountStorage, +} from "../src/accounts"; + +// In-memory fake of the Storage subset — keeps this framework/DOM-agnostic. +function fakeStorage(): AccountStorage & { map: Map } { + const map = new Map(); + return { + map, + getItem: (k) => (map.has(k) ? (map.get(k) as string) : null), + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + }; +} + +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", () => { + const s = fakeStorage(); + const store = new AccountStore(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"); +}); + +test("AccountStore: blank login is ignored, keeps the previous value", () => { + const store = new AccountStore(fakeStorage()); + store.login("bob"); + expect(store.login(" ")).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(); + expect(store.get()).toBeNull(); +}); + +test("AccountStore: null storage degrades to non-persisting (SSR-safe)", () => { + const store = new AccountStore(null); + expect(store.get()).toBeNull(); + expect(store.login("bob")).toBe("bob"); // returns the value, just doesn't persist + expect(store.get()).toBeNull(); + store.logout(); // no throw +}); + +test("AccountStore: swallows storage errors on read and write", () => { + const throwing: AccountStorage = { + getItem: () => { + throw new Error("boom"); + }, + setItem: () => { + throw new Error("boom"); + }, + removeItem: () => { + throw new Error("boom"); + }, + }; + const store = new AccountStore(throwing); + expect(store.get()).toBeNull(); // read error swallowed → null + expect(() => store.login("bob")).not.toThrow(); + expect(() => store.logout()).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"); +}); diff --git a/packages/client/test/docs.test.ts b/packages/client/test/docs.test.ts new file mode 100644 index 0000000..58f7d15 --- /dev/null +++ b/packages/client/test/docs.test.ts @@ -0,0 +1,91 @@ +import { test, expect, mock } from "bun:test"; +import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs"; +import * as ngProxy from "../src/ng-proxy"; + +// NOTE ORDER: the "not configured → throw" case MUST run before any configure() +// call, because configure() sets a module-level singleton with no public reset. + +test("throws a clear error when configure() was not called", async () => { + await expect(docCreate("sid", "Graph", "data:graph", "store")).rejects.toThrow( + /configure\(\) must be called before use/, + ); + await expect(sparqlUpdate("sid", "INSERT DATA {}")).rejects.toThrow( + /configure\(\) must be called before use/, + ); + await expect(sparqlQuery("sid", "SELECT * {}")).rejects.toThrow( + /configure\(\) must be called before use/, + ); +}); + +// From here on, a fake real `ng` is injected via configure(). +import { configure } from "../src/polyfill"; + +function fakeNg() { + return { + doc_create: mock(async (..._a: unknown[]) => "did:ng:o:new-doc"), + sparql_update: mock(async (..._a: unknown[]) => undefined), + sparql_query: mock(async (..._a: unknown[]) => ({ results: { bindings: [] } })), + // A sentinel: makeNg(), if ever used, would `.bind` and call THIS through + // the JS Proxy. We assert the primitives call the raw fns above directly. + }; +} + +function inject() { + const ng = fakeNg(); + configure({ ng: ng as any, useShape: (() => {}) as any }); + return ng; +} + +test("docCreate calls the real injected ng.doc_create with the exact args", async () => { + const ng = inject(); + const nuri = await docCreate("sid-1", "Graph", "data:graph", "store", undefined); + expect(nuri).toBe("did:ng:o:new-doc"); + expect(ng.doc_create).toHaveBeenCalledTimes(1); + expect(ng.doc_create.mock.calls[0]).toEqual(["sid-1", "Graph", "data:graph", "store", undefined]); +}); + +test("sparqlUpdate forwards (sessionId, query, anchor) to the real ng.sparql_update", async () => { + const ng = inject(); + await sparqlUpdate("sid-2", "INSERT DATA { GRAPH {

} }", "did:ng:o:a"); + expect(ng.sparql_update).toHaveBeenCalledTimes(1); + expect(ng.sparql_update.mock.calls[0]).toEqual([ + "sid-2", + "INSERT DATA { GRAPH {

} }", + "did:ng:o:a", + ]); +}); + +test("sparqlUpdate passes anchor=undefined when omitted", async () => { + const ng = inject(); + await sparqlUpdate("sid-3", "INSERT DATA {}"); + expect(ng.sparql_update.mock.calls[0]).toEqual(["sid-3", "INSERT DATA {}", undefined]); +}); + +test("sparqlQuery forwards (sessionId, query, base, anchor) and returns the raw result", async () => { + const ng = inject(); + const res = await sparqlQuery("sid-4", "SELECT ?e { GRAPH { ?s ?p ?e } }", undefined, "did:ng:o:g"); + expect(res).toEqual({ results: { bindings: [] } }); + expect(ng.sparql_query).toHaveBeenCalledTimes(1); + expect(ng.sparql_query.mock.calls[0]).toEqual([ + "sid-4", + "SELECT ?e { GRAPH { ?s ?p ?e } }", + undefined, + "did:ng:o:g", + ]); +}); + +test("the primitives do NOT route through the public ng proxy (makeNg)", async () => { + // makeNg builds a JS Proxy over the injected ng. If a primitive went through + // it, calls would land on the proxy's `get` trap, not on our raw mock fns. + // Spy on makeNg: it must never be invoked by the docs primitives. + const spy = mock(ngProxy.makeNg); + const ng = inject(); + await docCreate("sid", "Graph", "data:graph", "store"); + await sparqlUpdate("sid", "INSERT DATA {}"); + await sparqlQuery("sid", "SELECT * {}"); + expect(spy).toHaveBeenCalledTimes(0); + // And the raw injected fns were reached directly: + expect(ng.doc_create).toHaveBeenCalledTimes(1); + expect(ng.sparql_update).toHaveBeenCalledTimes(1); + expect(ng.sparql_query).toHaveBeenCalledTimes(1); +}); diff --git a/packages/client/test/isolation.test.ts b/packages/client/test/isolation.test.ts new file mode 100644 index 0000000..059bffd --- /dev/null +++ b/packages/client/test/isolation.test.ts @@ -0,0 +1,68 @@ +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 = { 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"]); +}); diff --git a/packages/client/test/store-registry.test.ts b/packages/client/test/store-registry.test.ts new file mode 100644 index 0000000..daae55f --- /dev/null +++ b/packages/client/test/store-registry.test.ts @@ -0,0 +1,202 @@ +import { test, expect, mock, beforeEach, afterAll } from "bun:test"; +import { + ensureAccount, + allAccounts, + loadShim, + resolveWriteGraph, + resolveReadGraphs, + createEntityDoc, + listEntityDocs, + resetRegistryCache, +} from "../src/store-registry"; +import type { RegistrySession } from "../src/store-registry"; +import { + configure, + configureStoreRegistry, + resetStoreRegistry, + resetConfig, +} from "../src/polyfill"; + +// This suite injects a fake `ng` via configure(); bun runs test files in a +// shared process with a single module singleton, and may run this file BEFORE +// docs.test.ts's order-dependent "not configured" guard. Restore the un- +// configured state when we're done so that guard still sees a null config. +afterAll(() => { + resetConfig(); + resetStoreRegistry(); +}); + +// NOTE ORDER: the "not configured → throw" case MUST run first — configure*() +// sets module-level singletons and this suite never fully un-injects the real +// `ng` (docs' getConfig has no reset), so we exercise the registry-deps guard. + +test("throws a clear error when configureStoreRegistry() was not called", async () => { + resetStoreRegistry(); + resetRegistryCache(); + await expect(ensureAccount("alice")).rejects.toThrow( + /configureStoreRegistry\(\) must be called before use/, + ); +}); + +// --- A stateful fake `ng` that emulates just enough SPARQL over an in-memory +// quad store: INSERT DATA parsing + the two SELECT shapes the registry issues. + +interface Quad { g: string; s: string; p: string; o: string } + +function makeFakeNg() { + const quads: Quad[] = []; + let docCounter = 0; + + const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`); + + // Parses `INSERT DATA { GRAPH {

"o"//;-lists } }`. + const sparql_update = mock(async (...a: unknown[]) => { + const query = a[1] as string; + const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); + if (!gm) return undefined; + const g = gm[1]!; + const body = gm[2]!; + // Subject is the first <...> token in the body. + const sm = body.match(/<([^>]+)>/); + if (!sm) return undefined; + const s = sm[1]!; + // Predicate/object pairs: `

"o"` or `

` (a == rdf:type ignored form + // handled as literal-free; here the registry only uses

"literal" and + //

and `a `). + const pairRe = /(?:a|<([^>]+)>)\s+(?:"([^"]*)"|<([^>]+)>)/g; + let m: RegExpExecArray | null; + // Skip the subject token so we don't treat it as a predicate. + const after = body.slice(body.indexOf(sm[0]) + sm[0].length); + while ((m = pairRe.exec(after)) !== null) { + const p = m[1] ?? "urn:ng-eventually:shim:Account"; // `a` → rdf:type-ish + const o = m[2] ?? m[3] ?? ""; + quads.push({ g, s, p, o }); + } + return undefined; + }); + + const sparql_query = mock(async (...a: unknown[]) => { + const query = a[1] as string; + const anchor = a[3] as string | undefined; + if (query.includes("")) { + // Account SELECT, scoped to the anchor graph. + const bySubject = new Map>(); + for (const q of quads) { + if (q.g !== anchor) 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: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) + .map((r) => ({ + username: { value: r.username! }, + docPublic: { value: r.docPublic ?? "" }, + docProtected: { value: r.docProtected ?? "" }, + docPrivate: { value: r.docPrivate ?? "" }, + })); + return { results: { bindings } }; + } + // Entity-index SELECT: ` ?e` in the anchor graph. + const bindings = quads + .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains") + .map((q) => ({ e: { value: q.o } })); + return { results: { bindings } }; + }); + + return { doc_create, sparql_update, sparql_query, _quads: quads }; +} + +const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" }; + +function inject() { + const ng = makeFakeNg(); + configure({ ng: ng as any, useShape: (() => {}) as any }); + configureStoreRegistry({ + getSession: async () => SESSION, + normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(), + }); + resetRegistryCache(); + return ng; +} + +let fake: ReturnType; +beforeEach(() => { + fake = inject(); +}); + +test("ensureAccount creates 3 scope docs and persists them to the shim", async () => { + const rec = await ensureAccount("Alice"); + expect(rec.username).toBe("Alice"); + expect(fake.doc_create).toHaveBeenCalledTimes(3); + expect(rec.docPublic).toMatch(/^did:ng:o:doc/); + expect(rec.docProtected).not.toBe(rec.docPublic); + expect(rec.docPrivate).not.toBe(rec.docProtected); + // Persisted into the shim anchor graph (did:ng:PRIV). + expect(fake.sparql_update.mock.calls[0]![2]).toBe("did:ng:PRIV"); +}); + +test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => { + const a = await ensureAccount("Alice"); + const b = await ensureAccount("@alice"); + expect(b).toEqual(a); + expect(fake.doc_create).toHaveBeenCalledTimes(3); // not 6 +}); + +test("loadShim round-trips a persisted account across a cache reset", async () => { + await ensureAccount("Bob"); + 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?.docPublic).toMatch(/^did:ng:o:doc/); +}); + +test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans out", async () => { + const rec = await ensureAccount("Carol"); + expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected); + expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]); +}); + +test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => { + const rec = await ensureAccount("Dave"); + const e1 = await createEntityDoc("dave", "public"); + const e2 = await createEntityDoc("dave", "public"); + const other = await createEntityDoc("dave", "protected"); + // Public listing unions dave's public entities only. + const pub = await listEntityDocs("public"); + expect(pub.sort()).toEqual([e1, e2].sort()); + const prot = await listEntityDocs("protected"); + expect(prot).toEqual([other]); + // The index append targets the account's public index doc. + expect(rec.docPublic).toMatch(/^did:ng:o:doc/); +}); + +test("listEntityDocs fans out across multiple accounts", async () => { + await ensureAccount("Eve"); + await ensureAccount("Frank"); + const e = await createEntityDoc("eve", "public"); + const f = await createEntityDoc("frank", "public"); + expect((await listEntityDocs("public")).sort()).toEqual([e, f].sort()); +}); + +test("allAccounts reflects every ensured account", async () => { + await ensureAccount("Gina"); + await ensureAccount("Hank"); + const names = (await allAccounts()).map((a) => a.username).sort(); + expect(names).toEqual(["Gina", "Hank"]); +}); + +test("normalizeUser defaults to trim when not provided", async () => { + const ng = makeFakeNg(); + configure({ ng: ng as any, useShape: (() => {}) as any }); + configureStoreRegistry({ getSession: async () => SESSION }); + resetRegistryCache(); + const a = await ensureAccount(" Ivy "); + const b = await ensureAccount("Ivy"); // trimmed key matches + expect(b).toEqual(a); + expect(ng.doc_create).toHaveBeenCalledTimes(3); +});