@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.
- package/README.md +4 -1
- package/dist/auth.d.ts +2 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +3 -1
- package/dist/auth.js.map +1 -1
- package/dist/bin.js +29 -2
- package/dist/bin.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/layer-registry-fs.d.ts +37 -0
- package/dist/layer-registry-fs.d.ts.map +1 -0
- package/dist/layer-registry-fs.js +240 -0
- package/dist/layer-registry-fs.js.map +1 -0
- package/dist/layer-registry-route.d.ts +49 -0
- package/dist/layer-registry-route.d.ts.map +1 -0
- package/dist/layer-registry-route.js +728 -0
- package/dist/layer-registry-route.js.map +1 -0
- package/dist/layer-registry.d.ts +123 -0
- package/dist/layer-registry.d.ts.map +1 -0
- package/dist/layer-registry.js +168 -0
- package/dist/layer-registry.js.map +1 -0
- package/dist/registry-webhooks.d.ts +20 -0
- package/dist/registry-webhooks.d.ts.map +1 -0
- package/dist/registry-webhooks.js +62 -0
- package/dist/registry-webhooks.js.map +1 -0
- package/dist/room-token.d.ts +14 -0
- package/dist/room-token.d.ts.map +1 -1
- package/dist/room-token.js +30 -0
- package/dist/room-token.js.map +1 -1
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +56 -0
- package/dist/server.js.map +1 -1
- package/package.json +4 -2
|
@@ -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
|