brainclaw 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Fédération v2 — ÉMISSION : projeter les plans et la mémoire projet vers l'outbox.
3
+ *
4
+ * ── CE QUE CE MODULE COMBLE ───────────────────────────────────────────────────
5
+ * pln#651 a livré toutes les primitives — projecteur et ses trois filets, HPKE,
6
+ * constructeur d'enveloppes, outbox, ingestion côté cloud, board aveugle — mais RIEN ne
7
+ * les reliait. Mesuré le 2026-08-09 : `buildEnvelope` avait ZÉRO appelant de production,
8
+ * `enqueue` aucun, et le cloud avait reçu zéro enveloppe. La fédération était complète et
9
+ * inerte.
10
+ *
11
+ * ── PÉRIMÈTRE, DÉCIDÉ PAR L'OPÉRATEUR (2026-08-09) ────────────────────────────
12
+ * « Tous les éléments liés au projet et aux plans. » Donc : les plans, leurs étapes, et la
13
+ * mémoire projet (décisions, contraintes, pièges, handoffs, séquences, notes). PAS les
14
+ * entités d'exécution — claims, assignations, runs, messages : elles décrivent QUI fait
15
+ * QUOI SUR QUELLE MACHINE, c'est-à-dire précisément la classe que dec#154 garde locale.
16
+ *
17
+ * ── IDEMPOTENCE, ET POURQUOI ELLE EST DANS LA CLÉ ─────────────────────────────
18
+ * La clé d'idempotence est `<id>@r<rev>`, et l'outbox en fait un NOM DE FICHIER. Émettre
19
+ * deux fois le même objet à la même révision ne peut donc pas produire deux entrées, même
20
+ * si l'émission est relancée au milieu. C'est le défaut que `materializeFederationSignal`
21
+ * avait en v1 : il frappait un nouvel id à chaque passage, rendant le dédoublonnage
22
+ * impossible par construction.
23
+ *
24
+ * ── CE MODULE N'ENVOIE RIEN ───────────────────────────────────────────────────
25
+ * Il scelle et met en file. Le transport est un autre module : une file qui saurait aussi
26
+ * parler au réseau serait un second chemin par lequel un objet peut sortir sans passer par
27
+ * les filets.
28
+ */
29
+ import { buildEnvelope } from './federation-projection.js';
30
+ import { enqueue } from './federation-outbox-v2.js';
31
+ import { loadConnectionState } from './federation-state.js';
32
+ import { epochPublicKey } from './federation-keyring.js';
33
+ import { loadAgentSigningKey, resolveCurrentAgentIdentity } from './agent-registry.js';
34
+ import { loadState } from './state.js';
35
+ import { listSequences } from './sequence.js';
36
+ import { opaqueIdFor } from './federation-opaque-ids.js';
37
+ import { logger } from './logger.js';
38
+ /**
39
+ * Les familles projetées, et RIEN d'autre.
40
+ *
41
+ * L'absence de `claim`, `assignment`, `agent_run`, `inbox_message` n'est pas un oubli :
42
+ * ce sont des entités d'exécution, dont les champs utiles (hôte, session, worktree,
43
+ * commande, pid) sont exactement ceux que `FORBIDDEN_LEAF_NAMES` refuse. Les projeter
44
+ * reviendrait à envoyer une coquille vide, ou à faire échouer la projection.
45
+ */
46
+ export const PROJECTED_KINDS = [
47
+ 'plan', 'plan_step', 'decision', 'constraint', 'trap', 'handoff', 'sequence', 'runtime_note',
48
+ ];
49
+ /** Normalise une priorité libre vers l'échelle publiée, sinon rien. */
50
+ function normalizePriority(value) {
51
+ return value === 'low' || value === 'medium' || value === 'high' || value === 'critical'
52
+ ? value
53
+ : undefined;
54
+ }
55
+ /**
56
+ * Révision d'un objet.
57
+ *
58
+ * Le magasin ne porte pas de compteur de révision : on dérive donc la révision de
59
+ * `updated_at`, en secondes depuis l'epoch Unix. Ce n'est PAS un numéro de version — c'est
60
+ * un ordre monotone par objet, ce dont l'anti-rejeu a besoin (`acceptsRevision` refuse une
61
+ * révision inférieure ou égale à la dernière vue). Deux mutations dans la même seconde
62
+ * produisent la même révision et la seconde sera ignorée : acceptable ici, et préférable à
63
+ * un compteur inventé côté émission qui divergerait entre deux machines.
64
+ */
65
+ function revisionOf(item) {
66
+ const stamp = item.updated_at ?? item.created_at;
67
+ const ms = stamp ? Date.parse(stamp) : Number.NaN;
68
+ return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
69
+ }
70
+ /**
71
+ * Rassemble ce qui doit partir, depuis le magasin local.
72
+ *
73
+ * Ne lit QUE les familles de PROJECTED_KINDS. Le contenu retenu par objet est délibérément
74
+ * étroit : ce qui n'est pas nommé ici ne peut pas partir, même chiffré.
75
+ */
76
+ export function collectProjectable(cwd = process.cwd()) {
77
+ const state = loadState(cwd);
78
+ const items = [];
79
+ for (const plan of state.plan_items ?? []) {
80
+ const p = plan;
81
+ const id = String(p['id'] ?? '');
82
+ if (!id)
83
+ continue;
84
+ items.push({
85
+ kind: 'plan',
86
+ id,
87
+ rev: revisionOf(p),
88
+ status: String(p['status'] ?? 'todo'),
89
+ priority: normalizePriority(p['priority']),
90
+ occurredAt: String(p['updated_at'] ?? p['created_at'] ?? new Date(0).toISOString()),
91
+ content: {
92
+ text: p['text'] ?? '',
93
+ type: p['type'] ?? undefined,
94
+ tags: p['tags'] ?? undefined,
95
+ short_label: p['short_label'] ?? undefined,
96
+ },
97
+ });
98
+ // Les étapes sont projetées comme objets À PART ENTIÈRE, pas imbriquées dans le plan.
99
+ // Une étape a son propre statut et son propre rang : l'imbriquer forcerait à réémettre
100
+ // le plan entier — donc à re-sceller tout son texte — pour cocher une case.
101
+ const steps = Array.isArray(p['steps']) ? p['steps'] : [];
102
+ steps.forEach((step, index) => {
103
+ const stepId = String(step['id'] ?? '');
104
+ if (!stepId)
105
+ return;
106
+ items.push({
107
+ kind: 'plan_step',
108
+ id: stepId,
109
+ rev: revisionOf(step),
110
+ status: String(step['status'] ?? 'todo'),
111
+ rank: index + 1,
112
+ // Le lien vers le plan porteur est une DÉPENDANCE, pas un champ de contenu : il
113
+ // décrit la structure, que le board aveugle doit pouvoir rendre sans déchiffrer.
114
+ deps: [{ from: stepId, to: id }],
115
+ occurredAt: String(step['updated_at'] ?? step['created_at'] ?? new Date(0).toISOString()),
116
+ content: { text: step['text'] ?? '', assignee: step['assignee'] ?? undefined },
117
+ });
118
+ });
119
+ }
120
+ const memoryFamilies = [
121
+ ['decision', 'recent_decisions'],
122
+ ['constraint', 'active_constraints'],
123
+ ['trap', 'known_traps'],
124
+ // ── DEUX NOMS DE CHAMP FAUX, DEUX FAMILLES MUETTES (corrigé 2026-08-10) ──────
125
+ // `handoffs` n'existe pas dans l'état : le champ s'appelle `open_handoffs`. La boucle
126
+ // faisait `if (!Array.isArray(rows)) continue;` — donc 438 handoffs de ce projet étaient
127
+ // déclarés projetables et collectés à ZÉRO, sans la moindre erreur. Un nom de champ faux
128
+ // ne casse rien : il rend simplement une famille entière invisible.
129
+ ['handoff', 'open_handoffs'],
130
+ ['runtime_note', 'runtime_notes'],
131
+ ];
132
+ for (const [kind, field] of memoryFamilies) {
133
+ const rows = state[field];
134
+ if (!Array.isArray(rows))
135
+ continue;
136
+ for (const row of rows) {
137
+ const id = String(row['id'] ?? '');
138
+ if (!id)
139
+ continue;
140
+ items.push({
141
+ kind,
142
+ id,
143
+ rev: revisionOf(row),
144
+ status: String(row['status'] ?? 'active'),
145
+ priority: normalizePriority(row['severity'] ?? row['priority']),
146
+ occurredAt: String(row['updated_at'] ?? row['created_at'] ?? new Date(0).toISOString()),
147
+ content: {
148
+ text: row['text'] ?? '',
149
+ tags: row['tags'] ?? undefined,
150
+ short_label: row['short_label'] ?? undefined,
151
+ },
152
+ });
153
+ }
154
+ }
155
+ // ── SÉQUENCES — déclarées projetables, sans AUCUN collecteur (corrigé 2026-08-10) ──
156
+ //
157
+ // `sequence` figurait dans PROJECTED_KINDS depuis l'origine, mais rien ne la ramassait :
158
+ // ni la boucle des plans, ni celle des familles mémoire. 64 séquences de ce projet ne
159
+ // sortaient donc jamais. Elles vivent dans des fichiers propres (coordination/sequences),
160
+ // pas dans l'état agrégé — d'où l'oubli.
161
+ //
162
+ // Le CONTENU retenu reste étroit, comme partout ailleurs : nom et description. Les
163
+ // `items` (les lanes, avec leurs planId locaux) NE SORTENT PAS — ils exposeraient des
164
+ // identifiants locaux et la structure d'exécution, que la table de classification ne
165
+ // range pas en clair. Le board rend une séquence et son état, pas son plan de bataille.
166
+ for (const sequence of listSequences(cwd)) {
167
+ const row = sequence;
168
+ const id = String(row['id'] ?? '');
169
+ if (!id)
170
+ continue;
171
+ items.push({
172
+ kind: 'sequence',
173
+ id,
174
+ rev: revisionOf(row),
175
+ status: String(row['status'] ?? 'draft'),
176
+ occurredAt: String(row['updated_at'] ?? row['created_at'] ?? new Date(0).toISOString()),
177
+ content: {
178
+ text: row['name'] ?? '',
179
+ tags: Array.isArray(row['tags']) ? row['tags'] : undefined,
180
+ short_label: row['short_label'] ?? undefined,
181
+ },
182
+ });
183
+ }
184
+ return items;
185
+ }
186
+ /**
187
+ * Scelle et met en file tout ce qui doit partir.
188
+ *
189
+ * REFUSE PLUTÔT QUE DE DEVINER : sans appairage actif, sans clé d'epoch d'écriture, ou
190
+ * sans identité signataire, la fonction lève. Émettre « au mieux » produirait des
191
+ * enveloppes que personne ne peut lire, et le cloud les accepterait sans broncher.
192
+ */
193
+ export function emitProjections(options = {}) {
194
+ const cwd = options.cwd ?? process.cwd();
195
+ const result = { collected: 0, enqueued: 0, skipped_duplicate: 0, refused: 0, refusals: [] };
196
+ const state = loadConnectionState(cwd);
197
+ if (!state || state.enrollment.stage !== 'active') {
198
+ throw new Error('Aucun appairage actif : lancez `brainclaw cloud connect` puis `brainclaw cloud await`. ' +
199
+ 'Rien n\'est émis tant que l\'appairage n\'est pas confirmé localement.');
200
+ }
201
+ const epoch = state.keys.current_epoch;
202
+ const recipient = epochPublicKey(state.cloud_project_id, epoch);
203
+ if (!recipient) {
204
+ throw new Error(`Clé d'epoch ${epoch} introuvable localement pour ce projet. Cet appareil ne peut pas ` +
205
+ 'sceller : il n\'a pas reçu la clé d\'écriture courante.');
206
+ }
207
+ // L'identité SIGNATAIRE est celle de l'AGENT, pas de l'appareil : c'est l'agent qui répond
208
+ // de l'origine d'une enveloppe. `DeviceRecord` ne porte volontairement pas d'agent_id —
209
+ // un même appareil peut servir plusieurs agents, et lier l'origine à la machine plutôt
210
+ // qu'à l'auteur rendrait la signature inutilisable pour dire QUI a émis.
211
+ // ── L'AGENT QUI SIGNE EST CELUI QUI EST APPAIRÉ, PAS CELUI DU REGISTRE LOCAL ──
212
+ //
213
+ // Constaté en production le 2026-08-10 : `resolveCurrentAgentIdentity` rend l'agent du
214
+ // registre du workspace (ici `agt_687aa…`), qui n'a AUCUN rapport avec l'agent enrôlé
215
+ // côté cloud (`claude-code-frams99`). Émettre sous cette identité produisait « Identité
216
+ // de signature Ed25519 introuvable » — et, si une clé avait existé, aurait produit des
217
+ // enveloppes signées par une identité NON ATTESTÉE que le cloud aurait refusées en 422.
218
+ //
219
+ // Le signataire doit être une identité que le cloud reconnaît pour CE projet : c'est par
220
+ // définition celle de l'appairage actif. Le registre local sert à d'autres fins et n'a
221
+ // aucune raison de coïncider.
222
+ const pairedAgentId = state.pairings?.find((p) => p.stage === 'active')?.agent_id;
223
+ const agentId = options.agentId ?? pairedAgentId ?? resolveCurrentAgentIdentity(cwd)?.agent_id;
224
+ if (!agentId) {
225
+ throw new Error('Agent courant non résolu : impossible de déterminer qui signe l\'origine des enveloppes.');
226
+ }
227
+ const identity = loadAgentSigningKey(agentId);
228
+ if (!identity) {
229
+ throw new Error(`Identité de signature Ed25519 introuvable pour ${agentId} : impossible de signer l'origine.`);
230
+ }
231
+ const wanted = new Set(options.kinds ?? PROJECTED_KINDS);
232
+ const items = collectProjectable(cwd).filter((i) => wanted.has(i.kind));
233
+ result.collected = items.length;
234
+ for (const item of items) {
235
+ const idempotencyKey = `${item.id}@r${item.rev}`;
236
+ try {
237
+ const envelope = buildEnvelope({
238
+ kind: item.kind,
239
+ idOpaque: opaqueIdFor(state.cloud_project_id, item.id, cwd),
240
+ cloudProjectId: state.cloud_project_id,
241
+ baseRev: item.rev,
242
+ statusObject: item.status,
243
+ priority: item.priority,
244
+ rank: item.rank,
245
+ // Les DÉPENDANCES portent aussi des ids : les laisser en local exposerait la
246
+ // structure du magasin, que le reste de la projection s'applique à cacher.
247
+ deps: item.deps?.map((d) => ({
248
+ from: opaqueIdFor(state.cloud_project_id, d.from, cwd),
249
+ to: opaqueIdFor(state.cloud_project_id, d.to, cwd),
250
+ })),
251
+ occurredAt: item.occurredAt,
252
+ wrapHint: `epoch:${epoch}`,
253
+ operationId: idempotencyKey,
254
+ keyEpoch: epoch,
255
+ content: item.content,
256
+ recipientPublicKeyPem: recipient.public_key_pem,
257
+ originKeyId: identity.fingerprint,
258
+ originPrivateKeyPem: identity.privateKeyPem,
259
+ });
260
+ if (options.dryRun)
261
+ continue;
262
+ const added = enqueue({ idempotency_key: idempotencyKey, operation_id: idempotencyKey, base_rev: item.rev, key_epoch: epoch, sealed: envelope, origin_agent_id: agentId }, cwd);
263
+ if (added)
264
+ result.enqueued += 1;
265
+ else
266
+ result.skipped_duplicate += 1;
267
+ }
268
+ catch (err) {
269
+ // Un refus est COMPTÉ ET NOMMÉ, jamais avalé. Les trois filets refusent précisément
270
+ // ce qui ne doit pas sortir : masquer leur verdict transformerait une protection en
271
+ // perte silencieuse d'objets.
272
+ result.refused += 1;
273
+ result.refusals.push({
274
+ kind: item.kind,
275
+ id: item.id,
276
+ reason: err instanceof Error ? err.message : String(err),
277
+ });
278
+ logger.warn(`Projection refusée — ${item.kind} ${item.id} : ${result.refusals.at(-1)?.reason}`);
279
+ }
280
+ }
281
+ return result;
282
+ }
283
+ //# sourceMappingURL=federation-emit.js.map
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Transport des remises de clés d'epoch : ce qui rend `federation-grant.ts` ATTEIGNABLE.
3
+ *
4
+ * ── POURQUOI CE MODULE EXISTE ─────────────────────────────────────────────────
5
+ * `buildEpochGrant` et `verifyAndStoreEpochGrant` sont corrects et testés — et n'avaient
6
+ * AUCUN appelant de production (mesuré le 2026-08-10, juste après leur livraison). C'est
7
+ * exactement le défaut qui avait rendu la fédération entière inerte : trois primitives
8
+ * impeccables que rien n'appelait. Une primitive sans appelant ne remet aucune clé.
9
+ *
10
+ * Ici : le custodian DÉPOSE (POST), le destinataire RÉCLAME et RANGE (GET + verify).
11
+ *
12
+ * ── CE QUE LE RELAIS NE PEUT PAS FAIRE, ET POURQUOI ON NE LUI FAIT PAS CONFIANCE ─
13
+ * Le cloud choisit QUELS grants il présente — il peut en omettre. Aucune cryptographie ne
14
+ * l'en empêche. Ce qu'il ne peut pas faire : en forger un. Le destinataire vérifie le
15
+ * signataire contre le roster des custodians ACTIFS, recompose l'AAD, compare sa propre
16
+ * clé à la cible, et redérive la clé publique pour la confronter à l'annonce signée — le
17
+ * tout AVANT d'écrire quoi que ce soit sur le disque.
18
+ */
19
+ import crypto from 'node:crypto';
20
+ import { buildEpochGrant, verifyAndStoreEpochGrant, } from './federation-grant.js';
21
+ import { loadConnectionState } from './federation-state.js';
22
+ import { heldEpochs } from './federation-keyring.js';
23
+ import { loadAgentSigningKey } from './agent-registry.js';
24
+ function headers(apiKey) {
25
+ const h = { accept: 'application/json', 'content-type': 'application/json' };
26
+ if (apiKey)
27
+ h['authorization'] = `Bearer ${apiKey}`;
28
+ return h;
29
+ }
30
+ function projectRoot(base, cloudProjectId) {
31
+ return `${base.replace(/\/+$/, '')}/api/v1/projects/${encodeURIComponent(cloudProjectId)}`;
32
+ }
33
+ async function readJson(res, label) {
34
+ if (!res.ok) {
35
+ throw new Error(`${label} refusé par le cloud (HTTP ${res.status}) : ${(await res.text()).slice(0, 200)}`);
36
+ }
37
+ try {
38
+ return await res.json();
39
+ }
40
+ catch {
41
+ throw new Error(`${label} : réponse JSON attendue.`);
42
+ }
43
+ }
44
+ function asRecord(v) {
45
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
46
+ }
47
+ /**
48
+ * Remet au destinataire les epochs demandés, un manifeste par epoch.
49
+ *
50
+ * UN MANIFESTE PAR EPOCH, et non un lot : chaque remise porte ses propres liaisons
51
+ * signées (projet, epoch, cible, grant_id). Un lot unique obligerait à faire confiance à
52
+ * une liaison collective, et un refus partiel deviendrait indistinguable d'un succès.
53
+ *
54
+ * L'HORIZON (dec#163 §1) est décidé par l'APPELANT et matérialisé dans `epochs` : tout
55
+ * l'historique pour un autre appareil de la même personne, l'epoch courant seulement pour
56
+ * un membre invité. Ce module ne devine pas la politique — il l'exécute.
57
+ */
58
+ export async function grantEpochsToDevice(options) {
59
+ const cwd = options.cwd ?? process.cwd();
60
+ const state = loadConnectionState(cwd);
61
+ if (!state)
62
+ throw new Error("Aucun appairage local : impossible de remettre une clé d'epoch.");
63
+ const signing = loadAgentSigningKey(options.custodianAgentId);
64
+ if (!signing) {
65
+ throw new Error(`Identité de signature introuvable pour ${options.custodianAgentId} : un custodian signe ` +
66
+ 'son manifeste, sans quoi le destinataire le refusera (non_custodian).');
67
+ }
68
+ const doFetch = options.fetchImpl ?? fetch;
69
+ const endpoint = `${projectRoot(options.url, state.cloud_project_id)}/epoch-grants`;
70
+ const result = { granted: [], skipped: [] };
71
+ for (const epoch of options.epochs) {
72
+ let manifest;
73
+ try {
74
+ manifest = buildEpochGrant({
75
+ cloudProjectId: state.cloud_project_id,
76
+ epoch,
77
+ grantId: crypto.randomUUID(),
78
+ policyRevision: options.policyRevision ?? 0,
79
+ custodian: { keyId: signing.fingerprint, privateKeyPem: signing.privateKeyPem, active: true },
80
+ // L'HORIZON DÉCIDÉ PAR L'APPELANT *EST* L'AUTORISATION (dec#163 §1) : `epochs` est
81
+ // le résultat de la politique (tout l'historique / l'epoch courant / une extension
82
+ // explicite). Le passer ici rend l'intention vérifiable par `buildEpochGrant`
83
+ // plutôt que supposée — une liste vide ferait échouer CHAQUE remise.
84
+ target: { ...options.target, authorizedEpochs: options.epochs },
85
+ home: undefined,
86
+ });
87
+ }
88
+ catch (err) {
89
+ // Un epoch non détenu ou hors horizon est SIGNALÉ, jamais avalé : l'opérateur doit
90
+ // savoir que la remise est partielle, sinon il croira le destinataire équipé.
91
+ result.skipped.push({ epoch, reason: err instanceof Error ? err.message : String(err) });
92
+ continue;
93
+ }
94
+ const res = await doFetch(endpoint, {
95
+ method: 'POST',
96
+ headers: headers(options.apiKey),
97
+ body: JSON.stringify({
98
+ epoch,
99
+ target_device_fingerprint: manifest.target.x25519_fingerprint,
100
+ target_agent_id: options.target.deviceId,
101
+ grantor_agent_id: options.custodianAgentId,
102
+ // Le manifeste part VERBATIM : le cloud le stocke tel quel et le rend au
103
+ // destinataire, qui doit pouvoir recalculer la signature sur les octets exacts.
104
+ manifest_json: JSON.stringify(manifest),
105
+ sealed_b64: Buffer.from(JSON.stringify(manifest.sealed), 'utf-8').toString('base64'),
106
+ }),
107
+ });
108
+ if (res.ok) {
109
+ result.granted.push(epoch);
110
+ continue;
111
+ }
112
+ if (res.status === 409) {
113
+ result.skipped.push({ epoch, reason: 'une remise est déjà en attente pour cet epoch et cet appareil' });
114
+ continue;
115
+ }
116
+ result.skipped.push({ epoch, reason: `HTTP ${res.status} : ${(await res.text()).slice(0, 160)}` });
117
+ }
118
+ return result;
119
+ }
120
+ /**
121
+ * Réclame les remises destinées à CET appareil, les vérifie, range celles qui passent.
122
+ *
123
+ * `target` est OBLIGATOIRE côté cloud : chacun ne demande que ce qui lui revient, ce qui
124
+ * empêche un agent d'énumérer les remises destinées aux autres.
125
+ *
126
+ * L'accusé de réception (`consume`) n'est envoyé qu'APRÈS rangement réussi. L'inverse
127
+ * ferait disparaître du feed une remise jamais appliquée — et la clé serait perdue sans
128
+ * qu'aucune erreur ne le dise.
129
+ */
130
+ export async function receiveEpochGrants(options) {
131
+ const cwd = options.cwd ?? process.cwd();
132
+ const state = loadConnectionState(cwd);
133
+ if (!state)
134
+ throw new Error('Aucun appairage local : rien à réclamer.');
135
+ const doFetch = options.fetchImpl ?? fetch;
136
+ const root = projectRoot(options.url, state.cloud_project_id);
137
+ const result = { offered: 0, stored: [], rejected: [] };
138
+ // L'empreinte de NOTRE clé X25519 : c'est la seule chose que le cloud accepte comme
139
+ // filtre, et c'est aussi ce que le manifeste doit désigner comme cible.
140
+ const { loadDevicePrivateKey, fingerprintKeyPem } = await import('./federation-keyring.js');
141
+ const priv = options.recipientPrivateKey ?? loadDevicePrivateKey(options.recipientDeviceId, options.home);
142
+ if (!priv) {
143
+ throw new Error(`Clé X25519 absente pour ${options.recipientDeviceId} : cet appareil ne peut recevoir ` +
144
+ "aucune remise tant que son identité de lecture n'existe pas.");
145
+ }
146
+ const myFingerprint = fingerprintKeyPem(crypto.createPublicKey(priv).export({ type: 'spki', format: 'pem' }).toString());
147
+ const body = await readJson(await doFetch(`${root}/epoch-grants?target=${encodeURIComponent(myFingerprint)}`, { headers: headers(options.apiKey) }), 'Remises de clés');
148
+ const grants = Array.isArray(asRecord(body)?.['grants']) ? asRecord(body)['grants'] : [];
149
+ result.offered = grants.length;
150
+ for (const entry of grants) {
151
+ const row = asRecord(entry);
152
+ const manifestJson = typeof row?.['manifest_json'] === 'string' ? row['manifest_json'] : null;
153
+ if (!manifestJson) {
154
+ result.rejected.push({ reason: 'schema_invalid', detail: 'remise sans manifeste verbatim' });
155
+ continue;
156
+ }
157
+ let raw;
158
+ try {
159
+ raw = JSON.parse(manifestJson);
160
+ }
161
+ catch {
162
+ result.rejected.push({ reason: 'schema_invalid', detail: 'manifeste illisible' });
163
+ continue;
164
+ }
165
+ const verdict = verifyAndStoreEpochGrant({
166
+ raw,
167
+ recipientDeviceId: options.recipientDeviceId,
168
+ activeCustodians: options.activeCustodians,
169
+ recipientPrivateKey: priv,
170
+ home: options.home,
171
+ });
172
+ if (!verdict.ok) {
173
+ result.rejected.push({
174
+ epoch: typeof row?.['epoch'] === 'number' ? row['epoch'] : undefined,
175
+ reason: verdict.reason,
176
+ detail: verdict.detail,
177
+ });
178
+ continue;
179
+ }
180
+ result.stored.push(verdict.manifest.epoch);
181
+ // Accusé APRÈS rangement. Un échec d'accusé n'annule rien : la clé est rangée, et
182
+ // `storeEpochPrivateKey` est idempotent sur une clé identique — un rejeu est sûr.
183
+ const id = typeof row?.['id'] === 'string' ? row['id'] : null;
184
+ if (id) {
185
+ await doFetch(`${root}/epoch-grants/${encodeURIComponent(id)}/consume`, {
186
+ method: 'POST', headers: headers(options.apiKey),
187
+ }).catch(() => { });
188
+ }
189
+ }
190
+ return result;
191
+ }
192
+ /** Les epochs réellement détenus après réception — la vérité du DISQUE, pas de l'état. */
193
+ export function readableEpochs(cloudProjectId, home) {
194
+ return heldEpochs(cloudProjectId, home);
195
+ }
196
+ //# sourceMappingURL=federation-grant-transport.js.map