63ecfeeff8
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>
135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
/**
|
|
* 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 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.)
|
|
*
|
|
* 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 (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. */
|
|
private readers = new Map<Nuri, Set<PrincipalId>>();
|
|
/** doc NURI → principals holding its WRITE cap. */
|
|
private writers = new Map<Nuri, Set<PrincipalId>>();
|
|
/** 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
|
|
* 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 `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`. */
|
|
grantWrite(doc: Nuri, principal: PrincipalId): void {
|
|
add(this.writers, doc, principal);
|
|
}
|
|
|
|
/** Mark `doc` public (readable without a cap — a public_store repo). */
|
|
makePublic(doc: Nuri): void {
|
|
this.publicDocs.add(doc);
|
|
}
|
|
|
|
/**
|
|
* Apply the caps a creator attaches to a fresh document, by scope. Public →
|
|
* world-readable; protected/private → only the owner reads. The owner always
|
|
* holds the write cap. Further sharing is a separate explicit grant.
|
|
*/
|
|
open(doc: Nuri, scope: Scope, owner: PrincipalId): void {
|
|
if (scope === "public") this.makePublic(doc);
|
|
else this.grantRead(doc, owner);
|
|
this.grantWrite(doc, owner);
|
|
this.policy.set(doc, { scope, owner });
|
|
}
|
|
|
|
/**
|
|
* 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 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.
|
|
*/
|
|
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.) */
|
|
governsRead(doc: Nuri): boolean {
|
|
return this.publicDocs.has(doc) || this.readers.has(doc);
|
|
}
|
|
|
|
/** Does `principal` hold a READ cap for `doc` (or is `doc` public)? */
|
|
canRead(doc: Nuri, principal: PrincipalId | null): boolean {
|
|
if (this.publicDocs.has(doc)) return true;
|
|
if (principal === null) return false;
|
|
return this.readers.get(doc)?.has(principal) ?? false;
|
|
}
|
|
|
|
/** Is `doc` under any WRITE-cap policy? */
|
|
governsWrite(doc: Nuri): boolean {
|
|
return this.writers.has(doc);
|
|
}
|
|
|
|
/** Does `principal` hold a WRITE cap for `doc`? */
|
|
canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
|
|
if (principal === null) return false;
|
|
return this.writers.get(doc)?.has(principal) ?? false;
|
|
}
|
|
|
|
/** No READ policy declared → the read filter stays inert (passthrough). */
|
|
hasReadPolicy(): boolean {
|
|
return this.readers.size > 0 || this.publicDocs.size > 0;
|
|
}
|
|
|
|
/** No WRITE policy declared → the write guard stays inert (passthrough). */
|
|
hasWritePolicy(): boolean {
|
|
return this.writers.size > 0;
|
|
}
|
|
|
|
clear(): void {
|
|
this.readers.clear();
|
|
this.writers.clear();
|
|
this.publicDocs.clear();
|
|
this.policy.clear();
|
|
}
|
|
}
|
|
|
|
function add(m: Map<Nuri, Set<PrincipalId>>, doc: Nuri, principal: PrincipalId): void {
|
|
let s = m.get(doc);
|
|
if (!s) m.set(doc, (s = new Set()));
|
|
s.add(principal);
|
|
}
|