0b936d2119
Suite de la revue adverse. Quatre trous de frontière, tous hors du champ « l'isolation est fausse jusqu'à P1b » — P1b parle de matériau de clé, ceux-ci sont des défauts de FORME et resteraient des trous avec une vraie clé. **La garde d'écriture reposait sur la mauvaise question.** Elle demandait « ce cap m'a-t-il été servi par un store public ? ». Ce prédicat était faux dans les deux sens à la fois : trop laxiste — une clé reçue dans une inbox donnait l'écriture, alors qu'en amont un Link est « external repos only » et qu'écrire est l'appartenance au repo ; trop strict — la propriétaire de son propre document public était refusée dès qu'elle l'ouvrait depuis sa référence avant que son store ne soit listé. Un prédicat poussé dans deux sens est le signe que c'était le mauvais prédicat. Écrire dépend désormais de la PROPRIÉTÉ, lue sur la branche Store (l'`AddRepo` émulé), plus la paternité de session pour les documents créés par la primitive brute qui n'a aucun store où s'inscrire. Conséquence assumée et documentée : seul le propriétaire écrit, ce qui est l'état amont d'un repo tant qu'aucun membre n'a été ajouté — mécanisme qu'on n'émule pas. **`docs.depositInto` quittait la frontière en la publiant.** Sa doc disait « `inbox.post` est le seul appelant » : vrai dans la bibliothèque, faux dès qu'on le publie. Démontré : avec la seule référence nue d'un document public, on réécrit l'adresse d'inbox posée dessus et on détourne les dépôts destinés à son propriétaire. Une porte qui saute une garde ne doit pas être ouvrable par une application — elle rejoint la machinerie. **Le filtre de lecture n'interceptait que trois membres** et transmettait tout le reste lié à la CIBLE : `.values()`, `.map()`, `.getById()` rendaient le contenu d'un autre utilisateur — précisément les membres qu'une API de set réactif met en avant. Les membres qui rendent des éléments sont désormais filtrés, les mutations passent (elles ne rendent rien), et **tout membre inconnu lève** au lieu de transmettre : une transmission est une fuite silencieuse, une levée est bruyante et greppable. **Le mémo du store public était par document.** Le premier demandeur déclenchait le téléchargement, le cap était classé chez LUI, et tout demandeur suivant recevait « oui » en ne détenant rien. En amont un broker qui sert un overlay externe répond à TOUS. Le mémo garde la valeur, l'appelant la classe pour qui est connecté. Aussi : l'exemption `declareInfrastructure` supprimée — zéro appelant, ensemble toujours vide, et une doc décrivant deux documents exemptés qui ne l'ont jamais été. Et les caps d'écriture décrits comme « partiels » sont dits **inertes**, ce qu'ils sont : `grantWrite` n'a aucun appelant de production. **Ce que l'e2e a rattrapé.** Ma première version de la garde refusait au créateur l'écriture sur un document fait par `docs.docCreate` — 7 étapes rouges contre le broker, après une suite unitaire restée verte. La primitive brute n'inscrit la paternité nulle part ; c'est ce que `mintedHere` couvre désormais. 185 tests unitaires (dont quatre régressions : la propriétaire écrit, le destinataire non, le store public sert tout demandeur, aucun membre non filtré ne transmet), e2e 40/40 et applicatif 10/10.
204 lines
8.3 KiB
TypeScript
204 lines
8.3 KiB
TypeScript
/**
|
|
* cold-start-anchor.test.ts — the shim ANCHOR (private-store-root) must be OPENED
|
|
* before the registry reads/writes it, or a cold anchor throws `RepoNotFound`.
|
|
*
|
|
* ── The gap this pins ──────────────────────────────────────────────────────
|
|
* The shim lives in the private-store-root graph (`did:ng:${privateStoreId}`, the
|
|
* "anchor"). Unlike a per-entity doc — whose anchored read on an unopened repo
|
|
* SILENTLY returns 0 rows — the private/store target resolves through the verifier's
|
|
* `resolve_target_for_sparql`, which HARD-errors `RepoNotFound` when the repo is not
|
|
* in `self.repos` (verified in nextgraph-rs `request_processor.rs`). On a wallet whose
|
|
* anchor repo is not yet loaded, both the shim READ (`resolveAccount`/`loadShim`) and
|
|
* the provision WRITE (`ensureAccount`) throw — so the account never provisions.
|
|
*
|
|
* The heal: `resolveAccount`/`loadShim`/`ensureAccount` call `ensureRepoOpen(anchor)`
|
|
* (open-repo.ts, via `doc_subscribe` + first-`State` barrier) before touching the
|
|
* shim — the same open-before-read guard `readUserStore` already applies to its
|
|
* index doc. This suite models a fake `ng` where the anchor throws `RepoNotFound`
|
|
* UNTIL it has been `doc_subscribe`-d, and asserts the registry provisions cleanly.
|
|
*
|
|
* RED without the heal (ensureAccount would throw on the cold anchor); GREEN with it.
|
|
*/
|
|
|
|
import { describe, it, expect, mock, afterAll, beforeEach } from "bun:test";
|
|
import { ensureAccount, resolveWriteGraph, resetRegistryCache } from "../src/shared-wallet/account-registry";
|
|
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
|
import { configure } from "../src/index";
|
|
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
|
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
|
|
|
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
|
|
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
|
|
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
// The reach guard is process-wide and so is the cap registry: once ANY cap exists
|
|
// the boundary applies to every reader. A suite that declares none must therefore
|
|
// start from an empty one, or it inherits another suite's enforcement.
|
|
beforeEach(() => {
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
function unescapeLiteral(s: string): string {
|
|
let out = "";
|
|
for (let i = 0; i < s.length; i++) {
|
|
if (s[i] === "\\" && i + 1 < s.length) {
|
|
const next = s[++i];
|
|
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
|
} else out += s[i];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* A fake `ng` whose ANCHOR repo behaves like the real private-store target:
|
|
* `sparql_query`/`sparql_update` anchored to it THROW `RepoNotFound` until the
|
|
* anchor has been `doc_subscribe`-d (i.e. opened into `self.repos`). Any OTHER
|
|
* anchor (per-entity docs) behaves normally. `doc_subscribe` fires the first
|
|
* `State` so `ensureRepoOpen` crosses the barrier.
|
|
*/
|
|
function makeColdAnchorNg() {
|
|
const quads: Quad[] = [];
|
|
let docCounter = 0;
|
|
const opened = new Set<string>();
|
|
let anchorSubscribes = 0;
|
|
|
|
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
|
|
|
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
|
if (nuri === ANCHOR) anchorSubscribes += 1;
|
|
opened.add(nuri);
|
|
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
|
return () => {};
|
|
});
|
|
|
|
const sparql_update = mock(async (_sid: string, query: string, anchor?: string) => {
|
|
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
|
|
// TWO shapes: the POINTER write uses `GRAPH <root>` (keyed by IRI); the account
|
|
// record write into the doc-shim has NO explicit GRAPH (keyed by the anchor arg).
|
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
|
let g: string;
|
|
let body: string;
|
|
if (gm) {
|
|
g = gm[1]!;
|
|
body = gm[2]!;
|
|
} else {
|
|
if (!anchor) return undefined;
|
|
g = anchor;
|
|
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
|
}
|
|
const sm = body.match(/<([^>]+)>/);
|
|
if (!sm) return undefined;
|
|
const s = sm[1]!;
|
|
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = pairRe.exec(after)) !== null) {
|
|
const p = m[1] ?? "urn:ng-eventually:shim:Account";
|
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
|
quads.push({ g, s, p, o });
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const sparql_query = mock(async (_sid: string, query: string, _base: unknown, anchor?: string) => {
|
|
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
|
|
// Pointer SELECT (store-root -> doc-shim).
|
|
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
}
|
|
const subjM = query.match(
|
|
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
|
|
);
|
|
const onlySubject = subjM ? subjM[1]! : null;
|
|
const bySubject = new Map<string, Record<string, string>>();
|
|
for (const q of quads) {
|
|
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: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.id)
|
|
.map((r) => ({
|
|
id: { value: r.id! },
|
|
docPublic: { value: r.docPublic ?? "" },
|
|
docProtected: { value: r.docProtected ?? "" },
|
|
docPrivate: { value: r.docPrivate ?? "" },
|
|
}));
|
|
return { results: { bindings } };
|
|
});
|
|
|
|
return {
|
|
doc_create, doc_subscribe, sparql_update, sparql_query,
|
|
_quads: quads,
|
|
anchorSubscribeCount: () => anchorSubscribes,
|
|
};
|
|
}
|
|
|
|
function inject(ng: ReturnType<typeof makeColdAnchorNg>) {
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({
|
|
getSession: async () => SESSION,
|
|
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
|
});
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
}
|
|
|
|
describe("cold-start anchor heal", () => {
|
|
it("ensureAccount provisions over a COLD anchor (RepoNotFound-until-opened) without throwing", async () => {
|
|
const ng = makeColdAnchorNg();
|
|
inject(ng);
|
|
|
|
// Without the open-before-shim heal, the read AND the provision write would both
|
|
// throw RepoNotFound on the cold anchor and the account would never persist.
|
|
const rec = await ensureAccount("@cold-alice");
|
|
expect(rec.docPublic).toBeTruthy();
|
|
expect(rec.docProtected).toBeTruthy();
|
|
expect(rec.docPrivate).toBeTruthy();
|
|
|
|
// The anchor repo was actually opened (doc_subscribe-d) before the shim op.
|
|
expect(ng.anchorSubscribeCount()).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("the provisioned account re-resolves from the shim (real persistence, no RepoNotFound)", async () => {
|
|
const ng = makeColdAnchorNg();
|
|
inject(ng);
|
|
|
|
const first = await ensureAccount("@cold-bob");
|
|
// Fresh cache → a real anchored re-read of the shim (anchor already opened → OK).
|
|
resetRegistryCache();
|
|
const again = await ensureAccount("@cold-bob");
|
|
expect(again.docPublic).toBe(first.docPublic);
|
|
expect(again.docProtected).toBe(first.docProtected);
|
|
expect(again.docPrivate).toBe(first.docPrivate);
|
|
});
|
|
|
|
it("resolveWriteGraph (scope resolver) works over a cold anchor", async () => {
|
|
const ng = makeColdAnchorNg();
|
|
inject(ng);
|
|
const g = await resolveWriteGraph("@cold-carol", "protected");
|
|
expect(g).toBeTruthy();
|
|
});
|
|
});
|