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:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user