brainclaw 1.21.0 → 1.22.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 +63 -0
- package/dist/cli.js +2 -3
- package/dist/commands/cloud.js +198 -0
- package/dist/commands/session-end.js +0 -102
- package/dist/commands/session-start.js +0 -23
- package/dist/core/claims.js +0 -18
- package/dist/core/coordination.js +1 -3
- package/dist/core/federation-attestation.js +96 -0
- package/dist/core/federation-canonical.js +95 -0
- package/dist/core/federation-hpke.js +213 -0
- package/dist/core/federation-inbound.js +187 -0
- package/dist/core/federation-keyring.js +241 -0
- package/dist/core/federation-message.js +5 -5
- package/dist/core/federation-outbox-v2.js +125 -0
- package/dist/core/federation-pairing.js +213 -0
- package/dist/core/federation-projection.js +336 -0
- package/dist/core/federation-relay.js +223 -0
- package/dist/core/federation-state.js +270 -0
- package/dist/core/ids.js +5 -0
- package/dist/core/schema.js +0 -17
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/docs/cli.md +70 -39
- package/docs/concepts/federation-v2-rfc.md +275 -0
- package/docs/index.md +1 -0
- package/package.json +2 -2
- package/dist/cli/register-federation.js +0 -258
- package/dist/core/federation-cloud.js +0 -245
- package/dist/core/federation-outbox.js +0 -292
- package/dist/core/federation-signing.js +0 -115
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Le projecteur et ses trois filets — fédération v2 (pln#651 étape 5, RFC §4).
|
|
3
|
+
*
|
|
4
|
+
* C'est ici que la table de classification devient EXÉCUTABLE. Trois classes, et
|
|
5
|
+
* « non classé » n'en est pas une quatrième : c'est un refus.
|
|
6
|
+
*
|
|
7
|
+
* ── POURQUOI TROIS FILETS ET NON UN SEUL ──────────────────────────────────────
|
|
8
|
+
* Établi par quatre critiques sur quatre pendant l'idéation, chacune par un angle
|
|
9
|
+
* différent :
|
|
10
|
+
* (i) Zod fait `.strip()` PAR DÉFAUT — un champ nouveau passe la validation en
|
|
11
|
+
* silence, sans jamais lever ;
|
|
12
|
+
* (ii) un test couvre la SORTIE, pas la CONSTRUCTION — N projecteurs, un seul testé,
|
|
13
|
+
* et les N-1 autres fuient (application littérale de trp#5a8fb7d9) ;
|
|
14
|
+
* (iii) `{...entity, ciphertext}` compile parfaitement et déverse tout.
|
|
15
|
+
*
|
|
16
|
+
* Et le typage seul ne borne RIEN. `pushToCloud(payload: { id: string })` suivi d'un
|
|
17
|
+
* `JSON.stringify` sérialise chaque clé présente à L'EXÉCUTION : le typage structurel de
|
|
18
|
+
* TypeScript est une BORNE INFÉRIEURE sur ce que l'objet contient, jamais une borne
|
|
19
|
+
* supérieure. C'est la raison pour laquelle le filet 2 existe malgré le filet 1.
|
|
20
|
+
*
|
|
21
|
+
* FILET 1 — un builder nominal, marque de type non fabricable ailleurs, sélection
|
|
22
|
+
* explicite champ par champ, JAMAIS de spread.
|
|
23
|
+
* FILET 2 — un parse `.strict()` à UN SEUL point de sortie que tout push traverse.
|
|
24
|
+
* FILET 3 — fixture golden byte-exacte + test de complétude sur l'inventaire.
|
|
25
|
+
*
|
|
26
|
+
* ── CE QUI RESTE VISIBLE, ÉCRIT ET NON MASQUÉ ─────────────────────────────────
|
|
27
|
+
* « Lane 3 » cache le libellé, pas le GRAPHE. Le nombre de lanes, la forme du graphe de
|
|
28
|
+
* dépendances et la cardinalité restent lisibles par le Cloud. C'est assumé : le board
|
|
29
|
+
* aveugle rend une structure, et une structure est une information. Le prétendre
|
|
30
|
+
* autrement serait mentir sur la garantie.
|
|
31
|
+
*/
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
import { z } from 'zod';
|
|
34
|
+
import { canonicalJson, canonicalSha256, b64url } from './federation-canonical.js';
|
|
35
|
+
import { seal, HPKE_SUITE } from './federation-hpke.js';
|
|
36
|
+
export const ENVELOPE_SCHEMA = 'brainclaw.federation-envelope/v1';
|
|
37
|
+
export const AAD_PROTOCOL = 'brainclaw/federation/v1';
|
|
38
|
+
/**
|
|
39
|
+
* Types d'entités PROJETABLES. Une famille absente de cette liste n'est pas « à faire
|
|
40
|
+
* plus tard » : son schéma entier est interdit de sortie (RFC §4.3). L'ajout d'une
|
|
41
|
+
* famille est un acte délibéré qui passe par la table de classification.
|
|
42
|
+
*/
|
|
43
|
+
export const FEDERATED_KINDS = [
|
|
44
|
+
'constraint', 'decision', 'trap', 'handoff', 'plan', 'plan_step', 'sequence',
|
|
45
|
+
'claim', 'candidate', 'runtime_note', 'inbox_message', 'assignment',
|
|
46
|
+
'agent_run', 'action_required', 'ai_task', 'runtime_event', 'lane_result',
|
|
47
|
+
];
|
|
48
|
+
// ── Champs INTERDITS DE SORTIR (RFC §4.2, classe 3) ──────────────────────────
|
|
49
|
+
/**
|
|
50
|
+
* Ces noms ne doivent apparaître NI dans meta NI dans sealed. Leur présence fait échouer
|
|
51
|
+
* la projection — un refus, pas une troncature.
|
|
52
|
+
*
|
|
53
|
+
* POURQUOI UNE LISTE DE NOMS ICI ALORS QUE J'AI ÉCRIT AILLEURS QU'UNE LISTE DE NOMS NE
|
|
54
|
+
* SUFFIT PAS : elle ne suffit effectivement pas SEULE, et n'est pas seule. Le filet 1
|
|
55
|
+
* empêche déjà tout champ non explicitement sélectionné d'entrer dans meta, et le filet 2
|
|
56
|
+
* refuse toute clé inconnue à la sortie. Cette liste est un TROISIÈME contrôle qui vise
|
|
57
|
+
* le SCELLÉ — la seule partie que les deux autres filets ne peuvent pas inspecter,
|
|
58
|
+
* puisqu'ils la traitent comme opaque. Sans elle, `worktree_path` chiffré partirait quand
|
|
59
|
+
* même : chiffré n'est pas « autorisé à sortir ».
|
|
60
|
+
*/
|
|
61
|
+
export const FORBIDDEN_LEAF_NAMES = new Set([
|
|
62
|
+
'host_id', 'session_id', 'worktree_path', 'project_path', 'storage_dir', 'related_paths',
|
|
63
|
+
'command', 'shell', 'pid', 'provider_run_id', 'base_sha', 'cwd',
|
|
64
|
+
'api_key', 'apiKey', 'secret', 'token', 'password', 'private_key', 'privateKey',
|
|
65
|
+
'env', 'environment',
|
|
66
|
+
]);
|
|
67
|
+
/** Motifs de VALEURS trahissant un chemin local, quel que soit le nom du champ. */
|
|
68
|
+
const LOCAL_PATH_PATTERNS = [
|
|
69
|
+
/^[A-Za-z]:[\\/]/, // C:\... ou C:/...
|
|
70
|
+
/^\/(?:home|Users|root)\//, // /home/x, /Users/x, /root/x
|
|
71
|
+
/\.brainclaw[\\/]worktrees/,
|
|
72
|
+
];
|
|
73
|
+
export class ProjectionRefused extends Error {
|
|
74
|
+
path;
|
|
75
|
+
constructor(message, path) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.path = path;
|
|
78
|
+
this.name = 'ProjectionRefused';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Parcours RÉCURSIF refusant tout champ interdit, par son nom ou par la forme de sa
|
|
83
|
+
* valeur.
|
|
84
|
+
*
|
|
85
|
+
* Appliqué au CLAIR AVANT scellement, donc y compris à ce qui partira chiffré. Le RFC est
|
|
86
|
+
* explicite : la classe interdite est « absente de meta ET de sealed ». Un chemin de
|
|
87
|
+
* worktree chiffré reste une donnée qui a quitté l'hôte, et le jour où la clé fuit, elle
|
|
88
|
+
* a fui aussi. Le chiffrement protège le contenu ; il ne rend pas licite de l'envoyer.
|
|
89
|
+
*/
|
|
90
|
+
export function assertNoForbiddenLeaf(value, path = '$') {
|
|
91
|
+
if (value === null || value === undefined)
|
|
92
|
+
return;
|
|
93
|
+
if (typeof value === 'string') {
|
|
94
|
+
for (const pattern of LOCAL_PATH_PATTERNS) {
|
|
95
|
+
if (pattern.test(value)) {
|
|
96
|
+
throw new ProjectionRefused(`Chemin local détecté en ${path} — interdit de sortir, même scellé (RFC §4.2).`, path);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
value.forEach((v, i) => assertNoForbiddenLeaf(v, `${path}[${i}]`));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (typeof value === 'object') {
|
|
106
|
+
for (const [key, v] of Object.entries(value)) {
|
|
107
|
+
if (FORBIDDEN_LEAF_NAMES.has(key)) {
|
|
108
|
+
throw new ProjectionRefused(`Champ interdit '${key}' en ${path} — absent de meta ET de sealed (RFC §4.2).`, `${path}.${key}`);
|
|
109
|
+
}
|
|
110
|
+
assertNoForbiddenLeaf(v, `${path}.${key}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ── FILET 2 : le schéma strict du point de sortie unique ─────────────────────
|
|
115
|
+
/**
|
|
116
|
+
* `.strict()` PARTOUT, et c'est le point.
|
|
117
|
+
*
|
|
118
|
+
* Zod `.strip()` — le défaut — accepterait un objet porteur d'une clé inconnue et la
|
|
119
|
+
* retirerait SILENCIEUSEMENT du résultat. Le push passerait, et personne n'apprendrait
|
|
120
|
+
* qu'un champ non classé a été ajouté au projecteur. `.strict()` LÈVE, ce qui transforme
|
|
121
|
+
* l'oubli de classification en échec de CI plutôt qu'en fuite discrète.
|
|
122
|
+
*/
|
|
123
|
+
const CanonicalAadSchema = z.object({
|
|
124
|
+
protocol: z.literal(AAD_PROTOCOL),
|
|
125
|
+
cloud_project_id: z.string().min(1),
|
|
126
|
+
object_id: z.string().min(1),
|
|
127
|
+
base_rev: z.number().int().nonnegative(),
|
|
128
|
+
object_type: z.enum(FEDERATED_KINDS),
|
|
129
|
+
schema: z.literal(ENVELOPE_SCHEMA),
|
|
130
|
+
}).strict();
|
|
131
|
+
const PublicMetaSchema = z.object({
|
|
132
|
+
id_opaque: z.string().uuid(),
|
|
133
|
+
kind: z.enum(FEDERATED_KINDS),
|
|
134
|
+
status: z.object({
|
|
135
|
+
object: z.string().min(1),
|
|
136
|
+
sync: z.enum(['pending', 'synced', 'conflict']).optional(),
|
|
137
|
+
}).strict(),
|
|
138
|
+
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
|
139
|
+
rank: z.number().int().optional(),
|
|
140
|
+
deps: z.array(z.object({ from: z.string().uuid(), to: z.string().uuid() }).strict()),
|
|
141
|
+
// Jour UTC, JAMAIS l'heure. Une heure précise trahit le rythme de travail d'une
|
|
142
|
+
// personne ; le jour ne dit que la cadence, ce que le RFC assume explicitement.
|
|
143
|
+
timestamp_bucket_jour: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
144
|
+
base_rev: z.number().int().nonnegative(),
|
|
145
|
+
aad: CanonicalAadSchema,
|
|
146
|
+
// Ne contient NI nom NI empreinte de destinataire : la référence de paquet de clés
|
|
147
|
+
// aurait divulgué le roster, canal de fuite que l'idéation avait manqué.
|
|
148
|
+
wrap_hint: z.string().min(1),
|
|
149
|
+
transport: z.object({
|
|
150
|
+
operation_id: z.string().min(1),
|
|
151
|
+
content_hash: z.string().min(1),
|
|
152
|
+
idempotency_key: z.string().min(1),
|
|
153
|
+
}).strict(),
|
|
154
|
+
}).strict();
|
|
155
|
+
const SealedSchema = z.object({
|
|
156
|
+
alg: z.literal(HPKE_SUITE),
|
|
157
|
+
enc: z.string().min(1),
|
|
158
|
+
nonce: z.string().min(1),
|
|
159
|
+
ciphertext: z.string().min(1),
|
|
160
|
+
}).strict();
|
|
161
|
+
export const FederationEnvelopeSchema = z.object({
|
|
162
|
+
schema: z.literal(ENVELOPE_SCHEMA),
|
|
163
|
+
meta: PublicMetaSchema,
|
|
164
|
+
sealed: SealedSchema,
|
|
165
|
+
key_epoch: z.number().int().nonnegative(),
|
|
166
|
+
origin_sig: z.object({
|
|
167
|
+
alg: z.literal('Ed25519'),
|
|
168
|
+
key_id: z.string().min(1),
|
|
169
|
+
value: z.string().min(1),
|
|
170
|
+
}).strict(),
|
|
171
|
+
}).strict();
|
|
172
|
+
/** Jour UTC. La troncature est faite ICI pour qu'aucun appelant ne puisse l'oublier. */
|
|
173
|
+
function dayBucket(when) {
|
|
174
|
+
const d = typeof when === 'string' ? new Date(when) : when;
|
|
175
|
+
if (Number.isNaN(d.getTime()))
|
|
176
|
+
throw new ProjectionRefused('Date invalide pour le bucket jour.', '$.occurredAt');
|
|
177
|
+
return d.toISOString().slice(0, 10);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Construit la projection publique — SÉLECTION EXPLICITE, CHAMP PAR CHAMP.
|
|
181
|
+
*
|
|
182
|
+
* Aucun spread nulle part dans cette fonction, et c'est délibéré : `{...input}` suffirait
|
|
183
|
+
* à déverser tout ce que l'appelant a mis dans l'objet. Chaque champ de meta est nommé
|
|
184
|
+
* ici ou n'existe pas. Ajouter un champ à `PublicMetaSchema` sans l'ajouter ici produit
|
|
185
|
+
* une erreur de compilation, et l'inverse un échec de parse `.strict()`.
|
|
186
|
+
*
|
|
187
|
+
* `priority` et `rank` ne sont posés QUE s'ils existent : l'absence d'un champ optionnel
|
|
188
|
+
* est SIGNIFICATIVE (RFC §3). Inventer `priority: 'medium'` pour un objet qui n'en porte
|
|
189
|
+
* pas créerait une fuite par normalisation — le Cloud croirait à une priorité choisie.
|
|
190
|
+
*/
|
|
191
|
+
export function toPublicProjection(input) {
|
|
192
|
+
const aad = {
|
|
193
|
+
protocol: AAD_PROTOCOL,
|
|
194
|
+
cloud_project_id: input.cloudProjectId,
|
|
195
|
+
object_id: input.idOpaque,
|
|
196
|
+
base_rev: input.baseRev,
|
|
197
|
+
object_type: input.kind,
|
|
198
|
+
schema: ENVELOPE_SCHEMA,
|
|
199
|
+
};
|
|
200
|
+
const meta = {
|
|
201
|
+
id_opaque: input.idOpaque,
|
|
202
|
+
kind: input.kind,
|
|
203
|
+
status: input.syncState
|
|
204
|
+
? { object: input.statusObject, sync: input.syncState }
|
|
205
|
+
: { object: input.statusObject },
|
|
206
|
+
deps: (input.deps ?? []).map((d) => ({ from: d.from, to: d.to })),
|
|
207
|
+
timestamp_bucket_jour: dayBucket(input.occurredAt),
|
|
208
|
+
base_rev: input.baseRev,
|
|
209
|
+
aad,
|
|
210
|
+
wrap_hint: input.wrapHint,
|
|
211
|
+
transport: {
|
|
212
|
+
operation_id: input.operationId,
|
|
213
|
+
// Dérivés du CIPHERTEXT, jamais du clair (RFC §3.2). En v1, content_hash portait
|
|
214
|
+
// sur le corps sémantique en clair : le Cloud pouvait alors confirmer une
|
|
215
|
+
// devinette sur un contenu à faible entropie en comparant des hachages.
|
|
216
|
+
content_hash: canonicalSha256(input.sealed),
|
|
217
|
+
idempotency_key: '',
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
if (input.priority !== undefined)
|
|
221
|
+
meta.priority = input.priority;
|
|
222
|
+
if (input.rank !== undefined)
|
|
223
|
+
meta.rank = input.rank;
|
|
224
|
+
return meta;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* LE SEUL point par lequel une enveloppe peut être produite.
|
|
228
|
+
*
|
|
229
|
+
* L'ordre des opérations est un contrat, pas un détail :
|
|
230
|
+
* 1. refus des champs interdits sur le CLAIR (avant qu'ils ne deviennent illisibles) ;
|
|
231
|
+
* 2. AAD canonique, lié au projet, à l'objet, à la révision et au type ;
|
|
232
|
+
* 3. scellement HPKE avec cet AAD exact ;
|
|
233
|
+
* 4. projection publique par le filet 1 ;
|
|
234
|
+
* 5. dérivés de transport calculés sur le CIPHERTEXT ;
|
|
235
|
+
* 6. signature d'origine sur meta ‖ sealed ‖ key_epoch ;
|
|
236
|
+
* 7. parse `.strict()` — le filet 2 — qui refuse toute clé inconnue.
|
|
237
|
+
*
|
|
238
|
+
* Inverser 1 et 3 laisserait passer un champ interdit dans le blob chiffré.
|
|
239
|
+
*/
|
|
240
|
+
export function buildEnvelope(params) {
|
|
241
|
+
// (1) Le contrôle porte sur le clair, y compris ce qui sera scellé.
|
|
242
|
+
assertNoForbiddenLeaf(params.content, '$.content');
|
|
243
|
+
// (2) AAD comme STRUCTURE canonique et non concaténation ambiguë : « a|b » et « a|b »
|
|
244
|
+
// issus de découpages différents produisent la même chaîne, donc deux contextes
|
|
245
|
+
// distincts indiscernables.
|
|
246
|
+
const aad = {
|
|
247
|
+
protocol: AAD_PROTOCOL,
|
|
248
|
+
cloud_project_id: params.cloudProjectId,
|
|
249
|
+
object_id: params.idOpaque,
|
|
250
|
+
base_rev: params.baseRev,
|
|
251
|
+
object_type: params.kind,
|
|
252
|
+
schema: ENVELOPE_SCHEMA,
|
|
253
|
+
};
|
|
254
|
+
const aadBytes = new TextEncoder().encode(canonicalJson(aad));
|
|
255
|
+
// (3)
|
|
256
|
+
const sealed = seal({
|
|
257
|
+
recipientPublicKeyPem: params.recipientPublicKeyPem,
|
|
258
|
+
plaintext: new TextEncoder().encode(canonicalJson(params.content)),
|
|
259
|
+
aadCanonicalBytes: aadBytes,
|
|
260
|
+
});
|
|
261
|
+
// (4)
|
|
262
|
+
const meta = toPublicProjection({
|
|
263
|
+
kind: params.kind,
|
|
264
|
+
idOpaque: params.idOpaque,
|
|
265
|
+
cloudProjectId: params.cloudProjectId,
|
|
266
|
+
baseRev: params.baseRev,
|
|
267
|
+
statusObject: params.statusObject,
|
|
268
|
+
syncState: params.syncState,
|
|
269
|
+
priority: params.priority,
|
|
270
|
+
rank: params.rank,
|
|
271
|
+
deps: params.deps,
|
|
272
|
+
occurredAt: params.occurredAt,
|
|
273
|
+
wrapHint: params.wrapHint,
|
|
274
|
+
operationId: params.operationId,
|
|
275
|
+
sealed,
|
|
276
|
+
});
|
|
277
|
+
// (5) idempotency_key = SHA-256(canonical(sealed) || operation_id || origin_sig.key_id).
|
|
278
|
+
// CLEFÉE par l'identité du signataire : deux agents qui pousseraient le même contenu
|
|
279
|
+
// n'auraient pas la même clé, donc le Cloud ne peut pas déduire qu'ils poussent la
|
|
280
|
+
// même chose.
|
|
281
|
+
meta.transport.idempotency_key = b64url(new Uint8Array(crypto.createHash('sha256')
|
|
282
|
+
.update(canonicalJson(sealed), 'utf-8')
|
|
283
|
+
.update(params.operationId, 'utf-8')
|
|
284
|
+
.update(params.originKeyId, 'utf-8')
|
|
285
|
+
.digest()));
|
|
286
|
+
// (6) L'entrée couvre alg, enc, nonce ET ciphertext, plus key_epoch — pas seulement
|
|
287
|
+
// « meta || ciphertext ». Le raccourci ne couvrirait pas les paramètres permettant
|
|
288
|
+
// d'INTERPRÉTER le ciphertext, qu'un Cloud pourrait alors modifier sans casser la
|
|
289
|
+
// signature.
|
|
290
|
+
const signingInput = Buffer.concat([
|
|
291
|
+
Buffer.from('brainclaw/federation-envelope/v1\0', 'utf-8'),
|
|
292
|
+
Buffer.from(canonicalJson(meta), 'utf-8'),
|
|
293
|
+
Buffer.from(canonicalJson(sealed), 'utf-8'),
|
|
294
|
+
Buffer.from(canonicalJson(params.keyEpoch), 'utf-8'),
|
|
295
|
+
]);
|
|
296
|
+
const signature = crypto.sign(null, signingInput, crypto.createPrivateKey(params.originPrivateKeyPem));
|
|
297
|
+
const envelope = {
|
|
298
|
+
schema: ENVELOPE_SCHEMA,
|
|
299
|
+
meta,
|
|
300
|
+
sealed,
|
|
301
|
+
key_epoch: params.keyEpoch,
|
|
302
|
+
origin_sig: { alg: 'Ed25519', key_id: params.originKeyId, value: signature.toString('base64url') },
|
|
303
|
+
};
|
|
304
|
+
// (7) FILET 2 — fail-closed. Une clé inconnue lève ici plutôt que de partir sur le fil.
|
|
305
|
+
return FederationEnvelopeSchema.parse(envelope);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Octets exacts sur lesquels porte la signature d'origine.
|
|
309
|
+
*
|
|
310
|
+
* Exporté pour que le VÉRIFICATEUR les reconstruise avec la même fonction que
|
|
311
|
+
* l'émetteur. Deux reconstructions indépendantes est précisément le défaut qui a rendu
|
|
312
|
+
* l'attestation d'appairage insatisfiable côté Cloud : le vérificateur fabriquait un
|
|
313
|
+
* champ que le signataire ne pouvait pas connaître.
|
|
314
|
+
*/
|
|
315
|
+
export function originSigningInput(meta, sealed, keyEpoch) {
|
|
316
|
+
return Buffer.concat([
|
|
317
|
+
Buffer.from('brainclaw/federation-envelope/v1\0', 'utf-8'),
|
|
318
|
+
Buffer.from(canonicalJson(meta), 'utf-8'),
|
|
319
|
+
Buffer.from(canonicalJson(sealed), 'utf-8'),
|
|
320
|
+
Buffer.from(canonicalJson(keyEpoch), 'utf-8'),
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
// ── Ids opaques ──────────────────────────────────────────────────────────────
|
|
324
|
+
/**
|
|
325
|
+
* Ids RE-ROULÉS en UUID v4, mapping local↔cloud gardé LOCAL.
|
|
326
|
+
*
|
|
327
|
+
* Pourquoi ne pas simplement hacher l'id local : un hachage est déterministe, donc le
|
|
328
|
+
* même objet exporté vers deux projets Cloud produirait le même identifiant, et un
|
|
329
|
+
* observateur corrélerait les deux projets. Un UUID aléatoire par projet coupe cette
|
|
330
|
+
* corrélation — c'est le sens de « stable dans un projet Cloud, pas cross-projet »
|
|
331
|
+
* (RFC §4.1).
|
|
332
|
+
*/
|
|
333
|
+
export function newOpaqueId() {
|
|
334
|
+
return crypto.randomUUID();
|
|
335
|
+
}
|
|
336
|
+
//# sourceMappingURL=federation-projection.js.map
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commandes cloud matérialisées localement — le RELAIS (pln#651 étape 7, dec#154).
|
|
3
|
+
*
|
|
4
|
+
* ── CE QUI SURVIT INTACT AU CHIFFREMENT ───────────────────────────────────────
|
|
5
|
+
* À écrire d'emblée, pour qu'on ne « résolve » pas un faux problème : `base_rev` compare
|
|
6
|
+
* un IDENTIFIANT DE RÉVISION, pas du contenu. L'idempotence, `operation_id`, la
|
|
7
|
+
* provenance et l'audit ne référencent que des ids et des actions. Rien de tout cela n'a
|
|
8
|
+
* besoin de lire le clair, et le chiffrement ne gêne donc en rien ce module.
|
|
9
|
+
*
|
|
10
|
+
* LE RELAIS N'ÉCRIT JAMAIS DE CONTENU. Le contenu reste en écriture depuis le local
|
|
11
|
+
* uniquement. Une commande venue du dashboard porte sur des MÉTADONNÉES : priorité,
|
|
12
|
+
* ordre, rang, statut, métadonnées de roadmap. C'est la conséquence directe de dec#154 —
|
|
13
|
+
* « le Cloud est une projection et un relais ; le local est la source de vérité ».
|
|
14
|
+
*
|
|
15
|
+
* ── LA TROISIÈME CLASSE D'APPELANTS (dec#155) ─────────────────────────────────
|
|
16
|
+
* Le relais cloud n'a NI session, NI cwd, NI contexte ambiant : seulement un id d'entité
|
|
17
|
+
* et un `base_rev`. C'est le cas le plus pur du routage autoritatif par l'entité, déjà en
|
|
18
|
+
* place côté core depuis la 1.21.0.
|
|
19
|
+
*
|
|
20
|
+
* NE PAS RÉINVENTER DE RÉSOLUTION AMBIANTE POUR LUI. La tentation est réelle — « si le
|
|
21
|
+
* projet n'est pas précisé, prendre le projet actif » — et c'est exactement la dérive que
|
|
22
|
+
* pln#648/649 ont corrigée pour le routage local. Une commande sans cible résoluble est
|
|
23
|
+
* REFUSÉE, jamais devinée.
|
|
24
|
+
*
|
|
25
|
+
* ── LES CONFLITS ──────────────────────────────────────────────────────────────
|
|
26
|
+
* Présentés avec une proposition de résolution, JAMAIS de last-write-wins silencieux. Et
|
|
27
|
+
* c'est le contrat de refus de T3 exprimé sur un autre transport : une divergence PROUVÉE
|
|
28
|
+
* refuse et nomme ; une ABSENCE retombe sur la réponse ambiante. Le même mécanisme, pas
|
|
29
|
+
* un second.
|
|
30
|
+
*/
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { z } from 'zod';
|
|
34
|
+
import { memoryDir, writeFileAtomic } from './io.js';
|
|
35
|
+
import { nowISO } from './ids.js';
|
|
36
|
+
import { canonicalJson } from './federation-canonical.js';
|
|
37
|
+
import { logger } from './logger.js';
|
|
38
|
+
const JOURNAL_DIR = ['coordination', 'federation', 'commands'];
|
|
39
|
+
/**
|
|
40
|
+
* Les seuls champs qu'une commande cloud peut toucher.
|
|
41
|
+
*
|
|
42
|
+
* LISTE FERMÉE ET NON EXTENSIBLE PAR CONFIGURATION : c'est la traduction exécutable de
|
|
43
|
+
* « le relais n'écrit jamais de contenu ». Ajouter `text` ou `description` ici ferait du
|
|
44
|
+
* Cloud une source d'écriture de contenu et retournerait dec#154.
|
|
45
|
+
*/
|
|
46
|
+
export const RELAYABLE_FIELDS = ['priority', 'rank', 'status'];
|
|
47
|
+
export const CloudCommandSchema = z.object({
|
|
48
|
+
/** Identité de l'opération, créée par l'émetteur et REJOUÉE à l'identique en cas de retry. */
|
|
49
|
+
operation_id: z.string().min(1),
|
|
50
|
+
/** Cible : un id d'entité opaque. Pas de projet, pas de cwd, pas de session (dec#155). */
|
|
51
|
+
object_id: z.string().min(1),
|
|
52
|
+
/** Révision sur laquelle l'émetteur s'appuie. Un décalage produit un conflit VISIBLE. */
|
|
53
|
+
base_rev: z.number().int().nonnegative(),
|
|
54
|
+
field: z.enum(RELAYABLE_FIELDS),
|
|
55
|
+
value: z.union([z.string(), z.number()]),
|
|
56
|
+
/** Identité Ed25519 de l'émetteur — audité, pas cru sur parole. */
|
|
57
|
+
issued_by: z.string().min(1),
|
|
58
|
+
issued_at: z.string().min(1),
|
|
59
|
+
}).strict();
|
|
60
|
+
function journalDir(cwd) {
|
|
61
|
+
return path.join(memoryDir(cwd), ...JOURNAL_DIR);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Le journal est indexé par `operation_id` — c'est ce qui rend le rejeu inoffensif.
|
|
65
|
+
*
|
|
66
|
+
* Un index par (object_id, base_rev) ne suffirait pas : deux commandes distinctes peuvent
|
|
67
|
+
* légitimement viser la même révision d'un même objet (changer la priorité, puis le rang).
|
|
68
|
+
*/
|
|
69
|
+
function entryPath(cwd, operationId) {
|
|
70
|
+
// Le nom de fichier est assaini : un operation_id venu du réseau ne doit pas pouvoir
|
|
71
|
+
// écrire hors du répertoire du journal via '../'.
|
|
72
|
+
const safe = operationId.replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
73
|
+
return path.join(journalDir(cwd), `${safe}.json`);
|
|
74
|
+
}
|
|
75
|
+
export function loadJournalEntry(cwd, operationId) {
|
|
76
|
+
const filepath = entryPath(cwd, operationId);
|
|
77
|
+
if (!fs.existsSync(filepath))
|
|
78
|
+
return undefined;
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
// Une entrée illisible n'est PAS traitée comme absente : la traiter ainsi ferait
|
|
84
|
+
// réappliquer une commande déjà appliquée, ce que tout ce module cherche à empêcher.
|
|
85
|
+
logger.warn(`Entrée de journal illisible (${operationId}) : ${err instanceof Error ? err.message : String(err)}`);
|
|
86
|
+
// `cause` conservée : sans elle, un opérateur voit « journal corrompu » sans savoir
|
|
87
|
+
// si c'est un JSON tronqué, un encodage cassé ou un disque plein.
|
|
88
|
+
throw new Error(`Journal corrompu pour l'opération ${operationId} — refus d'appliquer à l'aveugle.`, { cause: err });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function writeEntry(cwd, entry) {
|
|
92
|
+
const filepath = entryPath(cwd, entry.operation_id);
|
|
93
|
+
fs.mkdirSync(path.dirname(filepath), { recursive: true });
|
|
94
|
+
writeFileAtomic(filepath, `${JSON.stringify(entry, null, 2)}\n`);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Applique une commande cloud au journal local.
|
|
98
|
+
*
|
|
99
|
+
* `resolveLocalRev` est INJECTÉ plutôt que lu ici : ce module ne connaît pas le stockage
|
|
100
|
+
* des entités, et lui donner cette connaissance en ferait un second chemin d'écriture sur
|
|
101
|
+
* la mémoire — précisément ce que « le relais n'écrit jamais de contenu » interdit. Il
|
|
102
|
+
* écrit le journal ; c'est l'appelant qui, à partir du journal, applique un changement de
|
|
103
|
+
* métadonnée par la voie locale normale.
|
|
104
|
+
*
|
|
105
|
+
* TROIS ISSUES ET AUCUN ÉCRASEMENT SILENCIEUX :
|
|
106
|
+
* applied — base_rev correspond, l'effet est enregistré ;
|
|
107
|
+
* duplicate — operation_id déjà connu, aucun second effet (idempotence) ;
|
|
108
|
+
* conflict — base_rev périmé : l'entrée est écrite en état `conflict` AVEC une
|
|
109
|
+
* proposition, et attend une décision humaine.
|
|
110
|
+
*/
|
|
111
|
+
export function applyCloudCommand(params) {
|
|
112
|
+
const parsed = CloudCommandSchema.safeParse(params.raw);
|
|
113
|
+
if (!parsed.success) {
|
|
114
|
+
return { status: 'refused', reason: `commande invalide : ${parsed.error.issues.map((i) => i.path.join('.')).join(', ')}` };
|
|
115
|
+
}
|
|
116
|
+
const cmd = parsed.data;
|
|
117
|
+
// IDEMPOTENCE D'ABORD. Rejouer une commande déjà appliquée doit être un no-op, y compris
|
|
118
|
+
// si la révision locale a changé entre-temps — sinon un retry réseau produirait un
|
|
119
|
+
// conflit fantôme sur une opération pourtant déjà réussie.
|
|
120
|
+
const existing = loadJournalEntry(params.cwd, cmd.operation_id);
|
|
121
|
+
if (existing) {
|
|
122
|
+
return { status: 'duplicate', entry: existing };
|
|
123
|
+
}
|
|
124
|
+
const localRev = params.resolveLocalRev(cmd.object_id);
|
|
125
|
+
if (localRev === undefined) {
|
|
126
|
+
// TROISIÈME CLASSE D'APPELANTS (dec#155) : pas de repli ambiant. Un objet inconnu est
|
|
127
|
+
// refusé et nommé, jamais rattaché au « projet actif » par défaut.
|
|
128
|
+
return { status: 'refused', reason: `objet '${cmd.object_id}' inconnu localement — aucune résolution ambiante pour le relais cloud (dec#155).` };
|
|
129
|
+
}
|
|
130
|
+
const base = {
|
|
131
|
+
operation_id: cmd.operation_id,
|
|
132
|
+
object_id: cmd.object_id,
|
|
133
|
+
base_rev: cmd.base_rev,
|
|
134
|
+
field: cmd.field,
|
|
135
|
+
value: cmd.value,
|
|
136
|
+
issued_by: cmd.issued_by,
|
|
137
|
+
issued_at: cmd.issued_at,
|
|
138
|
+
materialized_at: nowISO(),
|
|
139
|
+
};
|
|
140
|
+
if (cmd.base_rev !== localRev) {
|
|
141
|
+
// CONFLIT VISIBLE, jamais un écrasement. La proposition est formulée ici parce que
|
|
142
|
+
// c'est ici qu'on connaît les deux révisions ; la rendre plus tard obligerait
|
|
143
|
+
// l'interface à re-déduire ce que le journal savait déjà.
|
|
144
|
+
const entry = {
|
|
145
|
+
...base,
|
|
146
|
+
state: 'conflict',
|
|
147
|
+
conflict: {
|
|
148
|
+
local_rev: localRev,
|
|
149
|
+
proposal: localRev > cmd.base_rev
|
|
150
|
+
? `Le local a avancé (rév. ${localRev} > ${cmd.base_rev}). Rejouer la commande sur la révision ${localRev}, ou l'abandonner si le changement local la rend caduque.`
|
|
151
|
+
: `La commande s'appuie sur une révision (${cmd.base_rev}) postérieure au local (${localRev}) — le local a probablement été restauré. Vérifier avant d'appliquer.`,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
writeEntry(params.cwd, entry);
|
|
155
|
+
return { status: 'conflict', entry };
|
|
156
|
+
}
|
|
157
|
+
const entry = { ...base, state: 'pending' };
|
|
158
|
+
writeEntry(params.cwd, entry);
|
|
159
|
+
return { status: 'applied', entry };
|
|
160
|
+
}
|
|
161
|
+
/** Marque une entrée synchronisée une fois l'effet local réellement appliqué. */
|
|
162
|
+
export function markCommandSynced(cwd, operationId) {
|
|
163
|
+
const entry = loadJournalEntry(cwd, operationId);
|
|
164
|
+
if (!entry)
|
|
165
|
+
return false;
|
|
166
|
+
writeEntry(cwd, { ...entry, state: 'synced' });
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Résout un conflit par une DÉCISION EXPLICITE.
|
|
171
|
+
*
|
|
172
|
+
* Il n'existe volontairement aucune résolution automatique : dec#154 dit « jamais de
|
|
173
|
+
* last-write-wins silencieux », et un mode « auto » finirait par être le défaut.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveCommandConflict(params) {
|
|
176
|
+
const entry = loadJournalEntry(params.cwd, params.operationId);
|
|
177
|
+
if (!entry || entry.state !== 'conflict')
|
|
178
|
+
return undefined;
|
|
179
|
+
const resolved = {
|
|
180
|
+
...entry,
|
|
181
|
+
state: params.decision === 'accept' ? 'pending' : 'synced',
|
|
182
|
+
conflict: undefined,
|
|
183
|
+
};
|
|
184
|
+
writeEntry(params.cwd, resolved);
|
|
185
|
+
return resolved;
|
|
186
|
+
}
|
|
187
|
+
/** Entrées du journal dans un état donné — sert à rendre les conflits visibles. */
|
|
188
|
+
export function listCommands(cwd, state) {
|
|
189
|
+
const dir = journalDir(cwd);
|
|
190
|
+
if (!fs.existsSync(dir))
|
|
191
|
+
return [];
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const name of fs.readdirSync(dir)) {
|
|
194
|
+
if (!name.endsWith('.json'))
|
|
195
|
+
continue;
|
|
196
|
+
try {
|
|
197
|
+
const entry = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
|
|
198
|
+
if (!state || entry.state === state)
|
|
199
|
+
out.push(entry);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
logger.warn(`Entrée de journal ignorée (illisible) : ${name}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return out.sort((a, b) => a.materialized_at.localeCompare(b.materialized_at));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Empreinte d'audit d'une commande — ce qui a été demandé, par qui, sur quelle révision.
|
|
209
|
+
*
|
|
210
|
+
* Calculée sur les octets CANONIQUES pour qu'elle soit reproductible des deux côtés : un
|
|
211
|
+
* audit qu'on ne peut pas recalculer identiquement ne prouve rien.
|
|
212
|
+
*/
|
|
213
|
+
export function commandAuditDigest(cmd) {
|
|
214
|
+
return canonicalJson({
|
|
215
|
+
operation_id: cmd.operation_id,
|
|
216
|
+
object_id: cmd.object_id,
|
|
217
|
+
base_rev: cmd.base_rev,
|
|
218
|
+
field: cmd.field,
|
|
219
|
+
value: cmd.value,
|
|
220
|
+
issued_by: cmd.issued_by,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
//# sourceMappingURL=federation-relay.js.map
|