66a40fbb89
Chaque étape de cet écran n'existe que parce qu'un wallet héberge plusieurs
identités. Une app qui l'implémente écrit du code qu'elle devra supprimer, et
pire, du code qui enseigne à ses auteurs un modèle que NextGraph n'a pas :
« je nomme mon identité ». Le premier consommateur en avait ~300 lignes — un
gate, un écran, un module wallet, un contexte d'identité, trois features. C'est
le travail de la bibliothèque, pas le sien.
`ensureIdentity()` : un appel, avant que l'app s'affiche. Il ne prend PAS
d'identifiant — nommer une identité est l'étape qui disparaîtra, donc elle ne
doit pas figurer dans la signature. Le jour où le wallet fournit l'identité,
l'appel se résout sans rien afficher et le code de l'appelant ne bouge pas.
L'écran est en DOM natif, sous shadow root : c'est une barrière technique
montrée avant qu'une application s'affiche, comme une demande de mot de passe
sur une bêta fermée. La lier à un framework obligerait chaque consommateur à
adopter ce framework pour un écran voué à disparaître.
L'ordre de résolution de l'identité est pinné par des tests, parce que s'y
tromper échoue en SILENCE : le parcours traverse deux partitions localStorage
distinctes — la page et l'iframe du broker — et seul l'URL franchit la
frontière. Si le stockage l'emportait, l'iframe lirait une identité vide,
provisionnerait un second utilisateur virtuel, et l'utilisateur reviendrait dans
un espace vide sans la moindre erreur.
Les identifiants du wallet partagé (fichier, mot de passe) passent par
`configure()` : ce sont des données de déploiement, et cet appel est déjà celui
qui devient inerte à la migration. Au passage, l'ancien champ `sharedWallet:
{ name, secret }` — inutilisé nulle part — est remplacé.
168 tests unitaires, typecheck vert.
226 lines
11 KiB
TypeScript
226 lines
11 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",
|
|
// 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");
|
|
|
|
/** Every identifier the two entry points publish, read from the `export` statements. */
|
|
function publishedNames(): string[] {
|
|
const out = new Set<string>();
|
|
for (const entry of ["index.ts", "polyfill.ts"]) {
|
|
const text = fs.readFileSync(path.join(SRC, entry), "utf8");
|
|
// `export * as ns from "…"`
|
|
for (const m of text.matchAll(/export \* as (\w+) from/g)) out.add(m[1]!);
|
|
// `export { a, b as c }` / `export type { … }`, 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);
|
|
}
|
|
}
|
|
// `export const x` / `export function x` / `export interface x`
|
|
for (const m of text.matchAll(/export (?:declare )?(?:const|function|class|interface|type) (\w+)/g)) {
|
|
out.add(m[1]!);
|
|
}
|
|
// `export * from "./x"` — re-exports every name that module declares.
|
|
for (const m of text.matchAll(/export \* from "\.\/([^"]+)"/g)) {
|
|
const file = path.join(SRC, m[1]! + ".ts");
|
|
if (!fs.existsSync(file)) continue;
|
|
const t = fs.readFileSync(file, "utf8");
|
|
for (const mm of t.matchAll(/^export (?:declare )?(?:const|function|class|interface|type) (\w+)/gm)) {
|
|
out.add(mm[1]!);
|
|
}
|
|
}
|
|
}
|
|
return [...out];
|
|
}
|
|
|
|
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 a consumer's normalizeId", async () => {
|
|
// The reserved namespace hosts infrastructure accounts, and its guarantee is that no
|
|
// user id lands there. That guarantee is not the library's to make — `normalizeId` is
|
|
// injected by the consumer — 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/polyfill");
|
|
const { ensureAccount, resetRegistryCache } = await import(
|
|
"../src/shared-wallet/account-registry"
|
|
);
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
|
|
normalizeId: () => " |