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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-capture.js +15 -0
- package/dist/cli/register-cloud.js +121 -13
- package/dist/commands/cloud.js +534 -39
- package/dist/commands/loops-handlers.js +0 -1
- package/dist/commands/mcp-catalog.js +24 -256
- package/dist/commands/mcp-read-handlers.js +5 -1
- package/dist/commands/mcp-schemas.generated.js +811 -1
- package/dist/commands/mcp-write-coordination.js +16 -7
- package/dist/commands/mcp.js +45 -1
- package/dist/commands/memory-confirm.js +83 -0
- package/dist/commands/switch.js +24 -2
- package/dist/core/assignment-request-schema.js +112 -0
- package/dist/core/capture-schema.js +62 -0
- package/dist/core/claim-request-schema.js +72 -0
- package/dist/core/code-map/aggregate.js +36 -1
- package/dist/core/federation-emit.js +283 -0
- package/dist/core/federation-grant-transport.js +196 -0
- package/dist/core/federation-grant.js +223 -0
- package/dist/core/federation-keyring.js +39 -0
- package/dist/core/federation-opaque-ids.js +111 -0
- package/dist/core/federation-outbox-v2.js +36 -2
- package/dist/core/federation-pairing.js +87 -12
- package/dist/core/federation-pull.js +375 -0
- package/dist/core/federation-push.js +274 -0
- package/dist/core/federation-rotation.js +124 -0
- package/dist/core/federation-state.js +81 -6
- package/dist/core/sequence-request-schema.js +93 -0
- package/dist/core/session-request-schema.js +90 -0
- package/dist/core/step-request-schema.js +112 -0
- package/dist/core/store-resolution.js +34 -5
- package/dist/core/warnings.js +37 -0
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/design/federation-onboarding-usecases.md +254 -0
- package/docs/design/pairing-v3-brief.md +80 -0
- package/docs/integrations/mcp.md +1 -1
- 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
|