brainclaw 1.23.0 → 1.25.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-cloud.js +121 -13
- package/dist/cli/register-code-map.js +9 -2
- package/dist/commands/cloud.js +534 -39
- package/dist/commands/code-map.js +119 -2
- package/dist/commands/mcp-catalog.js +46 -0
- package/dist/commands/mcp.js +54 -2
- package/dist/core/code-map/backend.js +158 -1
- package/dist/core/code-map/export.js +212 -0
- package/dist/core/code-map/freshness.js +3 -2
- package/dist/core/code-map/impact.js +377 -0
- package/dist/core/code-map/indexes.js +27 -3
- package/dist/core/code-map/lang/typescript/config.js +271 -0
- package/dist/core/code-map/lang/typescript/index.js +20 -4
- package/dist/core/code-map/query.js +76 -13
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +1 -0
- package/dist/core/code-map/types.js +15 -0
- 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 +523 -0
- package/dist/core/federation-push.js +287 -0
- package/dist/core/federation-rotation.js +124 -0
- package/dist/core/federation-state.js +81 -6
- package/dist/core/protocol-tool-policy.js +3 -0
- package/dist/core/worktree.js +89 -2
- package/dist/facts.js +14 -11
- package/dist/facts.json +13 -10
- package/docs/cli.md +8 -0
- package/docs/code-map.md +24 -1
- package/docs/design/federation-onboarding-usecases.md +254 -0
- package/docs/design/pairing-v3-brief.md +80 -0
- package/docs/integrations/mcp.md +5 -2
- package/docs/mcp-schema-changelog.md +11 -1
- package/package.json +1 -1
|
@@ -0,0 +1,523 @@
|
|
|
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 { createConstraint, createDecision, createTrap } from './operations/memory-write.js';
|
|
14
|
+
import { updateMemoryItem } from './operations/memory-mutation.js';
|
|
15
|
+
import { createSequence, updateSequence } from './sequence.js';
|
|
16
|
+
import { generateRuntimeNoteId, listRuntimeNotes, saveRuntimeNote } from './runtime.js';
|
|
17
|
+
import { HandoffSchema } from './schema.js';
|
|
18
|
+
import { generateIdWithLabel, nowISO } from './ids.js';
|
|
19
|
+
import { mutateState } from './state.js';
|
|
20
|
+
import { memoryDir, writeFileAtomic } from './io.js';
|
|
21
|
+
import { loadConnectionState, recordRevision, saveConnectionState } from './federation-state.js';
|
|
22
|
+
const INBOUND_SCHEMA = 'brainclaw.federation-inbound-pull/v1';
|
|
23
|
+
const INBOUND_FILE = 'inbound-pull.json';
|
|
24
|
+
/**
|
|
25
|
+
* Limite transitoire, volontairement rendue à l'UI : le roster signé de dec#159
|
|
26
|
+
* n'existe pas encore. Tirer les attestations du cloud demande donc au relais qui
|
|
27
|
+
* surveiller ; la signature protège le contenu, pas cette sélection de clés.
|
|
28
|
+
*/
|
|
29
|
+
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.';
|
|
30
|
+
function journalPath(cwd) {
|
|
31
|
+
return path.join(memoryDir(cwd), 'coordination', 'federation', INBOUND_FILE);
|
|
32
|
+
}
|
|
33
|
+
function loadJournal(cwd) {
|
|
34
|
+
const file = journalPath(cwd);
|
|
35
|
+
if (!fs.existsSync(file))
|
|
36
|
+
return { schema: INBOUND_SCHEMA, seen: [], pending: {} };
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
39
|
+
if (parsed.schema !== INBOUND_SCHEMA || !Array.isArray(parsed.seen) || !parsed.pending || typeof parsed.pending !== 'object') {
|
|
40
|
+
return { schema: INBOUND_SCHEMA, seen: [], pending: {} };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
schema: INBOUND_SCHEMA,
|
|
44
|
+
seen: parsed.seen.filter((key) => typeof key === 'string'),
|
|
45
|
+
pending: parsed.pending,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Un journal local corrompu n'est jamais une preuve qu'un message a été appliqué.
|
|
50
|
+
return { schema: INBOUND_SCHEMA, seen: [], pending: {} };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function saveJournal(journal, cwd) {
|
|
54
|
+
const file = journalPath(cwd);
|
|
55
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
56
|
+
writeFileAtomic(file, `${JSON.stringify(journal, null, 2)}\n`);
|
|
57
|
+
}
|
|
58
|
+
function asRecord(value) {
|
|
59
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
60
|
+
? value
|
|
61
|
+
: undefined;
|
|
62
|
+
}
|
|
63
|
+
function rawKey(raw) {
|
|
64
|
+
// Déduplication de stockage uniquement — jamais une décision d'authenticité.
|
|
65
|
+
return crypto.createHash('sha256').update(JSON.stringify(raw)).digest('hex');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Le cloud rend une LIGNE À PLAT dont un champ, `envelope_json`, porte l'enveloppe signée
|
|
69
|
+
* verbatim (dec#162). C'est ELLE que le vérificateur doit parser — la ligne plate n'a ni
|
|
70
|
+
* la forme imbriquée de FederationEnvelopeSchema ni la signature d'AUTEUR. Sans
|
|
71
|
+
* `envelope_json` (enveloppe poussée avant dec#162), on laisse passer l'objet tel quel :
|
|
72
|
+
* il échouera en `schema_invalid`, ce qui est le verdict juste — non vérifiable.
|
|
73
|
+
*/
|
|
74
|
+
function toEnvelope(item) {
|
|
75
|
+
const record = asRecord(item);
|
|
76
|
+
if (record && typeof record['envelope_json'] === 'string') {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(record['envelope_json']);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return item;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return item;
|
|
85
|
+
}
|
|
86
|
+
function responseArray(value, fields) {
|
|
87
|
+
const record = asRecord(value);
|
|
88
|
+
if (!record)
|
|
89
|
+
return undefined;
|
|
90
|
+
for (const field of fields)
|
|
91
|
+
if (Array.isArray(record[field]))
|
|
92
|
+
return record[field];
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
function parseDelta(body) {
|
|
96
|
+
if (Array.isArray(body))
|
|
97
|
+
return { envelopes: body };
|
|
98
|
+
const root = asRecord(body);
|
|
99
|
+
if (!root)
|
|
100
|
+
throw new Error('Delta cloud invalide : objet JSON attendu.');
|
|
101
|
+
const source = asRecord(root['data']) ?? asRecord(root['delta']) ?? root;
|
|
102
|
+
const envelopes = responseArray(source, ['envelopes', 'items', 'results']);
|
|
103
|
+
if (!envelopes)
|
|
104
|
+
throw new Error('Delta cloud invalide : tableau envelopes attendu.');
|
|
105
|
+
const cursor = source['next_seq'] ?? source['next_cursor'] ?? source['nextCursor'] ?? source['cursor'];
|
|
106
|
+
return { envelopes, cursor: typeof cursor === 'string' || typeof cursor === 'number' ? String(cursor) : undefined };
|
|
107
|
+
}
|
|
108
|
+
/** Lit les attestations cloud. Ce n'est PAS un roster signé : voir la constante exportée. */
|
|
109
|
+
function parseCloudRoster(body) {
|
|
110
|
+
const root = asRecord(body);
|
|
111
|
+
if (!root)
|
|
112
|
+
throw new Error('Roster cloud invalide : objet JSON attendu.');
|
|
113
|
+
const source = asRecord(root['data']) ?? root;
|
|
114
|
+
const keys = new Map();
|
|
115
|
+
const revoked = new Set();
|
|
116
|
+
const compact = asRecord(source['keys']);
|
|
117
|
+
if (compact) {
|
|
118
|
+
for (const [id, pem] of Object.entries(compact))
|
|
119
|
+
if (typeof pem === 'string')
|
|
120
|
+
keys.set(id, pem);
|
|
121
|
+
}
|
|
122
|
+
for (const value of responseArray(source, ['attestations', 'members', 'roster', 'items']) ?? []) {
|
|
123
|
+
const row = asRecord(value);
|
|
124
|
+
if (!row)
|
|
125
|
+
continue;
|
|
126
|
+
const id = row['key_id'] ?? row['identity_fingerprint'] ?? row['signer_fingerprint'];
|
|
127
|
+
const pem = row['identity_public_key_pem'] ?? row['ed25519_public_key_pem'] ?? row['public_key_pem'];
|
|
128
|
+
if (typeof id !== 'string' || typeof pem !== 'string')
|
|
129
|
+
continue;
|
|
130
|
+
keys.set(id, pem);
|
|
131
|
+
if (row['revoked'] === true || row['revoked_at'])
|
|
132
|
+
revoked.add(id);
|
|
133
|
+
}
|
|
134
|
+
return { keys, revoked };
|
|
135
|
+
}
|
|
136
|
+
function headers(apiKey) {
|
|
137
|
+
return apiKey ? { accept: 'application/json', authorization: `Bearer ${apiKey}` } : { accept: 'application/json' };
|
|
138
|
+
}
|
|
139
|
+
async function readJson(res, label) {
|
|
140
|
+
if (!res.ok)
|
|
141
|
+
throw new Error(`${label} refusé par le cloud (HTTP ${res.status}) : ${(await res.text()).slice(0, 200)}`);
|
|
142
|
+
try {
|
|
143
|
+
return await res.json();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new Error(`${label} invalide : JSON attendu.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function problem(raw, reason) {
|
|
150
|
+
const env = asRecord(raw);
|
|
151
|
+
const meta = asRecord(env?.['meta']);
|
|
152
|
+
const transport = asRecord(meta?.['transport']);
|
|
153
|
+
return {
|
|
154
|
+
idempotency_key: typeof transport?.['idempotency_key'] === 'string' ? transport['idempotency_key'] : undefined,
|
|
155
|
+
key_epoch: typeof env?.['key_epoch'] === 'number' ? env['key_epoch'] : undefined,
|
|
156
|
+
reason,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function contentOf(value) {
|
|
160
|
+
const content = asRecord(value);
|
|
161
|
+
if (!content || typeof content['text'] !== 'string')
|
|
162
|
+
throw new Error('clair vérifié non matérialisable : text est attendu.');
|
|
163
|
+
return content;
|
|
164
|
+
}
|
|
165
|
+
function tagsOf(content) {
|
|
166
|
+
return Array.isArray(content['tags']) && content['tags'].every((tag) => typeof tag === 'string')
|
|
167
|
+
? content['tags']
|
|
168
|
+
: undefined;
|
|
169
|
+
}
|
|
170
|
+
function priorityOf(value) {
|
|
171
|
+
return value === 'low' || value === 'medium' || value === 'high' || value === 'critical' ? value : undefined;
|
|
172
|
+
}
|
|
173
|
+
function statusOf(value, accepted) {
|
|
174
|
+
return typeof value === 'string' && accepted.includes(value) ? value : undefined;
|
|
175
|
+
}
|
|
176
|
+
function authorOf(accepted) {
|
|
177
|
+
// L'identité de signature a été vérifiée par verifyInboundBatch contre le roster :
|
|
178
|
+
// elle est sûre à conserver comme provenance locale, sans prétendre connaître un nom.
|
|
179
|
+
return `federation:${accepted.envelope.origin_sig.key_id}`;
|
|
180
|
+
}
|
|
181
|
+
function textAndTagsPatch(text, tags) {
|
|
182
|
+
return { text, ...(tags ? { tags } : {}) };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Handoff has no standalone create operation yet. This goes through mutateState, the same
|
|
186
|
+
* canonical mutation pipeline used by its lifecycle operations: it never writes an entity
|
|
187
|
+
* JSON file directly. The remote projection does not carry `from`/`to`, so their local
|
|
188
|
+
* receiver values deliberately describe the federation hop rather than invent source data.
|
|
189
|
+
*/
|
|
190
|
+
function saveFederatedHandoff(input, cwd) {
|
|
191
|
+
if (input.id) {
|
|
192
|
+
mutateState((state) => {
|
|
193
|
+
const current = state.open_handoffs.find((handoff) => handoff.id === input.id);
|
|
194
|
+
if (!current)
|
|
195
|
+
throw new Error(`handoff with id '${input.id}' not found locally despite its opaque mapping`);
|
|
196
|
+
const next = HandoffSchema.parse({
|
|
197
|
+
...current,
|
|
198
|
+
text: input.text,
|
|
199
|
+
tags: input.tags ?? current.tags,
|
|
200
|
+
status: input.status ?? current.status,
|
|
201
|
+
});
|
|
202
|
+
Object.assign(current, next);
|
|
203
|
+
}, cwd);
|
|
204
|
+
return input.id;
|
|
205
|
+
}
|
|
206
|
+
const { id, short_label } = generateIdWithLabel('open_handoffs', cwd);
|
|
207
|
+
mutateState((state) => {
|
|
208
|
+
state.open_handoffs.push(HandoffSchema.parse({
|
|
209
|
+
id,
|
|
210
|
+
short_label,
|
|
211
|
+
from: input.author,
|
|
212
|
+
to: 'local',
|
|
213
|
+
text: input.text,
|
|
214
|
+
created_at: nowISO(),
|
|
215
|
+
author: input.author,
|
|
216
|
+
status: input.status ?? 'open',
|
|
217
|
+
tags: input.tags ?? [],
|
|
218
|
+
}));
|
|
219
|
+
}, cwd);
|
|
220
|
+
return id;
|
|
221
|
+
}
|
|
222
|
+
class DeferredMaterialization extends Error {
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Passe par les opérations métier (donc leur pipeline de mutation/verrous), jamais par
|
|
226
|
+
* l'écriture d'un JSON d'entité. L'opaque est mappé seulement APRES la mutation réussie.
|
|
227
|
+
*/
|
|
228
|
+
function materialize(accepted, state, cwd) {
|
|
229
|
+
const opaque = accepted.envelope.meta.id_opaque;
|
|
230
|
+
const existing = localIdForOpaque(state.cloud_project_id, opaque, cwd);
|
|
231
|
+
const content = contentOf(accepted.content);
|
|
232
|
+
const priority = priorityOf(accepted.envelope.meta.priority);
|
|
233
|
+
const tags = tagsOf(content);
|
|
234
|
+
if (accepted.kind === 'plan') {
|
|
235
|
+
if (existing) {
|
|
236
|
+
updatePlan({
|
|
237
|
+
id: existing,
|
|
238
|
+
status: accepted.envelope.meta.status.object,
|
|
239
|
+
priority,
|
|
240
|
+
patch: { text: content['text'], tags },
|
|
241
|
+
}, cwd);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const created = createPlan({
|
|
245
|
+
text: content['text'],
|
|
246
|
+
author: 'federation',
|
|
247
|
+
type: typeof content['type'] === 'string' ? content['type'] : undefined,
|
|
248
|
+
priority,
|
|
249
|
+
tags,
|
|
250
|
+
}, cwd);
|
|
251
|
+
rememberOpaqueId(state.cloud_project_id, created.id, opaque, cwd);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (accepted.kind === 'plan_step') {
|
|
255
|
+
const parentOpaque = accepted.envelope.meta.deps.find((dependency) => dependency.from === opaque)?.to;
|
|
256
|
+
const parent = parentOpaque ? localIdForOpaque(state.cloud_project_id, parentOpaque, cwd) : undefined;
|
|
257
|
+
if (!parent)
|
|
258
|
+
throw new DeferredMaterialization('étape reçue avant son plan parent ; conservée pour relecture.');
|
|
259
|
+
if (existing) {
|
|
260
|
+
updateStep({ stepId: existing, planId: parent, text: content['text'], status: accepted.envelope.meta.status.object }, cwd);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const created = addStep({
|
|
264
|
+
planId: parent,
|
|
265
|
+
text: content['text'],
|
|
266
|
+
assignee: typeof content['assignee'] === 'string' ? content['assignee'] : undefined,
|
|
267
|
+
}, cwd);
|
|
268
|
+
rememberOpaqueId(state.cloud_project_id, created.stepId, opaque, cwd);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const author = authorOf(accepted);
|
|
272
|
+
const text = content['text'];
|
|
273
|
+
if (accepted.kind === 'decision') {
|
|
274
|
+
if (existing) {
|
|
275
|
+
updateMemoryItem({ id: existing, type: 'decision', patch: textAndTagsPatch(text, tags) }, cwd);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const created = createDecision({ text, author, tags }, cwd);
|
|
279
|
+
rememberOpaqueId(state.cloud_project_id, created.id, opaque, cwd);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (accepted.kind === 'constraint') {
|
|
283
|
+
const status = statusOf(accepted.envelope.meta.status.object, ['active', 'resolved', 'expired']);
|
|
284
|
+
if (existing) {
|
|
285
|
+
updateMemoryItem({
|
|
286
|
+
id: existing,
|
|
287
|
+
type: 'constraint',
|
|
288
|
+
patch: { ...textAndTagsPatch(text, tags), ...(status ? { status } : {}) },
|
|
289
|
+
}, cwd);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const created = createConstraint({ text, author, tags }, cwd);
|
|
293
|
+
// createConstraint correctly owns ID/provenance creation; its lifecycle starts at active,
|
|
294
|
+
// so apply a projected terminal state through the same mutation path afterwards.
|
|
295
|
+
if (status && status !== 'active') {
|
|
296
|
+
updateMemoryItem({ id: created.id, type: 'constraint', patch: { status } }, cwd);
|
|
297
|
+
}
|
|
298
|
+
rememberOpaqueId(state.cloud_project_id, created.id, opaque, cwd);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (accepted.kind === 'trap') {
|
|
302
|
+
const status = statusOf(accepted.envelope.meta.status.object, ['active', 'resolved', 'expired']);
|
|
303
|
+
const severity = accepted.envelope.meta.priority === 'low' || accepted.envelope.meta.priority === 'medium'
|
|
304
|
+
? accepted.envelope.meta.priority
|
|
305
|
+
: accepted.envelope.meta.priority === 'high' || accepted.envelope.meta.priority === 'critical'
|
|
306
|
+
? 'high'
|
|
307
|
+
: undefined;
|
|
308
|
+
if (existing) {
|
|
309
|
+
updateMemoryItem({
|
|
310
|
+
id: existing,
|
|
311
|
+
type: 'trap',
|
|
312
|
+
patch: {
|
|
313
|
+
...textAndTagsPatch(text, tags),
|
|
314
|
+
...(status ? { status } : {}),
|
|
315
|
+
...(severity ? { severity } : {}),
|
|
316
|
+
},
|
|
317
|
+
}, cwd);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const created = createTrap({ text, author, tags, status, severity }, cwd);
|
|
321
|
+
rememberOpaqueId(state.cloud_project_id, created.id, opaque, cwd);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (accepted.kind === 'handoff') {
|
|
325
|
+
const status = statusOf(accepted.envelope.meta.status.object, ['open', 'accepted', 'closed']);
|
|
326
|
+
const id = saveFederatedHandoff({ id: existing, text, tags, status, author }, cwd);
|
|
327
|
+
if (!existing)
|
|
328
|
+
rememberOpaqueId(state.cloud_project_id, id, opaque, cwd);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (accepted.kind === 'sequence') {
|
|
332
|
+
const status = statusOf(accepted.envelope.meta.status.object, ['draft', 'active', 'archived']);
|
|
333
|
+
if (existing) {
|
|
334
|
+
updateSequence({ id: existing, name: text, tags, status }, cwd);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const created = createSequence({ name: text, author, tags, status }, cwd);
|
|
338
|
+
rememberOpaqueId(state.cloud_project_id, created.id, opaque, cwd);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (accepted.kind === 'runtime_note') {
|
|
342
|
+
if (existing) {
|
|
343
|
+
const current = listRuntimeNotes({ visibility: 'all', includeAllHosts: true }, cwd)
|
|
344
|
+
.find((note) => note.id === existing);
|
|
345
|
+
if (!current)
|
|
346
|
+
throw new Error(`runtime_note with id '${existing}' not found locally despite its opaque mapping`);
|
|
347
|
+
saveRuntimeNote({ ...current, text, tags: tags ?? current.tags }, cwd);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const id = generateRuntimeNoteId();
|
|
351
|
+
saveRuntimeNote({
|
|
352
|
+
id,
|
|
353
|
+
agent: 'federation',
|
|
354
|
+
agent_id: accepted.envelope.origin_sig.key_id,
|
|
355
|
+
text,
|
|
356
|
+
created_at: nowISO(),
|
|
357
|
+
tags: tags ?? [],
|
|
358
|
+
visibility: 'shared',
|
|
359
|
+
note_type: 'observation',
|
|
360
|
+
}, cwd);
|
|
361
|
+
rememberOpaqueId(state.cloud_project_id, id, opaque, cwd);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Les familles hors projection restent dans le journal : les accepter dans high_water
|
|
365
|
+
// les ferait disparaître du feed sans jamais atteindre le magasin local.
|
|
366
|
+
throw new DeferredMaterialization(`kind '${accepted.kind}' sans mutation canonique de réception.`);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* GET du delta + attestations, vérification du lot et matérialisation. Une absence de clé
|
|
370
|
+
* d'epoch est le seul échec conservé explicitement comme « illisible » : l'enveloppe reste
|
|
371
|
+
* dans le journal entrant et sera repassée à verifyInboundBatch après remise de la clé.
|
|
372
|
+
*/
|
|
373
|
+
export async function pullFederationDelta(options) {
|
|
374
|
+
const cwd = options.cwd ?? process.cwd();
|
|
375
|
+
const current = loadConnectionState(cwd);
|
|
376
|
+
if (!current || current.enrollment.stage !== 'active') {
|
|
377
|
+
throw new Error("Aucun appairage actif : un pull n'est autorisé qu'après approbation locale.");
|
|
378
|
+
}
|
|
379
|
+
const base = options.url.replace(/\/+$/, '');
|
|
380
|
+
if (!base)
|
|
381
|
+
throw new Error('Adresse du cloud absente : aucune origine ne sera devinée.');
|
|
382
|
+
const root = `${base}/api/v1/projects/${encodeURIComponent(current.cloud_project_id)}`;
|
|
383
|
+
const query = new URLSearchParams();
|
|
384
|
+
// Contrat réel de handleListEnvelopes : since_seq est un curseur exclusif et
|
|
385
|
+
// include_sealed est indispensable au déchiffrement local.
|
|
386
|
+
if (current.sync.feed_cursor)
|
|
387
|
+
query.set('since_seq', current.sync.feed_cursor);
|
|
388
|
+
query.set('include_sealed', 'true');
|
|
389
|
+
if (options.limit !== undefined)
|
|
390
|
+
query.set('limit', String(options.limit));
|
|
391
|
+
const deltaUrl = `${root}/projection/envelopes${query.size ? `?${query}` : ''}`;
|
|
392
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
393
|
+
const delta = parseDelta(await readJson(await doFetch(deltaUrl, { headers: headers(options.apiKey) }), 'Delta'));
|
|
394
|
+
// ROSTER PROVISOIRE : le commentaire et le résultat nomment la limite dec#159.
|
|
395
|
+
// Une erreur de roster est fatale AVANT le lot : remplacer la liste par une liste vide
|
|
396
|
+
// ferait passer les refus unknown_signer pour des échecs ordinaires et masquerait la
|
|
397
|
+
// vraie borne de confiance.
|
|
398
|
+
let roster;
|
|
399
|
+
try {
|
|
400
|
+
// `/projection/roster` (dec#162) : joignable par la clé d'API de l'agent et rendant
|
|
401
|
+
// {empreinte -> PEM Ed25519}. `/attestations` ne convenait pas — withUserAuth (une clé
|
|
402
|
+
// d'agent y est refusée) et sans le PEM public dont dépend la vérification de signature.
|
|
403
|
+
roster = parseCloudRoster(await readJson(await doFetch(`${root}/projection/roster`, { headers: headers(options.apiKey) }), 'Roster'));
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
407
|
+
throw new Error(`${CLOUD_ROSTER_LIMITATION} Roster indisponible : ${detail}`, { cause: err });
|
|
408
|
+
}
|
|
409
|
+
const journal = loadJournal(cwd);
|
|
410
|
+
const pending = { ...journal.pending };
|
|
411
|
+
const combined = new Map();
|
|
412
|
+
// Les entrées en attente sont DÉJÀ des enveloppes (parsées au tour précédent) ; le delta
|
|
413
|
+
// frais arrive à plat et doit être déballé de `envelope_json` avant toute vérification.
|
|
414
|
+
for (const [key, entry] of Object.entries(pending))
|
|
415
|
+
combined.set(key, entry.raw);
|
|
416
|
+
for (const item of delta.envelopes) {
|
|
417
|
+
const envelope = toEnvelope(item);
|
|
418
|
+
combined.set(rawKey(envelope), envelope);
|
|
419
|
+
}
|
|
420
|
+
const entries = [...combined.entries()];
|
|
421
|
+
if (entries.length > 0 && roster.keys.size === 0) {
|
|
422
|
+
throw new Error(`${CLOUD_ROSTER_LIMITATION} Le endpoint n'a fourni aucune clé Ed25519 publiquement vérifiable ; aucun clair ne sera matérialisé.`);
|
|
423
|
+
}
|
|
424
|
+
// ── RÉCEPTION DES REMISES DE CLÉS, AVANT DE CHOISIR LES CLÉS D'EPOCH (pln#658) ──
|
|
425
|
+
//
|
|
426
|
+
// L'ORDRE EST LE POINT : une clé reçue MAINTENANT rend lisibles, DANS LE MÊME PULL, les
|
|
427
|
+
// enveloppes conservées comme « illisibles, epoch absent ». Recevoir après aurait obligé
|
|
428
|
+
// à un second pull pour que la remise produise son effet — et l'opérateur aurait vu un
|
|
429
|
+
// « 0 matérialisé » juste après avoir reçu ses clés.
|
|
430
|
+
//
|
|
431
|
+
// Le roster des custodians est celui des identités attestées actives (dec#163 §2 : tout
|
|
432
|
+
// détenteur actif peut remettre). Un échec de réception n'interrompt PAS le pull : les
|
|
433
|
+
// enveloppes déjà lisibles doivent arriver même si la remise de clés échoue.
|
|
434
|
+
const grantOutcome = { stored: [], rejected: [] };
|
|
435
|
+
try {
|
|
436
|
+
const { receiveEpochGrants } = await import('./federation-grant-transport.js');
|
|
437
|
+
const received = await receiveEpochGrants({
|
|
438
|
+
cwd,
|
|
439
|
+
url: base,
|
|
440
|
+
apiKey: options.apiKey,
|
|
441
|
+
fetchImpl: options.fetchImpl,
|
|
442
|
+
recipientDeviceId: current.device.device_id,
|
|
443
|
+
activeCustodians: roster.keys,
|
|
444
|
+
});
|
|
445
|
+
grantOutcome.stored = received.stored;
|
|
446
|
+
grantOutcome.rejected = received.rejected;
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
grantOutcome.rejected.push({
|
|
450
|
+
reason: 'unavailable',
|
|
451
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
const epochKeys = new Map();
|
|
455
|
+
for (const [, raw] of entries) {
|
|
456
|
+
const epoch = asRecord(raw)?.['key_epoch'];
|
|
457
|
+
if (typeof epoch === 'number' && !epochKeys.has(epoch)) {
|
|
458
|
+
const key = options.epochKeyFor
|
|
459
|
+
? options.epochKeyFor(current.cloud_project_id, epoch)
|
|
460
|
+
: loadEpochPrivateKey(current.cloud_project_id, epoch);
|
|
461
|
+
if (key)
|
|
462
|
+
epochKeys.set(epoch, key);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const batch = verifyInboundBatch({
|
|
466
|
+
envelopes: entries.map(([, raw]) => raw), roster, state: current, epochKeys,
|
|
467
|
+
seenIdempotencyKeys: new Set(journal.seen),
|
|
468
|
+
});
|
|
469
|
+
const result = {
|
|
470
|
+
received: delta.envelopes.length, verified: batch.accepted, materialized: 0,
|
|
471
|
+
unreadable_epoch_absent: [], rejected: [], deferred: [], retained: 0,
|
|
472
|
+
feed_cursor: delta.cursor, roster_limitation: CLOUD_ROSTER_LIMITATION,
|
|
473
|
+
epoch_keys_received: grantOutcome.stored,
|
|
474
|
+
epoch_keys_rejected: grantOutcome.rejected,
|
|
475
|
+
};
|
|
476
|
+
let next = current;
|
|
477
|
+
const seen = new Set(journal.seen);
|
|
478
|
+
for (const [index, verdict] of batch.results.entries()) {
|
|
479
|
+
const [key, raw] = entries[index];
|
|
480
|
+
if (!verdict.ok) {
|
|
481
|
+
const item = problem(raw, verdict.detail);
|
|
482
|
+
if (verdict.reason === 'undecryptable' && item.key_epoch !== undefined && !epochKeys.has(item.key_epoch)) {
|
|
483
|
+
pending[key] = { raw, key_epoch: item.key_epoch, received_at: pending[key]?.received_at ?? nowISO() };
|
|
484
|
+
result.unreadable_epoch_absent.push({ ...item, reason: `reçue, illisible : epoch ${item.key_epoch} absent ; conservée pour relecture après remise de clé.` });
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
delete pending[key];
|
|
488
|
+
result.rejected.push({ ...item, reason: `${verdict.reason}: ${verdict.detail}` });
|
|
489
|
+
}
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
materialize(verdict, next, cwd);
|
|
494
|
+
delete pending[key];
|
|
495
|
+
seen.add(verdict.idempotencyKey);
|
|
496
|
+
next = recordRevision(next, verdict.envelope.meta.id_opaque, verdict.envelope.meta.base_rev);
|
|
497
|
+
result.materialized++;
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
501
|
+
pending[key] = { raw, key_epoch: verdict.envelope.key_epoch, received_at: pending[key]?.received_at ?? nowISO() };
|
|
502
|
+
if (err instanceof DeferredMaterialization)
|
|
503
|
+
result.deferred.push(problem(raw, reason));
|
|
504
|
+
else
|
|
505
|
+
result.rejected.push(problem(raw, `matérialisation: ${reason} (conservée pour reprise)`));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// Le curseur n'est qu'une optimisation ; la barrière anti-rejeu persistée dans sync
|
|
509
|
+
// porte la sûreté. Le journal est écrit avant l'état/cursor afin que les epochs absents
|
|
510
|
+
// restent relisibles même si le cloud ne les retourne plus dans le prochain delta.
|
|
511
|
+
saveJournal({ schema: INBOUND_SCHEMA, seen: [...seen], pending }, cwd);
|
|
512
|
+
saveConnectionState({
|
|
513
|
+
...next,
|
|
514
|
+
sync: {
|
|
515
|
+
...next.sync,
|
|
516
|
+
feed_cursor: delta.cursor ?? next.sync.feed_cursor,
|
|
517
|
+
last_pull_at: nowISO(),
|
|
518
|
+
},
|
|
519
|
+
}, cwd);
|
|
520
|
+
result.retained = Object.keys(pending).length;
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
523
|
+
//# sourceMappingURL=federation-pull.js.map
|