@ifc-lite/collab-server 0.2.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,728 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ import * as crypto from 'node:crypto';
5
+ import { getProvenance } from '@ifc-lite/ifcx';
6
+ import { mergeIntoRef } from '@ifc-lite/merge';
7
+ import { ANONYMOUS_USER_ID } from './auth.js';
8
+ import { LayerPushError, } from './layer-registry.js';
9
+ import { emitRegistryEvent } from './registry-webhooks.js';
10
+ const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
11
+ const BASE = '/api/v1/';
12
+ /**
13
+ * Registry credentials are `Authorization: Bearer` ONLY — no `?token=`
14
+ * fallback. A query-string secret leaks via access logs, reverse proxies,
15
+ * traces, and copied URLs (same reasoning as the /metrics endpoint; the
16
+ * websocket path keeps `?token=` only because browsers cannot set
17
+ * handshake headers, which does not apply to registry API clients).
18
+ */
19
+ function extractToken(req) {
20
+ const header = req.headers['authorization'];
21
+ if (typeof header === 'string') {
22
+ const m = /^Bearer\s+(.+)$/i.exec(header.trim());
23
+ if (m)
24
+ return m[1];
25
+ }
26
+ return undefined;
27
+ }
28
+ function json(res, status, body) {
29
+ res.writeHead(status, { 'content-type': 'application/json' });
30
+ res.end(JSON.stringify(body));
31
+ return true;
32
+ }
33
+ async function readBody(req, maxBytes) {
34
+ const chunks = [];
35
+ let total = 0;
36
+ for await (const chunk of req) {
37
+ const buf = chunk;
38
+ total += buf.byteLength;
39
+ if (total > maxBytes)
40
+ return null;
41
+ chunks.push(buf);
42
+ }
43
+ return Buffer.concat(chunks).toString('utf-8');
44
+ }
45
+ /** Parse JSON, surfacing the failure reason instead of swallowing it. */
46
+ function parseJson(text) {
47
+ try {
48
+ return { value: JSON.parse(text) };
49
+ }
50
+ catch (err) {
51
+ return { error: err instanceof Error ? err.message : String(err) };
52
+ }
53
+ }
54
+ /**
55
+ * Map a store-thrown `LayerPushError` onto the HTTP surface: capacity
56
+ * exhaustion is 507, integrity/conflict gates are 409. Every route that
57
+ * writes through the store (push, ref PUT, merge, reviews) must route
58
+ * through this — an unwrapped throw becomes a bare 500.
59
+ */
60
+ function handlePushError(res, err) {
61
+ if (err instanceof LayerPushError) {
62
+ return json(res, err.code === 'registry-full' ? 507 : 409, { error: err.message, code: err.code });
63
+ }
64
+ return undefined;
65
+ }
66
+ /** Runtime shape validation for ref policies; undefined = invalid. */
67
+ function parseRefPolicy(value) {
68
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
69
+ return undefined;
70
+ const raw = value;
71
+ const policy = {};
72
+ if (raw.requireHumanApproval !== undefined) {
73
+ if (typeof raw.requireHumanApproval !== 'boolean')
74
+ return undefined;
75
+ policy.requireHumanApproval = raw.requireHumanApproval;
76
+ }
77
+ if (raw.requiredChecks !== undefined) {
78
+ if (!Array.isArray(raw.requiredChecks) || !raw.requiredChecks.every((c) => typeof c === 'string')) {
79
+ return undefined;
80
+ }
81
+ policy.requiredChecks = raw.requiredChecks;
82
+ }
83
+ if (raw.autoMerge !== undefined) {
84
+ if (typeof raw.autoMerge !== 'boolean')
85
+ return undefined;
86
+ policy.autoMerge = raw.autoMerge;
87
+ }
88
+ return policy;
89
+ }
90
+ /**
91
+ * Auto-merge (10-registry.md §10.4): after a push, conflict-free +
92
+ * all-green candidates merge unattended into every `autoMerge` ref.
93
+ * Fail-closed by construction:
94
+ * - `requireHumanApproval` refs never auto-merge (an unattended merge
95
+ * cannot satisfy an approval).
96
+ * - Baseless candidates never auto-merge (three-way against an empty
97
+ * ancestor reads every op as "new" — a disjoint layer would land on
98
+ * every auto-merge ref).
99
+ * - `conflicts` / `policy-failure` / `unrelated-base` outcomes have no
100
+ * side effects in the shared flow, and any throw is contained — an
101
+ * auto-merge can never fail the push that triggered it.
102
+ */
103
+ function runAutoMerges(registry, pushedId, webhooks) {
104
+ let manifest;
105
+ try {
106
+ manifest = getProvenance(registry.loadLayer(pushedId));
107
+ }
108
+ catch {
109
+ return;
110
+ }
111
+ if (!manifest?.base)
112
+ return;
113
+ // "All-green" is the whole candidate manifest, not just the ref's
114
+ // required checks: a failing check the policy forgot to require must
115
+ // still keep the merge attended.
116
+ if ((manifest.checks ?? []).some((check) => check.result !== 'pass'))
117
+ return;
118
+ for (const [name, entry] of Object.entries(registry.listRefs())) {
119
+ const policy = entry.policy;
120
+ if (!policy?.autoMerge || policy.requireHumanApproval)
121
+ continue;
122
+ if (entry.layers.includes(pushedId))
123
+ continue;
124
+ // Idempotency: a candidate that already landed via a three-way merge
125
+ // is represented by its MERGE layer, not its own id — re-merging it
126
+ // would append a duplicate (usually empty) merge layer per re-push.
127
+ const alreadyMerged = entry.layers.some((layerId) => {
128
+ try {
129
+ return getProvenance(registry.loadLayer(layerId))?.merge?.candidate === pushedId;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ });
135
+ if (alreadyMerged)
136
+ continue;
137
+ try {
138
+ const outcome = mergeIntoRef(registry, {
139
+ candidateId: pushedId,
140
+ into: name,
141
+ principal: 'registry-automerge',
142
+ });
143
+ if (outcome.status === 'fast-forward' || outcome.status === 'merged') {
144
+ emitRegistryEvent(webhooks, 'ref.merged', {
145
+ ref: name,
146
+ candidate: pushedId,
147
+ status: outcome.status,
148
+ auto: true,
149
+ ...(outcome.status === 'merged' ? { merge_layer: outcome.mergeLayerId } : {}),
150
+ });
151
+ }
152
+ }
153
+ catch {
154
+ // Contained by contract (see above).
155
+ }
156
+ }
157
+ }
158
+ /** Runtime shape validation for review decisions; undefined = invalid. */
159
+ function parseReviewDecision(value) {
160
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
161
+ return undefined;
162
+ const raw = value;
163
+ if (typeof raw.entity !== 'string' || (raw.decision !== 'accept' && raw.decision !== 'reject')) {
164
+ return undefined;
165
+ }
166
+ const decision = { entity: raw.entity, decision: raw.decision };
167
+ if (raw.componentKey !== undefined) {
168
+ if (typeof raw.componentKey !== 'string')
169
+ return undefined;
170
+ decision.componentKey = raw.componentKey;
171
+ }
172
+ if (raw.comment !== undefined) {
173
+ if (typeof raw.comment !== 'string')
174
+ return undefined;
175
+ decision.comment = raw.comment;
176
+ }
177
+ return decision;
178
+ }
179
+ /**
180
+ * Handle a registry request. Returns false (untouched response) when the
181
+ * path is not a registry path, true when a response was written.
182
+ */
183
+ export async function handleLayerRegistryRequest(req, res, opts) {
184
+ const url = new URL(req.url ?? '/', 'http://localhost');
185
+ if (!url.pathname.startsWith(BASE))
186
+ return false;
187
+ const rawSegments = url.pathname.slice(BASE.length).split('/').filter(Boolean);
188
+ const [head] = rawSegments;
189
+ if (head !== 'layers' && head !== 'refs' && head !== 'reviews' && head !== 'reports')
190
+ return false;
191
+ let segments;
192
+ try {
193
+ segments = rawSegments.map(decodeURIComponent);
194
+ }
195
+ catch (err) {
196
+ // Malformed percent-escapes are a client error, not a server fault.
197
+ return json(res, 400, {
198
+ error: `malformed percent-encoding in path: ${err instanceof Error ? err.message : String(err)}`,
199
+ });
200
+ }
201
+ const method = req.method ?? 'GET';
202
+ let principal = null;
203
+ if (opts.authorize) {
204
+ principal = await opts.authorize(extractToken(req), method);
205
+ if (!principal)
206
+ return json(res, 401, { error: 'unauthorized' });
207
+ }
208
+ const registry = opts.registry;
209
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
210
+ const webhooks = opts.webhooks ?? [];
211
+ // ----- layers ------------------------------------------------------------
212
+ if (head === 'layers') {
213
+ if (method === 'GET' && segments.length === 1) {
214
+ return json(res, 200, { layers: registry.listLayers() });
215
+ }
216
+ if (method === 'GET' && segments.length === 2) {
217
+ const id = segments[1].startsWith('blake3:') ? segments[1] : `blake3:${segments[1]}`;
218
+ if (!registry.hasLayer(id))
219
+ return json(res, 404, { error: `no layer ${id}` });
220
+ return json(res, 200, registry.loadLayer(id));
221
+ }
222
+ if (method === 'POST' && segments.length === 1) {
223
+ const text = await readBody(req, maxBytes);
224
+ if (text === null)
225
+ return json(res, 413, { error: `body exceeds ${maxBytes} bytes` });
226
+ const parsed = parseJson(text);
227
+ if (parsed.error !== undefined)
228
+ return json(res, 400, { error: `invalid JSON body: ${parsed.error}` });
229
+ const file = parsed.value;
230
+ if (!file || typeof file.header !== 'object' || !Array.isArray(file.data)) {
231
+ return json(res, 400, { error: 'body must be an IFCX layer document' });
232
+ }
233
+ // Bind the manifest author to the push credential: provenance is
234
+ // otherwise self-asserted, and a spoofed author.principal defeats
235
+ // both the no-self-approval guard and requireHumanApproval (the
236
+ // approver "differs" from a name the real author invented). A
237
+ // manifest-less push would dodge the binding entirely — and later
238
+ // dodge the self-approval separation (no author to compare) — so
239
+ // authenticated pushes must carry provenance. Only enforceable when
240
+ // the deployment authenticates real identities; the anonymous dev
241
+ // sentinel is not a credential to bind against.
242
+ if (principal && principal.userId !== ANONYMOUS_USER_ID) {
243
+ const manifest = getProvenance(file);
244
+ if (!manifest) {
245
+ return json(res, 400, {
246
+ error: 'authenticated pushes must carry a provenance manifest (author identity is bound to the credential)',
247
+ });
248
+ }
249
+ if (manifest.author.principal !== principal.userId) {
250
+ return json(res, 403, {
251
+ error: `manifest author.principal "${manifest.author.principal}" must match the authenticated principal "${principal.userId}"`,
252
+ });
253
+ }
254
+ }
255
+ try {
256
+ const id = registry.push(file);
257
+ emitRegistryEvent(webhooks, 'layer.pushed', { id });
258
+ runAutoMerges(registry, id, webhooks);
259
+ return json(res, 201, { id });
260
+ }
261
+ catch (err) {
262
+ const handled = handlePushError(res, err);
263
+ if (handled)
264
+ return handled;
265
+ // Content that cannot be canonicalized (non-finite numbers, exotic
266
+ // value types) is a client error, not a server fault.
267
+ if (err instanceof Error && err.message.includes('canonicalizable')) {
268
+ return json(res, 400, { error: err.message });
269
+ }
270
+ throw err;
271
+ }
272
+ }
273
+ return json(res, 405, { error: `unsupported ${method} on layers` });
274
+ }
275
+ // ----- reports (check evidence, 08-review.md §8.4) ------------------------
276
+ // The IDS spec/report files whose digests provenance `checks` entries
277
+ // carry. Content-addressed under the digest the manifest already names,
278
+ // digest-verified on write, immutable. Evidence is text (IDS XML, report
279
+ // JSON), so the utf-8 body path is safe.
280
+ if (head === 'reports') {
281
+ if (segments.length !== 2) {
282
+ return json(res, method === 'GET' || method === 'PUT' ? 404 : 405, {
283
+ error: `reports are addressed by digest: ${method} /api/v1/reports/<blake3:hex>`,
284
+ });
285
+ }
286
+ const digest = segments[1].startsWith('blake3:') ? segments[1] : `blake3:${segments[1]}`;
287
+ if (method === 'GET') {
288
+ if (!registry.getReport)
289
+ return json(res, 501, { error: 'this registry store does not persist check evidence' });
290
+ const bytes = registry.getReport(digest);
291
+ if (bytes === undefined)
292
+ return json(res, 404, { error: `no report ${digest}` });
293
+ res.writeHead(200, {
294
+ 'content-type': 'application/octet-stream',
295
+ 'content-length': String(bytes.byteLength),
296
+ 'x-report-digest': digest,
297
+ });
298
+ res.end(Buffer.from(bytes));
299
+ return true;
300
+ }
301
+ if (method === 'PUT') {
302
+ if (!registry.putReport)
303
+ return json(res, 501, { error: 'this registry store does not persist check evidence' });
304
+ const text = await readBody(req, maxBytes);
305
+ if (text === null)
306
+ return json(res, 413, { error: `body exceeds ${maxBytes} bytes` });
307
+ try {
308
+ return json(res, 201, { digest: registry.putReport(digest, Buffer.from(text, 'utf-8')) });
309
+ }
310
+ catch (err) {
311
+ const handled = handlePushError(res, err);
312
+ if (handled)
313
+ return handled;
314
+ throw err;
315
+ }
316
+ }
317
+ return json(res, 405, { error: `unsupported ${method} on reports` });
318
+ }
319
+ // ----- refs --------------------------------------------------------------
320
+ if (head === 'refs') {
321
+ if (method === 'GET' && segments.length === 1) {
322
+ return json(res, 200, { refs: registry.listRefs() });
323
+ }
324
+ if (method === 'PUT' && segments.length === 2) {
325
+ const text = await readBody(req, maxBytes);
326
+ if (text === null)
327
+ return json(res, 413, { error: `body exceeds ${maxBytes} bytes` });
328
+ const parsed = parseJson(text);
329
+ if (parsed.error !== undefined)
330
+ return json(res, 400, { error: `invalid JSON body: ${parsed.error}` });
331
+ const body = parsed.value;
332
+ if (!body ||
333
+ (body.layers !== undefined &&
334
+ !(Array.isArray(body.layers) && body.layers.every((id) => typeof id === 'string')))) {
335
+ return json(res, 400, { error: 'body must be { layers?: string[], policy?: RefPolicy }' });
336
+ }
337
+ const policy = body.policy === undefined ? undefined : parseRefPolicy(body.policy);
338
+ if (body.policy !== undefined && policy === undefined) {
339
+ return json(res, 400, {
340
+ error: 'policy must be { requireHumanApproval?: boolean, requiredChecks?: string[] }',
341
+ });
342
+ }
343
+ const name = segments[1];
344
+ const existing = registry.getRef(name);
345
+ const layers = body.layers ?? existing?.layers ?? [];
346
+ const missing = layers.filter((id) => !registry.hasLayer(id));
347
+ if (missing.length > 0)
348
+ return json(res, 422, { error: `unknown layer(s): ${missing.join(', ')}` });
349
+ // Policy-protected refs only move through the merge endpoint — that
350
+ // is where required checks and approval rules are enforced.
351
+ const layersChanged = existing !== undefined && JSON.stringify(existing.layers) !== JSON.stringify(layers);
352
+ if (existing?.policy && layersChanged) {
353
+ return json(res, 409, {
354
+ error: `ref ${name} is policy-protected; move it via POST ${BASE}refs/${name}/merge`,
355
+ });
356
+ }
357
+ // A protected ref's policy is immutable through this API in v1:
358
+ // letting any write principal PUT `{ "policy": {} }` would strip
359
+ // requireHumanApproval/requiredChecks and neuter the merge gate.
360
+ // (Idempotent re-PUT of the identical policy is allowed.)
361
+ if (existing?.policy &&
362
+ policy !== undefined &&
363
+ JSON.stringify(existing.policy) !== JSON.stringify(policy)) {
364
+ return json(res, 409, {
365
+ error: `ref ${name} is policy-protected; its policy cannot be changed via PUT`,
366
+ });
367
+ }
368
+ const entry = {
369
+ layers,
370
+ ...(policy ? { policy } : existing?.policy ? { policy: existing.policy } : {}),
371
+ };
372
+ try {
373
+ registry.setRef(name, entry);
374
+ }
375
+ catch (err) {
376
+ const handled = handlePushError(res, err);
377
+ if (handled)
378
+ return handled;
379
+ throw err;
380
+ }
381
+ if (body?.layers !== undefined) {
382
+ emitRegistryEvent(webhooks, 'ref.moved', { ref: name, layers: entry.layers });
383
+ }
384
+ return json(res, existing ? 200 : 201, { ref: name, ...entry });
385
+ }
386
+ if (method === 'GET' && segments.length === 2) {
387
+ const entry = registry.getRef(segments[1]);
388
+ if (!entry)
389
+ return json(res, 404, { error: `no ref ${segments[1]}` });
390
+ return json(res, 200, { ref: segments[1], ...entry });
391
+ }
392
+ if (method === 'POST' && segments.length === 3 && segments[2] === 'merge') {
393
+ const text = await readBody(req, maxBytes);
394
+ if (text === null)
395
+ return json(res, 413, { error: `body exceeds ${maxBytes} bytes` });
396
+ const parsed = parseJson(text);
397
+ if (parsed.error !== undefined)
398
+ return json(res, 400, { error: `invalid JSON body: ${parsed.error}` });
399
+ const body = parsed.value;
400
+ if (!body?.candidate)
401
+ return json(res, 400, { error: 'body must include { candidate: <layer id> }' });
402
+ if (!registry.getRef(segments[1]))
403
+ return json(res, 404, { error: `no ref ${segments[1]}` });
404
+ if (!registry.hasLayer(body.candidate))
405
+ return json(res, 404, { error: `no layer ${body.candidate}` });
406
+ const init = { candidateId: body.candidate, into: segments[1] };
407
+ if (body.preview)
408
+ init.preview = true;
409
+ if (body.resolve === 'ours' || body.resolve === 'theirs')
410
+ init.resolve = body.resolve;
411
+ // Per-conflict resolutions from the review UI: strictly-shaped
412
+ // ours/theirs decisions, or edit-in-place with the replacement
413
+ // attributes (08-review.md §8.3). The engine re-validates edited
414
+ // targets (componentKey-scoped, non-relation) when applying.
415
+ if (body.resolutions !== undefined) {
416
+ if (!Array.isArray(body.resolutions)) {
417
+ return json(res, 400, { error: 'resolutions must be an array of { path, component_key?, choice }' });
418
+ }
419
+ const parsedResolutions = [];
420
+ for (const item of body.resolutions) {
421
+ if (typeof item !== 'object' || item === null) {
422
+ return json(res, 400, { error: 'each resolution must be { path, component_key?, choice: "ours" | "theirs" | "edited" }' });
423
+ }
424
+ const raw = item;
425
+ const choice = raw.choice;
426
+ if (typeof raw.path !== 'string' || (choice !== 'ours' && choice !== 'theirs' && choice !== 'edited')) {
427
+ return json(res, 400, { error: 'each resolution must be { path, component_key?, choice: "ours" | "theirs" | "edited" }' });
428
+ }
429
+ if (raw.component_key !== undefined && typeof raw.component_key !== 'string') {
430
+ return json(res, 400, { error: 'component_key must be a string when present' });
431
+ }
432
+ if (choice === 'edited') {
433
+ if (typeof raw.component_key !== 'string' ||
434
+ typeof raw.attributes !== 'object' ||
435
+ raw.attributes === null ||
436
+ Array.isArray(raw.attributes)) {
437
+ return json(res, 400, {
438
+ error: 'an edited resolution must be { path, component_key, choice: "edited", attributes: { ... } }',
439
+ });
440
+ }
441
+ }
442
+ parsedResolutions.push({
443
+ path: raw.path,
444
+ choice,
445
+ ...(raw.component_key !== undefined ? { componentKey: raw.component_key } : {}),
446
+ ...(choice === 'edited'
447
+ ? { attributes: raw.attributes }
448
+ : {}),
449
+ });
450
+ }
451
+ init.resolutions = parsedResolutions;
452
+ }
453
+ if (Array.isArray(body.waivers))
454
+ init.waivers = body.waivers;
455
+ if (body.allow_unrelated)
456
+ init.allowUnrelated = true;
457
+ if (principal)
458
+ init.principal = principal.userId;
459
+ // `requireHumanApproval` derives from server-verified state — an
460
+ // approved review object for this (candidate, ref), recorded by the
461
+ // feedback endpoint with the approver's authenticated identity. A
462
+ // caller-asserted approved_by body field would let any write-capable
463
+ // agent bypass the branch protection (unlike the CLI, where the
464
+ // local store's operator IS the approver). Only the LATEST review
465
+ // for the pair is authoritative: a stale approval must not outlive
466
+ // a newer review that was reopened or marked changes-requested.
467
+ const latestReview = registry
468
+ .listReviews()
469
+ .filter((r) => r.layerId === body.candidate && r.into === segments[1])
470
+ .reduce((acc, r) => (acc === undefined || r.openedAt >= acc.openedAt ? r : acc), undefined);
471
+ if (latestReview?.status === 'approved' && latestReview.approvedBy !== undefined) {
472
+ init.approvedBy = latestReview.approvedBy;
473
+ }
474
+ // The shared flow's requireHumanApproval only fires for
475
+ // `author.kind === 'agent'`, but the kind is self-asserted — an
476
+ // agent that claims to be human would skip the gate. The registry
477
+ // cannot attest species, so for protected refs it requires an
478
+ // approval for EVERY candidate, from a principal other than the
479
+ // (push-credential-bound) author. Previews stay read-only.
480
+ const targetRef = registry.getRef(segments[1]);
481
+ if (targetRef?.policy?.requireHumanApproval && !body.preview) {
482
+ if (init.approvedBy === undefined) {
483
+ return json(res, 403, {
484
+ status: 'policy-failure',
485
+ reason: `ref ${segments[1]} requires an approved review for every merge candidate`,
486
+ });
487
+ }
488
+ const candidateAuthor = getProvenance(registry.loadLayer(body.candidate))?.author.principal;
489
+ if (candidateAuthor === undefined) {
490
+ // Unknown authorship means approver-vs-author separation cannot
491
+ // be verified — fail closed rather than let a de-facto author
492
+ // approve their own manifest-stripped layer.
493
+ return json(res, 403, {
494
+ status: 'policy-failure',
495
+ reason: `candidate ${body.candidate} carries no provenance author; requireHumanApproval refs need attributable candidates`,
496
+ });
497
+ }
498
+ if (init.approvedBy === candidateAuthor) {
499
+ return json(res, 403, {
500
+ status: 'policy-failure',
501
+ reason: `approval by the layer author ${candidateAuthor} does not satisfy requireHumanApproval`,
502
+ });
503
+ }
504
+ }
505
+ let outcome;
506
+ try {
507
+ outcome = mergeIntoRef(registry, init);
508
+ }
509
+ catch (err) {
510
+ const handled = handlePushError(res, err);
511
+ if (handled)
512
+ return handled;
513
+ // The engine refuses malformed edited resolutions (entity-level
514
+ // conflict, relation pseudo-component): a client error, not 500.
515
+ if (err instanceof Error && err.message.includes('edited resolution')) {
516
+ return json(res, 400, { error: err.message });
517
+ }
518
+ throw err;
519
+ }
520
+ switch (outcome.status) {
521
+ case 'fast-forward':
522
+ emitRegistryEvent(webhooks, 'ref.merged', {
523
+ ref: segments[1],
524
+ candidate: body.candidate,
525
+ status: outcome.status,
526
+ auto: false,
527
+ });
528
+ return json(res, 200, { status: outcome.status, layers: outcome.refLayers });
529
+ case 'merged':
530
+ emitRegistryEvent(webhooks, 'ref.merged', {
531
+ ref: segments[1],
532
+ candidate: body.candidate,
533
+ status: outcome.status,
534
+ auto: false,
535
+ merge_layer: outcome.mergeLayerId,
536
+ });
537
+ return json(res, 200, {
538
+ status: outcome.status,
539
+ merge_layer: outcome.mergeLayerId,
540
+ layers: outcome.refLayers,
541
+ ancestor_matched: outcome.ancestorMatched,
542
+ });
543
+ case 'preview':
544
+ return json(res, 200, { status: outcome.status, plan: outcome.plan });
545
+ case 'conflicts':
546
+ return json(res, 409, { status: outcome.status, conflicts: outcome.conflicts });
547
+ case 'policy-failure':
548
+ return json(res, 403, { status: outcome.status, reason: outcome.reason });
549
+ case 'unrelated-base':
550
+ return json(res, 422, { status: outcome.status, declared_base: outcome.declaredBase });
551
+ }
552
+ }
553
+ return json(res, 405, { error: `unsupported ${method} on refs` });
554
+ }
555
+ // ----- reviews -----------------------------------------------------------
556
+ if (method === 'GET' && segments.length === 1) {
557
+ return json(res, 200, { reviews: registry.listReviews() });
558
+ }
559
+ if (method === 'GET' && segments.length === 2) {
560
+ const review = registry.getReview(segments[1]);
561
+ if (!review)
562
+ return json(res, 404, { error: `no review ${segments[1]}` });
563
+ return json(res, 200, review);
564
+ }
565
+ if (method === 'POST' && segments.length === 1) {
566
+ const text = await readBody(req, maxBytes);
567
+ if (text === null)
568
+ return json(res, 413, { error: `body exceeds ${maxBytes} bytes` });
569
+ const parsed = parseJson(text);
570
+ if (parsed.error !== undefined)
571
+ return json(res, 400, { error: `invalid JSON body: ${parsed.error}` });
572
+ const body = parsed.value;
573
+ if (!body?.layer_id || !body.into) {
574
+ return json(res, 400, { error: 'body must include { layer_id, into }' });
575
+ }
576
+ if (!registry.hasLayer(body.layer_id))
577
+ return json(res, 404, { error: `no layer ${body.layer_id}` });
578
+ if (!registry.getRef(body.into))
579
+ return json(res, 404, { error: `no ref ${body.into}` });
580
+ const review = {
581
+ id: crypto.randomUUID(),
582
+ layerId: body.layer_id,
583
+ into: body.into,
584
+ reviewers: Array.isArray(body.reviewers) ? body.reviewers : [],
585
+ status: 'open',
586
+ feedback: [],
587
+ ...(principal ? { openedBy: principal.userId } : {}),
588
+ openedAt: new Date().toISOString(),
589
+ };
590
+ try {
591
+ registry.putReview(review);
592
+ }
593
+ catch (err) {
594
+ const handled = handlePushError(res, err);
595
+ if (handled)
596
+ return handled;
597
+ throw err;
598
+ }
599
+ emitRegistryEvent(webhooks, 'review.opened', { id: review.id, layer_id: review.layerId, into: review.into });
600
+ return json(res, 201, { id: review.id });
601
+ }
602
+ // Review comments as BCF topics bound to (review, entity, componentKey?)
603
+ // per 08-review.md §8.6. Reads are open to any authenticated principal
604
+ // (agents consume them via get_review_feedback); writes follow the same
605
+ // named-reviewers gate as decisions.
606
+ if (segments.length === 3 && segments[2] === 'topics') {
607
+ const review = registry.getReview(segments[1]);
608
+ if (!review)
609
+ return json(res, 404, { error: `no review ${segments[1]}` });
610
+ if (method === 'GET') {
611
+ return json(res, 200, { topics: review.topics ?? [] });
612
+ }
613
+ if (method === 'POST') {
614
+ const actor = principal?.userId ?? 'anonymous';
615
+ if (review.reviewers.length > 0 && !review.reviewers.includes(actor)) {
616
+ return json(res, 403, { error: `only the named reviewers may act on review ${review.id}` });
617
+ }
618
+ const text = await readBody(req, maxBytes);
619
+ if (text === null)
620
+ return json(res, 413, { error: `body exceeds ${maxBytes} bytes` });
621
+ const parsed = parseJson(text);
622
+ if (parsed.error !== undefined)
623
+ return json(res, 400, { error: `invalid JSON body: ${parsed.error}` });
624
+ const body = parsed.value;
625
+ if (!body || typeof body.title !== 'string' || body.title.trim().length === 0 || typeof body.entity !== 'string') {
626
+ return json(res, 400, { error: 'body must include { title: string, entity: string }' });
627
+ }
628
+ if (body.description !== undefined && typeof body.description !== 'string') {
629
+ return json(res, 400, { error: 'description must be a string when present' });
630
+ }
631
+ if (body.component_key !== undefined && typeof body.component_key !== 'string') {
632
+ return json(res, 400, { error: 'component_key must be a string when present' });
633
+ }
634
+ if (body.viewpoint !== undefined &&
635
+ (typeof body.viewpoint !== 'object' || body.viewpoint === null || Array.isArray(body.viewpoint))) {
636
+ return json(res, 400, { error: 'viewpoint must be an object when present' });
637
+ }
638
+ const topic = {
639
+ guid: crypto.randomUUID(),
640
+ title: body.title.trim(),
641
+ entity: body.entity,
642
+ ...(body.description !== undefined ? { description: body.description } : {}),
643
+ ...(body.component_key !== undefined ? { componentKey: body.component_key } : {}),
644
+ ...(principal ? { author: principal.userId } : {}),
645
+ createdAt: new Date().toISOString(),
646
+ ...(body.viewpoint !== undefined ? { viewpoint: body.viewpoint } : {}),
647
+ };
648
+ review.topics = [...(review.topics ?? []), topic];
649
+ try {
650
+ registry.putReview(review);
651
+ }
652
+ catch (err) {
653
+ const handled = handlePushError(res, err);
654
+ if (handled)
655
+ return handled;
656
+ throw err;
657
+ }
658
+ emitRegistryEvent(webhooks, 'review.commented', { id: review.id, guid: topic.guid, entity: topic.entity });
659
+ return json(res, 201, { guid: topic.guid, topic_count: review.topics.length });
660
+ }
661
+ return json(res, 405, { error: `unsupported ${method} on review topics` });
662
+ }
663
+ if (method === 'POST' && segments.length === 3 && segments[2] === 'feedback') {
664
+ const review = registry.getReview(segments[1]);
665
+ if (!review)
666
+ return json(res, 404, { error: `no review ${segments[1]}` });
667
+ // When the review names reviewers, only they may act on it.
668
+ const actor = principal?.userId ?? 'anonymous';
669
+ if (review.reviewers.length > 0 && !review.reviewers.includes(actor)) {
670
+ return json(res, 403, { error: `only the named reviewers may act on review ${review.id}` });
671
+ }
672
+ const text = await readBody(req, maxBytes);
673
+ if (text === null)
674
+ return json(res, 413, { error: `body exceeds ${maxBytes} bytes` });
675
+ const parsed = parseJson(text);
676
+ if (parsed.error !== undefined)
677
+ return json(res, 400, { error: `invalid JSON body: ${parsed.error}` });
678
+ const body = parsed.value;
679
+ if (!body || !Array.isArray(body.decisions)) {
680
+ return json(res, 400, { error: 'body must include { decisions: [...] }' });
681
+ }
682
+ // Stored reviews are a contract for downstream tooling: reject unknown
683
+ // decision shapes and status values instead of persisting them verbatim.
684
+ const decisions = [];
685
+ for (const item of body.decisions) {
686
+ const decision = parseReviewDecision(item);
687
+ if (!decision) {
688
+ return json(res, 400, {
689
+ error: 'each decision must be { entity: string, decision: "accept" | "reject", componentKey?: string, comment?: string }',
690
+ });
691
+ }
692
+ decisions.push(decision);
693
+ }
694
+ if (body.status !== undefined && body.status !== 'approved' && body.status !== 'changes-requested') {
695
+ return json(res, 400, { error: 'status must be "approved" or "changes-requested"' });
696
+ }
697
+ if (body.status === 'approved') {
698
+ // No self-approval: the layer's manifest author cannot satisfy the
699
+ // approval its own merge needs. (Human-vs-agent identity of the
700
+ // approver is the auth provider's responsibility — the registry
701
+ // enforces attributability and separation, not species.)
702
+ const layer = registry.hasLayer(review.layerId) ? registry.loadLayer(review.layerId) : undefined;
703
+ const author = layer ? getProvenance(layer)?.author.principal : undefined;
704
+ if (author !== undefined && author === actor) {
705
+ return json(res, 403, { error: `layer author ${author} cannot approve their own review` });
706
+ }
707
+ }
708
+ review.feedback.push(...decisions);
709
+ if (body.status === 'approved' || body.status === 'changes-requested') {
710
+ review.status = body.status;
711
+ // Approval identity is server-recorded, never caller-asserted: the
712
+ // merge endpoint reads it back for requireHumanApproval policies.
713
+ if (body.status === 'approved')
714
+ review.approvedBy = actor;
715
+ else
716
+ delete review.approvedBy;
717
+ }
718
+ registry.putReview(review);
719
+ emitRegistryEvent(webhooks, 'review.updated', {
720
+ id: review.id,
721
+ status: review.status,
722
+ decision_count: review.feedback.length,
723
+ });
724
+ return json(res, 200, { id: review.id, status: review.status, decision_count: review.feedback.length });
725
+ }
726
+ return json(res, 405, { error: `unsupported ${method} on reviews` });
727
+ }
728
+ //# sourceMappingURL=layer-registry-route.js.map