cc8a95d303
Pour démarrer, une application devait écrire une promesse autour du callback
d'init(), attraper l'événement loggedin, puis fournir un thunk getSession qui
dépiaute session_id et les trois identifiants de store dans notre forme. Plus un
normalizeId. C'est précisément la plomberie que ce paquet existe pour absorber :
chaque application la réécrirait à l'identique, et c'est elle qui a produit deux
défauts aujourd'hui — un blocage et un partage cassé en silence.
En amont, une session est RENDUE ; une application n'en assemble jamais une à
partir de champs bruts. Et les identités virtuelles sont une invention du
polyfill, donc leur normalisation lui appartient.
Le wrapper init() enveloppe désormais le callback de l'appelant : il capture
l'événement, en dérive la session, puis appelle le callback avec le même
événement. Le paquet n'appelle jamais init de sa propre initiative — il
l'enveloppe. Sans callback, il capture quand même.
getSession et normalizeId quittent la surface publiée. Le chemin d'injection
reste pour les harnais, mais inatteignable depuis l'entrée : vérifié par un
import à l'exécution et par un configure() refusé à la compilation.
Défaut trouvé et corrigé en route : le broker envoie session_id en NOMBRE, et le
convertir en chaîne faisait refuser tous les appels par le binding wasm. La
valeur ne fait que transiter, elle est relayée telle quelle. Reste que toute la
chaîne la type string — inexactitude antérieure à ce commit, à traiter à part.
Une application écrit maintenant : configure({ ng, useShape, init, sharedWallet }).
309 lines
15 KiB
TypeScript
309 lines
15 KiB
TypeScript
/**
|
|
* The published names may only use words the TARGET uses, or a marker that says why
|
|
* they exist here.
|
|
*
|
|
* ── Why this is a test and not a rule ─────────────────────────────────────
|
|
* The library corrected its vocabulary on 2026-07-30 — upstream a *wallet* is only a
|
|
* keyring, and what owns stores is a **user** (a *site*) — by a manual pass over the
|
|
* code and docs. `walletInbox` survived that pass and lived on for weeks, and it did
|
|
* damage: the name made "one inbox per wallet" sound obvious, hiding that a user
|
|
* upstream has **two** (public store repo and protected store repo — the only two
|
|
* `AddInboxCap` commits in the engine, `engine/verifier/src/site.rs:128,149`). A
|
|
* discipline applied by hand misses one; a test does not.
|
|
*
|
|
* So this pins the naming half of the design principle (`README.md`): a name either
|
|
* belongs to the target's vocabulary — in which case it needs no translation and
|
|
* survives migration — or it carries a marker saying WHY it exists only here, which
|
|
* also says when it disappears.
|
|
*
|
|
* ── What it checks, and what it deliberately does not ─────────────────────
|
|
* Only the PUBLISHED names, the ones a consumer application types. Internal names are
|
|
* held to the same intent but not mechanically: the folder they live in already states
|
|
* their fate, and pinning every internal identifier would fight refactoring for little.
|
|
*/
|
|
import { test, expect } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
|
|
/**
|
|
* Words the TARGET itself uses, verified in `nextgraph-rs`. A published name built
|
|
* from these needs no translation at migration.
|
|
*/
|
|
const TARGET_WORDS = new Set([
|
|
// addressing and objects
|
|
"nuri", "doc", "docs", "document", "repo", "store", "stores", "branch", "graph",
|
|
"overlay", "cap", "caps", "read", "write", "link", "links", "shape", "shapes",
|
|
// actors and containers
|
|
"user", "users", "session", "wallet", "inbox", "inboxes", "site", "principal",
|
|
// scopes (upstream store types, `StoreRepo::from_type_and_repo`)
|
|
"public", "protected", "private", "group", "dialog", "scope",
|
|
// acts the target performs
|
|
"create", "subscribe", "unsubscribe", "query", "update", "post", "share", "open",
|
|
"fetch", "init", "watch", "sparql", "ng", "orm", "type", "types",
|
|
// RDF / SPARQL terms the engine's own query paths use
|
|
"subject", "base", "schema", "connected", "identity", "identities",
|
|
// `publisher` is upstream's word for a pub/sub role on a topic (`as_publisher`,
|
|
// `publisher_advert`, 126 occurrences in the engine). Our own "publish a document" is
|
|
// banned as ambiguous, but that ban never reaches upstream's term — see the traps
|
|
// block in `docs/readcap-and-nuri-model.md`.
|
|
"publisher", "topic", "advert",
|
|
// the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`)
|
|
"observable", "deep", "signal", "set",
|
|
]);
|
|
|
|
/**
|
|
* Markers that name WHY something exists only in this library. Each says when it
|
|
* disappears, which a bare `fake`/`tmp` would not.
|
|
*/
|
|
const EMULATION_MARKERS = new Set([
|
|
"virtual", "physical", "shim", "emulated", "polyfill",
|
|
// `shared` as in "shared wallet" — the single fact every piece of scaffolding in this
|
|
// library descends from. A name carrying it says both what it is and when it goes.
|
|
"shared",
|
|
]);
|
|
|
|
/** Glue with no domain meaning — never the load-bearing part of a name. */
|
|
const NEUTRAL = new Set([
|
|
"get", "set", "is", "has", "to", "for", "of", "my", "own", "all", "by", "with",
|
|
"current", "reset", "configure", "config", "deps", "id", "ids", "address", "entity",
|
|
"list", "resolve", "assert", "escape", "literal", "iri", "record", "registry",
|
|
"change", "changed", "state", "value", "data", "info", "count", "the", "a", "an",
|
|
"options", "opts", "result", "error", "signal", "filter", "placement", "and", "or",
|
|
"make", "use", "on", "off", "from", "into", "at", "in", "out", "up", "down",
|
|
// `union` is OURS — the bounded multi-document read — but it names an operation,
|
|
// not a domain notion a consumer would have to unlearn. `eventually` is the
|
|
// library's own name.
|
|
"union", "eventually", "ensure",
|
|
// `…Like` is a structural-typing suffix (`NgLike` = "whatever has ng's shape"), not
|
|
// a domain word: it describes how the injection is typed, not what the thing is.
|
|
"like",
|
|
]);
|
|
|
|
/** `documentInboxAddress` → ["document","inbox","address"] ; `NG` → ["ng"]. */
|
|
function words(name: string): string[] {
|
|
return name
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
.split(/[\s_]+/)
|
|
.map((w) => w.toLowerCase())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
const SRC = path.join(import.meta.dir, "..", "src");
|
|
|
|
/**
|
|
* The names a module really EXPORTS — declarations and named re-exports, comments
|
|
* stripped first.
|
|
*
|
|
* ── Why the comments have to go, and why it is not a detail ────────────────
|
|
* This used to be a word-search over the module's whole text. A name mentioned
|
|
* ANYWHERE — including in a comment explaining why it was removed — counted as
|
|
* exported. Measured on 2026-08-07: `documentInboxAddress`, `escapeLiteral`,
|
|
* `getCaps`, `addLink`, `setCurrentUser` all passed, and so did **`linkTo`** — the
|
|
* function deleted for breaking the access rule, whose absence is documented in a
|
|
* comment inside `surface/placement.ts`. So the two checks below could wave through
|
|
* a phantom, which is how the contract drifted through five sections unseen.
|
|
*
|
|
* A gate that says yes to a name that is not there is worse than no gate: it reads
|
|
* as verified.
|
|
*/
|
|
function moduleExports(file: string): Set<string> {
|
|
const out = new Set<string>();
|
|
if (!fs.existsSync(file)) return out;
|
|
const text = fs
|
|
.readFileSync(file, "utf8")
|
|
.replace(/\/\*[\s\S]*?\*\//g, "") // block and JSDoc comments
|
|
.replace(/^\s*\/\/.*$/gm, ""); // line comments
|
|
// `export function x` / `export const x` / `export interface x` / …
|
|
for (const m of text.matchAll(/^export (?:declare )?(?:async )?(?:const|function|class|interface|type) (\w+)/gm)) {
|
|
out.add(m[1]!);
|
|
}
|
|
// `export { a, b as c }` and `export { … } from "…"`, single- and multi-line.
|
|
for (const m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) {
|
|
for (const raw of m[1]!.split(",")) {
|
|
const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim();
|
|
if (name) out.add(name);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** namespace name → the names it really carries (`export * as ns from "./x"`). */
|
|
function namespaces(): Map<string, Set<string>> {
|
|
const text = fs.readFileSync(path.join(SRC, "index.ts"), "utf8");
|
|
const out = new Map<string, Set<string>>();
|
|
for (const m of text.matchAll(/export \* as (\w+) from "\.\/([^"]+)"/g)) {
|
|
out.set(m[1]!, moduleExports(path.join(SRC, m[2]! + ".ts")));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Every identifier the entry point publishes, read from its `export` statements. */
|
|
function publishedNames(): string[] {
|
|
const text = fs.readFileSync(path.join(SRC, "index.ts"), "utf8");
|
|
const out = moduleExports(path.join(SRC, "index.ts"));
|
|
for (const m of text.matchAll(/export \* as (\w+) from/g)) out.add(m[1]!);
|
|
// `export * from "./x"` — re-exports every name that module declares.
|
|
for (const m of text.matchAll(/export \* from "\.\/([^"]+)"/g)) {
|
|
for (const n of moduleExports(path.join(SRC, m[1]! + ".ts"))) out.add(n);
|
|
}
|
|
return [...out];
|
|
}
|
|
|
|
/** Names that live inside a re-exported namespace rather than on the entry itself. */
|
|
function isNamespaceMember(name: string): boolean {
|
|
for (const members of namespaces().values()) if (members.has(name)) return true;
|
|
return false;
|
|
}
|
|
|
|
test("every published name is built from the target's vocabulary, or carries an emulation marker", () => {
|
|
const offenders: string[] = [];
|
|
for (const name of publishedNames()) {
|
|
const ws = words(name);
|
|
// A marker anywhere in the name licenses the whole name: it declares the thing
|
|
// as ours and says when it goes.
|
|
if (ws.some((w) => EMULATION_MARKERS.has(w))) continue;
|
|
const unknown = ws.filter((w) => !TARGET_WORDS.has(w) && !NEUTRAL.has(w));
|
|
if (unknown.length > 0) offenders.push(`${name} → ${unknown.join(", ")}`);
|
|
}
|
|
// A failure here is not "rename to satisfy the test": it is a question. Does the
|
|
// target have a word for this? Use it. Does the thing exist only here? Say so with a
|
|
// marker. Is the word genuinely neutral glue? Add it to NEUTRAL, deliberately.
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
test("no published name says `wallet` where the target says `user`", () => {
|
|
// The specific regression that motivated this file. `wallet` is a legitimate target
|
|
// word (a keyring IS a wallet upstream), so the generic check above cannot catch it —
|
|
// what is wrong is using it for the thing that owns stores and inboxes.
|
|
const wrong = publishedNames().filter((n) =>
|
|
/wallet/i.test(n) && /(inbox|store|doc|cap)/i.test(n),
|
|
);
|
|
expect(wrong).toEqual([]);
|
|
});
|
|
|
|
// --- the invariant the internal contract flagged as a migration risk -------
|
|
|
|
test("a reserved-namespace key cannot be produced by the identity key rule", async () => {
|
|
// The reserved namespace hosts infrastructure accounts, and its guarantee is that no
|
|
// user id lands there. The rule that produces the key does not enforce it — the
|
|
// package's own only trims, strips a leading `@` and lowercases, and the internal wiring
|
|
// path lets a suite substitute another — so a careless one must be refused, not trusted. A
|
|
// collision would key a user onto an infrastructure account: reads and writes on
|
|
// documents that are not theirs.
|
|
const { configureStoreRegistry, resetStoreRegistry } = await import("../src/shared-wallet/bootstrap");
|
|
const { ensureAccount, resetRegistryCache } = await import(
|
|
"../src/shared-wallet/account-registry"
|
|
);
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
|
|
normalizeId: () => " |