refactor(layout): ranger les modules par destin à la migration

Les 25 modules étaient à plat, nommés d'après ce qu'ils font mécaniquement
(`store-registry`, `read-model`, `reach`, `caps`). Rien dans l'arborescence ne
disait lesquels DEVIENDRONT le vrai SDK, lesquels tiennent lieu du travail que
le verifier fera nativement, et lesquels n'existent que parce qu'un wallet est
partagé — trois destins sans rapport.

Quatre dossiers, les deux fichiers d'entrée restant à la racine pour que
l'`exports` du paquet et le code du consommateur ne bougent pas :

- `model/` — le modèle d'adressage de la cible, transcrit : vocabulaire pur,
  pas d'I/O. Survit comme connaissance.
- `surface/` — ce que l'app touche, chaque symbole ayant un pendant cible
  documenté. Supprimé quand l'alias bascule ; le code de l'app est inchangé.
- `emulated-verifier/` — les doublures de ce que le verifier fait nativement :
  possession, dépôt des caps, frontière, non-livraison, traitement des inbox,
  registres de branche, ouverture de repo. **C'est le dossier où diverger du
  modèle est possible.** Le préfixe `emulated-` porte le sens : tient lieu de,
  jamais est — cette bibliothèque ne réside dans aucune couche de la cible,
  elle les référence.
- `shared-wallet/` — n'existe que parce qu'un wallet héberge toutes les
  identités. Aucun pendant, rien sur quoi s'aligner ; sa seule loi est de
  rester invisible depuis `surface/`. S'évapore, remplacé par rien.

`store-registry-api.ts` devient `surface/placement.ts` : il faisait déjà à la
main ce que la frontière de dossier fait structurellement — c'est la meilleure
preuve interne du bien-fondé de ce rangement.

Ce commit ne fait que déplacer et recâbler les imports (src, test, e2e). Les
scissions des modules à cheval suivent.

157 tests unitaires, typecheck src/test/e2e vert.
This commit is contained in:
Sylvain Duchesne
2026-08-04 12:46:44 +02:00
parent d07b3642aa
commit 88914f50ae
46 changed files with 302 additions and 137 deletions
@@ -0,0 +1,133 @@
/**
* reach — may the CONNECTED virtual user touch this document at all?
*
* The one predicate every path to `ng` consults, so the boundary is decided in a
* single place instead of being re-argued at each call site.
*
* ── The boundary ──────────────────────────────────────────────────────────
* A virtual user must simulate the boundary of the future single-user wallet:
* every access function is confined to the user currently connected
* (`setCurrentUser`), and no cross-user access is permitted. Otherwise the
* consumer is coded against a reach that will never exist — the same failure mode
* as an ACL where the real model is key possession, one level down.
*
* Two ways a document is legitimately reachable, and no others:
*
* 1. **You hold its cap.** Either because you created it (the store refiles the
* cap) or because someone delivered it to you. This is the whole of the
* access model, so it is the whole of the predicate.
* 2. **It is declared INFRASTRUCTURE.** A short, explicitly-registered list —
* never inferred from the shape of a NURI, because an inferred exemption is
* a hole. See {@link declareInfrastructure}.
*
* ── What may be exempt, and why so little ─────────────────────────────────
* > The only reads/writes not confined to a virtual user are those that make
* > multi-user operation possible at all. Nothing common — only the indexing
* > mechanisms that make the virtual users work.
*
* The test an exemption must pass: *does removing it stop the virtual users from
* functioning, or does it merely stop users from seeing each other's content?*
* Only the first qualifies. The shim passes (remove it and no user is resolvable
* at all); a shared index of user content does not (remove it and every user still
* works — you simply have to be given links).
*
* Depositing into another user's inbox is NOT handled here: it is a write to a
* document you do not hold, and it is legitimate — the only channel by which a
* link crosses from one user to another, hence the bootstrap of the whole
* reachability graph. It is allowed at the inbox surface, which is where the
* asymmetry (deposit yes, read no) is expressed.
*
* At migration this module disappears: the boundary becomes the wallet itself.
*/
import { getCaps } from "../polyfill";
import { targetOf } from "../model/nuri";
import type { Nuri } from "../model/types";
/**
* NURIs of the polyfill's own scaffolding, registered as they are resolved.
*
* Explicit registration rather than pattern-matching: the store-root and the
* doc-shim are exempt because they ARE the index of virtual users, not because
* they look a certain way. A NURI is in here because some code path put it here,
* knowing what it was.
*/
const infrastructure = new Set<Nuri>();
/**
* Register `nuri` as scaffolding that the boundary does not apply to. Called by
* the store-registry as it resolves the store-root pointer and the doc-shim —
* the only two documents that qualify, because without them no virtual user can
* be resolved at all.
*
* Deliberately NOT exported from the package: nothing outside the library may
* widen the exemption list.
*/
export function declareInfrastructure(nuri: Nuri): void {
infrastructure.add(nuri);
}
/** Is `nuri` registered scaffolding? */
export function isInfrastructure(nuri: Nuri): boolean {
return infrastructure.has(nuri);
}
/** Forget every declared exemption (tests / a fresh wallet). */
export function resetInfrastructure(): void {
infrastructure.clear();
}
/**
* Do we POSSESS the cap of `nuri`? Not "does this string carry one" — a caller may
* legitimately be holding the bare form and possess the cap elsewhere, which is the
* normal case: NURIs travel bare through content and indexes, while the cap sits in
* what the user holds. Possession is what decides; the shape of the reference the
* caller happens to have in hand decides nothing.
*
* `targetOf` first, so a cap-bearing reference and its bare form answer alike.
*
* Inert until the first cap exists (`caps.isEnforcing()`), so a consumer that never
* touches caps keeps working. Once ANY cap has been issued the boundary applies to
* every user, including one holding nothing: that is the isolation.
*/
export function mayReach(nuri: Nuri): boolean {
const caps = getCaps();
if (!caps.isEnforcing()) return true;
const target = targetOf(nuri);
return isInfrastructure(target) || caps.capFor(target) !== undefined;
}
/**
* **Rule 1 — authorization**, at the PASSAGE POINTS (`docs.*`, `subscribe`).
*
* Nothing reaches `ng` unless the connected user possesses the document's cap. This
* is the guard: it fires on a request that should never have been made, and its job
* is to make sure the attempt fails rather than succeeds quietly.
*
* Deliberately duplicated with rule 2 below — see {@link mustNotAttempt}. Two rules,
* two places, one criterion: a lapse in either is caught by the other.
*/
export function assertMayReach(nuri: Nuri, op: string): void {
if (mayReach(nuri)) return;
throw new Error(
`[ng-eventually] ${op}: refused — the connected user does not hold this document's ` +
"cap. Naming a document does not grant access to it: a cap is looked up in what " +
`you hold, or it was delivered to you. ${JSON.stringify(nuri)}`,
);
}
/**
* **Rule 2 — do not even attempt**, at the CALLERS (`read-model`, `open-repo`,
* `subscribe`'s callers…).
*
* A reader that does not hold a document's cap must not issue the operation at all.
* Not attempting and being refused are different things: the first is a caller that
* knows what it holds, the second is one that hoped and got caught. Only the first
* is the model — upstream you cannot even address a repo you have no cap for.
*
* Practically it also stops the library from asking the broker for documents it has
* no business asking about, which is work, noise, and a leak of intent.
*/
export function mustNotAttempt(nuri: Nuri): boolean {
return !mayReach(nuri);
}