brainclaw 1.22.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.
Files changed (38) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-capture.js +15 -0
  3. package/dist/cli/register-cloud.js +121 -13
  4. package/dist/commands/cloud.js +534 -39
  5. package/dist/commands/loops-handlers.js +0 -1
  6. package/dist/commands/mcp-catalog.js +24 -256
  7. package/dist/commands/mcp-read-handlers.js +5 -1
  8. package/dist/commands/mcp-schemas.generated.js +811 -1
  9. package/dist/commands/mcp-write-coordination.js +16 -7
  10. package/dist/commands/mcp.js +45 -1
  11. package/dist/commands/memory-confirm.js +83 -0
  12. package/dist/commands/switch.js +24 -2
  13. package/dist/core/assignment-request-schema.js +112 -0
  14. package/dist/core/capture-schema.js +62 -0
  15. package/dist/core/claim-request-schema.js +72 -0
  16. package/dist/core/code-map/aggregate.js +36 -1
  17. package/dist/core/federation-emit.js +283 -0
  18. package/dist/core/federation-grant-transport.js +196 -0
  19. package/dist/core/federation-grant.js +223 -0
  20. package/dist/core/federation-keyring.js +39 -0
  21. package/dist/core/federation-opaque-ids.js +111 -0
  22. package/dist/core/federation-outbox-v2.js +36 -2
  23. package/dist/core/federation-pairing.js +87 -12
  24. package/dist/core/federation-pull.js +375 -0
  25. package/dist/core/federation-push.js +274 -0
  26. package/dist/core/federation-rotation.js +124 -0
  27. package/dist/core/federation-state.js +81 -6
  28. package/dist/core/sequence-request-schema.js +93 -0
  29. package/dist/core/session-request-schema.js +90 -0
  30. package/dist/core/step-request-schema.js +112 -0
  31. package/dist/core/store-resolution.js +34 -5
  32. package/dist/core/warnings.js +37 -0
  33. package/dist/facts.js +7 -7
  34. package/dist/facts.json +6 -6
  35. package/docs/design/federation-onboarding-usecases.md +254 -0
  36. package/docs/design/pairing-v3-brief.md +80 -0
  37. package/docs/integrations/mcp.md +1 -1
  38. package/package.json +1 -1
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Fédération v2 — pull : le cloud livre un delta, ce module vérifie puis
3
+ * matérialise seulement le clair accepté. Le cloud reste un relais, jamais la
4
+ * source de vérité locale.
5
+ */
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { verifyInboundBatch } from './federation-inbound.js';
10
+ import { loadEpochPrivateKey } from './federation-keyring.js';
11
+ import { localIdForOpaque, rememberOpaqueId } from './federation-opaque-ids.js';
12
+ import { addStep, createPlan, updatePlan, updateStep } from './operations/plan.js';
13
+ import { memoryDir, writeFileAtomic } from './io.js';
14
+ import { nowISO } from './ids.js';
15
+ import { loadConnectionState, recordRevision, saveConnectionState } from './federation-state.js';
16
+ const INBOUND_SCHEMA = 'brainclaw.federation-inbound-pull/v1';
17
+ const INBOUND_FILE = 'inbound-pull.json';
18
+ /**
19
+ * Limite transitoire, volontairement rendue à l'UI : le roster signé de dec#159
20
+ * n'existe pas encore. Tirer les attestations du cloud demande donc au relais qui
21
+ * surveiller ; la signature protège le contenu, pas cette sélection de clés.
22
+ */
23
+ export const CLOUD_ROSTER_LIMITATION = 'Roster provisoire : attestations tirées du cloud, pas encore un roster signé (dec#159 §5). Le relais peut influencer qui est accepté comme signataire.';
24
+ function journalPath(cwd) {
25
+ return path.join(memoryDir(cwd), 'coordination', 'federation', INBOUND_FILE);
26
+ }
27
+ function loadJournal(cwd) {
28
+ const file = journalPath(cwd);
29
+ if (!fs.existsSync(file))
30
+ return { schema: INBOUND_SCHEMA, seen: [], pending: {} };
31
+ try {
32
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
33
+ if (parsed.schema !== INBOUND_SCHEMA || !Array.isArray(parsed.seen) || !parsed.pending || typeof parsed.pending !== 'object') {
34
+ return { schema: INBOUND_SCHEMA, seen: [], pending: {} };
35
+ }
36
+ return {
37
+ schema: INBOUND_SCHEMA,
38
+ seen: parsed.seen.filter((key) => typeof key === 'string'),
39
+ pending: parsed.pending,
40
+ };
41
+ }
42
+ catch {
43
+ // Un journal local corrompu n'est jamais une preuve qu'un message a été appliqué.
44
+ return { schema: INBOUND_SCHEMA, seen: [], pending: {} };
45
+ }
46
+ }
47
+ function saveJournal(journal, cwd) {
48
+ const file = journalPath(cwd);
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ writeFileAtomic(file, `${JSON.stringify(journal, null, 2)}\n`);
51
+ }
52
+ function asRecord(value) {
53
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
54
+ ? value
55
+ : undefined;
56
+ }
57
+ function rawKey(raw) {
58
+ // Déduplication de stockage uniquement — jamais une décision d'authenticité.
59
+ return crypto.createHash('sha256').update(JSON.stringify(raw)).digest('hex');
60
+ }
61
+ /**
62
+ * Le cloud rend une LIGNE À PLAT dont un champ, `envelope_json`, porte l'enveloppe signée
63
+ * verbatim (dec#162). C'est ELLE que le vérificateur doit parser — la ligne plate n'a ni
64
+ * la forme imbriquée de FederationEnvelopeSchema ni la signature d'AUTEUR. Sans
65
+ * `envelope_json` (enveloppe poussée avant dec#162), on laisse passer l'objet tel quel :
66
+ * il échouera en `schema_invalid`, ce qui est le verdict juste — non vérifiable.
67
+ */
68
+ function toEnvelope(item) {
69
+ const record = asRecord(item);
70
+ if (record && typeof record['envelope_json'] === 'string') {
71
+ try {
72
+ return JSON.parse(record['envelope_json']);
73
+ }
74
+ catch {
75
+ return item;
76
+ }
77
+ }
78
+ return item;
79
+ }
80
+ function responseArray(value, fields) {
81
+ const record = asRecord(value);
82
+ if (!record)
83
+ return undefined;
84
+ for (const field of fields)
85
+ if (Array.isArray(record[field]))
86
+ return record[field];
87
+ return undefined;
88
+ }
89
+ function parseDelta(body) {
90
+ if (Array.isArray(body))
91
+ return { envelopes: body };
92
+ const root = asRecord(body);
93
+ if (!root)
94
+ throw new Error('Delta cloud invalide : objet JSON attendu.');
95
+ const source = asRecord(root['data']) ?? asRecord(root['delta']) ?? root;
96
+ const envelopes = responseArray(source, ['envelopes', 'items', 'results']);
97
+ if (!envelopes)
98
+ throw new Error('Delta cloud invalide : tableau envelopes attendu.');
99
+ const cursor = source['next_seq'] ?? source['next_cursor'] ?? source['nextCursor'] ?? source['cursor'];
100
+ return { envelopes, cursor: typeof cursor === 'string' || typeof cursor === 'number' ? String(cursor) : undefined };
101
+ }
102
+ /** Lit les attestations cloud. Ce n'est PAS un roster signé : voir la constante exportée. */
103
+ function parseCloudRoster(body) {
104
+ const root = asRecord(body);
105
+ if (!root)
106
+ throw new Error('Roster cloud invalide : objet JSON attendu.');
107
+ const source = asRecord(root['data']) ?? root;
108
+ const keys = new Map();
109
+ const revoked = new Set();
110
+ const compact = asRecord(source['keys']);
111
+ if (compact) {
112
+ for (const [id, pem] of Object.entries(compact))
113
+ if (typeof pem === 'string')
114
+ keys.set(id, pem);
115
+ }
116
+ for (const value of responseArray(source, ['attestations', 'members', 'roster', 'items']) ?? []) {
117
+ const row = asRecord(value);
118
+ if (!row)
119
+ continue;
120
+ const id = row['key_id'] ?? row['identity_fingerprint'] ?? row['signer_fingerprint'];
121
+ const pem = row['identity_public_key_pem'] ?? row['ed25519_public_key_pem'] ?? row['public_key_pem'];
122
+ if (typeof id !== 'string' || typeof pem !== 'string')
123
+ continue;
124
+ keys.set(id, pem);
125
+ if (row['revoked'] === true || row['revoked_at'])
126
+ revoked.add(id);
127
+ }
128
+ return { keys, revoked };
129
+ }
130
+ function headers(apiKey) {
131
+ return apiKey ? { accept: 'application/json', authorization: `Bearer ${apiKey}` } : { accept: 'application/json' };
132
+ }
133
+ async function readJson(res, label) {
134
+ if (!res.ok)
135
+ throw new Error(`${label} refusé par le cloud (HTTP ${res.status}) : ${(await res.text()).slice(0, 200)}`);
136
+ try {
137
+ return await res.json();
138
+ }
139
+ catch {
140
+ throw new Error(`${label} invalide : JSON attendu.`);
141
+ }
142
+ }
143
+ function problem(raw, reason) {
144
+ const env = asRecord(raw);
145
+ const meta = asRecord(env?.['meta']);
146
+ const transport = asRecord(meta?.['transport']);
147
+ return {
148
+ idempotency_key: typeof transport?.['idempotency_key'] === 'string' ? transport['idempotency_key'] : undefined,
149
+ key_epoch: typeof env?.['key_epoch'] === 'number' ? env['key_epoch'] : undefined,
150
+ reason,
151
+ };
152
+ }
153
+ function contentOf(value) {
154
+ const content = asRecord(value);
155
+ if (!content || typeof content['text'] !== 'string')
156
+ throw new Error('clair vérifié non matérialisable : text est attendu.');
157
+ return content;
158
+ }
159
+ function tagsOf(content) {
160
+ return Array.isArray(content['tags']) && content['tags'].every((tag) => typeof tag === 'string')
161
+ ? content['tags']
162
+ : undefined;
163
+ }
164
+ function priorityOf(value) {
165
+ return value === 'low' || value === 'medium' || value === 'high' || value === 'critical' ? value : undefined;
166
+ }
167
+ class DeferredMaterialization extends Error {
168
+ }
169
+ /**
170
+ * Passe par les opérations métier (donc leur pipeline de mutation/verrous), jamais par
171
+ * l'écriture d'un JSON d'entité. L'opaque est mappé seulement APRES la mutation réussie.
172
+ */
173
+ function materialize(accepted, state, cwd) {
174
+ const opaque = accepted.envelope.meta.id_opaque;
175
+ const existing = localIdForOpaque(state.cloud_project_id, opaque, cwd);
176
+ const content = contentOf(accepted.content);
177
+ const priority = priorityOf(accepted.envelope.meta.priority);
178
+ const tags = tagsOf(content);
179
+ if (accepted.kind === 'plan') {
180
+ if (existing) {
181
+ updatePlan({
182
+ id: existing,
183
+ status: accepted.envelope.meta.status.object,
184
+ priority,
185
+ patch: { text: content['text'], tags },
186
+ }, cwd);
187
+ return;
188
+ }
189
+ const created = createPlan({
190
+ text: content['text'],
191
+ author: 'federation',
192
+ type: typeof content['type'] === 'string' ? content['type'] : undefined,
193
+ priority,
194
+ tags,
195
+ }, cwd);
196
+ rememberOpaqueId(state.cloud_project_id, created.id, opaque, cwd);
197
+ return;
198
+ }
199
+ if (accepted.kind === 'plan_step') {
200
+ const parentOpaque = accepted.envelope.meta.deps.find((dependency) => dependency.from === opaque)?.to;
201
+ const parent = parentOpaque ? localIdForOpaque(state.cloud_project_id, parentOpaque, cwd) : undefined;
202
+ if (!parent)
203
+ throw new DeferredMaterialization('étape reçue avant son plan parent ; conservée pour relecture.');
204
+ if (existing) {
205
+ updateStep({ stepId: existing, planId: parent, text: content['text'], status: accepted.envelope.meta.status.object }, cwd);
206
+ return;
207
+ }
208
+ const created = addStep({
209
+ planId: parent,
210
+ text: content['text'],
211
+ assignee: typeof content['assignee'] === 'string' ? content['assignee'] : undefined,
212
+ }, cwd);
213
+ rememberOpaqueId(state.cloud_project_id, created.stepId, opaque, cwd);
214
+ return;
215
+ }
216
+ // Les familles sans mutation canonique correspondante sont différées. Les accepter dans
217
+ // high_water les ferait disparaître du feed sans jamais atteindre le magasin local.
218
+ throw new DeferredMaterialization(`kind '${accepted.kind}' sans mutation canonique de réception.`);
219
+ }
220
+ /**
221
+ * GET du delta + attestations, vérification du lot et matérialisation. Une absence de clé
222
+ * d'epoch est le seul échec conservé explicitement comme « illisible » : l'enveloppe reste
223
+ * dans le journal entrant et sera repassée à verifyInboundBatch après remise de la clé.
224
+ */
225
+ export async function pullFederationDelta(options) {
226
+ const cwd = options.cwd ?? process.cwd();
227
+ const current = loadConnectionState(cwd);
228
+ if (!current || current.enrollment.stage !== 'active') {
229
+ throw new Error("Aucun appairage actif : un pull n'est autorisé qu'après approbation locale.");
230
+ }
231
+ const base = options.url.replace(/\/+$/, '');
232
+ if (!base)
233
+ throw new Error('Adresse du cloud absente : aucune origine ne sera devinée.');
234
+ const root = `${base}/api/v1/projects/${encodeURIComponent(current.cloud_project_id)}`;
235
+ const query = new URLSearchParams();
236
+ // Contrat réel de handleListEnvelopes : since_seq est un curseur exclusif et
237
+ // include_sealed est indispensable au déchiffrement local.
238
+ if (current.sync.feed_cursor)
239
+ query.set('since_seq', current.sync.feed_cursor);
240
+ query.set('include_sealed', 'true');
241
+ if (options.limit !== undefined)
242
+ query.set('limit', String(options.limit));
243
+ const deltaUrl = `${root}/projection/envelopes${query.size ? `?${query}` : ''}`;
244
+ const doFetch = options.fetchImpl ?? fetch;
245
+ const delta = parseDelta(await readJson(await doFetch(deltaUrl, { headers: headers(options.apiKey) }), 'Delta'));
246
+ // ROSTER PROVISOIRE : le commentaire et le résultat nomment la limite dec#159.
247
+ // Une erreur de roster est fatale AVANT le lot : remplacer la liste par une liste vide
248
+ // ferait passer les refus unknown_signer pour des échecs ordinaires et masquerait la
249
+ // vraie borne de confiance.
250
+ let roster;
251
+ try {
252
+ // `/projection/roster` (dec#162) : joignable par la clé d'API de l'agent et rendant
253
+ // {empreinte -> PEM Ed25519}. `/attestations` ne convenait pas — withUserAuth (une clé
254
+ // d'agent y est refusée) et sans le PEM public dont dépend la vérification de signature.
255
+ roster = parseCloudRoster(await readJson(await doFetch(`${root}/projection/roster`, { headers: headers(options.apiKey) }), 'Roster'));
256
+ }
257
+ catch (err) {
258
+ const detail = err instanceof Error ? err.message : String(err);
259
+ throw new Error(`${CLOUD_ROSTER_LIMITATION} Roster indisponible : ${detail}`, { cause: err });
260
+ }
261
+ const journal = loadJournal(cwd);
262
+ const pending = { ...journal.pending };
263
+ const combined = new Map();
264
+ // Les entrées en attente sont DÉJÀ des enveloppes (parsées au tour précédent) ; le delta
265
+ // frais arrive à plat et doit être déballé de `envelope_json` avant toute vérification.
266
+ for (const [key, entry] of Object.entries(pending))
267
+ combined.set(key, entry.raw);
268
+ for (const item of delta.envelopes) {
269
+ const envelope = toEnvelope(item);
270
+ combined.set(rawKey(envelope), envelope);
271
+ }
272
+ const entries = [...combined.entries()];
273
+ if (entries.length > 0 && roster.keys.size === 0) {
274
+ throw new Error(`${CLOUD_ROSTER_LIMITATION} Le endpoint n'a fourni aucune clé Ed25519 publiquement vérifiable ; aucun clair ne sera matérialisé.`);
275
+ }
276
+ // ── RÉCEPTION DES REMISES DE CLÉS, AVANT DE CHOISIR LES CLÉS D'EPOCH (pln#658) ──
277
+ //
278
+ // L'ORDRE EST LE POINT : une clé reçue MAINTENANT rend lisibles, DANS LE MÊME PULL, les
279
+ // enveloppes conservées comme « illisibles, epoch absent ». Recevoir après aurait obligé
280
+ // à un second pull pour que la remise produise son effet — et l'opérateur aurait vu un
281
+ // « 0 matérialisé » juste après avoir reçu ses clés.
282
+ //
283
+ // Le roster des custodians est celui des identités attestées actives (dec#163 §2 : tout
284
+ // détenteur actif peut remettre). Un échec de réception n'interrompt PAS le pull : les
285
+ // enveloppes déjà lisibles doivent arriver même si la remise de clés échoue.
286
+ const grantOutcome = { stored: [], rejected: [] };
287
+ try {
288
+ const { receiveEpochGrants } = await import('./federation-grant-transport.js');
289
+ const received = await receiveEpochGrants({
290
+ cwd,
291
+ url: base,
292
+ apiKey: options.apiKey,
293
+ fetchImpl: options.fetchImpl,
294
+ recipientDeviceId: current.device.device_id,
295
+ activeCustodians: roster.keys,
296
+ });
297
+ grantOutcome.stored = received.stored;
298
+ grantOutcome.rejected = received.rejected;
299
+ }
300
+ catch (err) {
301
+ grantOutcome.rejected.push({
302
+ reason: 'unavailable',
303
+ detail: err instanceof Error ? err.message : String(err),
304
+ });
305
+ }
306
+ const epochKeys = new Map();
307
+ for (const [, raw] of entries) {
308
+ const epoch = asRecord(raw)?.['key_epoch'];
309
+ if (typeof epoch === 'number' && !epochKeys.has(epoch)) {
310
+ const key = options.epochKeyFor
311
+ ? options.epochKeyFor(current.cloud_project_id, epoch)
312
+ : loadEpochPrivateKey(current.cloud_project_id, epoch);
313
+ if (key)
314
+ epochKeys.set(epoch, key);
315
+ }
316
+ }
317
+ const batch = verifyInboundBatch({
318
+ envelopes: entries.map(([, raw]) => raw), roster, state: current, epochKeys,
319
+ seenIdempotencyKeys: new Set(journal.seen),
320
+ });
321
+ const result = {
322
+ received: delta.envelopes.length, verified: batch.accepted, materialized: 0,
323
+ unreadable_epoch_absent: [], rejected: [], deferred: [], retained: 0,
324
+ feed_cursor: delta.cursor, roster_limitation: CLOUD_ROSTER_LIMITATION,
325
+ epoch_keys_received: grantOutcome.stored,
326
+ epoch_keys_rejected: grantOutcome.rejected,
327
+ };
328
+ let next = current;
329
+ const seen = new Set(journal.seen);
330
+ for (const [index, verdict] of batch.results.entries()) {
331
+ const [key, raw] = entries[index];
332
+ if (!verdict.ok) {
333
+ const item = problem(raw, verdict.detail);
334
+ if (verdict.reason === 'undecryptable' && item.key_epoch !== undefined && !epochKeys.has(item.key_epoch)) {
335
+ pending[key] = { raw, key_epoch: item.key_epoch, received_at: pending[key]?.received_at ?? nowISO() };
336
+ result.unreadable_epoch_absent.push({ ...item, reason: `reçue, illisible : epoch ${item.key_epoch} absent ; conservée pour relecture après remise de clé.` });
337
+ }
338
+ else {
339
+ delete pending[key];
340
+ result.rejected.push({ ...item, reason: `${verdict.reason}: ${verdict.detail}` });
341
+ }
342
+ continue;
343
+ }
344
+ try {
345
+ materialize(verdict, next, cwd);
346
+ delete pending[key];
347
+ seen.add(verdict.idempotencyKey);
348
+ next = recordRevision(next, verdict.envelope.meta.id_opaque, verdict.envelope.meta.base_rev);
349
+ result.materialized++;
350
+ }
351
+ catch (err) {
352
+ const reason = err instanceof Error ? err.message : String(err);
353
+ pending[key] = { raw, key_epoch: verdict.envelope.key_epoch, received_at: pending[key]?.received_at ?? nowISO() };
354
+ if (err instanceof DeferredMaterialization)
355
+ result.deferred.push(problem(raw, reason));
356
+ else
357
+ result.rejected.push(problem(raw, `matérialisation: ${reason} (conservée pour reprise)`));
358
+ }
359
+ }
360
+ // Le curseur n'est qu'une optimisation ; la barrière anti-rejeu persistée dans sync
361
+ // porte la sûreté. Le journal est écrit avant l'état/cursor afin que les epochs absents
362
+ // restent relisibles même si le cloud ne les retourne plus dans le prochain delta.
363
+ saveJournal({ schema: INBOUND_SCHEMA, seen: [...seen], pending }, cwd);
364
+ saveConnectionState({
365
+ ...next,
366
+ sync: {
367
+ ...next.sync,
368
+ feed_cursor: delta.cursor ?? next.sync.feed_cursor,
369
+ last_pull_at: nowISO(),
370
+ },
371
+ }, cwd);
372
+ result.retained = Object.keys(pending).length;
373
+ return result;
374
+ }
375
+ //# sourceMappingURL=federation-pull.js.map
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Fédération v2 — TRANSPORT : drainer l'outbox vers le cloud.
3
+ *
4
+ * ── POURQUOI CE MODULE EST SÉPARÉ DE L'ÉMISSION ───────────────────────────────
5
+ * `federation-emit.ts` scelle et met en file ; celui-ci envoie. La séparation n'est pas
6
+ * cosmétique : une file qui saurait aussi parler au réseau serait un SECOND chemin par
7
+ * lequel un objet pourrait sortir sans passer par les trois filets du projecteur. Ici, tout
8
+ * ce qui part a déjà été scellé — ce module ne voit que des `sealed` opaques et ne peut donc
9
+ * pas divulguer de clair, même par erreur de programmation.
10
+ *
11
+ * ── CE QU'IL FAIT DE L'ÉCHEC ──────────────────────────────────────────────────
12
+ * Une entrée qui échoue RESTE en attente, avec son erreur enregistrée. Elle n'est ni
13
+ * supprimée ni déplacée en `conflict` : un échec réseau n'est pas un conflit de révision, et
14
+ * les confondre ferait disparaître de la file une opération jamais émise.
15
+ *
16
+ * Seul un 409 fait passer en `conflict` — c'est le cas où le cloud dit « ta base_rev est
17
+ * périmée », donc un désaccord d'état qu'un renvoi ne résoudra pas.
18
+ */
19
+ import crypto from 'node:crypto';
20
+ import { list, transition } from './federation-outbox-v2.js';
21
+ import { loadConnectionState } from './federation-state.js';
22
+ import { loadAgentSigningKey } from './agent-registry.js';
23
+ import { logger } from './logger.js';
24
+ /**
25
+ * Met une entrée d'outbox à la forme que le cloud attend RÉELLEMENT.
26
+ *
27
+ * ── POURQUOI CETTE FONCTION EXISTE, ET CE QU'ELLE A COÛTÉ ────────────────────
28
+ * La première version envoyait `{ envelope, key_epoch, base_rev }` — la forme que je
29
+ * SUPPOSAIS. Le cloud attend des champs À PLAT : id, entity_kind, entity_id, rev,
30
+ * sealed_b64, content_hash, meta, et quatre champs d'origine. Mon test de bout en bout
31
+ * n'a rien vu : il utilisait un `fetch` simulé, donc il validait mon hypothèse de l'API
32
+ * contre elle-même.
33
+ *
34
+ * C'est la même leçon que d'habitude, à un endroit nouveau : un contrat inter-services ne
35
+ * se vérifie que contre le service, jamais contre l'idée qu'on s'en fait.
36
+ *
37
+ * `sealed` est encodé en base64 d'un JSON canonique : le cloud le stocke sans jamais
38
+ * l'ouvrir — il n'en a pas la clé.
39
+ */
40
+ /** En-têtes communs : type, idempotence, et porteur si le déploiement l'exige. */
41
+ function authHeaders(idempotencyKey, apiKey) {
42
+ const headers = {
43
+ 'content-type': 'application/json',
44
+ // La clé d'idempotence voyage AUSSI en en-tête : le cloud doit pouvoir dédoublonner
45
+ // sans ouvrir le corps, qu'il ne peut de toute façon pas lire.
46
+ 'idempotency-key': idempotencyKey,
47
+ };
48
+ if (apiKey)
49
+ headers['authorization'] = `Bearer ${apiKey}`;
50
+ return headers;
51
+ }
52
+ /**
53
+ * Charge SIGNÉE DU TRANSPORT — reconstruite exactement comme le cloud la reconstruit.
54
+ *
55
+ * L'ordre des clés compte : le cloud fait `JSON.stringify` sur cet objet littéral, sans
56
+ * tri. Une clé déplacée produit une chaîne différente, donc un condensé différent, donc un
57
+ * refus SIG_PAYLOAD_HASH_MISMATCH que rien dans le message n'expliquerait.
58
+ */
59
+ function transportPayload(wire, baseRev) {
60
+ return JSON.stringify({
61
+ v: 1,
62
+ kind: 'brainclaw.federation.v2.envelope',
63
+ envelope_id: wire['id'],
64
+ project_id: wire['__project_id'],
65
+ entity_kind: wire['entity_kind'],
66
+ entity_id: wire['entity_id'],
67
+ rev: wire['rev'],
68
+ base_rev: baseRev,
69
+ content_hash: wire['content_hash'],
70
+ key_epoch: wire['key_epoch'],
71
+ is_tombstone: false,
72
+ });
73
+ }
74
+ /**
75
+ * Signe la charge de transport et renseigne les trois champs qui en dépendent.
76
+ *
77
+ * ── POURQUOI LE TRANSPORT SIGNE, ALORS QUE L'ENVELOPPE EST DÉJÀ SIGNÉE ───────
78
+ * Ce sont DEUX garanties distinctes. La signature d'enveloppe (RFC) lie meta ‖ sealed ‖
79
+ * key_epoch : elle dit « ce contenu vient de cet auteur ». La signature de transport lie
80
+ * envelope_id, rev, base_rev et content_hash : elle dit « cette opération-ci s'applique à
81
+ * CETTE révision », ce que la première ne peut pas dire — ces champs n'existent pas encore
82
+ * au moment de sceller.
83
+ *
84
+ * ── ET C'EST CE QUI DÉBLOQUE LE RECALAGE SUR 409 ────────────────────────────
85
+ * J'avais d'abord jugé les deux contrats incompatibles (dec#160 §6), parce que `base_rev`
86
+ * peut changer au réessai et invaliderait une signature calculée à l'émission. Faire signer
87
+ * le TRANSPORT lève l'objection : il resigne simplement avec la nouvelle valeur.
88
+ *
89
+ * Cette clé Ed25519 SIGNE, elle ne déchiffre rien : la propriété « ce module ne peut pas
90
+ * divulguer de clair » tient toujours.
91
+ */
92
+ function signTransport(wire, baseRev, identityPem) {
93
+ const payload = Buffer.from(transportPayload(wire, baseRev), 'utf-8');
94
+ const signature = crypto.sign(null, payload, crypto.createPrivateKey(identityPem));
95
+ const body = { ...wire, base_rev: baseRev };
96
+ delete body['__project_id'];
97
+ return {
98
+ ...body,
99
+ origin_sig: signature.toString('base64'),
100
+ origin_sig_payload_hash: crypto.createHash('sha256').update(payload).digest('hex'),
101
+ };
102
+ }
103
+ function toWireBody(entry) {
104
+ const env = entry.sealed;
105
+ const meta = env.meta;
106
+ const transport = (meta['transport'] ?? {});
107
+ // ── L'EMPREINTE PORTE SUR CE QUI EST RÉELLEMENT ENVOYÉ ──────────────────────
108
+ //
109
+ // Le cloud recalcule SHA-256 sur les octets DÉCODÉS de `sealed_b64`, en hexadécimal, et
110
+ // refuse tout écart (CONTENT_HASH_MISMATCH). Le `content_hash` du core est calculé
111
+ // autrement — base64url sur une autre sérialisation — donc le réutiliser tel quel
112
+ // échouait systématiquement.
113
+ //
114
+ // Le calculer ICI, sur les octets exacts qu'on encode, rend l'accord vrai PAR
115
+ // CONSTRUCTION plutôt que par coïncidence de conventions. Deux sérialisations qui
116
+ // doivent produire le même condensé sont un pari ; hacher ce qu'on envoie n'en est pas un.
117
+ const sealedBytes = Buffer.from(JSON.stringify(env.sealed), 'utf-8');
118
+ const contentHashHex = crypto.createHash('sha256').update(sealedBytes).digest('hex');
119
+ return {
120
+ id: String(transport['idempotency_key'] ?? entry.idempotency_key),
121
+ entity_kind: meta['kind'],
122
+ entity_id: meta['id_opaque'],
123
+ rev: String(meta['base_rev'] ?? entry.base_rev ?? 0),
124
+ // Aucune tête connue au premier envoi : le cloud l'annonce dans son 409 et l'appelant
125
+ // se recale une fois. Suivre cette tête localement dupliquerait un état dont le cloud
126
+ // est déjà l'autorité.
127
+ base_rev: null,
128
+ sealed_b64: sealedBytes.toString('base64'),
129
+ key_epoch: env.key_epoch,
130
+ content_hash: contentHashHex,
131
+ idempotency_key: String(transport['idempotency_key'] ?? entry.idempotency_key),
132
+ // `key_id` de l'enveloppe EST l'empreinte du signataire (cf. federation-emit).
133
+ // L'agent d'origine voyage à part : le cloud vérifie l'un ET l'autre.
134
+ origin_agent_id: entry.origin_agent_id ?? env.origin_sig.key_id,
135
+ // Renseignés par `signTransport` : ils dépendent de `base_rev`, connu seulement ici.
136
+ origin_sig: '',
137
+ origin_sig_payload_hash: '',
138
+ origin_signer_fingerprint: env.origin_sig.key_id,
139
+ meta,
140
+ // ── L'ENVELOPPE SIGNÉE, VERBATIM (dec#162) ──────────────────────────────────
141
+ //
142
+ // La signature de TRANSPORT ci-dessus prouve « cette opération s'applique à cette
143
+ // révision » ; elle ne prouve RIEN sur l'auteur du contenu. La signature d'AUTEUR
144
+ // (origin_sig sur meta‖sealed‖key_epoch, cf. buildEnvelope) est la seule qui le fasse —
145
+ // et `toWireBody` l'écrasait jusqu'ici en réutilisant le champ `origin_sig` pour le
146
+ // transport. Un lecteur (pull) ne pouvait donc pas vérifier qui a écrit l'enveloppe.
147
+ //
148
+ // On transporte l'enveloppe complète SANS LA TOUCHER. Le cloud la stocke telle quelle et
149
+ // la rend au pull ; le vérificateur y retrouve `origin_sig.value` d'auteur et
150
+ // recanonicalise meta/sealed lui-même (federation-inbound). Aucune donnée NOUVELLE ne
151
+ // fuit : `meta` partait déjà en clair au relais (ligne ci-dessus) — on ne fait que
152
+ // PERSISTER ce qui transitait déjà. Le champ n'entre pas dans la signature de transport :
153
+ // son intégrité est portée par la signature d'auteur qu'il contient.
154
+ envelope_json: JSON.stringify(entry.sealed),
155
+ };
156
+ }
157
+ /**
158
+ * Envoie les enveloppes en attente.
159
+ *
160
+ * REFUSE plutôt que de deviner : sans appairage actif, sans URL, la fonction lève. Pousser
161
+ * vers une adresse devinée enverrait des enveloppes chiffrées à un tiers — inintelligibles
162
+ * pour lui, mais c'est une fuite de métadonnées et de trafic, pas un non-événement.
163
+ */
164
+ export async function pushPending(options = {}) {
165
+ const cwd = options.cwd ?? process.cwd();
166
+ const doFetch = options.fetchImpl ?? fetch;
167
+ const result = { attempted: 0, sent: 0, conflicts: 0, failed: 0, errors: [] };
168
+ const state = loadConnectionState(cwd);
169
+ if (!state || state.enrollment.stage !== 'active') {
170
+ throw new Error('Aucun appairage actif : rien n\'est envoyé tant que l\'appairage n\'est pas confirmé localement.');
171
+ }
172
+ // MANQUE MESURÉ (2026-08-09) : `FederationConnectionState` ne persiste AUCUNE adresse de
173
+ // cloud — ni à la racine, ni dans `sync`. L'appairage prend `--url` puis l'oublie. Chaque
174
+ // commande doit donc la repasser. C'est un défaut d'ergonomie à corriger côté appairage,
175
+ // pas ici : deviner une adresse enverrait le trafic d'un projet à un tiers — illisible
176
+ // pour lui, mais une fuite de métadonnées et de trafic n'est pas un non-événement.
177
+ const base = (options.url ?? '').replace(/\/+$/, '');
178
+ if (!base) {
179
+ throw new Error('Adresse du cloud inconnue : passez --url. Elle n\'est pas conservée par l\'appairage ' +
180
+ '(état de connexion sans champ d\'URL), et aucune adresse n\'est devinée.');
181
+ }
182
+ // L'identité SIGNATAIRE du transport : celle de l'agent qui a produit les enveloppes.
183
+ // Elle est portée par l'entrée d'outbox, donc le transport n'a pas à deviner qui signe.
184
+ const agentId = options.agentId ?? list('pending', cwd)[0]?.origin_agent_id;
185
+ const identity = agentId ? loadAgentSigningKey(agentId) : undefined;
186
+ if (!identity) {
187
+ throw new Error('Identité de signature introuvable : le cloud vérifie une signature de TRANSPORT ' +
188
+ 'liant envelope_id, rev et base_rev. Sans elle, chaque envoi est refusé en 422.');
189
+ }
190
+ const identityPem = identity.privateKeyPem;
191
+ const pending = list('pending', cwd);
192
+ const batch = options.limit ? pending.slice(0, options.limit) : pending;
193
+ result.attempted = batch.length;
194
+ if (options.dryRun)
195
+ return result;
196
+ const endpoint = `${base}/api/v1/projects/${state.cloud_project_id}/projection/envelopes`;
197
+ for (const entry of batch) {
198
+ try {
199
+ const wire = { ...toWireBody(entry), __project_id: state.cloud_project_id };
200
+ let signed = signTransport(wire, null, identityPem);
201
+ let res = await doFetch(endpoint, {
202
+ method: 'POST',
203
+ headers: authHeaders(entry.idempotency_key, options.apiKey),
204
+ body: JSON.stringify(signed),
205
+ });
206
+ // ── RECALAGE SUR 409, UNE SEULE FOIS ──────────────────────────────────
207
+ //
208
+ // Le cloud applique une concurrence optimiste : `base_rev` doit désigner la tête
209
+ // courante. Un émetteur qui n'a jamais poussé ne CONNAÎT pas cette tête — et la
210
+ // suivre localement dupliquerait un état dont le cloud est déjà l'autorité.
211
+ //
212
+ // Le refus la contient (`expected_base_rev`) : on renvoie donc une fois avec la
213
+ // valeur annoncée. UNE SEULE, délibérément : boucler transformerait un désaccord
214
+ // réel en écrasement silencieux du travail d'un autre appareil.
215
+ if (res.status === 409) {
216
+ const detail = (await res.clone().json().catch(() => ({})));
217
+ const expected = detail['expected_base_rev'] ?? detail['expected'];
218
+ if (typeof expected === 'string' || typeof expected === 'number') {
219
+ // On RESIGNE avec la nouvelle base_rev : c'est précisément ce que la signature
220
+ // au niveau du transport rend possible, et qu'une signature figée à l'émission
221
+ // interdisait.
222
+ signed = signTransport(wire, String(expected), identityPem);
223
+ res = await doFetch(endpoint, {
224
+ method: 'POST',
225
+ headers: authHeaders(entry.idempotency_key, options.apiKey),
226
+ body: JSON.stringify(signed),
227
+ });
228
+ }
229
+ }
230
+ if (res.ok || res.status === 202) {
231
+ transition(entry.idempotency_key, 'pending', 'synced', cwd);
232
+ result.sent += 1;
233
+ continue;
234
+ }
235
+ if (res.status === 409) {
236
+ // Désaccord de révision : un renvoi à l'identique échouera pareil. L'entrée passe
237
+ // en `conflict` pour être VUE, pas retentée en boucle.
238
+ transition(entry.idempotency_key, 'pending', 'conflict', cwd, (e) => ({
239
+ ...e,
240
+ last_error: `409 conflit de révision (base_rev=${entry.base_rev})`,
241
+ }));
242
+ result.conflicts += 1;
243
+ result.errors.push({ idempotency_key: entry.idempotency_key, status: 409, reason: 'conflit de révision' });
244
+ continue;
245
+ }
246
+ const body = await res.text();
247
+ result.failed += 1;
248
+ result.errors.push({
249
+ idempotency_key: entry.idempotency_key,
250
+ status: res.status,
251
+ reason: body.slice(0, 200),
252
+ });
253
+ // RESTE en attente : un refus temporaire ne doit pas faire disparaître l'opération.
254
+ transition(entry.idempotency_key, 'pending', 'pending', cwd, (e) => ({
255
+ ...e,
256
+ attempts: e.attempts + 1,
257
+ last_error: `HTTP ${res.status}`,
258
+ }));
259
+ }
260
+ catch (err) {
261
+ const reason = err instanceof Error ? err.message : String(err);
262
+ result.failed += 1;
263
+ result.errors.push({ idempotency_key: entry.idempotency_key, reason });
264
+ transition(entry.idempotency_key, 'pending', 'pending', cwd, (e) => ({
265
+ ...e,
266
+ attempts: e.attempts + 1,
267
+ last_error: reason,
268
+ }));
269
+ logger.warn(`Envoi échoué (${entry.idempotency_key}) : ${reason}`);
270
+ }
271
+ }
272
+ return result;
273
+ }
274
+ //# sourceMappingURL=federation-push.js.map