@brightbeamai/chap-coordinator 0.3.4 → 0.3.5
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 +54 -8
- package/dist/{api-59aU01Bs.d.cts → api-CjZQnj63.d.cts} +128 -14
- package/dist/{api-CxciGFHA.d.ts → api-DG0VFGVr.d.ts} +128 -14
- package/dist/api.cjs +3 -1
- package/dist/api.cjs.map +1 -1
- package/dist/api.d.cts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/api.js +3 -1
- package/dist/api.js.map +1 -1
- package/dist/index.cjs +1336 -219
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -5
- package/dist/index.d.ts +19 -5
- package/dist/index.js +1336 -219
- package/dist/index.js.map +1 -1
- package/dist/jsonrpc.cjs +1 -0
- package/dist/jsonrpc.cjs.map +1 -1
- package/dist/jsonrpc.d.cts +1 -0
- package/dist/jsonrpc.d.ts +1 -0
- package/dist/jsonrpc.js +1 -0
- package/dist/jsonrpc.js.map +1 -1
- package/dist/methods.cjs.map +1 -1
- package/dist/methods.d.cts +36 -3
- package/dist/methods.d.ts +36 -3
- package/dist/storage/sqlite.cjs +81 -19
- package/dist/storage/sqlite.cjs.map +1 -1
- package/dist/storage/sqlite.d.cts +19 -9
- package/dist/storage/sqlite.d.ts +19 -9
- package/dist/storage/sqlite.js +81 -19
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/storage/store.cjs +20 -15
- package/dist/storage/store.cjs.map +1 -1
- package/dist/storage/store.d.cts +24 -6
- package/dist/storage/store.d.ts +24 -6
- package/dist/storage/store.js +20 -15
- package/dist/storage/store.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +56 -1
- package/dist/types.d.ts +56 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -62,7 +62,7 @@ module.exports = __toCommonJS(src_exports);
|
|
|
62
62
|
var import_node_crypto = require("crypto");
|
|
63
63
|
var ZERO_HASH = "sha256:" + "0".repeat(64);
|
|
64
64
|
var NON_INTEGER_ERROR = 'CHAP canonical numbers must be integers; represent decimals as strings (e.g. "8.2") so the hash is deterministic across implementations.';
|
|
65
|
-
var NUMBER_RANGE_ERROR = "CHAP canonical integers must be within the safe-integer range (abs value <=
|
|
65
|
+
var NUMBER_RANGE_ERROR = "CHAP canonical integers must be within the safe-integer range (abs value <= 2^53 - 1); represent larger numbers as strings.";
|
|
66
66
|
var LONE_SURROGATE_ERROR = "CHAP canonical strings must be valid Unicode (I-JSON / RFC 7493); unpaired UTF-16 surrogates are not permitted.";
|
|
67
67
|
function assertNoLoneSurrogate(s) {
|
|
68
68
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -83,7 +83,7 @@ function canonicalString(s) {
|
|
|
83
83
|
function canonicalNumber(n) {
|
|
84
84
|
if (!Number.isFinite(n)) {
|
|
85
85
|
throw new Error(
|
|
86
|
-
"Non-finite numbers are not permitted in a CHAP canonical value"
|
|
86
|
+
"Non-finite numbers are not permitted in a CHAP canonical value."
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
if (!Number.isInteger(n)) {
|
|
@@ -156,6 +156,7 @@ var OWNING_PROFILE = Object.freeze({
|
|
|
156
156
|
"participant.leave": "core/1.0",
|
|
157
157
|
"participant.revoke_key": "core/1.0",
|
|
158
158
|
"participant.rotate_key": "core/1.0",
|
|
159
|
+
"review.acknowledge": "review/1.0",
|
|
159
160
|
"review.depth": "routing/1.0",
|
|
160
161
|
"review.request": "review/1.0",
|
|
161
162
|
"task.complete": "core/1.0",
|
|
@@ -166,6 +167,7 @@ var OWNING_PROFILE = Object.freeze({
|
|
|
166
167
|
"whisper.ask": "whisper/1.0",
|
|
167
168
|
"workspace.create": "core/1.0",
|
|
168
169
|
"workspace.describe": "core/1.0",
|
|
170
|
+
"workspace.invite": "core/1.0",
|
|
169
171
|
"workspace.set_profiles": "core/1.0"
|
|
170
172
|
});
|
|
171
173
|
var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
|
|
@@ -174,6 +176,20 @@ var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
|
|
|
174
176
|
"audit.verify_chain",
|
|
175
177
|
"audit.verify_receipt"
|
|
176
178
|
]);
|
|
179
|
+
var PROFILE_VERSIONS = Object.freeze({
|
|
180
|
+
"audit-scitt": "1.0",
|
|
181
|
+
"control": "1.0",
|
|
182
|
+
"core": "1.0",
|
|
183
|
+
"deliberation": "1.0",
|
|
184
|
+
"handoff": "1.0",
|
|
185
|
+
"identity-oidc": "1.0",
|
|
186
|
+
"identity-vc": "1.0",
|
|
187
|
+
"modes": "1.0",
|
|
188
|
+
"review": "1.0",
|
|
189
|
+
"routing": "1.0",
|
|
190
|
+
"security-signed": "1.0",
|
|
191
|
+
"whisper": "1.0"
|
|
192
|
+
});
|
|
177
193
|
|
|
178
194
|
// src/crypto.ts
|
|
179
195
|
var import_node_crypto2 = require("crypto");
|
|
@@ -225,21 +241,132 @@ function signEnvelope(canonical, privateKey, kid) {
|
|
|
225
241
|
const sig = (0, import_node_crypto2.sign)(null, canonical, privateKey);
|
|
226
242
|
return `ed25519:${kid}:${sig.toString("base64")}`;
|
|
227
243
|
}
|
|
244
|
+
function strictBase64(s) {
|
|
245
|
+
const bytes = Buffer.from(s, "base64");
|
|
246
|
+
return bytes.toString("base64") === s ? bytes : void 0;
|
|
247
|
+
}
|
|
228
248
|
function verifyEnvelope(canonical, sigTag, publicKey) {
|
|
229
249
|
if (!sigTag.startsWith("ed25519:")) return false;
|
|
230
250
|
const parts = sigTag.split(":");
|
|
231
251
|
if (parts.length < 2) return false;
|
|
232
|
-
const
|
|
252
|
+
const sig = strictBase64(parts[parts.length - 1]);
|
|
253
|
+
if (sig === void 0) return false;
|
|
233
254
|
try {
|
|
234
|
-
const sig = Buffer.from(sigB64, "base64");
|
|
235
255
|
return (0, import_node_crypto2.verify)(null, canonical, publicKey, sig);
|
|
236
256
|
} catch {
|
|
237
257
|
return false;
|
|
238
258
|
}
|
|
239
259
|
}
|
|
260
|
+
function isEd25519Key(jwk) {
|
|
261
|
+
if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return false;
|
|
262
|
+
const key = jwk;
|
|
263
|
+
return key.kty === "OKP" && key.crv === "Ed25519" && typeof key.x === "string" && COORDINATE.test(key.x);
|
|
264
|
+
}
|
|
240
265
|
function publicKeyFromJwk(jwk) {
|
|
241
266
|
return publicKeyFromRaw(b64urlDecode(jwk.x));
|
|
242
267
|
}
|
|
268
|
+
function jwkThumbprint(jwk) {
|
|
269
|
+
if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return void 0;
|
|
270
|
+
const key = jwk;
|
|
271
|
+
const names = key.kty === "OKP" ? ["crv", "kty", "x"] : key.kty === "EC" ? ["crv", "kty", "x", "y"] : void 0;
|
|
272
|
+
if (!names || names.some((n) => typeof key[n] !== "string")) return void 0;
|
|
273
|
+
const members = Object.fromEntries(names.map((n) => [n, key[n]]));
|
|
274
|
+
return b64urlNoPad((0, import_node_crypto2.createHash)("sha256").update(canonicalize(members)).digest());
|
|
275
|
+
}
|
|
276
|
+
var B64URL = /^[A-Za-z0-9_-]+$/;
|
|
277
|
+
function parseWebAuthnAssertion(part) {
|
|
278
|
+
const pieces = part.split(".");
|
|
279
|
+
if (pieces.length !== 3 || pieces.some((p) => !B64URL.test(p) || p.length % 4 === 1)) return void 0;
|
|
280
|
+
const [authenticatorData, clientDataJSON, signature] = pieces.map((p) => Buffer.from(p, "base64url"));
|
|
281
|
+
return { authenticatorData, clientDataJSON, signature };
|
|
282
|
+
}
|
|
283
|
+
var COORDINATE = /^[A-Za-z0-9_-]{43}$/;
|
|
284
|
+
function isWebAuthnKey(jwk) {
|
|
285
|
+
if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return false;
|
|
286
|
+
const key = jwk;
|
|
287
|
+
const w = key.webauthn;
|
|
288
|
+
return key.kty === "EC" && key.crv === "P-256" && typeof key.x === "string" && COORDINATE.test(key.x) && typeof key.y === "string" && COORDINATE.test(key.y) && (key.alg === void 0 || key.alg === "ES256") && w !== null && typeof w === "object" && !Array.isArray(w) && typeof w.rp_id === "string" && w.rp_id !== "" && (w.origins === void 0 || Array.isArray(w.origins) && w.origins.every((o) => typeof o === "string"));
|
|
289
|
+
}
|
|
290
|
+
var UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
291
|
+
var MAX_CLIENT_DATA_NESTING = 64;
|
|
292
|
+
function jsonDepth(text) {
|
|
293
|
+
let depth = 0;
|
|
294
|
+
let deepest = 0;
|
|
295
|
+
let inString = false;
|
|
296
|
+
for (let i = 0; i < text.length; i++) {
|
|
297
|
+
const ch = text[i];
|
|
298
|
+
if (inString) {
|
|
299
|
+
if (ch === "\\") i++;
|
|
300
|
+
else if (ch === '"') inString = false;
|
|
301
|
+
} else if (ch === '"') {
|
|
302
|
+
inString = true;
|
|
303
|
+
} else if (ch === "{" || ch === "[") {
|
|
304
|
+
deepest = Math.max(deepest, ++depth);
|
|
305
|
+
} else if (ch === "}" || ch === "]") {
|
|
306
|
+
depth--;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return deepest;
|
|
310
|
+
}
|
|
311
|
+
var UP = 1;
|
|
312
|
+
var UV = 4;
|
|
313
|
+
var BE = 8;
|
|
314
|
+
var BS = 16;
|
|
315
|
+
var AT = 64;
|
|
316
|
+
var ED = 128;
|
|
317
|
+
function webAuthnFailure(canonical, assertion, jwk) {
|
|
318
|
+
const w = jwk.webauthn;
|
|
319
|
+
if (!Array.isArray(w.origins) || w.origins.length === 0) return "WebAuthn key lists no origin";
|
|
320
|
+
let text;
|
|
321
|
+
try {
|
|
322
|
+
text = UTF8.decode(assertion.clientDataJSON);
|
|
323
|
+
} catch {
|
|
324
|
+
text = void 0;
|
|
325
|
+
}
|
|
326
|
+
if (text !== void 0 && jsonDepth(text) > MAX_CLIENT_DATA_NESTING) {
|
|
327
|
+
return `WebAuthn clientDataJSON nests deeper than ${MAX_CLIENT_DATA_NESTING} levels`;
|
|
328
|
+
}
|
|
329
|
+
let client;
|
|
330
|
+
try {
|
|
331
|
+
client = text === void 0 ? void 0 : JSON.parse(text);
|
|
332
|
+
} catch {
|
|
333
|
+
client = void 0;
|
|
334
|
+
}
|
|
335
|
+
if (client === null || typeof client !== "object" || Array.isArray(client)) {
|
|
336
|
+
return "WebAuthn clientDataJSON is not a JSON object";
|
|
337
|
+
}
|
|
338
|
+
const data = client;
|
|
339
|
+
if (data.type !== "webauthn.get") return "WebAuthn clientDataJSON type is not webauthn.get";
|
|
340
|
+
if (data.challenge !== b64urlNoPad((0, import_node_crypto2.createHash)("sha256").update(canonical).digest())) {
|
|
341
|
+
return "WebAuthn challenge is not the digest of this request";
|
|
342
|
+
}
|
|
343
|
+
if (!w.origins.includes(data.origin)) return "WebAuthn origin is not one the key allows";
|
|
344
|
+
if (data.crossOrigin === true || data.topOrigin !== void 0) {
|
|
345
|
+
return "WebAuthn assertion was made in a cross-origin frame";
|
|
346
|
+
}
|
|
347
|
+
const auth = assertion.authenticatorData;
|
|
348
|
+
if (auth.length < 37) return "WebAuthn authenticatorData is too short";
|
|
349
|
+
if (!auth.subarray(0, 32).equals((0, import_node_crypto2.createHash)("sha256").update(w.rp_id, "utf-8").digest())) {
|
|
350
|
+
return "WebAuthn rpIdHash does not match the key's rp_id";
|
|
351
|
+
}
|
|
352
|
+
const flags = auth[32];
|
|
353
|
+
if ((flags & (UP | UV)) !== (UP | UV)) return "WebAuthn assertion lacks user presence or verification";
|
|
354
|
+
if (flags & BS && !(flags & BE)) return "WebAuthn assertion is backed up but not backup eligible";
|
|
355
|
+
if (flags & AT) return "WebAuthn assertion carries attested credential data";
|
|
356
|
+
if (flags & ED ? auth.length === 37 : auth.length !== 37) {
|
|
357
|
+
return "WebAuthn authenticatorData does not end where its flags say";
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const key = (0, import_node_crypto2.createPublicKey)({
|
|
361
|
+
key: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y },
|
|
362
|
+
format: "jwk"
|
|
363
|
+
});
|
|
364
|
+
const signed = Buffer.concat([auth, (0, import_node_crypto2.createHash)("sha256").update(assertion.clientDataJSON).digest()]);
|
|
365
|
+
return (0, import_node_crypto2.verify)("sha256", signed, { key, dsaEncoding: "der" }, assertion.signature) ? null : "Signature failed verification";
|
|
366
|
+
} catch {
|
|
367
|
+
return "Signature failed verification";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
243
370
|
function deriveKeypair(uri) {
|
|
244
371
|
const seed = deriveSeed(uri);
|
|
245
372
|
const privateKey = privateKeyFromSeed(seed);
|
|
@@ -356,6 +483,7 @@ var E = {
|
|
|
356
483
|
SIG_KEY_REVOKED: -32072,
|
|
357
484
|
SIG_ROTATION_KEY_MISMATCH: -32073,
|
|
358
485
|
SIG_ARTEFACT_DIGEST_MISMATCH: -32074,
|
|
486
|
+
SIG_ALREADY_ACCEPTED: -32075,
|
|
359
487
|
// audit-scitt/1.0 profile (profiles/audit-scitt.md S8)
|
|
360
488
|
SCITT_UNREACHABLE: -32080,
|
|
361
489
|
SCITT_STATEMENT_REJECTED: -32081,
|
|
@@ -388,6 +516,44 @@ function isValidEnvelope(env) {
|
|
|
388
516
|
return e.jsonrpc === "2.0";
|
|
389
517
|
}
|
|
390
518
|
|
|
519
|
+
// src/params.ts
|
|
520
|
+
var NAMES = {
|
|
521
|
+
string: "a string",
|
|
522
|
+
strings: "a list of strings",
|
|
523
|
+
boolean: "a boolean",
|
|
524
|
+
object: "an object",
|
|
525
|
+
list: "a list",
|
|
526
|
+
integer: "an integer"
|
|
527
|
+
};
|
|
528
|
+
function isKind(value, kind) {
|
|
529
|
+
switch (kind) {
|
|
530
|
+
case "string":
|
|
531
|
+
return typeof value === "string";
|
|
532
|
+
case "strings":
|
|
533
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
534
|
+
case "boolean":
|
|
535
|
+
return typeof value === "boolean";
|
|
536
|
+
case "object":
|
|
537
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
538
|
+
case "list":
|
|
539
|
+
return Array.isArray(value);
|
|
540
|
+
case "integer":
|
|
541
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function paramsError(params, checks) {
|
|
545
|
+
for (const [name, kind] of checks) {
|
|
546
|
+
let value = params;
|
|
547
|
+
for (const part of name.split(".")) {
|
|
548
|
+
value = typeof value === "object" && value !== null && !Array.isArray(value) ? value[part] : void 0;
|
|
549
|
+
}
|
|
550
|
+
if (value !== void 0 && value !== null && !isKind(value, kind)) {
|
|
551
|
+
return { error: rpcError(E.PARAMS, `${name} must be ${NAMES[kind]}`) };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
391
557
|
// src/audit.ts
|
|
392
558
|
function entryRecord(entry) {
|
|
393
559
|
if (entry.outcome != null) return { outcome: entry.outcome, request: entry.request };
|
|
@@ -431,7 +597,8 @@ var UNRECORDED_CODES = /* @__PURE__ */ new Set([
|
|
|
431
597
|
E.SIG_VERIFY_FAILED,
|
|
432
598
|
E.SIG_KEY_NOT_FOUND,
|
|
433
599
|
E.SIG_KEY_REVOKED,
|
|
434
|
-
E.SIG_ROTATION_KEY_MISMATCH
|
|
600
|
+
E.SIG_ROTATION_KEY_MISMATCH,
|
|
601
|
+
E.SIG_ALREADY_ACCEPTED
|
|
435
602
|
]);
|
|
436
603
|
var UNRECORDED_METHODS = /* @__PURE__ */ new Set(["workspace.create", "participant.join"]);
|
|
437
604
|
function refusalIsRecorded(method, error, privileged) {
|
|
@@ -848,7 +1015,8 @@ var COLLECTIONS = [
|
|
|
848
1015
|
"deliberations",
|
|
849
1016
|
"handoffs",
|
|
850
1017
|
"snapshots",
|
|
851
|
-
"route_decisions"
|
|
1018
|
+
"route_decisions",
|
|
1019
|
+
"invitations"
|
|
852
1020
|
];
|
|
853
1021
|
var IDEMPOTENCY_KEYS = "idempotency_keys";
|
|
854
1022
|
var StoreConflictError = class extends Error {
|
|
@@ -859,7 +1027,7 @@ var StoreConflictError = class extends Error {
|
|
|
859
1027
|
base;
|
|
860
1028
|
constructor(workspace, held, base) {
|
|
861
1029
|
super(
|
|
862
|
-
`The store holds ${held} audit entries for workspace ${workspace} and this write expected ${base}, so nothing was written. Another coordinator may be writing to this workspace, the store may have been changed outside this coordinator
|
|
1030
|
+
`The store holds ${held} audit entries for workspace ${workspace} and this write expected ${base}, so nothing was written. Another coordinator may be writing to this workspace, or the store may have been changed outside this coordinator. A coordinator reloads the workspace and answers the call as failed, and the call may be sent again.`
|
|
863
1031
|
);
|
|
864
1032
|
this.name = "StoreConflictError";
|
|
865
1033
|
this.workspace = workspace;
|
|
@@ -936,20 +1104,24 @@ function assembleSnapshot(meta, entries, objects) {
|
|
|
936
1104
|
var MemoryStore = class {
|
|
937
1105
|
held = /* @__PURE__ */ new Map();
|
|
938
1106
|
load() {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
|
|
948
|
-
version: h.version,
|
|
949
|
-
updated_at: h.updated_at
|
|
950
|
-
});
|
|
1107
|
+
return [...this.held.keys()].map((id) => this.loadOne(id));
|
|
1108
|
+
}
|
|
1109
|
+
loadOne(id) {
|
|
1110
|
+
const h = this.held.get(id);
|
|
1111
|
+
if (!h) return void 0;
|
|
1112
|
+
const objects = [];
|
|
1113
|
+
for (const [collection, items] of h.objects) {
|
|
1114
|
+
for (const [key, value] of items) objects.push([collection, key, JSON.parse(value)]);
|
|
951
1115
|
}
|
|
952
|
-
return
|
|
1116
|
+
return {
|
|
1117
|
+
id,
|
|
1118
|
+
data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
|
|
1119
|
+
version: h.version,
|
|
1120
|
+
updated_at: h.updated_at
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
version(id) {
|
|
1124
|
+
return this.held.get(id)?.entries.length;
|
|
953
1125
|
}
|
|
954
1126
|
save(record) {
|
|
955
1127
|
this.apply(changeFromRecord(record));
|
|
@@ -1076,7 +1248,8 @@ function makeApi(coord) {
|
|
|
1076
1248
|
workspace: {
|
|
1077
1249
|
create: (p) => c("workspace.create", p),
|
|
1078
1250
|
describe: (p) => c("workspace.describe", p),
|
|
1079
|
-
set_profiles: (p) => c("workspace.set_profiles", p)
|
|
1251
|
+
set_profiles: (p) => c("workspace.set_profiles", p),
|
|
1252
|
+
invite: (p) => c("workspace.invite", p)
|
|
1080
1253
|
},
|
|
1081
1254
|
participant: {
|
|
1082
1255
|
join: (p) => c("participant.join", p),
|
|
@@ -1092,6 +1265,7 @@ function makeApi(coord) {
|
|
|
1092
1265
|
},
|
|
1093
1266
|
review: {
|
|
1094
1267
|
request: (p) => c("review.request", p),
|
|
1268
|
+
acknowledge: (p) => c("review.acknowledge", p),
|
|
1095
1269
|
depth: (p) => c("review.depth", p)
|
|
1096
1270
|
},
|
|
1097
1271
|
decide: {
|
|
@@ -1165,10 +1339,17 @@ function registerAuditScitt(coord) {
|
|
|
1165
1339
|
coord.handlers.set("audit.submit_to_scitt", (p) => {
|
|
1166
1340
|
const ws = coord.workspaces.get(p.workspace);
|
|
1167
1341
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1342
|
+
const bad = paramsError(p, [
|
|
1343
|
+
["range", "object"],
|
|
1344
|
+
["range.from_seq", "integer"],
|
|
1345
|
+
["range.to_seq", "integer"],
|
|
1346
|
+
["issuer", "string"]
|
|
1347
|
+
]);
|
|
1348
|
+
if (bad) return bad;
|
|
1168
1349
|
const range = p.range ?? {};
|
|
1169
1350
|
const fromSeq = range.from_seq ?? 0;
|
|
1170
1351
|
const toSeq = range.to_seq ?? ws.audit.length;
|
|
1171
|
-
const issuer = p.issuer
|
|
1352
|
+
const issuer = p.issuer || "service:coordinator";
|
|
1172
1353
|
if (!coord.options.scittSubmitter) {
|
|
1173
1354
|
const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, entryRecord(e), issuer));
|
|
1174
1355
|
return { result: {
|
|
@@ -1198,7 +1379,7 @@ function registerAuditScitt(coord) {
|
|
|
1198
1379
|
});
|
|
1199
1380
|
coord.handlers.set("audit.verify_receipt", (p) => {
|
|
1200
1381
|
const receipt = p.receipt;
|
|
1201
|
-
if (typeof receipt !== "object" || receipt === null) {
|
|
1382
|
+
if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
|
|
1202
1383
|
return { error: rpcError(E.PARAMS, "receipt must be an object") };
|
|
1203
1384
|
}
|
|
1204
1385
|
if (coord.options.verifyScittReceipt) {
|
|
@@ -1212,7 +1393,7 @@ function registerAuditScitt(coord) {
|
|
|
1212
1393
|
if (!ok) return { error: rpcError(E.SCITT_RECEIPT_INVALID, "Receipt did not verify") };
|
|
1213
1394
|
return { result: { verified: true } };
|
|
1214
1395
|
}
|
|
1215
|
-
return { error: rpcError(E.SCITT_RECEIPT_INVALID, "No
|
|
1396
|
+
return { error: rpcError(E.SCITT_RECEIPT_INVALID, "No receipt verifier is configured; receipt not verified") };
|
|
1216
1397
|
});
|
|
1217
1398
|
coord.handlers.set("audit.verify_chain", (p) => {
|
|
1218
1399
|
const ws = coord.workspaces.get(p.workspace);
|
|
@@ -1220,7 +1401,7 @@ function registerAuditScitt(coord) {
|
|
|
1220
1401
|
if (!(ws.chain_enabled || coord.options.enableChain)) {
|
|
1221
1402
|
return { error: rpcError(E.PARAMS, "Chain not enabled for this workspace") };
|
|
1222
1403
|
}
|
|
1223
|
-
if (p.from_seq
|
|
1404
|
+
if (p.from_seq != null || p.to_seq != null) {
|
|
1224
1405
|
return { error: rpcError(
|
|
1225
1406
|
E.PARAMS,
|
|
1226
1407
|
"Range verification is not implemented; omit from_seq and to_seq to verify the whole log."
|
|
@@ -1282,13 +1463,21 @@ function registerAuditScitt(coord) {
|
|
|
1282
1463
|
// src/profiles/control.ts
|
|
1283
1464
|
var VALID_MODES = /* @__PURE__ */ new Set(["shadow", "trial", "production"]);
|
|
1284
1465
|
var VALID_SCOPES = /* @__PURE__ */ new Set(["task", "participant", "workspace"]);
|
|
1466
|
+
var SCOPE_ERROR = "scope must be task, participant or workspace";
|
|
1285
1467
|
function registerControl(coord) {
|
|
1286
1468
|
coord.handlers.set("control.pause", (p) => {
|
|
1287
1469
|
const ws = coord.workspaces.get(p.workspace);
|
|
1288
1470
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1471
|
+
const bad = paramsError(p, [
|
|
1472
|
+
["scope", "string"],
|
|
1473
|
+
["participant_uri", "string"],
|
|
1474
|
+
["uri", "string"],
|
|
1475
|
+
["in_flight_policy", "string"]
|
|
1476
|
+
]);
|
|
1477
|
+
if (bad) return bad;
|
|
1289
1478
|
const scope = p.scope || "task";
|
|
1290
1479
|
if (!VALID_SCOPES.has(scope)) {
|
|
1291
|
-
return { error: rpcError(E.PARAMS,
|
|
1480
|
+
return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
|
|
1292
1481
|
}
|
|
1293
1482
|
if (scope === "task") {
|
|
1294
1483
|
const task = ws.tasks.get(p.task_id);
|
|
@@ -1306,7 +1495,8 @@ function registerControl(coord) {
|
|
|
1306
1495
|
}
|
|
1307
1496
|
if (scope === "participant") {
|
|
1308
1497
|
const uri = p.participant_uri || p.uri;
|
|
1309
|
-
if (!uri
|
|
1498
|
+
if (!uri) return { error: rpcError(E.PARAMS, "participant_uri is required") };
|
|
1499
|
+
if (!ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
|
|
1310
1500
|
ws.members.get(uri).paused = true;
|
|
1311
1501
|
return { result: {
|
|
1312
1502
|
scope: "participant",
|
|
@@ -1321,9 +1511,11 @@ function registerControl(coord) {
|
|
|
1321
1511
|
coord.handlers.set("control.resume", (p) => {
|
|
1322
1512
|
const ws = coord.workspaces.get(p.workspace);
|
|
1323
1513
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1514
|
+
const bad = paramsError(p, [["scope", "string"], ["participant_uri", "string"], ["uri", "string"]]);
|
|
1515
|
+
if (bad) return bad;
|
|
1324
1516
|
const scope = p.scope || "task";
|
|
1325
1517
|
if (!VALID_SCOPES.has(scope)) {
|
|
1326
|
-
return { error: rpcError(E.PARAMS,
|
|
1518
|
+
return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
|
|
1327
1519
|
}
|
|
1328
1520
|
if (scope === "task") {
|
|
1329
1521
|
const task = ws.tasks.get(p.task_id);
|
|
@@ -1341,7 +1533,9 @@ function registerControl(coord) {
|
|
|
1341
1533
|
}
|
|
1342
1534
|
if (scope === "participant") {
|
|
1343
1535
|
const uri = p.participant_uri || p.uri;
|
|
1344
|
-
if (!uri
|
|
1536
|
+
if (!uri) return { error: rpcError(E.PARAMS, "participant_uri is required") };
|
|
1537
|
+
if (!ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
|
|
1538
|
+
if (uri === p.from) return { error: rpcError(E.NOT_AUTHORISED, "A paused participant does not lift its own pause") };
|
|
1345
1539
|
ws.members.get(uri).paused = false;
|
|
1346
1540
|
return { result: { scope: "participant", participant_uri: uri, paused: false } };
|
|
1347
1541
|
}
|
|
@@ -1356,6 +1550,12 @@ function registerControl(coord) {
|
|
|
1356
1550
|
if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
|
|
1357
1551
|
return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
|
|
1358
1552
|
}
|
|
1553
|
+
if (openRequiredReviewAuthors(ws, task).includes(p.from)) {
|
|
1554
|
+
return { error: rpcError(
|
|
1555
|
+
E.NOT_AUTHORISED,
|
|
1556
|
+
"The authors of the work under review cannot end its review without a decision"
|
|
1557
|
+
) };
|
|
1558
|
+
}
|
|
1359
1559
|
task.state = "cancelled";
|
|
1360
1560
|
task.updated_at = coord.now();
|
|
1361
1561
|
task.history.push({ ts: task.updated_at, from: p.from, state: "cancelled", note: p.reason });
|
|
@@ -1364,6 +1564,8 @@ function registerControl(coord) {
|
|
|
1364
1564
|
coord.handlers.set("control.snapshot", (p) => {
|
|
1365
1565
|
const ws = coord.workspaces.get(p.workspace);
|
|
1366
1566
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1567
|
+
const bad = paramsError(p, [["include", "strings"], ["label", "string"]]);
|
|
1568
|
+
if (bad) return bad;
|
|
1367
1569
|
if (Array.isArray(p.include) && p.include.length === 0) {
|
|
1368
1570
|
return { error: rpcError(E.PARAMS, "include must not be empty") };
|
|
1369
1571
|
}
|
|
@@ -1418,6 +1620,14 @@ function registerControl(coord) {
|
|
|
1418
1620
|
coord.handlers.set("control.rollback", (p) => {
|
|
1419
1621
|
const ws = coord.workspaces.get(p.workspace);
|
|
1420
1622
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1623
|
+
const bad = paramsError(p, [
|
|
1624
|
+
["to_snapshot_artefact_id", "string"],
|
|
1625
|
+
["snapshot_artefact_id", "string"],
|
|
1626
|
+
["snapshot_id", "string"],
|
|
1627
|
+
["what_to_restore", "strings"],
|
|
1628
|
+
["reason", "string"]
|
|
1629
|
+
]);
|
|
1630
|
+
if (bad) return bad;
|
|
1421
1631
|
const snapId = p.to_snapshot_artefact_id || p.snapshot_artefact_id || p.snapshot_id;
|
|
1422
1632
|
if (!snapId) return { error: rpcError(E.PARAMS, "to_snapshot_artefact_id is required") };
|
|
1423
1633
|
const snap = ws.snapshots.get(snapId);
|
|
@@ -1439,7 +1649,8 @@ function registerControl(coord) {
|
|
|
1439
1649
|
for (const [uri, snapM] of snapByUri) {
|
|
1440
1650
|
if (ws.members.has(uri)) {
|
|
1441
1651
|
const cur = ws.members.get(uri);
|
|
1442
|
-
|
|
1652
|
+
const role = snapM.role ?? cur.role;
|
|
1653
|
+
if (role !== "admin" && cur.role !== "admin") cur.role = role;
|
|
1443
1654
|
cur.scopes = Array.isArray(snapM.scopes) ? [...snapM.scopes] : void 0;
|
|
1444
1655
|
}
|
|
1445
1656
|
}
|
|
@@ -1458,10 +1669,18 @@ function registerControl(coord) {
|
|
|
1458
1669
|
const oldId = p.task_id;
|
|
1459
1670
|
const old = ws.tasks.get(oldId);
|
|
1460
1671
|
if (!old) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
1672
|
+
if (openRequiredReviewAuthors(ws, old).includes(p.from)) {
|
|
1673
|
+
return { error: rpcError(
|
|
1674
|
+
E.NOT_AUTHORISED,
|
|
1675
|
+
"The authors of the work under review cannot end its review without a decision"
|
|
1676
|
+
) };
|
|
1677
|
+
}
|
|
1461
1678
|
const newSpec = p.successor_task ?? {};
|
|
1462
|
-
if (!newSpec.kind) {
|
|
1679
|
+
if (typeof newSpec.kind !== "string" || !newSpec.kind) {
|
|
1463
1680
|
return { error: rpcError(E.PARAMS, "successor_task must include kind") };
|
|
1464
1681
|
}
|
|
1682
|
+
const bad = paramsError(p, [["successor_task.assignee", "string"], ["reason", "string"]]);
|
|
1683
|
+
if (bad) return bad;
|
|
1465
1684
|
const assignee = newSpec.assignee || old.assignee;
|
|
1466
1685
|
if (typeof assignee !== "string" || !ws.members.has(assignee)) {
|
|
1467
1686
|
return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
|
|
@@ -1508,6 +1727,7 @@ function registerControl(coord) {
|
|
|
1508
1727
|
} else if ("review_required" in newSpec) {
|
|
1509
1728
|
newTask.review_required = !!newSpec.review_required;
|
|
1510
1729
|
}
|
|
1730
|
+
newTask.prior_authors = successorAuthors(old, p.from);
|
|
1511
1731
|
ws.tasks.set(newId, newTask);
|
|
1512
1732
|
old.state = "superseded";
|
|
1513
1733
|
old.superseded_by = newId;
|
|
@@ -1518,27 +1738,37 @@ function registerControl(coord) {
|
|
|
1518
1738
|
state: "superseded",
|
|
1519
1739
|
note: `-> ${newId}: ${p.reason || ""}`
|
|
1520
1740
|
});
|
|
1521
|
-
return { result: {
|
|
1741
|
+
return { result: {
|
|
1742
|
+
superseded_task_id: old.id,
|
|
1743
|
+
new_task_id: newId,
|
|
1744
|
+
...p.reason != null ? { reason: p.reason } : {}
|
|
1745
|
+
} };
|
|
1522
1746
|
});
|
|
1523
1747
|
coord.handlers.set("control.set_mode_ceiling", (p) => {
|
|
1524
1748
|
const ws = coord.workspaces.get(p.workspace);
|
|
1525
1749
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1750
|
+
const bad = paramsError(p, [["reason", "string"]]);
|
|
1751
|
+
if (bad) return bad;
|
|
1526
1752
|
const m = p.new_ceiling ?? p.mode_ceiling;
|
|
1527
1753
|
if (!VALID_MODES.has(m)) {
|
|
1528
|
-
return { error: rpcError(E.PARAMS,
|
|
1754
|
+
return { error: rpcError(E.PARAMS, "new_ceiling must be shadow, trial or production") };
|
|
1529
1755
|
}
|
|
1530
1756
|
ws.mode_ceiling = m;
|
|
1531
|
-
return { result: { mode_ceiling: ws.mode_ceiling, reason: p.reason } };
|
|
1757
|
+
return { result: { mode_ceiling: ws.mode_ceiling, ...p.reason != null ? { reason: p.reason } : {} } };
|
|
1532
1758
|
});
|
|
1533
1759
|
}
|
|
1534
1760
|
|
|
1535
1761
|
// src/profiles/deliberation.ts
|
|
1536
1762
|
var RESERVED_IDS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1763
|
+
var QUORUM_ARG = /^[0-9]+$/;
|
|
1764
|
+
var THRESHOLD_ARG = /^[0-9]+(\.[0-9]+)?$/;
|
|
1537
1765
|
function parseRule(rule) {
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1766
|
+
const colon = rule.indexOf(":");
|
|
1767
|
+
if (colon >= 0) {
|
|
1768
|
+
const kind = rule.slice(0, colon);
|
|
1769
|
+
const arg = rule.slice(colon + 1);
|
|
1770
|
+
if (kind === "quorum" && QUORUM_ARG.test(arg)) return { kind, params: { n: parseInt(arg, 10) } };
|
|
1771
|
+
if ((kind === "weighted_vote" || kind === "weighted_vote_with_veto") && THRESHOLD_ARG.test(arg)) {
|
|
1542
1772
|
return { kind, params: { threshold: parseFloat(arg) } };
|
|
1543
1773
|
}
|
|
1544
1774
|
}
|
|
@@ -1623,6 +1853,23 @@ function registerDeliberation(coord) {
|
|
|
1623
1853
|
for (const f of ["from", "rule"]) {
|
|
1624
1854
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
1625
1855
|
}
|
|
1856
|
+
for (const name of ["to", "participants"]) {
|
|
1857
|
+
const v = p[name];
|
|
1858
|
+
if (v != null && typeof v !== "string" && !(Array.isArray(v) && v.every((x) => typeof x === "string"))) {
|
|
1859
|
+
return { error: rpcError(E.PARAMS, `${name} must be a string or a list of strings`) };
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
if (typeof p.rule !== "string") return { error: rpcError(E.PARAMS, "rule must be a string") };
|
|
1863
|
+
const bad = paramsError(p, [["weights", "object"], ["veto", "object"]]);
|
|
1864
|
+
if (bad) return bad;
|
|
1865
|
+
const weights = p.weights ?? {};
|
|
1866
|
+
if (!Object.values(weights).every((w) => typeof w === "number" && Number.isInteger(w))) {
|
|
1867
|
+
return { error: rpcError(E.PARAMS, "weights must map participants to integers") };
|
|
1868
|
+
}
|
|
1869
|
+
const veto = p.veto ?? {};
|
|
1870
|
+
if (!Object.values(veto).every((v) => typeof v === "boolean")) {
|
|
1871
|
+
return { error: rpcError(E.PARAMS, "veto must map participants to booleans") };
|
|
1872
|
+
}
|
|
1626
1873
|
const to = p.to ?? p.participants;
|
|
1627
1874
|
const participants = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
|
|
1628
1875
|
if (!participants.length) {
|
|
@@ -1673,6 +1920,8 @@ function registerDeliberation(coord) {
|
|
|
1673
1920
|
if (delib.state !== "open") {
|
|
1674
1921
|
return { error: rpcError(E.DELIB_CLOSED_OR_LAPSED, "Deliberation not open") };
|
|
1675
1922
|
}
|
|
1923
|
+
const bad = paramsError(p, [["comment", "string"], ["text", "string"]]);
|
|
1924
|
+
if (bad) return bad;
|
|
1676
1925
|
delib.comments.push({
|
|
1677
1926
|
ts: coord.now(),
|
|
1678
1927
|
from: p.from,
|
|
@@ -1695,6 +1944,8 @@ function registerDeliberation(coord) {
|
|
|
1695
1944
|
if (v !== "yea" && v !== "nay" && v !== "abstain") {
|
|
1696
1945
|
return { error: rpcError(E.PARAMS, "vote must be yea/nay/abstain") };
|
|
1697
1946
|
}
|
|
1947
|
+
const bad = paramsError(p, [["veto_invoked", "boolean"]]);
|
|
1948
|
+
if (bad) return bad;
|
|
1698
1949
|
const voter = p.from;
|
|
1699
1950
|
if (!delib.participants.includes(voter)) {
|
|
1700
1951
|
return { error: rpcError(
|
|
@@ -1736,6 +1987,9 @@ function registerHandoff(coord) {
|
|
|
1736
1987
|
for (const f of ["from", "to", "tasks"]) {
|
|
1737
1988
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
1738
1989
|
}
|
|
1990
|
+
if (typeof p.to !== "string") return { error: rpcError(E.PARAMS, "to must be a string") };
|
|
1991
|
+
const bad = paramsError(p, [["context_links", "strings"]]);
|
|
1992
|
+
if (bad) return bad;
|
|
1739
1993
|
const proposer = p.from;
|
|
1740
1994
|
const recipient = p.to;
|
|
1741
1995
|
if (typeof recipient === "string" && !recipient.startsWith("group:")) {
|
|
@@ -1752,6 +2006,11 @@ function registerHandoff(coord) {
|
|
|
1752
2006
|
}
|
|
1753
2007
|
const proposed = [];
|
|
1754
2008
|
for (const t of tasksIn) {
|
|
2009
|
+
if (t === null || typeof t !== "object" || Array.isArray(t) || typeof t.task_id !== "string") {
|
|
2010
|
+
return { error: rpcError(E.PARAMS, "each entry in tasks must be an object with a string task_id") };
|
|
2011
|
+
}
|
|
2012
|
+
const badItem = paramsError(t, [["blockers", "strings"]]);
|
|
2013
|
+
if (badItem) return badItem;
|
|
1755
2014
|
const tid = t.task_id;
|
|
1756
2015
|
if (!tid || !ws.tasks.has(tid)) {
|
|
1757
2016
|
return { error: rpcError(E.PARAMS, `Unknown task in handoff: ${tid}`) };
|
|
@@ -1833,6 +2092,8 @@ function registerHandoff(coord) {
|
|
|
1833
2092
|
if (ws.members.get(acceptor).paused) {
|
|
1834
2093
|
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Acceptor ${acceptor} is paused`) };
|
|
1835
2094
|
}
|
|
2095
|
+
const bad = paramsError(p, [["accepted_task_ids", "strings"]]);
|
|
2096
|
+
if (bad) return bad;
|
|
1836
2097
|
if (Array.isArray(p.accepted_task_ids) && p.accepted_task_ids.length === 0) {
|
|
1837
2098
|
return { error: rpcError(E.PARAMS, "accepted_task_ids must not be empty") };
|
|
1838
2099
|
}
|
|
@@ -1843,9 +2104,18 @@ function registerHandoff(coord) {
|
|
|
1843
2104
|
return { error: rpcError(E.PARAMS, `Task ${tid} not in this handoff`) };
|
|
1844
2105
|
}
|
|
1845
2106
|
}
|
|
2107
|
+
for (const tid of acceptedIds) {
|
|
2108
|
+
if (ws.tasks.get(tid)?.assignee !== ho.proposer) {
|
|
2109
|
+
return { error: rpcError(
|
|
2110
|
+
E.HANDOFF_TASKS_NOT_ASSIGNED_TO_PROPOSER,
|
|
2111
|
+
`Task ${tid} is not currently assigned to ${ho.proposer}`
|
|
2112
|
+
) };
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
1846
2115
|
const now = coord.now();
|
|
1847
2116
|
for (const tid of acceptedIds) {
|
|
1848
2117
|
const task = ws.tasks.get(tid);
|
|
2118
|
+
if (acceptor !== task.assignee) task.prior_authors = unionOf(task.prior_authors ?? [], heldBy(task, ho.proposer));
|
|
1849
2119
|
task.assignee = acceptor;
|
|
1850
2120
|
task.updated_at = now;
|
|
1851
2121
|
task.history.push({
|
|
@@ -1886,16 +2156,20 @@ function registerHandoff(coord) {
|
|
|
1886
2156
|
|
|
1887
2157
|
// src/profiles/routing.ts
|
|
1888
2158
|
var CRIT = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
1889
|
-
|
|
2159
|
+
var DECIMAL = /^-?[0-9]+(\.[0-9]+)?$/;
|
|
2160
|
+
function confidenceOf(hints) {
|
|
2161
|
+
const v = hints.confidence;
|
|
2162
|
+
if (typeof v === "number" && Number.isInteger(v)) return v;
|
|
2163
|
+
if (typeof v === "string" && DECIMAL.test(v)) return Number(v);
|
|
2164
|
+
return 1;
|
|
2165
|
+
}
|
|
2166
|
+
function criticalityOf(hints) {
|
|
1890
2167
|
const crit = hints.criticality ?? "medium";
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
}
|
|
1897
|
-
if (isNaN(conf)) conf = 1;
|
|
1898
|
-
const r = CRIT[crit] ?? 1;
|
|
2168
|
+
return typeof crit === "string" && Object.prototype.hasOwnProperty.call(CRIT, crit) ? CRIT[crit] : 1;
|
|
2169
|
+
}
|
|
2170
|
+
function defaultDepth(hints) {
|
|
2171
|
+
const conf = confidenceOf(hints);
|
|
2172
|
+
const r = criticalityOf(hints);
|
|
1899
2173
|
if (r >= 3) return { depth: "full", summary: "criticality=critical: full review" };
|
|
1900
2174
|
if (r === 2 && conf < 0.7) return { depth: "full", summary: "criticality=high and confidence<0.7: full review" };
|
|
1901
2175
|
if (r === 0 && conf >= 0.95) return { depth: "skip", summary: "very low criticality + very high confidence: skip" };
|
|
@@ -1961,6 +2235,8 @@ function registerRouting(coord) {
|
|
|
1961
2235
|
if (ws.members.get(selected).paused) {
|
|
1962
2236
|
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${selected} is paused`) };
|
|
1963
2237
|
}
|
|
2238
|
+
const leaving = heldBy(task, p.from);
|
|
2239
|
+
if (selected !== task.assignee && leaving.length) task.prior_authors = unionOf(task.prior_authors ?? [], leaving);
|
|
1964
2240
|
task.assignee = selected;
|
|
1965
2241
|
task.updated_at = coord.now();
|
|
1966
2242
|
task.history.push({
|
|
@@ -1991,6 +2267,8 @@ function registerRouting(coord) {
|
|
|
1991
2267
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1992
2268
|
const task = ws.tasks.get(p.task_id);
|
|
1993
2269
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2270
|
+
const bad = paramsError(p, [["artefact_routing_hints", "object"]]);
|
|
2271
|
+
if (bad) return bad;
|
|
1994
2272
|
const hints = {
|
|
1995
2273
|
...task.routing_hints ?? {},
|
|
1996
2274
|
...p.artefact_routing_hints ?? {}
|
|
@@ -2048,6 +2326,8 @@ function registerRouting(coord) {
|
|
|
2048
2326
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
2049
2327
|
const task = ws.tasks.get(p.task_id);
|
|
2050
2328
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2329
|
+
const bad = paramsError(p, [["default_escalation_target", "string"]]);
|
|
2330
|
+
if (bad) return bad;
|
|
2051
2331
|
const hints = { ...task.routing_hints ?? {} };
|
|
2052
2332
|
let escalate, to, rule;
|
|
2053
2333
|
if (coord.options.escalationPolicy) {
|
|
@@ -2061,15 +2341,8 @@ function registerRouting(coord) {
|
|
|
2061
2341
|
return { error: rpcError(E.ROUTING_POLICY_UNREACHABLE, `escalation policy error: ${msg}`) };
|
|
2062
2342
|
}
|
|
2063
2343
|
} else {
|
|
2064
|
-
const
|
|
2065
|
-
|
|
2066
|
-
try {
|
|
2067
|
-
conf = Number(hints.confidence ?? 1);
|
|
2068
|
-
} catch {
|
|
2069
|
-
conf = 1;
|
|
2070
|
-
}
|
|
2071
|
-
if (isNaN(conf)) conf = 1;
|
|
2072
|
-
const r = CRIT[crit] ?? 1;
|
|
2344
|
+
const conf = confidenceOf(hints);
|
|
2345
|
+
const r = criticalityOf(hints);
|
|
2073
2346
|
escalate = r >= 3 || r >= 2 && conf < 0.6;
|
|
2074
2347
|
to = p.default_escalation_target;
|
|
2075
2348
|
rule = {
|
|
@@ -2126,6 +2399,9 @@ function registerSecuritySigned(coord) {
|
|
|
2126
2399
|
for (const f of ["from", "old_kid", "new_jwk"]) {
|
|
2127
2400
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
2128
2401
|
}
|
|
2402
|
+
for (const f of ["from", "old_kid"]) {
|
|
2403
|
+
if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
|
|
2404
|
+
}
|
|
2129
2405
|
const member = ws.members.get(p.from);
|
|
2130
2406
|
if (!member) return { error: rpcError(E.SIG_KEY_NOT_FOUND, `Unknown participant: ${p.from}`) };
|
|
2131
2407
|
const oldKid = p.old_kid;
|
|
@@ -2136,6 +2412,9 @@ function registerSecuritySigned(coord) {
|
|
|
2136
2412
|
if (!newJwk || typeof newJwk.kid !== "string") {
|
|
2137
2413
|
return { error: rpcError(E.PARAMS, "new_jwk must include kid") };
|
|
2138
2414
|
}
|
|
2415
|
+
if (member.keys.some((k) => k.kid === newJwk.kid)) {
|
|
2416
|
+
return { error: rpcError(E.PARAMS, `${p.from} already holds a key ${newJwk.kid}; a new key takes a kid of its own`) };
|
|
2417
|
+
}
|
|
2139
2418
|
const now = coord.now();
|
|
2140
2419
|
oldKey.valid_until = now;
|
|
2141
2420
|
member.keys.push({
|
|
@@ -2151,6 +2430,11 @@ function registerSecuritySigned(coord) {
|
|
|
2151
2430
|
for (const f of ["target_uri", "kid"]) {
|
|
2152
2431
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
2153
2432
|
}
|
|
2433
|
+
for (const f of ["target_uri", "kid"]) {
|
|
2434
|
+
if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
|
|
2435
|
+
}
|
|
2436
|
+
const bad = paramsError(p, [["from", "string"], ["reason", "string"]]);
|
|
2437
|
+
if (bad) return bad;
|
|
2154
2438
|
const sender = p.from;
|
|
2155
2439
|
if (sender !== p.target_uri) {
|
|
2156
2440
|
const caller = sender ? ws.members.get(sender) : void 0;
|
|
@@ -2193,8 +2477,10 @@ function registerWhisper(coord) {
|
|
|
2193
2477
|
if (!ws.tasks.has(p.task_id)) {
|
|
2194
2478
|
return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2195
2479
|
}
|
|
2196
|
-
|
|
2197
|
-
|
|
2480
|
+
const bad = paramsError(p, [["options", "list"], ["urgency", "string"]]);
|
|
2481
|
+
if (bad) return bad;
|
|
2482
|
+
if (typeof p.deadline_ms !== "number" || !Number.isInteger(p.deadline_ms)) {
|
|
2483
|
+
return { error: rpcError(E.PARAMS, "deadline_ms must be an integer") };
|
|
2198
2484
|
}
|
|
2199
2485
|
const to = p.to;
|
|
2200
2486
|
const askee = Array.isArray(to) ? to : [to];
|
|
@@ -2257,7 +2543,7 @@ function registerWhisper(coord) {
|
|
|
2257
2543
|
if (!hasOption) {
|
|
2258
2544
|
return { error: rpcError(E.PARAMS, "answer_option is required when options are defined") };
|
|
2259
2545
|
}
|
|
2260
|
-
const valid = new Set(prompt.options.map((o) => o.id));
|
|
2546
|
+
const valid = new Set(prompt.options.filter((o) => o !== null && typeof o === "object" && !Array.isArray(o)).map((o) => o.id));
|
|
2261
2547
|
if (!valid.has(answerOption)) {
|
|
2262
2548
|
return { error: rpcError(
|
|
2263
2549
|
E.WHISPER_OPTION_NOT_IN_SET,
|
|
@@ -2275,12 +2561,13 @@ function registerWhisper(coord) {
|
|
|
2275
2561
|
prompt.comment = p.comment;
|
|
2276
2562
|
return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
|
|
2277
2563
|
});
|
|
2278
|
-
coord.checkWhisperLapses = (workspaceId, now) =>
|
|
2564
|
+
coord.checkWhisperLapses = (workspaceId, now) => coord.outsideCall(workspaceId, () => lapse(workspaceId, now));
|
|
2565
|
+
function lapse(workspaceId, now) {
|
|
2279
2566
|
const ws = coord.workspaces.get(workspaceId);
|
|
2280
2567
|
if (!ws) return [];
|
|
2281
2568
|
const cutoff = parseIso(now ?? coord.now());
|
|
2282
2569
|
const emitted = [];
|
|
2283
|
-
for (const prompt of ws.whispers
|
|
2570
|
+
for (const prompt of rawValues(ws.whispers)) {
|
|
2284
2571
|
if (prompt.state !== "pending") continue;
|
|
2285
2572
|
const asked = parseIso(prompt.asked_at);
|
|
2286
2573
|
const deadline = asked + prompt.deadline_ms;
|
|
@@ -2310,7 +2597,7 @@ function registerWhisper(coord) {
|
|
|
2310
2597
|
emitted.push(notify);
|
|
2311
2598
|
}
|
|
2312
2599
|
return emitted;
|
|
2313
|
-
}
|
|
2600
|
+
}
|
|
2314
2601
|
}
|
|
2315
2602
|
|
|
2316
2603
|
// src/coordinator.ts
|
|
@@ -2323,6 +2610,7 @@ var PRIVILEGED_METHODS = /* @__PURE__ */ new Set([
|
|
|
2323
2610
|
"control.rollback",
|
|
2324
2611
|
"control.set_mode_ceiling",
|
|
2325
2612
|
"workspace.set_profiles",
|
|
2613
|
+
"workspace.invite",
|
|
2326
2614
|
"participant.rotate_key",
|
|
2327
2615
|
"participant.revoke_key"
|
|
2328
2616
|
]);
|
|
@@ -2366,12 +2654,9 @@ function missing(params, fields) {
|
|
|
2366
2654
|
for (const f of fields) if (!(f in params)) return f;
|
|
2367
2655
|
return null;
|
|
2368
2656
|
}
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
return { error: rpcError(E.PARAMS, "tags must be a list of strings") };
|
|
2373
|
-
}
|
|
2374
|
-
return null;
|
|
2657
|
+
var MODES = /* @__PURE__ */ new Set(["shadow", "trial", "production"]);
|
|
2658
|
+
function isObject(value) {
|
|
2659
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2375
2660
|
}
|
|
2376
2661
|
function reviewRuleSupported(rule) {
|
|
2377
2662
|
if (rule === "any_one_approves" || rule === "all_approve") return true;
|
|
@@ -2392,8 +2677,214 @@ function reviewSatisfied(review) {
|
|
|
2392
2677
|
if (rule.startsWith("quorum:")) return approvers.size >= parseInt(rule.slice("quorum:".length), 10);
|
|
2393
2678
|
return approvers.size >= 1;
|
|
2394
2679
|
}
|
|
2680
|
+
function advertises(ws, name) {
|
|
2681
|
+
return ws.profiles.some((p) => p === name || p.startsWith(name + "/"));
|
|
2682
|
+
}
|
|
2683
|
+
function knownProfile(id) {
|
|
2684
|
+
const m = /^([a-z][a-z0-9-]*)\/(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(id);
|
|
2685
|
+
if (!m || !Object.prototype.hasOwnProperty.call(PROFILE_VERSIONS, m[1])) return false;
|
|
2686
|
+
return PROFILE_VERSIONS[m[1]].split(".")[0] === m[2];
|
|
2687
|
+
}
|
|
2688
|
+
var LASTING_PROFILES = ["security-signed", "identity-oidc", "identity-vc"];
|
|
2689
|
+
function profilesError(profiles) {
|
|
2690
|
+
const unknown = profiles.find((x) => !knownProfile(x));
|
|
2691
|
+
if (unknown === void 0) return null;
|
|
2692
|
+
return { error: rpcError(
|
|
2693
|
+
E.PARAMS,
|
|
2694
|
+
`Unknown profile ${JSON.stringify(unknown)}: a profile id is <name>/<major>.<minor> for a profile the catalogue lists`
|
|
2695
|
+
) };
|
|
2696
|
+
}
|
|
2697
|
+
var RESERVED_KEYS = ["__proto__", "constructor", "prototype"];
|
|
2698
|
+
var PARTICIPANT_TYPES = ["human", "agent", "service", "group", "workspace"];
|
|
2699
|
+
var ADMIN_TYPES = ["human", "service"];
|
|
2700
|
+
function participantError(type, uri, field) {
|
|
2701
|
+
if (!PARTICIPANT_TYPES.includes(type)) {
|
|
2702
|
+
return { error: rpcError(E.PARAMS, "type must be human, agent, service, group or workspace") };
|
|
2703
|
+
}
|
|
2704
|
+
if (!uri.startsWith(`${type}:`)) {
|
|
2705
|
+
return { error: rpcError(E.PARAMS, `${field} must start with "${type}:" for type ${type}`) };
|
|
2706
|
+
}
|
|
2707
|
+
if (type === "group" || type === "workspace") {
|
|
2708
|
+
return { error: rpcError(E.PARAMS, "group: and workspace: URIs name address scopes, which cannot join") };
|
|
2709
|
+
}
|
|
2710
|
+
return null;
|
|
2711
|
+
}
|
|
2712
|
+
function inForce(key, now) {
|
|
2713
|
+
return !(key.revoked_at !== void 0 && now >= key.revoked_at) && !(key.valid_until !== void 0 && now >= key.valid_until);
|
|
2714
|
+
}
|
|
2715
|
+
function sameKey(a, b) {
|
|
2716
|
+
const material = (k) => jwkThumbprint(k) ?? canonicalize(k).toString("utf-8");
|
|
2717
|
+
return a.kid === b.kid && material(a) === material(b);
|
|
2718
|
+
}
|
|
2719
|
+
function credentialKeys(cnf, nonce, joinKeys) {
|
|
2720
|
+
if (isObject(cnf)) {
|
|
2721
|
+
const kid = typeof cnf.kid === "string" && cnf.kid !== "" ? cnf.kid : jwkThumbprint(cnf);
|
|
2722
|
+
if (kid === void 0) return null;
|
|
2723
|
+
const key = { ...cnf, kid };
|
|
2724
|
+
return joinKeys.every((j) => sameKey(key, j)) ? [key] : null;
|
|
2725
|
+
}
|
|
2726
|
+
if (typeof nonce === "string") return joinKeys.every((j) => jwkThumbprint(j) === nonce) ? joinKeys : null;
|
|
2727
|
+
return void 0;
|
|
2728
|
+
}
|
|
2729
|
+
function rawInvitation(ws, uri) {
|
|
2730
|
+
return Map.prototype.get.call(ws.invitations, uri);
|
|
2731
|
+
}
|
|
2732
|
+
function awaitsFirstMember(ws) {
|
|
2733
|
+
if (ws.members.size > 0) return false;
|
|
2734
|
+
return !ws.audit.some((e) => e.outcome == null && entryCall(e)?.method === "participant.join");
|
|
2735
|
+
}
|
|
2736
|
+
function signingKeys(jwks) {
|
|
2737
|
+
const keys = isObject(jwks) && Array.isArray(jwks.keys) ? jwks.keys : [];
|
|
2738
|
+
return keys.filter((j) => isObject(j) && typeof j.kid === "string" && j.kid !== "");
|
|
2739
|
+
}
|
|
2740
|
+
function recordedForm(envelope) {
|
|
2741
|
+
if (envelope.method !== "participant.join" || !isObject(envelope.params)) return envelope;
|
|
2742
|
+
const params = { ...envelope.params };
|
|
2743
|
+
if (typeof params.oidc_token === "string") params.oidc_token = sha256Hex(params.oidc_token);
|
|
2744
|
+
if (isObject(params.vc_presentation)) params.vc_presentation = contentHash(params.vc_presentation);
|
|
2745
|
+
return { ...envelope, params };
|
|
2746
|
+
}
|
|
2747
|
+
function storeFailed() {
|
|
2748
|
+
return rpcError(E.INTERNAL, "Internal error", { reason: "store" });
|
|
2749
|
+
}
|
|
2750
|
+
function targetOf(envelope) {
|
|
2751
|
+
const params = isObject(envelope) ? envelope.params : void 0;
|
|
2752
|
+
const id = isObject(params) ? params.workspace : void 0;
|
|
2753
|
+
return typeof id === "string" && id !== "" ? id : void 0;
|
|
2754
|
+
}
|
|
2755
|
+
var KEPT_NULLS = { whispers: ["default_if_lapsed"] };
|
|
2756
|
+
function dropNulls(o, keep = []) {
|
|
2757
|
+
for (const key of Object.keys(o)) if (o[key] === null && !keep.includes(key)) delete o[key];
|
|
2758
|
+
}
|
|
2759
|
+
function shapeObject(name, item) {
|
|
2760
|
+
if (name === "route_decisions") {
|
|
2761
|
+
const extra = item.extra;
|
|
2762
|
+
delete item.extra;
|
|
2763
|
+
if (isObject(extra)) Object.assign(item, extra);
|
|
2764
|
+
item.kind = "route_decision";
|
|
2765
|
+
}
|
|
2766
|
+
dropNulls(item, KEPT_NULLS[name]);
|
|
2767
|
+
const each = (o, field, f) => {
|
|
2768
|
+
if (!Array.isArray(o[field])) return;
|
|
2769
|
+
for (const x of o[field]) {
|
|
2770
|
+
if (isObject(x)) {
|
|
2771
|
+
dropNulls(x);
|
|
2772
|
+
f?.(x);
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
};
|
|
2776
|
+
if (name === "members") each(item, "keys");
|
|
2777
|
+
if (name === "tasks") {
|
|
2778
|
+
each(item, "history", (h) => {
|
|
2779
|
+
if (h.from === void 0 && typeof h.from_ === "string") h.from = h.from_;
|
|
2780
|
+
delete h.from_;
|
|
2781
|
+
});
|
|
2782
|
+
if (isObject(item.review)) {
|
|
2783
|
+
dropNulls(item.review);
|
|
2784
|
+
each(item.review, "decisions");
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
if (name === "deliberations") each(item, "votes");
|
|
2788
|
+
if (name === "handoffs") {
|
|
2789
|
+
each(item, "tasks");
|
|
2790
|
+
if (Array.isArray(item.accepted_task_ids) && item.accepted_task_ids.length === 0) delete item.accepted_task_ids;
|
|
2791
|
+
}
|
|
2792
|
+
return item;
|
|
2793
|
+
}
|
|
2794
|
+
function storedCopy(name, item) {
|
|
2795
|
+
return shapeObject(name, structuredClone(item));
|
|
2796
|
+
}
|
|
2797
|
+
function shapeSnapshot(w) {
|
|
2798
|
+
const out = { ...w };
|
|
2799
|
+
dropNulls(out);
|
|
2800
|
+
for (const name of COLLECTIONS) {
|
|
2801
|
+
if (Array.isArray(out[name])) {
|
|
2802
|
+
out[name] = out[name].map((x) => isObject(x) ? storedCopy(name, x) : x);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
return out;
|
|
2806
|
+
}
|
|
2807
|
+
function reviewRequired(ws, task) {
|
|
2808
|
+
return task.review_required === true || task.mode === "trial" && advertises(ws, "modes");
|
|
2809
|
+
}
|
|
2810
|
+
function rawMember(ws, uri) {
|
|
2811
|
+
return typeof uri === "string" ? Map.prototype.get.call(ws.members, uri) : void 0;
|
|
2812
|
+
}
|
|
2813
|
+
function unionOf(...lists) {
|
|
2814
|
+
const out = [];
|
|
2815
|
+
for (const list of lists) for (const uri of list) if (!out.includes(uri)) out.push(uri);
|
|
2816
|
+
return out;
|
|
2817
|
+
}
|
|
2818
|
+
function authorsOf(task, completer) {
|
|
2819
|
+
const authors = [task.assignee];
|
|
2820
|
+
if (typeof completer === "string" && completer !== task.assignee) authors.push(completer);
|
|
2821
|
+
return unionOf(authors, Array.isArray(task.prior_authors) ? task.prior_authors : []);
|
|
2822
|
+
}
|
|
2823
|
+
function stateBefore(history, i) {
|
|
2824
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
2825
|
+
if (history[j].state !== "paused") return history[j].state;
|
|
2826
|
+
}
|
|
2827
|
+
return void 0;
|
|
2828
|
+
}
|
|
2829
|
+
function workCompletion(history, i) {
|
|
2830
|
+
if (history[i].state !== "completed") return false;
|
|
2831
|
+
const before = stateBefore(history, i);
|
|
2832
|
+
return before !== "review_requested" && before !== "completed";
|
|
2833
|
+
}
|
|
2834
|
+
function lastCompleter(task) {
|
|
2835
|
+
for (let i = task.history.length - 1; i >= 0; i--) {
|
|
2836
|
+
if (workCompletion(task.history, i)) return task.history[i].from;
|
|
2837
|
+
}
|
|
2838
|
+
return void 0;
|
|
2839
|
+
}
|
|
2840
|
+
var OPENED_ON_COMPLETE = "review required; opened on task.complete";
|
|
2841
|
+
var NEW_ROUND_ON_COMPLETE = "a new round of the review; opened on task.complete";
|
|
2842
|
+
function storedAuthors(task) {
|
|
2843
|
+
let requester;
|
|
2844
|
+
const withRequester = (authors) => unionOf(authors, requester !== void 0 ? [requester] : []);
|
|
2845
|
+
for (let i = task.history.length - 1; i >= 0; i--) {
|
|
2846
|
+
const h = task.history[i];
|
|
2847
|
+
const opens = h.state === "review_requested" && stateBefore(task.history, i) !== "review_requested";
|
|
2848
|
+
if (opens && (h.note === OPENED_ON_COMPLETE || h.note === NEW_ROUND_ON_COMPLETE)) return withRequester(authorsOf(task, h.from));
|
|
2849
|
+
if (opens && requester === void 0) requester = h.from;
|
|
2850
|
+
if (workCompletion(task.history, i)) return withRequester(authorsOf(task, h.from));
|
|
2851
|
+
}
|
|
2852
|
+
return withRequester(authorsOf(task, void 0));
|
|
2853
|
+
}
|
|
2854
|
+
var ENDED = ["completed", "declined", "cancelled", "superseded", "escalated"];
|
|
2855
|
+
function reviewHolds(ws, task) {
|
|
2856
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2857
|
+
for (let t = task; t && !seen.has(t.id); t = typeof t.supersedes === "string" ? ws.tasks.get(t.supersedes) : void 0) {
|
|
2858
|
+
seen.add(t.id);
|
|
2859
|
+
if (!reviewRequired(ws, t)) return [];
|
|
2860
|
+
if (t.review) {
|
|
2861
|
+
const decided = t.review.decisions.some((d) => d.kind === "reject" || d.kind === "override") || reviewSatisfied(t.review);
|
|
2862
|
+
return decided ? [] : t.review.authors;
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
return [];
|
|
2866
|
+
}
|
|
2867
|
+
function openRequiredReviewAuthors(ws, task) {
|
|
2868
|
+
return ENDED.includes(task.state) ? [] : reviewHolds(ws, task);
|
|
2869
|
+
}
|
|
2870
|
+
function heldBy(task, mover) {
|
|
2871
|
+
return mover === task.assignee ? [task.assignee] : [];
|
|
2872
|
+
}
|
|
2873
|
+
function successorAuthors(old, mover) {
|
|
2874
|
+
const completer = lastCompleter(old);
|
|
2875
|
+
return unionOf(
|
|
2876
|
+
old.review ? old.review.authors : [],
|
|
2877
|
+
heldBy(old, mover),
|
|
2878
|
+
completer !== void 0 ? [completer] : [],
|
|
2879
|
+
Array.isArray(old.prior_authors) ? old.prior_authors : []
|
|
2880
|
+
);
|
|
2881
|
+
}
|
|
2882
|
+
function isScope(uri) {
|
|
2883
|
+
return typeof uri === "string" && (uri.startsWith("workspace:") || uri.startsWith("group:"));
|
|
2884
|
+
}
|
|
2395
2885
|
function reply(env, body) {
|
|
2396
|
-
const
|
|
2886
|
+
const sent = env !== null && typeof env === "object" && !Array.isArray(env) ? env.id : void 0;
|
|
2887
|
+
const out = { jsonrpc: "2.0", id: sent ?? null };
|
|
2397
2888
|
if (body.error !== void 0) out.error = body.error;
|
|
2398
2889
|
else out.result = body.result;
|
|
2399
2890
|
return out;
|
|
@@ -2408,7 +2899,7 @@ function indexSigned(index, entry) {
|
|
|
2408
2899
|
return;
|
|
2409
2900
|
}
|
|
2410
2901
|
if (entry.outcome != null) index.refused.set(digest, { seq: entry.seq, code: entry.outcome.code });
|
|
2411
|
-
else index.accepted.
|
|
2902
|
+
else if (!index.accepted.has(digest)) index.accepted.set(digest, entry.seq);
|
|
2412
2903
|
}
|
|
2413
2904
|
function isThenable(value) {
|
|
2414
2905
|
return typeof value?.then === "function";
|
|
@@ -2436,13 +2927,21 @@ var Coordinator = class {
|
|
|
2436
2927
|
unflushed = /* @__PURE__ */ new Set();
|
|
2437
2928
|
/** Workspaces with a write queued behind an async write in flight. */
|
|
2438
2929
|
flushQueued = /* @__PURE__ */ new Set();
|
|
2930
|
+
/**
|
|
2931
|
+
* Workspaces whose store copy may differ from memory after a synchronous
|
|
2932
|
+
* write failed or conflicted. Each is reloaded from the store before the
|
|
2933
|
+
* next call on it, which is refused while the reload fails.
|
|
2934
|
+
*/
|
|
2935
|
+
held = /* @__PURE__ */ new Set();
|
|
2936
|
+
/** Entries appended and not yet written, which the audit listeners hear of once they are. */
|
|
2937
|
+
unannounced = /* @__PURE__ */ new WeakMap();
|
|
2439
2938
|
clockMs;
|
|
2440
2939
|
frozenNow;
|
|
2441
2940
|
auditListeners = [];
|
|
2442
2941
|
/**
|
|
2443
2942
|
* The signed calls on each workspace's log, by what their senders signed:
|
|
2444
|
-
* recorded refusals with their seq and code, and accepted calls
|
|
2445
|
-
* from the log when missing, so a snapshot never carries it.
|
|
2943
|
+
* recorded refusals with their seq and code, and accepted calls with their
|
|
2944
|
+
* seq. Rebuilt from the log when missing, so a snapshot never carries it.
|
|
2446
2945
|
*/
|
|
2447
2946
|
signedIndexes = /* @__PURE__ */ new WeakMap();
|
|
2448
2947
|
/** Method handler registry; profiles plug into this. */
|
|
@@ -2578,6 +3077,7 @@ var Coordinator = class {
|
|
|
2578
3077
|
change = this.takeChange(ws, p);
|
|
2579
3078
|
} catch (err) {
|
|
2580
3079
|
p.replace = true;
|
|
3080
|
+
if (this.options.storeErrors !== "log") this.held.add(ws.id);
|
|
2581
3081
|
this.reportStoreError(err, { id: ws.id, version: ws.audit.length });
|
|
2582
3082
|
return;
|
|
2583
3083
|
}
|
|
@@ -2586,6 +3086,7 @@ var Coordinator = class {
|
|
|
2586
3086
|
const done = () => {
|
|
2587
3087
|
p.seq = taken.version;
|
|
2588
3088
|
if (taken.replace) p.replace = false;
|
|
3089
|
+
this.announce(ws, taken.version);
|
|
2589
3090
|
};
|
|
2590
3091
|
const failed = (err) => {
|
|
2591
3092
|
this.giveBack(ws, p, taken);
|
|
@@ -2595,13 +3096,34 @@ var Coordinator = class {
|
|
|
2595
3096
|
try {
|
|
2596
3097
|
result = this.store.apply(taken);
|
|
2597
3098
|
} catch (err) {
|
|
3099
|
+
if (err instanceof StoreConflictError || this.options.storeErrors !== "log") this.held.add(ws.id);
|
|
2598
3100
|
failed(err);
|
|
2599
3101
|
return;
|
|
2600
3102
|
}
|
|
2601
3103
|
if (isThenable(result)) return Promise.resolve(result).then(done, failed);
|
|
2602
3104
|
done();
|
|
2603
3105
|
}
|
|
2604
|
-
/**
|
|
3106
|
+
/** Tell the audit listeners of the entries now written, up to `upTo`. */
|
|
3107
|
+
announce(ws, upTo) {
|
|
3108
|
+
const queued = this.unannounced.get(ws);
|
|
3109
|
+
if (!queued?.length) return;
|
|
3110
|
+
const now = queued.filter((e) => e.seq < upTo);
|
|
3111
|
+
this.unannounced.set(ws, queued.filter((e) => e.seq >= upTo));
|
|
3112
|
+
for (const entry of now) {
|
|
3113
|
+
for (const l of this.auditListeners) {
|
|
3114
|
+
try {
|
|
3115
|
+
l(ws, entry);
|
|
3116
|
+
} catch {
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
3122
|
+
* The change since the last write, taking the recorded keys with it. Each
|
|
3123
|
+
* object is written as a copy in the shape a restore gives back, so a
|
|
3124
|
+
* coordinator restarted on the store holds what this one holds, and the
|
|
3125
|
+
* workspace's own objects are left as they are.
|
|
3126
|
+
*/
|
|
2605
3127
|
takeChange(ws, p) {
|
|
2606
3128
|
const replace = p.replace;
|
|
2607
3129
|
const objects = {};
|
|
@@ -2610,12 +3132,15 @@ var Coordinator = class {
|
|
|
2610
3132
|
if (replace) {
|
|
2611
3133
|
const all = {};
|
|
2612
3134
|
for (const [key, value] of Map.prototype.entries.call(map)) {
|
|
2613
|
-
all[key] = value;
|
|
3135
|
+
all[key] = storedCopy(name, value);
|
|
2614
3136
|
}
|
|
2615
3137
|
objects[name] = all;
|
|
2616
3138
|
} else if (map.dirty.size) {
|
|
2617
3139
|
const changed = {};
|
|
2618
|
-
for (const key of map.dirty)
|
|
3140
|
+
for (const key of map.dirty) {
|
|
3141
|
+
const value = map.peek(key);
|
|
3142
|
+
changed[key] = value === void 0 ? null : storedCopy(name, value);
|
|
3143
|
+
}
|
|
2619
3144
|
objects[name] = changed;
|
|
2620
3145
|
}
|
|
2621
3146
|
map.dirty.clear();
|
|
@@ -2667,24 +3192,28 @@ var Coordinator = class {
|
|
|
2667
3192
|
this.reportStoreError(err, { id: ws.id, version: next });
|
|
2668
3193
|
return;
|
|
2669
3194
|
}
|
|
3195
|
+
const upTo = ws.audit.length;
|
|
2670
3196
|
const pending = this.saveChains.get(ws.id);
|
|
2671
3197
|
if (pending) {
|
|
2672
|
-
this.trackSave(pending.then(() => this.store.save(record)), record);
|
|
3198
|
+
this.trackSave(pending.then(() => this.store.save(record)), record, () => this.announce(ws, upTo));
|
|
2673
3199
|
return;
|
|
2674
3200
|
}
|
|
2675
3201
|
let result;
|
|
2676
3202
|
try {
|
|
2677
3203
|
result = this.store.save(record);
|
|
2678
3204
|
} catch (err) {
|
|
3205
|
+
if (this.options.storeErrors !== "log") this.held.add(ws.id);
|
|
2679
3206
|
this.reportStoreError(err, record);
|
|
2680
3207
|
return;
|
|
2681
3208
|
}
|
|
2682
3209
|
if (isThenable(result)) {
|
|
2683
|
-
this.trackSave(Promise.resolve(result), record);
|
|
3210
|
+
this.trackSave(Promise.resolve(result), record, () => this.announce(ws, upTo));
|
|
3211
|
+
return;
|
|
2684
3212
|
}
|
|
3213
|
+
this.announce(ws, upTo);
|
|
2685
3214
|
}
|
|
2686
|
-
trackSave(save, record) {
|
|
2687
|
-
const guarded = save.
|
|
3215
|
+
trackSave(save, record, saved) {
|
|
3216
|
+
const guarded = save.then(saved, (err) => this.reportStoreError(err, record));
|
|
2688
3217
|
this.saveChains.set(record.id, guarded);
|
|
2689
3218
|
void guarded.then(() => {
|
|
2690
3219
|
if (this.saveChains.get(record.id) === guarded) {
|
|
@@ -2692,6 +3221,17 @@ var Coordinator = class {
|
|
|
2692
3221
|
}
|
|
2693
3222
|
});
|
|
2694
3223
|
}
|
|
3224
|
+
reportInternalError(err, method, workspace) {
|
|
3225
|
+
const call2 = { method, ...workspace !== void 0 ? { workspace } : {} };
|
|
3226
|
+
if (this.options.onInternalError) {
|
|
3227
|
+
try {
|
|
3228
|
+
this.options.onInternalError(err, call2);
|
|
3229
|
+
} catch {
|
|
3230
|
+
}
|
|
3231
|
+
return;
|
|
3232
|
+
}
|
|
3233
|
+
console.error(`chap coordinator: ${method} failed with an internal error:`, err);
|
|
3234
|
+
}
|
|
2695
3235
|
reportStoreError(err, record) {
|
|
2696
3236
|
if (this.options.onStoreError) {
|
|
2697
3237
|
try {
|
|
@@ -2701,7 +3241,9 @@ var Coordinator = class {
|
|
|
2701
3241
|
return;
|
|
2702
3242
|
}
|
|
2703
3243
|
const message = err instanceof Error ? err.message : String(err);
|
|
2704
|
-
|
|
3244
|
+
const line = `chap coordinator: writing workspace ${record.id} to the store failed: ${message}`;
|
|
3245
|
+
if (err instanceof StoreConflictError) console.warn(line);
|
|
3246
|
+
else console.error(line);
|
|
2705
3247
|
}
|
|
2706
3248
|
async drainSaves() {
|
|
2707
3249
|
while (this.saveChains.size) {
|
|
@@ -2752,16 +3294,25 @@ var Coordinator = class {
|
|
|
2752
3294
|
return null;
|
|
2753
3295
|
}
|
|
2754
3296
|
/**
|
|
2755
|
-
* Assert that `sender`
|
|
2756
|
-
* review-decision methods (decide.* and abstain.declare)
|
|
2757
|
-
* review
|
|
2758
|
-
*
|
|
2759
|
-
*
|
|
2760
|
-
*
|
|
3297
|
+
* Assert that `sender` may decide the task's open review. Applied to
|
|
3298
|
+
* review-decision methods (decide.* and abstain.declare) and to
|
|
3299
|
+
* review.acknowledge: to act on a review you must not be an author of the
|
|
3300
|
+
* work under review, must be a human member where the task requires
|
|
3301
|
+
* review, and must be one of the reviewers it was addressed to (the `to`
|
|
3302
|
+
* set on review.request).
|
|
3303
|
+
* Membership is the floor; this is the eligibility ceiling for decisions.
|
|
3304
|
+
* Returns an error fragment if not eligible, else null.
|
|
2761
3305
|
*/
|
|
2762
|
-
requireReviewer(task, sender) {
|
|
3306
|
+
requireReviewer(ws, task, sender) {
|
|
2763
3307
|
const review = task.review;
|
|
2764
|
-
if (!review
|
|
3308
|
+
if (!review) return null;
|
|
3309
|
+
if (typeof sender === "string" && review.authors.includes(sender)) {
|
|
3310
|
+
return { error: rpcError(E.NOT_AUTHORISED, "The author of the work under review cannot decide it") };
|
|
3311
|
+
}
|
|
3312
|
+
if (reviewRequired(ws, task) && rawMember(ws, sender)?.type !== "human") {
|
|
3313
|
+
return { error: rpcError(E.NOT_AUTHORISED, `This task requires a person's review: ${String(sender)} is not a human member`) };
|
|
3314
|
+
}
|
|
3315
|
+
if (!review.requested_to || review.requested_to.length === 0) {
|
|
2765
3316
|
return null;
|
|
2766
3317
|
}
|
|
2767
3318
|
if (review.requested_to.some((r) => typeof r === "string" && (r.startsWith("workspace:") || r.startsWith("group:")))) {
|
|
@@ -2790,23 +3341,31 @@ var Coordinator = class {
|
|
|
2790
3341
|
routing_policy_uri: ws.routing_policy_uri,
|
|
2791
3342
|
step_up_window_sec: ws.step_up_window_sec,
|
|
2792
3343
|
min_acr: ws.min_acr,
|
|
3344
|
+
signature_policy: ws.signature_policy,
|
|
3345
|
+
join_policy: ws.join_policy,
|
|
3346
|
+
review_rule: ws.review_rule,
|
|
2793
3347
|
chain_head: ws.chain_head,
|
|
2794
3348
|
chain_enabled: ws.chain_enabled
|
|
2795
3349
|
};
|
|
2796
3350
|
}
|
|
2797
|
-
/**
|
|
3351
|
+
/**
|
|
3352
|
+
* One workspace as `snapshot()` holds it: each object of its collections a
|
|
3353
|
+
* copy in the shape the store keeps. Reads nothing as a change.
|
|
3354
|
+
*/
|
|
2798
3355
|
snapshotOf(ws) {
|
|
2799
3356
|
const { chain_head, chain_enabled, ...own } = this.metaOf(ws);
|
|
3357
|
+
const stored = (name) => Array.from(rawValues(ws[name])).map((v) => storedCopy(name, v));
|
|
2800
3358
|
return {
|
|
2801
3359
|
...own,
|
|
2802
|
-
members:
|
|
2803
|
-
tasks:
|
|
2804
|
-
overrides:
|
|
2805
|
-
whispers:
|
|
2806
|
-
deliberations:
|
|
2807
|
-
handoffs:
|
|
2808
|
-
snapshots:
|
|
2809
|
-
route_decisions:
|
|
3360
|
+
members: stored("members"),
|
|
3361
|
+
tasks: stored("tasks"),
|
|
3362
|
+
overrides: stored("overrides"),
|
|
3363
|
+
whispers: stored("whispers"),
|
|
3364
|
+
deliberations: stored("deliberations"),
|
|
3365
|
+
handoffs: stored("handoffs"),
|
|
3366
|
+
snapshots: stored("snapshots"),
|
|
3367
|
+
route_decisions: stored("route_decisions"),
|
|
3368
|
+
invitations: stored("invitations"),
|
|
2810
3369
|
idempotency_keys: { ...ws.idempotency_keys },
|
|
2811
3370
|
audit: ws.audit,
|
|
2812
3371
|
chain_head,
|
|
@@ -2834,7 +3393,8 @@ var Coordinator = class {
|
|
|
2834
3393
|
}
|
|
2835
3394
|
}
|
|
2836
3395
|
/** One workspace from a snapshot entry, its maps tracked. */
|
|
2837
|
-
rebuild(
|
|
3396
|
+
rebuild(snapshot) {
|
|
3397
|
+
const w = shapeSnapshot(snapshot);
|
|
2838
3398
|
const ws = {
|
|
2839
3399
|
id: w.id,
|
|
2840
3400
|
created: w.created,
|
|
@@ -2845,6 +3405,11 @@ var Coordinator = class {
|
|
|
2845
3405
|
routing_policy_uri: w.routing_policy_uri,
|
|
2846
3406
|
step_up_window_sec: w.step_up_window_sec ?? 300,
|
|
2847
3407
|
min_acr: w.min_acr,
|
|
3408
|
+
// A workspace written before the policy existed verified every call,
|
|
3409
|
+
// and one written before the join policy existed admitted anyone.
|
|
3410
|
+
signature_policy: w.signature_policy === "humans" ? "humans" : "all",
|
|
3411
|
+
join_policy: w.join_policy === "invite" ? "invite" : "open",
|
|
3412
|
+
review_rule: typeof w.review_rule === "string" && reviewRuleSupported(w.review_rule) ? w.review_rule : void 0,
|
|
2848
3413
|
members: this.newMap(),
|
|
2849
3414
|
tasks: this.newMap(),
|
|
2850
3415
|
overrides: this.newMap(),
|
|
@@ -2853,6 +3418,7 @@ var Coordinator = class {
|
|
|
2853
3418
|
handoffs: this.newMap(),
|
|
2854
3419
|
snapshots: this.newMap(),
|
|
2855
3420
|
route_decisions: this.newMap(),
|
|
3421
|
+
invitations: this.newMap(),
|
|
2856
3422
|
idempotency_keys: w.idempotency_keys ?? {},
|
|
2857
3423
|
audit: w.audit ?? [],
|
|
2858
3424
|
chain_head: w.chain_head,
|
|
@@ -2861,9 +3427,16 @@ var Coordinator = class {
|
|
|
2861
3427
|
for (const m of w.members ?? []) {
|
|
2862
3428
|
if (!Array.isArray(m.keys)) m.keys = [];
|
|
2863
3429
|
if (m.paused === void 0) m.paused = false;
|
|
3430
|
+
const scheme = typeof m.uri === "string" ? m.uri.split(":")[0] : "";
|
|
3431
|
+
if (PARTICIPANT_TYPES.includes(scheme)) m.type = scheme;
|
|
3432
|
+
if (m.role === "admin" && !ADMIN_TYPES.includes(m.type)) m.role = "participant";
|
|
2864
3433
|
ws.members.set(m.uri, m);
|
|
2865
3434
|
}
|
|
2866
|
-
for (const t of w.tasks ?? [])
|
|
3435
|
+
for (const t of w.tasks ?? []) {
|
|
3436
|
+
if (t.review && !Array.isArray(t.review.authors)) t.review.authors = storedAuthors(t);
|
|
3437
|
+
if (t.review && !Array.isArray(t.review.acknowledgements)) t.review.acknowledgements = [];
|
|
3438
|
+
ws.tasks.set(t.id, t);
|
|
3439
|
+
}
|
|
2867
3440
|
for (const o of w.overrides ?? []) ws.overrides.set(o.id, o);
|
|
2868
3441
|
for (const x of w.whispers ?? [])
|
|
2869
3442
|
ws.whispers.set(x.id, x);
|
|
@@ -2907,20 +3480,119 @@ var Coordinator = class {
|
|
|
2907
3480
|
}
|
|
2908
3481
|
for (const x of w.route_decisions ?? [])
|
|
2909
3482
|
ws.route_decisions.set(x.id, x);
|
|
3483
|
+
for (const x of w.invitations ?? []) ws.invitations.set(x.uri, x);
|
|
2910
3484
|
return ws;
|
|
2911
3485
|
}
|
|
2912
3486
|
// -- dispatch ------------------------------------------------------
|
|
2913
3487
|
dispatch(envelope) {
|
|
2914
3488
|
this.frozenNow = this.advanceClock();
|
|
2915
3489
|
this.tracker.depth++;
|
|
3490
|
+
let response;
|
|
3491
|
+
let target;
|
|
3492
|
+
let ran = false;
|
|
2916
3493
|
try {
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
3494
|
+
target = targetOf(envelope);
|
|
3495
|
+
const refused = target === void 0 ? null : this.keepUp(target);
|
|
3496
|
+
if (refused) {
|
|
3497
|
+
response = reply(envelope, { error: refused });
|
|
3498
|
+
} else {
|
|
3499
|
+
ran = true;
|
|
3500
|
+
response = this._dispatch(envelope);
|
|
3501
|
+
if (response.error) this.recordRefusal(envelope, response.error);
|
|
3502
|
+
else if (target === void 0 && envelope.method === "workspace.create") {
|
|
3503
|
+
target = response.result?.workspace;
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
2920
3506
|
} finally {
|
|
2921
3507
|
if (--this.tracker.depth === 0) this.flushUnflushed();
|
|
2922
3508
|
this.frozenNow = void 0;
|
|
2923
3509
|
}
|
|
3510
|
+
if (ran && target !== void 0 && this.tracker.depth === 0 && this.held.has(target)) {
|
|
3511
|
+
this.reload(target);
|
|
3512
|
+
return reply(envelope, { error: storeFailed() });
|
|
3513
|
+
}
|
|
3514
|
+
return response;
|
|
3515
|
+
}
|
|
3516
|
+
/**
|
|
3517
|
+
* Work that changes a workspace outside any call, a whisper lapse check
|
|
3518
|
+
* for one, kept to the rules a call keeps (SPECIFICATION 10.3): the
|
|
3519
|
+
* workspace is first brought up to date with the store, and left alone
|
|
3520
|
+
* as long as it is held; what the work changed is written once, when it ends;
|
|
3521
|
+
* and where that write fails, the workspace goes back to what the store
|
|
3522
|
+
* holds and the work's results are dropped, since none took effect.
|
|
3523
|
+
*/
|
|
3524
|
+
outsideCall(id, work) {
|
|
3525
|
+
if (this.tracker.depth > 0) return work();
|
|
3526
|
+
if (this.keepUp(id)) return [];
|
|
3527
|
+
this.tracker.depth++;
|
|
3528
|
+
let out;
|
|
3529
|
+
try {
|
|
3530
|
+
out = work();
|
|
3531
|
+
} finally {
|
|
3532
|
+
if (--this.tracker.depth === 0) this.flushUnflushed();
|
|
3533
|
+
}
|
|
3534
|
+
if (this.held.has(id)) {
|
|
3535
|
+
this.reload(id);
|
|
3536
|
+
return [];
|
|
3537
|
+
}
|
|
3538
|
+
return out;
|
|
3539
|
+
}
|
|
3540
|
+
/**
|
|
3541
|
+
* Before a call on a workspace: where the store holds a different number of
|
|
3542
|
+
* audit entries for it than this instance last wrote, another writer has
|
|
3543
|
+
* written it, and it is reloaded. A workspace held after a failed write is
|
|
3544
|
+
* reloaded as well, and the call refused while that fails.
|
|
3545
|
+
*/
|
|
3546
|
+
keepUp(id) {
|
|
3547
|
+
if (this.held.has(id)) return this.reload(id) ? null : storeFailed();
|
|
3548
|
+
if (typeof this.store.version !== "function" || this.saveChains.has(id)) return null;
|
|
3549
|
+
let stored;
|
|
3550
|
+
try {
|
|
3551
|
+
stored = this.store.version(id);
|
|
3552
|
+
} catch {
|
|
3553
|
+
return null;
|
|
3554
|
+
}
|
|
3555
|
+
if (isThenable(stored)) return null;
|
|
3556
|
+
const ws = this.workspaces.get(id);
|
|
3557
|
+
const p = ws ? this.persisted.get(ws) : void 0;
|
|
3558
|
+
if (ws && (!p || p.replace)) return null;
|
|
3559
|
+
if (stored === p?.seq) return null;
|
|
3560
|
+
return this.reload(id) ? null : storeFailed();
|
|
3561
|
+
}
|
|
3562
|
+
/** Replace a workspace with what the store holds of it. False when it cannot be read. */
|
|
3563
|
+
reload(id) {
|
|
3564
|
+
let record;
|
|
3565
|
+
try {
|
|
3566
|
+
if (typeof this.store.loadOne === "function") {
|
|
3567
|
+
record = this.store.loadOne(id);
|
|
3568
|
+
} else {
|
|
3569
|
+
const all = this.store.load();
|
|
3570
|
+
if (isThenable(all)) return false;
|
|
3571
|
+
record = all.find((r) => r.id === id);
|
|
3572
|
+
}
|
|
3573
|
+
if (isThenable(record)) return false;
|
|
3574
|
+
} catch (err) {
|
|
3575
|
+
this.reportStoreError(err, { id, version: this.workspaces.get(id)?.audit.length ?? 0 });
|
|
3576
|
+
return false;
|
|
3577
|
+
}
|
|
3578
|
+
const old = this.workspaces.get(id);
|
|
3579
|
+
if (old) this.unflushed.delete(old);
|
|
3580
|
+
if (record) {
|
|
3581
|
+
let ws;
|
|
3582
|
+
try {
|
|
3583
|
+
ws = this.rebuild(record.data);
|
|
3584
|
+
} catch (err) {
|
|
3585
|
+
this.reportStoreError(err, { id, version: record.version });
|
|
3586
|
+
return false;
|
|
3587
|
+
}
|
|
3588
|
+
this.workspaces.set(id, ws);
|
|
3589
|
+
this.markWritten(ws);
|
|
3590
|
+
this.wsVersions.set(id, record.version);
|
|
3591
|
+
} else {
|
|
3592
|
+
this.workspaces.delete(id);
|
|
3593
|
+
}
|
|
3594
|
+
this.held.delete(id);
|
|
3595
|
+
return true;
|
|
2924
3596
|
}
|
|
2925
3597
|
_dispatch(envelope) {
|
|
2926
3598
|
if (!isValidEnvelope(envelope) || typeof envelope.method !== "string" || !envelope.method) {
|
|
@@ -2957,11 +3629,16 @@ var Coordinator = class {
|
|
|
2957
3629
|
const msg = canonicalError instanceof Error ? canonicalError.message : String(canonicalError);
|
|
2958
3630
|
return reply(envelope, { error: rpcError(E.PARAMS, msg) });
|
|
2959
3631
|
}
|
|
3632
|
+
if (params.workspace !== void 0 && (typeof params.workspace !== "string" || !params.workspace)) {
|
|
3633
|
+
return reply(envelope, { error: rpcError(E.PARAMS, "workspace must be a non-empty string") });
|
|
3634
|
+
}
|
|
2960
3635
|
if (isSigned(envelope)) {
|
|
2961
3636
|
const wsId2 = params.workspace;
|
|
2962
3637
|
const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
|
|
2963
3638
|
if (ws) {
|
|
2964
|
-
const
|
|
3639
|
+
const index = this.signedIndex(ws);
|
|
3640
|
+
const digest = signedDigest(recordedForm(envelope));
|
|
3641
|
+
const prior = index.refused.get(digest);
|
|
2965
3642
|
if (prior) {
|
|
2966
3643
|
return reply(envelope, { error: rpcError(
|
|
2967
3644
|
prior.code,
|
|
@@ -2969,15 +3646,24 @@ var Coordinator = class {
|
|
|
2969
3646
|
{ refused_at_seq: prior.seq }
|
|
2970
3647
|
) });
|
|
2971
3648
|
}
|
|
3649
|
+
const accepted = index.accepted.get(digest);
|
|
3650
|
+
if (accepted !== void 0) {
|
|
3651
|
+
return reply(envelope, { error: rpcError(
|
|
3652
|
+
E.SIG_ALREADY_ACCEPTED,
|
|
3653
|
+
`Accepted at seq ${accepted}; a signed request takes effect once`,
|
|
3654
|
+
{ accepted_at_seq: accepted }
|
|
3655
|
+
) });
|
|
3656
|
+
}
|
|
2972
3657
|
}
|
|
2973
3658
|
}
|
|
2974
|
-
|
|
3659
|
+
const target = typeof params.workspace === "string" ? this.workspaces.get(params.workspace) : void 0;
|
|
3660
|
+
if (this.requiresSignatures(target) && method !== "participant.join" && method !== "workspace.create" && !(!isSigned(envelope) && target !== void 0 && !this.mustSign(target, rawMember(target, params.from)?.type))) {
|
|
2975
3661
|
const sigErr = this.verifySignature(envelope);
|
|
2976
3662
|
if (sigErr) return reply(envelope, { error: sigErr });
|
|
2977
3663
|
if (method === "participant.rotate_key") {
|
|
2978
3664
|
const signingKid = envelope.sig.split(":")[1];
|
|
2979
3665
|
const oldKid = params.old_kid;
|
|
2980
|
-
if (oldKid
|
|
3666
|
+
if (oldKid != null && signingKid !== oldKid) {
|
|
2981
3667
|
return reply(envelope, { error: rpcError(
|
|
2982
3668
|
E.SIG_ROTATION_KEY_MISMATCH,
|
|
2983
3669
|
"Rotation must be signed with the old key"
|
|
@@ -3032,14 +3718,14 @@ var Coordinator = class {
|
|
|
3032
3718
|
try {
|
|
3033
3719
|
out = handler(ownCopy(params));
|
|
3034
3720
|
} catch (e) {
|
|
3035
|
-
|
|
3036
|
-
return reply(envelope, { error: rpcError(E.INTERNAL, "Internal error"
|
|
3721
|
+
this.reportInternalError(e, method, params.workspace);
|
|
3722
|
+
return reply(envelope, { error: rpcError(E.INTERNAL, "Internal error") });
|
|
3037
3723
|
}
|
|
3038
3724
|
if (out.error) return reply(envelope, { error: out.error });
|
|
3039
|
-
const wsId = params.workspace;
|
|
3725
|
+
const wsId = method === "workspace.create" ? out.result.workspace : params.workspace;
|
|
3040
3726
|
if (typeof wsId === "string" && !READ_ONLY_METHODS.has(method)) {
|
|
3041
3727
|
const ws = this.workspaces.get(wsId);
|
|
3042
|
-
if (ws) this.recordAudit(ws, envelope);
|
|
3728
|
+
if (ws) this.recordAudit(ws, recordedForm(envelope));
|
|
3043
3729
|
}
|
|
3044
3730
|
return reply(envelope, { result: ownCopy(out.result) });
|
|
3045
3731
|
}
|
|
@@ -3079,7 +3765,7 @@ var Coordinator = class {
|
|
|
3079
3765
|
signedIndex(ws) {
|
|
3080
3766
|
let index = this.signedIndexes.get(ws);
|
|
3081
3767
|
if (!index) {
|
|
3082
|
-
index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new
|
|
3768
|
+
index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Map() };
|
|
3083
3769
|
for (const entry of ws.audit) indexSigned(index, entry);
|
|
3084
3770
|
this.signedIndexes.set(ws, index);
|
|
3085
3771
|
}
|
|
@@ -3095,12 +3781,9 @@ var Coordinator = class {
|
|
|
3095
3781
|
ws.audit.push(entry);
|
|
3096
3782
|
const index = this.signedIndexes.get(ws);
|
|
3097
3783
|
if (index) indexSigned(index, entry);
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
} catch {
|
|
3102
|
-
}
|
|
3103
|
-
}
|
|
3784
|
+
const queued = this.unannounced.get(ws);
|
|
3785
|
+
if (queued) queued.push(entry);
|
|
3786
|
+
else this.unannounced.set(ws, [entry]);
|
|
3104
3787
|
this.persist(ws);
|
|
3105
3788
|
}
|
|
3106
3789
|
// -- signature verification (security-signed/1.0) -----------------
|
|
@@ -3110,7 +3793,12 @@ var Coordinator = class {
|
|
|
3110
3793
|
return rpcError(E.SIG_VERIFY_FAILED, "Missing top-level `sig` field");
|
|
3111
3794
|
}
|
|
3112
3795
|
const parts = sig.split(":");
|
|
3113
|
-
|
|
3796
|
+
const scheme = parts[0];
|
|
3797
|
+
if (parts.length !== 3 || scheme !== "ed25519" && scheme !== "webauthn") {
|
|
3798
|
+
return rpcError(E.SIG_VERIFY_FAILED, "Malformed signature tag");
|
|
3799
|
+
}
|
|
3800
|
+
const assertion = scheme === "webauthn" ? parseWebAuthnAssertion(parts[2]) : void 0;
|
|
3801
|
+
if (scheme === "webauthn" && !assertion) {
|
|
3114
3802
|
return rpcError(E.SIG_VERIFY_FAILED, "Malformed signature tag");
|
|
3115
3803
|
}
|
|
3116
3804
|
const kid = parts[1];
|
|
@@ -3144,6 +3832,14 @@ var Coordinator = class {
|
|
|
3144
3832
|
if (revoked) return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
|
|
3145
3833
|
return rpcError(E.SIG_KEY_NOT_FOUND, `No key ${kid} valid at ${ts} for ${sender}`);
|
|
3146
3834
|
}
|
|
3835
|
+
if (assertion) {
|
|
3836
|
+
if (!isWebAuthnKey(key.jwk)) return rpcError(E.SIG_VERIFY_FAILED, `Key ${kid} is not a WebAuthn key`);
|
|
3837
|
+
const stripped = JSON.parse(JSON.stringify(envelope));
|
|
3838
|
+
delete stripped.sig;
|
|
3839
|
+
const failure = webAuthnFailure(canonicalize(stripped), assertion, key.jwk);
|
|
3840
|
+
return failure === null ? null : rpcError(E.SIG_VERIFY_FAILED, failure);
|
|
3841
|
+
}
|
|
3842
|
+
if (!isEd25519Key(key.jwk)) return rpcError(E.SIG_VERIFY_FAILED, `Key ${kid} is not an Ed25519 key`);
|
|
3147
3843
|
try {
|
|
3148
3844
|
const stripped = JSON.parse(JSON.stringify(envelope));
|
|
3149
3845
|
delete stripped.sig;
|
|
@@ -3157,6 +3853,39 @@ var Coordinator = class {
|
|
|
3157
3853
|
return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
|
|
3158
3854
|
}
|
|
3159
3855
|
}
|
|
3856
|
+
/**
|
|
3857
|
+
* SPECIFICATION 6.5: an identity profile states that a verifier checks
|
|
3858
|
+
* people's credentials, so advertising one this coordinator has no
|
|
3859
|
+
* verifier for describes a guarantee it does not give.
|
|
3860
|
+
*/
|
|
3861
|
+
unverifiedIdentity(profiles) {
|
|
3862
|
+
const named = (name) => profiles.some((x) => x === name || x.startsWith(name + "/"));
|
|
3863
|
+
if (named("identity-oidc") && this.options.verifyOidcToken === void 0) {
|
|
3864
|
+
return rpcError(
|
|
3865
|
+
E.PARAMS,
|
|
3866
|
+
"identity-oidc/1.0 is advertised but no token verifier is configured. A workspace that advertises it and does not enforce it describes a guarantee it does not give."
|
|
3867
|
+
);
|
|
3868
|
+
}
|
|
3869
|
+
if (named("identity-vc") && this.options.verifyVc === void 0) {
|
|
3870
|
+
return rpcError(
|
|
3871
|
+
E.PARAMS,
|
|
3872
|
+
"identity-vc/1.0 is advertised but no presentation verifier is configured. A workspace that advertises it and does not enforce it describes a guarantee it does not give."
|
|
3873
|
+
);
|
|
3874
|
+
}
|
|
3875
|
+
return null;
|
|
3876
|
+
}
|
|
3877
|
+
/** Whether a workspace requires signatures: it advertises security-signed/1.0, or this coordinator is configured to. */
|
|
3878
|
+
requiresSignatures(ws) {
|
|
3879
|
+
return !!this.options.requireSignatures || ws !== void 0 && advertises(ws, "security-signed");
|
|
3880
|
+
}
|
|
3881
|
+
/**
|
|
3882
|
+
* Whether a participant of this type signs its calls in a workspace that
|
|
3883
|
+
* requires signatures: every participant under "all", and every one but an
|
|
3884
|
+
* agent under "humans". A non-member signs.
|
|
3885
|
+
*/
|
|
3886
|
+
mustSign(ws, type) {
|
|
3887
|
+
return ws.signature_policy === "all" || type !== "agent";
|
|
3888
|
+
}
|
|
3160
3889
|
/** Lookup the key valid for a given participant at a given time. */
|
|
3161
3890
|
lookupKey(member, kid, ts) {
|
|
3162
3891
|
for (const k of member.keys) {
|
|
@@ -3230,6 +3959,7 @@ var Coordinator = class {
|
|
|
3230
3959
|
this.handlers.set("workspace.create", (p) => this.opWorkspaceCreate(p));
|
|
3231
3960
|
this.handlers.set("workspace.describe", (p) => this.opWorkspaceDescribe(p));
|
|
3232
3961
|
this.handlers.set("workspace.set_profiles", (p) => this.opWorkspaceSetProfiles(p));
|
|
3962
|
+
this.handlers.set("workspace.invite", (p) => this.opWorkspaceInvite(p));
|
|
3233
3963
|
this.handlers.set("participant.join", (p) => this.opParticipantJoin(p));
|
|
3234
3964
|
this.handlers.set("participant.leave", (p) => this.opParticipantLeave(p));
|
|
3235
3965
|
this.handlers.set("task.create", (p) => this.opTaskCreate(p));
|
|
@@ -3237,6 +3967,7 @@ var Coordinator = class {
|
|
|
3237
3967
|
this.handlers.set("task.complete", (p) => this.opTaskComplete(p));
|
|
3238
3968
|
this.handlers.set("audit.read", (p) => this.opAuditRead(p));
|
|
3239
3969
|
this.handlers.set("review.request", (p) => this.opReviewRequest(p));
|
|
3970
|
+
this.handlers.set("review.acknowledge", (p) => this.opReviewAcknowledge(p));
|
|
3240
3971
|
this.handlers.set("decide.approve", (p) => this.opDecide(p, "approve"));
|
|
3241
3972
|
this.handlers.set("decide.reject", (p) => this.opDecide(p, "reject"));
|
|
3242
3973
|
this.handlers.set("decide.override", (p) => this.opDecideOverride(p));
|
|
@@ -3256,24 +3987,59 @@ var Coordinator = class {
|
|
|
3256
3987
|
// Core method handlers
|
|
3257
3988
|
// ==========================================================
|
|
3258
3989
|
opWorkspaceCreate(p) {
|
|
3990
|
+
const built = this.buildWorkspace(p);
|
|
3991
|
+
if (built.error) return { error: built.error };
|
|
3992
|
+
this.addWorkspace(built.ws);
|
|
3993
|
+
return { result: { workspace: built.ws.id, created: built.ws.created } };
|
|
3994
|
+
}
|
|
3995
|
+
/**
|
|
3996
|
+
* The workspace a workspace.create with these params makes, or why it
|
|
3997
|
+
* cannot be made. Nothing is registered, so participant.join can judge a
|
|
3998
|
+
* join by the workspace it would make and make it only once the join
|
|
3999
|
+
* passes.
|
|
4000
|
+
*/
|
|
4001
|
+
buildWorkspace(p) {
|
|
4002
|
+
const bad = paramsError(p, [
|
|
4003
|
+
["profiles", "strings"],
|
|
4004
|
+
["mode", "string"],
|
|
4005
|
+
["mode_ceiling", "string"],
|
|
4006
|
+
["routing_policy_uri", "string"],
|
|
4007
|
+
["step_up_window_sec", "integer"],
|
|
4008
|
+
["min_acr", "string"]
|
|
4009
|
+
]);
|
|
4010
|
+
if (bad) return bad;
|
|
4011
|
+
for (const name of ["mode", "mode_ceiling"]) {
|
|
4012
|
+
if (p[name] != null && !MODES.has(p[name])) {
|
|
4013
|
+
return { error: rpcError(E.PARAMS, `${name} must be shadow, trial or production`) };
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
const policy = p.signature_policy ?? this.options.signaturePolicy ?? "all";
|
|
4017
|
+
if (policy !== "all" && policy !== "humans") {
|
|
4018
|
+
return { error: rpcError(E.PARAMS, "signature_policy must be all or humans") };
|
|
4019
|
+
}
|
|
4020
|
+
const joinPolicy = p.join_policy ?? this.options.joinPolicy ?? "open";
|
|
4021
|
+
if (joinPolicy !== "open" && joinPolicy !== "invite") {
|
|
4022
|
+
return { error: rpcError(E.PARAMS, "join_policy must be open or invite") };
|
|
4023
|
+
}
|
|
4024
|
+
const reviewRule = p.review_rule ?? void 0;
|
|
4025
|
+
if (reviewRule !== void 0 && (typeof reviewRule !== "string" || !reviewRuleSupported(reviewRule))) {
|
|
4026
|
+
return { error: rpcError(E.PARAMS, "review_rule must be any_one_approves, all_approve or quorum:<n>") };
|
|
4027
|
+
}
|
|
3259
4028
|
const id = p.workspace || this.ids.workspaceId();
|
|
3260
|
-
if (typeof id !== "string") return { error: rpcError(E.PARAMS, "workspace must be a string id") };
|
|
3261
4029
|
if (this.workspaces.has(id)) return { error: rpcError(E.PARAMS, `workspace already exists: ${id}`) };
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
["identity-oidc/1.0", this.options.verifyOidcToken !== void 0, "verifyOidcToken"]
|
|
3266
|
-
]) {
|
|
3267
|
-
const name = profile.split("/")[0];
|
|
3268
|
-
const advertised = profiles.some((x) => x === name || x.startsWith(name + "/"));
|
|
3269
|
-
if (enforced && !advertised) profiles.push(profile);
|
|
3270
|
-
else if (advertised && !enforced) {
|
|
3271
|
-
return { error: rpcError(
|
|
3272
|
-
E.PARAMS,
|
|
3273
|
-
`${profile} is advertised but ${option} is not configured. A workspace that advertises it and does not enforce it describes a guarantee it does not give.`
|
|
3274
|
-
) };
|
|
3275
|
-
}
|
|
4030
|
+
if (Array.isArray(p.profiles)) {
|
|
4031
|
+
const unknown = profilesError(p.profiles);
|
|
4032
|
+
if (unknown) return unknown;
|
|
3276
4033
|
}
|
|
4034
|
+
const profiles = Array.isArray(p.profiles) ? [...p.profiles] : [...this.options.defaultProfiles ?? ["core/1.0", "review/1.0"]];
|
|
4035
|
+
const signed = profiles.some((x) => x === "security-signed" || x.startsWith("security-signed/"));
|
|
4036
|
+
if (this.options.requireSignatures && !signed) profiles.push("security-signed/1.0");
|
|
4037
|
+
const oidc = profiles.some((x) => x === "identity-oidc" || x.startsWith("identity-oidc/"));
|
|
4038
|
+
if (this.options.verifyOidcToken !== void 0 && !oidc) profiles.push("identity-oidc/1.0");
|
|
4039
|
+
const vc = profiles.some((x) => x === "identity-vc" || x.startsWith("identity-vc/"));
|
|
4040
|
+
if (this.options.verifyVc !== void 0 && !vc) profiles.push("identity-vc/1.0");
|
|
4041
|
+
const unverified = this.unverifiedIdentity(profiles);
|
|
4042
|
+
if (unverified) return { error: unverified };
|
|
3277
4043
|
const chainEnabled = profiles.includes("audit-scitt/1.0") || !!this.options.enableChain;
|
|
3278
4044
|
const ws = {
|
|
3279
4045
|
id,
|
|
@@ -3282,9 +4048,14 @@ var Coordinator = class {
|
|
|
3282
4048
|
profiles,
|
|
3283
4049
|
mode: p.mode ?? "trial",
|
|
3284
4050
|
mode_ceiling: p.mode_ceiling ?? "production",
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
4051
|
+
// A param given as null gives no value, as in the Python reference: a
|
|
4052
|
+
// null min_acr sets no minimum.
|
|
4053
|
+
routing_policy_uri: p.routing_policy_uri ?? void 0,
|
|
4054
|
+
step_up_window_sec: p.step_up_window_sec ?? 300,
|
|
4055
|
+
min_acr: p.min_acr ?? void 0,
|
|
4056
|
+
signature_policy: policy,
|
|
4057
|
+
join_policy: joinPolicy,
|
|
4058
|
+
...reviewRule !== void 0 ? { review_rule: reviewRule } : {},
|
|
3288
4059
|
members: this.newMap(),
|
|
3289
4060
|
tasks: this.newMap(),
|
|
3290
4061
|
overrides: this.newMap(),
|
|
@@ -3293,14 +4064,17 @@ var Coordinator = class {
|
|
|
3293
4064
|
handoffs: this.newMap(),
|
|
3294
4065
|
snapshots: this.newMap(),
|
|
3295
4066
|
route_decisions: this.newMap(),
|
|
4067
|
+
invitations: this.newMap(),
|
|
3296
4068
|
idempotency_keys: {},
|
|
3297
4069
|
audit: [],
|
|
3298
4070
|
chain_enabled: chainEnabled,
|
|
3299
4071
|
chain_head: chainEnabled ? ZERO_HASH : void 0
|
|
3300
4072
|
};
|
|
3301
|
-
|
|
4073
|
+
return { ws };
|
|
4074
|
+
}
|
|
4075
|
+
addWorkspace(ws) {
|
|
4076
|
+
this.workspaces.set(ws.id, ws);
|
|
3302
4077
|
this.persisted.set(ws, { seq: 0, replace: false, keys: /* @__PURE__ */ new Set() });
|
|
3303
|
-
return { result: { workspace: id, created: ws.created } };
|
|
3304
4078
|
}
|
|
3305
4079
|
opWorkspaceDescribe(p) {
|
|
3306
4080
|
const miss = missing(p, ["workspace"]);
|
|
@@ -3319,6 +4093,9 @@ var Coordinator = class {
|
|
|
3319
4093
|
mode_ceiling: ws.mode_ceiling,
|
|
3320
4094
|
max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
|
|
3321
4095
|
step_up_window_sec: ws.step_up_window_sec,
|
|
4096
|
+
signature_policy: ws.signature_policy,
|
|
4097
|
+
join_policy: ws.join_policy,
|
|
4098
|
+
review_rule: ws.review_rule ?? "any_one_approves",
|
|
3322
4099
|
profiles: ws.profiles,
|
|
3323
4100
|
members: Array.from(rawValues(ws.members)).map(memberToDict),
|
|
3324
4101
|
audit_count: ws.audit.length,
|
|
@@ -3334,6 +4111,11 @@ var Coordinator = class {
|
|
|
3334
4111
|
opWorkspaceSetProfiles(p) {
|
|
3335
4112
|
const miss = missing(p, ["workspace", "from", "profiles"]);
|
|
3336
4113
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4114
|
+
if (!Array.isArray(p.profiles) || !p.profiles.every((x) => typeof x === "string")) {
|
|
4115
|
+
return { error: rpcError(E.PARAMS, "profiles must be a list of strings") };
|
|
4116
|
+
}
|
|
4117
|
+
const unknown = profilesError(p.profiles);
|
|
4118
|
+
if (unknown) return unknown;
|
|
3337
4119
|
const ws = this.workspaces.get(p.workspace);
|
|
3338
4120
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3339
4121
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3342,6 +4124,14 @@ var Coordinator = class {
|
|
|
3342
4124
|
return { error: rpcError(E.NOT_AUTHORISED, "workspace.set_profiles requires the admin role") };
|
|
3343
4125
|
}
|
|
3344
4126
|
const newProfiles = [...p.profiles];
|
|
4127
|
+
for (const name of LASTING_PROFILES) {
|
|
4128
|
+
const held = ws.profiles.find((x) => x === name || x.startsWith(name + "/"));
|
|
4129
|
+
if (held !== void 0 && !newProfiles.some((x) => x.startsWith(name + "/"))) {
|
|
4130
|
+
return { error: rpcError(E.NOT_AUTHORISED, `${held} cannot be removed once advertised`) };
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
const unverified = this.unverifiedIdentity(newProfiles);
|
|
4134
|
+
if (unverified) return { error: unverified };
|
|
3345
4135
|
if (!newProfiles.some((pr) => pr.startsWith("core/"))) newProfiles.push("core/1.0");
|
|
3346
4136
|
ws.profiles = newProfiles;
|
|
3347
4137
|
if (newProfiles.includes("audit-scitt/1.0") && !ws.chain_enabled) {
|
|
@@ -3353,20 +4143,76 @@ var Coordinator = class {
|
|
|
3353
4143
|
opParticipantJoin(p) {
|
|
3354
4144
|
const miss = missing(p, ["workspace", "from", "type"]);
|
|
3355
4145
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4146
|
+
for (const f of ["from", "type"]) {
|
|
4147
|
+
if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
|
|
4148
|
+
}
|
|
4149
|
+
const type = p.type;
|
|
4150
|
+
const named = participantError(type, p.from, "from");
|
|
4151
|
+
if (named) return named;
|
|
4152
|
+
const bad = paramsError(p, [
|
|
4153
|
+
["role", "string"],
|
|
4154
|
+
["display_name", "string"],
|
|
4155
|
+
["scopes", "strings"],
|
|
4156
|
+
["capabilities", "object"],
|
|
4157
|
+
["jwks", "object"],
|
|
4158
|
+
["jwks.keys", "list"],
|
|
4159
|
+
["oidc_token", "string"],
|
|
4160
|
+
["vc_presentation", "object"]
|
|
4161
|
+
]);
|
|
4162
|
+
if (bad) return bad;
|
|
4163
|
+
if (p.role === "admin" && !ADMIN_TYPES.includes(type)) {
|
|
4164
|
+
return { error: rpcError(E.PARAMS, "The admin role is held by a human or a service") };
|
|
4165
|
+
}
|
|
3356
4166
|
let ws = this.workspaces.get(p.workspace);
|
|
4167
|
+
const made = ws === void 0;
|
|
3357
4168
|
if (!ws) {
|
|
3358
|
-
const
|
|
3359
|
-
if (
|
|
3360
|
-
ws =
|
|
4169
|
+
const built = this.buildWorkspace({ workspace: p.workspace });
|
|
4170
|
+
if (built.error) return { error: built.error };
|
|
4171
|
+
ws = built.ws;
|
|
3361
4172
|
}
|
|
3362
4173
|
const uri = p.from;
|
|
3363
4174
|
const now = this.now();
|
|
3364
4175
|
const existing = ws.members.get(uri);
|
|
3365
4176
|
let tokenVerified = false;
|
|
4177
|
+
let holderVerified = false;
|
|
4178
|
+
let credentialBound = false;
|
|
4179
|
+
const invitation = existing ? void 0 : rawInvitation(ws, uri);
|
|
4180
|
+
const asked = typeof p.role === "string" && p.role !== "" ? p.role : void 0;
|
|
4181
|
+
let role = asked ?? "participant";
|
|
4182
|
+
if (!existing) {
|
|
4183
|
+
const first = awaitsFirstMember(ws);
|
|
4184
|
+
if (ws.join_policy === "invite" && !invitation && !(first && ADMIN_TYPES.includes(type))) {
|
|
4185
|
+
return { error: rpcError(E.NOT_AUTHORISED, `${uri} has no invitation to this workspace`) };
|
|
4186
|
+
}
|
|
4187
|
+
if (invitation?.role !== void 0) {
|
|
4188
|
+
if (asked === "admin" && invitation.role !== "admin") {
|
|
4189
|
+
return { error: rpcError(E.NOT_AUTHORISED, "The admin role is granted by an admin") };
|
|
4190
|
+
}
|
|
4191
|
+
role = invitation.role;
|
|
4192
|
+
} else if (first && ws.join_policy === "invite") {
|
|
4193
|
+
if (asked !== void 0 && asked !== "admin") {
|
|
4194
|
+
return { error: rpcError(
|
|
4195
|
+
E.PARAMS,
|
|
4196
|
+
"The first member of a workspace that admits by invitation is its admin"
|
|
4197
|
+
) };
|
|
4198
|
+
}
|
|
4199
|
+
role = "admin";
|
|
4200
|
+
} else if (asked === "admin" && !first) {
|
|
4201
|
+
return { error: rpcError(E.NOT_AUTHORISED, "The admin role is granted by an admin") };
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
if (type === "human") {
|
|
4205
|
+
const oidc = this.options.verifyOidcToken !== void 0 && advertises(ws, "identity-oidc");
|
|
4206
|
+
const vc = this.options.verifyVc !== void 0 && advertises(ws, "identity-vc");
|
|
4207
|
+
const presented = oidc && typeof p.oidc_token === "string" || vc && isObject(p.vc_presentation);
|
|
4208
|
+
if ((oidc || vc) && !presented) {
|
|
4209
|
+
return { error: oidc ? rpcError(E.OIDC_TOKEN_INVALID, vc ? "An oidc_token or a vc_presentation is required to join as a human participant" : "An oidc_token is required to join as a human participant") : rpcError(E.VC_VP_INVALID, "A vc_presentation is required to join as a human participant") };
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
3366
4212
|
const member = {
|
|
3367
4213
|
uri,
|
|
3368
|
-
type
|
|
3369
|
-
role
|
|
4214
|
+
type,
|
|
4215
|
+
role,
|
|
3370
4216
|
joined: now,
|
|
3371
4217
|
display_name: p.display_name,
|
|
3372
4218
|
capabilities: p.capabilities,
|
|
@@ -3374,6 +4220,9 @@ var Coordinator = class {
|
|
|
3374
4220
|
keys: [],
|
|
3375
4221
|
paused: false
|
|
3376
4222
|
};
|
|
4223
|
+
if (invitation?.oidc_sub !== void 0 && (this.options.verifyOidcToken === void 0 || typeof p.oidc_token !== "string")) {
|
|
4224
|
+
return { error: rpcError(E.OIDC_TOKEN_INVALID, "An oidc_token is required: the invitation names a token subject") };
|
|
4225
|
+
}
|
|
3377
4226
|
if (this.options.verifyOidcToken && typeof p.oidc_token === "string") {
|
|
3378
4227
|
const claims = this.options.verifyOidcToken(p.oidc_token);
|
|
3379
4228
|
if (claims === null) return { error: rpcError(E.OIDC_TOKEN_INVALID, "OIDC token invalid") };
|
|
@@ -3382,6 +4231,9 @@ var Coordinator = class {
|
|
|
3382
4231
|
if (boundUri != null && boundUri !== uri) {
|
|
3383
4232
|
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token is bound to another participant") };
|
|
3384
4233
|
}
|
|
4234
|
+
if (invitation?.oidc_sub !== void 0 && sub !== invitation.oidc_sub) {
|
|
4235
|
+
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token subject does not match the invitation") };
|
|
4236
|
+
}
|
|
3385
4237
|
if (existing) {
|
|
3386
4238
|
if (existing.oidc_sub != null) {
|
|
3387
4239
|
if (sub !== existing.oidc_sub) {
|
|
@@ -3390,19 +4242,23 @@ var Coordinator = class {
|
|
|
3390
4242
|
} else if (boundUri !== uri) {
|
|
3391
4243
|
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this member") };
|
|
3392
4244
|
}
|
|
4245
|
+
} else if (type === "human" && boundUri !== uri && invitation?.oidc_sub === void 0) {
|
|
4246
|
+
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this participant") };
|
|
3393
4247
|
}
|
|
3394
4248
|
member.oidc_sub = sub;
|
|
3395
4249
|
tokenVerified = true;
|
|
3396
4250
|
const at = claims.auth_time;
|
|
3397
|
-
if (typeof at === "number") member.oidc_auth_time = at;
|
|
4251
|
+
if (typeof at === "number" && Number.isFinite(at) && Math.abs(at) <= Number.MAX_SAFE_INTEGER) member.oidc_auth_time = Math.floor(at);
|
|
3398
4252
|
member.oidc_acr = typeof claims.acr === "string" ? claims.acr : void 0;
|
|
3399
|
-
const cnf = claims.cnf;
|
|
3400
|
-
const
|
|
3401
|
-
if (
|
|
3402
|
-
|
|
4253
|
+
const cnf = isObject(claims.cnf) ? claims.cnf.jwk : void 0;
|
|
4254
|
+
const bound = credentialKeys(cnf, cnf == null ? claims.nonce : void 0, signingKeys(p.jwks));
|
|
4255
|
+
if (bound === null) return { error: rpcError(E.OIDC_CNF_MISMATCH, "The join's keys are not the key the token binds") };
|
|
4256
|
+
if (bound !== void 0) {
|
|
4257
|
+
credentialBound = true;
|
|
4258
|
+
for (const j of bound) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
|
|
3403
4259
|
}
|
|
3404
4260
|
}
|
|
3405
|
-
if (this.options.verifyVc &&
|
|
4261
|
+
if (this.options.verifyVc && isObject(p.vc_presentation)) {
|
|
3406
4262
|
const subject = this.options.verifyVc(p.vc_presentation);
|
|
3407
4263
|
if (subject === null) return { error: rpcError(E.VC_VP_INVALID, "VC presentation invalid") };
|
|
3408
4264
|
const holder = [subject.holder, subject.id].find(
|
|
@@ -3412,21 +4268,54 @@ var Coordinator = class {
|
|
|
3412
4268
|
return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "Presentation holder does not match the member") };
|
|
3413
4269
|
}
|
|
3414
4270
|
member.vc_holder = holder;
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
4271
|
+
holderVerified = true;
|
|
4272
|
+
const bound = credentialKeys(subject.cnf_jwk, void 0, signingKeys(p.jwks));
|
|
4273
|
+
if (bound === null) {
|
|
4274
|
+
return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "The join's keys are not the key the presentation binds") };
|
|
4275
|
+
}
|
|
4276
|
+
if (bound !== void 0) {
|
|
4277
|
+
credentialBound = true;
|
|
4278
|
+
for (const j of bound) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
|
|
3418
4279
|
}
|
|
3419
4280
|
}
|
|
4281
|
+
if (!existing && type === "human" && (tokenVerified || holderVerified) && !credentialBound && !invitation?.jwks && signingKeys(p.jwks).length > 0) {
|
|
4282
|
+
return { error: tokenVerified ? rpcError(E.OIDC_CNF_MISMATCH, "The token binds no key: a person it verifies signs with a key it binds") : rpcError(
|
|
4283
|
+
E.VC_HOLDER_BINDING_INVALID,
|
|
4284
|
+
"The presentation binds no key: a person it verifies signs with a key it binds"
|
|
4285
|
+
) };
|
|
4286
|
+
}
|
|
4287
|
+
const pinned = invitation?.jwks?.keys;
|
|
4288
|
+
if (pinned) {
|
|
4289
|
+
const strays = [...signingKeys(p.jwks), ...member.keys.map((k) => k.jwk)].filter((j) => !pinned.some((k) => sameKey(k, j)));
|
|
4290
|
+
if (strays.length) {
|
|
4291
|
+
return { error: rpcError(
|
|
4292
|
+
E.NOT_AUTHORISED,
|
|
4293
|
+
`${uri} joins with keys its invitation does not name`,
|
|
4294
|
+
{ kids: [...new Set(strays.map((j) => j.kid))] }
|
|
4295
|
+
) };
|
|
4296
|
+
}
|
|
4297
|
+
member.keys = pinned.map((j) => ({ jwk: j, kid: j.kid, valid_from: now }));
|
|
4298
|
+
}
|
|
3420
4299
|
const attested = [...member.keys];
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
if (j.kid && !member.keys.some((k) => k.kid === j.kid)) {
|
|
4300
|
+
if (attested.length === 0) {
|
|
4301
|
+
for (const j of signingKeys(p.jwks)) {
|
|
4302
|
+
if (!member.keys.some((k) => k.kid === j.kid)) {
|
|
3425
4303
|
member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
|
|
3426
4304
|
}
|
|
3427
4305
|
}
|
|
3428
4306
|
}
|
|
3429
4307
|
if (existing) {
|
|
4308
|
+
if (attested.length === 0) {
|
|
4309
|
+
const keys = signingKeys(p.jwks);
|
|
4310
|
+
const held = keys.some((j) => existing.keys.some((k) => sameKey(k.jwk, j)));
|
|
4311
|
+
if (keys.length > 0 && !held) {
|
|
4312
|
+
return { error: rpcError(
|
|
4313
|
+
E.NOT_AUTHORISED,
|
|
4314
|
+
`${uri} is already a member, with other keys; a new key arrives through participant.rotate_key`,
|
|
4315
|
+
{ kids: [...new Set(keys.map((j) => j.kid))] }
|
|
4316
|
+
) };
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
3430
4319
|
if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
|
|
3431
4320
|
if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
|
|
3432
4321
|
if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
|
|
@@ -3437,18 +4326,126 @@ var Coordinator = class {
|
|
|
3437
4326
|
}
|
|
3438
4327
|
return { result: { joined: true, as: uri } };
|
|
3439
4328
|
}
|
|
4329
|
+
if (this.requiresSignatures(ws) && this.mustSign(ws, type) && member.keys.length === 0) {
|
|
4330
|
+
return { error: rpcError(E.PARAMS, `${uri} must sign in this workspace, and joins with no key`) };
|
|
4331
|
+
}
|
|
4332
|
+
if (made) this.addWorkspace(ws);
|
|
3440
4333
|
ws.members.set(uri, member);
|
|
4334
|
+
if (invitation) ws.invitations.delete(uri);
|
|
3441
4335
|
return { result: { joined: true, as: uri } };
|
|
3442
4336
|
}
|
|
4337
|
+
/**
|
|
4338
|
+
* SPECIFICATION 6.3.3: an admin invites a participant. For a URI that is
|
|
4339
|
+
* not a member it records an invitation, which the invitee's join takes
|
|
4340
|
+
* up. For a member that holds no key in force it registers the
|
|
4341
|
+
* invitation's keys: the way back for a member whose keys were all revoked,
|
|
4342
|
+
* or who joined a workspace that requires signatures with none.
|
|
4343
|
+
*/
|
|
4344
|
+
opWorkspaceInvite(p) {
|
|
4345
|
+
const miss = missing(p, ["workspace", "from", "uri", "type"]);
|
|
4346
|
+
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4347
|
+
for (const f of ["uri", "type"]) {
|
|
4348
|
+
if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
|
|
4349
|
+
}
|
|
4350
|
+
const bad = paramsError(p, [
|
|
4351
|
+
["role", "string"],
|
|
4352
|
+
["jwks", "object"],
|
|
4353
|
+
["jwks.keys", "list"],
|
|
4354
|
+
["oidc_sub", "string"]
|
|
4355
|
+
]);
|
|
4356
|
+
if (bad) return bad;
|
|
4357
|
+
const ws = this.workspaces.get(p.workspace);
|
|
4358
|
+
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
4359
|
+
const notMember = this.requireMember(ws, p.from);
|
|
4360
|
+
if (notMember) return notMember;
|
|
4361
|
+
if (rawMember(ws, p.from).role !== "admin") {
|
|
4362
|
+
return { error: rpcError(E.NOT_AUTHORISED, "workspace.invite requires the admin role") };
|
|
4363
|
+
}
|
|
4364
|
+
const uri = p.uri;
|
|
4365
|
+
const type = p.type;
|
|
4366
|
+
const named = participantError(type, uri, "uri");
|
|
4367
|
+
if (named) return named;
|
|
4368
|
+
const role = typeof p.role === "string" && p.role !== "" ? p.role : void 0;
|
|
4369
|
+
if (role === "admin" && !ADMIN_TYPES.includes(type)) {
|
|
4370
|
+
return { error: rpcError(E.PARAMS, "The admin role is held by a human or a service") };
|
|
4371
|
+
}
|
|
4372
|
+
const sub = typeof p.oidc_sub === "string" ? p.oidc_sub : void 0;
|
|
4373
|
+
if (sub !== void 0 && this.options.verifyOidcToken === void 0) {
|
|
4374
|
+
return { error: rpcError(E.PARAMS, "oidc_sub names a token subject, and this Coordinator has no token verifier") };
|
|
4375
|
+
}
|
|
4376
|
+
let pinned;
|
|
4377
|
+
if (p.jwks != null) {
|
|
4378
|
+
pinned = signingKeys(p.jwks);
|
|
4379
|
+
if (pinned.length === 0) return { error: rpcError(E.PARAMS, "jwks names no key: each key needs a kid") };
|
|
4380
|
+
}
|
|
4381
|
+
const now = this.now();
|
|
4382
|
+
const member = ws.members.get(uri);
|
|
4383
|
+
if (member) {
|
|
4384
|
+
if (member.keys.some((k) => inForce(k, now))) {
|
|
4385
|
+
return { error: rpcError(
|
|
4386
|
+
E.PARAMS,
|
|
4387
|
+
`${uri} is a member and holds a key in force; its keys change through participant.rotate_key`
|
|
4388
|
+
) };
|
|
4389
|
+
}
|
|
4390
|
+
if (!pinned) {
|
|
4391
|
+
return { error: rpcError(E.PARAMS, `${uri} is a member; an invitation for a member registers the keys its jwks names`) };
|
|
4392
|
+
}
|
|
4393
|
+
if (this.options.verifyOidcToken !== void 0 && (member.type === "human" || member.oidc_sub !== void 0) || this.options.verifyVc !== void 0 && (member.type === "human" || member.vc_holder !== void 0)) {
|
|
4394
|
+
return { error: rpcError(
|
|
4395
|
+
E.PARAMS,
|
|
4396
|
+
`${uri} registers a new key by joining again with a token that binds it; an invitation does not register a person's keys here`
|
|
4397
|
+
) };
|
|
4398
|
+
}
|
|
4399
|
+
if (role !== void 0 && role !== member.role || sub !== void 0 && sub !== member.oidc_sub) {
|
|
4400
|
+
return { error: rpcError(E.PARAMS, `${uri} is a member; an invitation for a member registers keys and changes nothing else`) };
|
|
4401
|
+
}
|
|
4402
|
+
const reused = pinned.find((j) => member.keys.some((k) => k.kid === j.kid));
|
|
4403
|
+
if (reused) return { error: rpcError(E.PARAMS, `Key ${reused.kid} was ${uri}'s; a new key takes a new kid`) };
|
|
4404
|
+
for (const j of pinned) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
|
|
4405
|
+
return { result: { invited: true, uri, registered: pinned.map((j) => j.kid) } };
|
|
4406
|
+
}
|
|
4407
|
+
if (pinned && (type === "human" || sub !== void 0) && (this.options.verifyOidcToken !== void 0 || this.options.verifyVc !== void 0)) {
|
|
4408
|
+
return { error: rpcError(
|
|
4409
|
+
E.PARAMS,
|
|
4410
|
+
`${uri} joins with a token that binds its key; an invitation does not register a person's keys here`
|
|
4411
|
+
) };
|
|
4412
|
+
}
|
|
4413
|
+
const invitation = {
|
|
4414
|
+
uri,
|
|
4415
|
+
type,
|
|
4416
|
+
invited_by: p.from,
|
|
4417
|
+
invited_at: now
|
|
4418
|
+
};
|
|
4419
|
+
if (role !== void 0) invitation.role = role;
|
|
4420
|
+
if (pinned) invitation.jwks = { keys: pinned };
|
|
4421
|
+
if (sub !== void 0) invitation.oidc_sub = sub;
|
|
4422
|
+
ws.invitations.set(uri, invitation);
|
|
4423
|
+
return { result: { invited: true, uri } };
|
|
4424
|
+
}
|
|
3443
4425
|
opParticipantLeave(p) {
|
|
3444
4426
|
const ws = this.workspaces.get(p.workspace);
|
|
3445
4427
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
4428
|
+
if (rawMember(ws, p.from)?.paused) {
|
|
4429
|
+
return { error: rpcError(E.NOT_AUTHORISED, "A paused participant does not leave until another member lifts its pause") };
|
|
4430
|
+
}
|
|
3446
4431
|
ws.members.delete(p.from);
|
|
3447
4432
|
return { result: { left: true } };
|
|
3448
4433
|
}
|
|
3449
4434
|
opTaskCreate(p) {
|
|
3450
4435
|
const miss = missing(p, ["workspace", "from", "kind", "input"]);
|
|
3451
4436
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4437
|
+
const bad = paramsError(p, [
|
|
4438
|
+
["assignee", "string"],
|
|
4439
|
+
["to", "string"],
|
|
4440
|
+
["mode", "string"],
|
|
4441
|
+
["review_required", "boolean"],
|
|
4442
|
+
["routing_hints", "object"],
|
|
4443
|
+
["idempotency_key", "string"]
|
|
4444
|
+
]);
|
|
4445
|
+
if (bad) return bad;
|
|
4446
|
+
if (RESERVED_KEYS.includes(p.idempotency_key)) {
|
|
4447
|
+
return { error: rpcError(E.PARAMS, "idempotency_key must not be __proto__, constructor or prototype") };
|
|
4448
|
+
}
|
|
3452
4449
|
const ws = this.workspaces.get(p.workspace);
|
|
3453
4450
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3454
4451
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3460,9 +4457,6 @@ var Coordinator = class {
|
|
|
3460
4457
|
if (ws.members.get(assignee).paused) {
|
|
3461
4458
|
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
|
|
3462
4459
|
}
|
|
3463
|
-
if (p.mode !== void 0 && p.mode !== null && typeof p.mode !== "string") {
|
|
3464
|
-
return { error: rpcError(E.PARAMS, "mode must be a string") };
|
|
3465
|
-
}
|
|
3466
4460
|
const requestedMode = p.mode || ws.mode;
|
|
3467
4461
|
if (!modeLE(requestedMode, ws.mode_ceiling)) {
|
|
3468
4462
|
return { error: rpcError(
|
|
@@ -3470,9 +4464,6 @@ var Coordinator = class {
|
|
|
3470
4464
|
`Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
|
|
3471
4465
|
) };
|
|
3472
4466
|
}
|
|
3473
|
-
if (p.review_required !== void 0 && p.review_required !== null && typeof p.review_required !== "boolean") {
|
|
3474
|
-
return { error: rpcError(E.PARAMS, "review_required must be a boolean") };
|
|
3475
|
-
}
|
|
3476
4467
|
const taskId = this.ids.taskId();
|
|
3477
4468
|
const now = this.now();
|
|
3478
4469
|
const task = {
|
|
@@ -3511,6 +4502,7 @@ var Coordinator = class {
|
|
|
3511
4502
|
if (notMember) return notMember;
|
|
3512
4503
|
const task = ws.tasks.get(p.task_id);
|
|
3513
4504
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
4505
|
+
if (typeof p.state !== "string") return { error: rpcError(E.PARAMS, "state must be a string") };
|
|
3514
4506
|
const newState = p.state;
|
|
3515
4507
|
const legal = {
|
|
3516
4508
|
created: ["in_progress", "declined"],
|
|
@@ -3521,12 +4513,42 @@ var Coordinator = class {
|
|
|
3521
4513
|
if (!legal[task.state]?.includes(newState)) {
|
|
3522
4514
|
return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
|
|
3523
4515
|
}
|
|
3524
|
-
if (newState === "completed" && task
|
|
4516
|
+
if (newState === "completed" && reviewRequired(ws, task)) {
|
|
3525
4517
|
return { error: rpcError(
|
|
3526
4518
|
E.PARAMS,
|
|
3527
4519
|
"This task requires review. Submit the output with task.complete, which opens the review; a reviewer decision completes it."
|
|
3528
4520
|
) };
|
|
3529
4521
|
}
|
|
4522
|
+
if (newState === "completed" && task.review) {
|
|
4523
|
+
return { error: rpcError(
|
|
4524
|
+
E.PARAMS,
|
|
4525
|
+
"This task holds a review. Submit the output with task.complete, which opens a new round; a reviewer decision completes it."
|
|
4526
|
+
) };
|
|
4527
|
+
}
|
|
4528
|
+
if (task.state === "review_requested" && !task.review.authors.includes(p.from)) {
|
|
4529
|
+
return { error: rpcError(
|
|
4530
|
+
E.NOT_AUTHORISED,
|
|
4531
|
+
"Only the authors of the work under review take it back from review"
|
|
4532
|
+
) };
|
|
4533
|
+
}
|
|
4534
|
+
if (newState === "cancelled" && openRequiredReviewAuthors(ws, task).includes(p.from)) {
|
|
4535
|
+
return { error: rpcError(
|
|
4536
|
+
E.NOT_AUTHORISED,
|
|
4537
|
+
"The authors of the work under review cannot end its review without a decision"
|
|
4538
|
+
) };
|
|
4539
|
+
}
|
|
4540
|
+
if (newState === "declined" && task.review && !task.review.decisions.some((d) => d.kind === "reject")) {
|
|
4541
|
+
return { error: rpcError(
|
|
4542
|
+
E.PARAMS,
|
|
4543
|
+
"This task holds a review no decision has closed; a reviewer's decision ends it"
|
|
4544
|
+
) };
|
|
4545
|
+
}
|
|
4546
|
+
if (newState === "declined" && !task.review && openRequiredReviewAuthors(ws, task).length > 0) {
|
|
4547
|
+
return { error: rpcError(
|
|
4548
|
+
E.PARAMS,
|
|
4549
|
+
"This task carries on a review no decision has closed; completing it opens the review a reviewer decides"
|
|
4550
|
+
) };
|
|
4551
|
+
}
|
|
3530
4552
|
task.state = newState;
|
|
3531
4553
|
task.updated_at = this.now();
|
|
3532
4554
|
task.history.push({
|
|
@@ -3547,42 +4569,60 @@ var Coordinator = class {
|
|
|
3547
4569
|
if (task.state !== "created" && task.state !== "in_progress") {
|
|
3548
4570
|
return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
|
|
3549
4571
|
}
|
|
3550
|
-
if (
|
|
4572
|
+
if (p.confidence != null && typeof p.confidence !== "number" && typeof p.confidence !== "string") {
|
|
4573
|
+
return { error: rpcError(E.PARAMS, "confidence must be a number or a string") };
|
|
4574
|
+
}
|
|
4575
|
+
const required = reviewRequired(ws, task);
|
|
4576
|
+
if (required && p.from !== task.assignee) {
|
|
4577
|
+
return { error: rpcError(E.NOT_AUTHORISED, "Only the assignee completes a task that requires review") };
|
|
4578
|
+
}
|
|
4579
|
+
if (required || task.review) {
|
|
3551
4580
|
const now = this.now();
|
|
4581
|
+
const authors = authorsOf(task, p.from);
|
|
4582
|
+
let note = OPENED_ON_COMPLETE;
|
|
3552
4583
|
if (!task.review) {
|
|
3553
|
-
const
|
|
3554
|
-
const eligible = [...rawEntries(ws.members)].filter(([uri, member]) => uri !== producer && uri !== task.assignee && member.type === "human").map(([uri]) => uri);
|
|
4584
|
+
const eligible = [...rawEntries(ws.members)].filter(([uri, member]) => !authors.includes(uri) && member.type === "human").map(([uri]) => uri);
|
|
3555
4585
|
if (eligible.length === 0) {
|
|
3556
4586
|
return { error: rpcError(
|
|
3557
4587
|
E.NOT_AUTHORISED,
|
|
3558
4588
|
"Task requires review but has no eligible human reviewer (needs a human member other than its assignee and completer)"
|
|
3559
4589
|
) };
|
|
3560
4590
|
}
|
|
3561
|
-
task.review = {
|
|
4591
|
+
task.review = {
|
|
4592
|
+
requested_at: now,
|
|
4593
|
+
requested_to: eligible,
|
|
4594
|
+
rule: ws.review_rule ?? "any_one_approves",
|
|
4595
|
+
decisions: [],
|
|
4596
|
+
authors,
|
|
4597
|
+
acknowledgements: []
|
|
4598
|
+
};
|
|
3562
4599
|
} else {
|
|
3563
|
-
const
|
|
3564
|
-
const reviewers = task.review.requested_to.filter((r) => r !==
|
|
4600
|
+
const roundAuthors = unionOf(task.review.authors, authors);
|
|
4601
|
+
const reviewers = task.review.requested_to.filter((r) => !roundAuthors.includes(r) && !(required && rawMember(ws, r) !== void 0 && rawMember(ws, r).type !== "human"));
|
|
3565
4602
|
if (reviewers.length === 0) {
|
|
3566
4603
|
return { error: rpcError(
|
|
3567
4604
|
E.NOT_AUTHORISED,
|
|
3568
|
-
"
|
|
4605
|
+
"No reviewer of this task remains once the authors of the work are left out"
|
|
3569
4606
|
) };
|
|
3570
4607
|
}
|
|
3571
|
-
task.review = {
|
|
4608
|
+
task.review = {
|
|
4609
|
+
...task.review,
|
|
4610
|
+
requested_at: now,
|
|
4611
|
+
requested_to: reviewers,
|
|
4612
|
+
decisions: [],
|
|
4613
|
+
authors: roundAuthors,
|
|
4614
|
+
acknowledgements: []
|
|
4615
|
+
};
|
|
4616
|
+
note = NEW_ROUND_ON_COMPLETE;
|
|
3572
4617
|
}
|
|
3573
4618
|
task.pending_artefact = p.output;
|
|
3574
4619
|
task.state = "review_requested";
|
|
3575
4620
|
task.updated_at = now;
|
|
3576
|
-
task.history.push({
|
|
3577
|
-
ts: now,
|
|
3578
|
-
from: p.from,
|
|
3579
|
-
state: "review_requested",
|
|
3580
|
-
note: "review required; opened on task.complete"
|
|
3581
|
-
});
|
|
4621
|
+
task.history.push({ ts: now, from: p.from, state: "review_requested", note });
|
|
3582
4622
|
return { result: { state: "review_requested", review_id: task.id } };
|
|
3583
4623
|
}
|
|
3584
4624
|
task.output = p.output;
|
|
3585
|
-
if (
|
|
4625
|
+
if (p.confidence != null) task.confidence = p.confidence;
|
|
3586
4626
|
task.state = "completed";
|
|
3587
4627
|
task.updated_at = this.now();
|
|
3588
4628
|
task.history.push({ ts: task.updated_at, from: p.from, state: "completed" });
|
|
@@ -3595,8 +4635,16 @@ var Coordinator = class {
|
|
|
3595
4635
|
const notMember = this.requireMember(ws, p.from);
|
|
3596
4636
|
if (notMember) return notMember;
|
|
3597
4637
|
}
|
|
3598
|
-
const
|
|
3599
|
-
|
|
4638
|
+
const bad = paramsError(p, [
|
|
4639
|
+
["range.from_seq", "integer"],
|
|
4640
|
+
["range.to_seq", "integer"],
|
|
4641
|
+
["filter.method", "string"],
|
|
4642
|
+
["filter.from", "string"],
|
|
4643
|
+
["filter.task_id", "string"]
|
|
4644
|
+
]);
|
|
4645
|
+
if (bad) return bad;
|
|
4646
|
+
const range = isObject(p.range) ? p.range : {};
|
|
4647
|
+
const filter = isObject(p.filter) ? p.filter : {};
|
|
3600
4648
|
if (filter.outcome != null && filter.outcome !== "accepted" && filter.outcome !== "refused") {
|
|
3601
4649
|
return { error: rpcError(E.PARAMS, "filter.outcome must be 'accepted' or 'refused'") };
|
|
3602
4650
|
}
|
|
@@ -3647,37 +4695,73 @@ var Coordinator = class {
|
|
|
3647
4695
|
return { error: rpcError(E.NOT_REVIEWABLE, `Cannot request review for task in state: ${task.state}`) };
|
|
3648
4696
|
}
|
|
3649
4697
|
const to = p.to;
|
|
4698
|
+
if (to != null && typeof to !== "string" && !(Array.isArray(to) && to.every((r) => typeof r === "string"))) {
|
|
4699
|
+
return { error: rpcError(E.PARAMS, "to must be a string or a list of strings") };
|
|
4700
|
+
}
|
|
3650
4701
|
const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
|
|
3651
4702
|
if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
|
|
4703
|
+
const bad = paramsError(p, [["rule", "string"]]);
|
|
4704
|
+
if (bad) return bad;
|
|
3652
4705
|
const now = this.now();
|
|
3653
4706
|
const declaredRule = p.rule;
|
|
3654
4707
|
const rule = declaredRule || "any_one_approves";
|
|
3655
4708
|
if (!reviewRuleSupported(rule)) {
|
|
3656
4709
|
return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
|
|
3657
4710
|
}
|
|
3658
|
-
|
|
3659
|
-
|
|
4711
|
+
const open = task.state === "review_requested" ? task.review : void 0;
|
|
4712
|
+
if (open) {
|
|
4713
|
+
if (contentHash(task.pending_artefact ?? null) !== contentHash(p.artefact ?? null)) {
|
|
3660
4714
|
return { error: rpcError(
|
|
3661
4715
|
E.REVIEW_ALREADY_OPEN,
|
|
3662
4716
|
"A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
|
|
3663
4717
|
) };
|
|
3664
4718
|
}
|
|
3665
|
-
if (declaredRule
|
|
4719
|
+
if (declaredRule != null && declaredRule !== open.rule) {
|
|
3666
4720
|
return { error: rpcError(
|
|
3667
4721
|
E.REVIEW_ALREADY_OPEN,
|
|
3668
|
-
`Cannot change the decision rule of an open review (currently ${
|
|
4722
|
+
`Cannot change the decision rule of an open review (currently ${open.rule})`
|
|
4723
|
+
) };
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
const authors = open ? open.authors : unionOf(task.review ? task.review.authors : [], authorsOf(task, lastCompleter(task)));
|
|
4727
|
+
if (reviewRequired(ws, task)) {
|
|
4728
|
+
if (!open) {
|
|
4729
|
+
return { error: rpcError(
|
|
4730
|
+
E.NOT_AUTHORISED,
|
|
4731
|
+
"This task requires a person's review, which opens on task.complete; review.request only adds reviewers to it once it is open"
|
|
4732
|
+
) };
|
|
4733
|
+
}
|
|
4734
|
+
if (authors.includes(p.from)) {
|
|
4735
|
+
return { error: rpcError(E.NOT_AUTHORISED, "The author of the work under review cannot request its review") };
|
|
4736
|
+
}
|
|
4737
|
+
const scope = reviewers.find(isScope);
|
|
4738
|
+
if (scope !== void 0) {
|
|
4739
|
+
return { error: rpcError(
|
|
4740
|
+
E.PARAMS,
|
|
4741
|
+
`A review that requires a person names its reviewers, and ${scope} is an address scope`
|
|
3669
4742
|
) };
|
|
3670
4743
|
}
|
|
3671
4744
|
for (const r of reviewers) {
|
|
3672
|
-
if (
|
|
4745
|
+
if (authors.includes(r)) {
|
|
4746
|
+
return { error: rpcError(E.NOT_AUTHORISED, `The author of the work under review cannot review it: ${r}`) };
|
|
4747
|
+
}
|
|
4748
|
+
const member = rawMember(ws, r);
|
|
4749
|
+
if (member && member.type !== "human") {
|
|
4750
|
+
return { error: rpcError(E.NOT_AUTHORISED, `This task requires a person's review: ${r} is not a human member`) };
|
|
4751
|
+
}
|
|
3673
4752
|
}
|
|
3674
|
-
|
|
4753
|
+
}
|
|
4754
|
+
if (open) {
|
|
4755
|
+
for (const r of reviewers) {
|
|
4756
|
+
if (!open.requested_to.includes(r)) open.requested_to.push(r);
|
|
4757
|
+
}
|
|
4758
|
+
if (p.deadline != null) open.deadline = p.deadline;
|
|
3675
4759
|
task.updated_at = now;
|
|
3676
4760
|
return { result: {
|
|
3677
4761
|
state: "review_requested",
|
|
3678
4762
|
review_id: task.id,
|
|
3679
4763
|
amended: true,
|
|
3680
|
-
requested_to: [...
|
|
4764
|
+
requested_to: [...open.requested_to]
|
|
3681
4765
|
} };
|
|
3682
4766
|
}
|
|
3683
4767
|
task.state = "review_requested";
|
|
@@ -3687,12 +4771,41 @@ var Coordinator = class {
|
|
|
3687
4771
|
requested_to: reviewers,
|
|
3688
4772
|
rule,
|
|
3689
4773
|
deadline: p.deadline,
|
|
3690
|
-
decisions: []
|
|
4774
|
+
decisions: [],
|
|
4775
|
+
authors,
|
|
4776
|
+
acknowledgements: []
|
|
3691
4777
|
};
|
|
3692
4778
|
task.history.push({ ts: now, from: p.from, state: "review_requested" });
|
|
3693
4779
|
task.pending_artefact = p.artefact;
|
|
3694
4780
|
return { result: { state: "review_requested", review_id: task.id } };
|
|
3695
4781
|
}
|
|
4782
|
+
/**
|
|
4783
|
+
* review/1.0 S3.6: a reviewer says they have begun the open review. Only
|
|
4784
|
+
* someone who may decide it can, so the log's record of who took a review
|
|
4785
|
+
* up, and when, names reviewers alone. It changes nothing else: the review
|
|
4786
|
+
* stays open and the acknowledgement counts towards no rule.
|
|
4787
|
+
*/
|
|
4788
|
+
opReviewAcknowledge(p) {
|
|
4789
|
+
const miss = missing(p, ["workspace", "from", "task_id"]);
|
|
4790
|
+
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4791
|
+
const ws = this.workspaces.get(p.workspace);
|
|
4792
|
+
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
4793
|
+
const notMember = this.requireMember(ws, p.from);
|
|
4794
|
+
if (notMember) return notMember;
|
|
4795
|
+
const task = ws.tasks.get(p.task_id);
|
|
4796
|
+
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
4797
|
+
if (task.state !== "review_requested") {
|
|
4798
|
+
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
4799
|
+
}
|
|
4800
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
4801
|
+
if (notReviewer) return notReviewer;
|
|
4802
|
+
const review = task.review;
|
|
4803
|
+
if (review.acknowledgements.some((a) => a.reviewer === p.from)) {
|
|
4804
|
+
return { error: rpcError(E.PARAMS, "Already acknowledged in this round") };
|
|
4805
|
+
}
|
|
4806
|
+
review.acknowledgements.push({ reviewer: p.from, ts: this.now() });
|
|
4807
|
+
return { result: { acknowledged: true, requested_at: review.requested_at } };
|
|
4808
|
+
}
|
|
3696
4809
|
/**
|
|
3697
4810
|
* Verify an optional `approved_artefact_digest` against the artefact under
|
|
3698
4811
|
* review (review/1.0 S3.2).
|
|
@@ -3711,7 +4824,7 @@ var Coordinator = class {
|
|
|
3711
4824
|
if (typeof declared !== "string") {
|
|
3712
4825
|
return { error: rpcError(E.PARAMS, "approved_artefact_digest must be a string") };
|
|
3713
4826
|
}
|
|
3714
|
-
if (task.pending_artefact === void 0) {
|
|
4827
|
+
if (task.pending_artefact === void 0 || task.pending_artefact === null) {
|
|
3715
4828
|
return { error: rpcError(E.PARAMS, "No artefact under review to bind a digest to") };
|
|
3716
4829
|
}
|
|
3717
4830
|
const actual = contentHash(task.pending_artefact);
|
|
@@ -3727,8 +4840,8 @@ var Coordinator = class {
|
|
|
3727
4840
|
opDecide(p, kind) {
|
|
3728
4841
|
const miss = missing(p, ["workspace", "from", "task_id"]);
|
|
3729
4842
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
3730
|
-
const
|
|
3731
|
-
if (
|
|
4843
|
+
const bad = paramsError(p, [["tags", "strings"], ["request_revision", "boolean"]]);
|
|
4844
|
+
if (bad) return bad;
|
|
3732
4845
|
const ws = this.workspaces.get(p.workspace);
|
|
3733
4846
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3734
4847
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3738,7 +4851,7 @@ var Coordinator = class {
|
|
|
3738
4851
|
if (task.state !== "review_requested") {
|
|
3739
4852
|
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
3740
4853
|
}
|
|
3741
|
-
const notReviewer = this.requireReviewer(task, p.from);
|
|
4854
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
3742
4855
|
if (notReviewer) return notReviewer;
|
|
3743
4856
|
const digestError = this.checkArtefactDigest(p, task);
|
|
3744
4857
|
if (digestError) return digestError;
|
|
@@ -3765,8 +4878,8 @@ var Coordinator = class {
|
|
|
3765
4878
|
opDecideOverride(p) {
|
|
3766
4879
|
const miss = missing(p, ["workspace", "from", "task_id", "diff", "rationale"]);
|
|
3767
4880
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
3768
|
-
const
|
|
3769
|
-
if (
|
|
4881
|
+
const bad = paramsError(p, [["tags", "strings"], ["policy_refs", "strings"]]);
|
|
4882
|
+
if (bad) return bad;
|
|
3770
4883
|
const ws = this.workspaces.get(p.workspace);
|
|
3771
4884
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3772
4885
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3776,12 +4889,12 @@ var Coordinator = class {
|
|
|
3776
4889
|
if (task.state !== "review_requested") {
|
|
3777
4890
|
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
3778
4891
|
}
|
|
3779
|
-
const notReviewer = this.requireReviewer(task, p.from);
|
|
4892
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
3780
4893
|
if (notReviewer) return notReviewer;
|
|
3781
4894
|
const digestError = this.checkArtefactDigest(p, task);
|
|
3782
4895
|
if (digestError) return digestError;
|
|
3783
4896
|
const base = task.pending_artefact;
|
|
3784
|
-
if (base === void 0) return { error: rpcError(E.PARAMS, "No base artefact for override") };
|
|
4897
|
+
if (base === void 0 || base === null) return { error: rpcError(E.PARAMS, "No base artefact for override") };
|
|
3785
4898
|
let applied;
|
|
3786
4899
|
try {
|
|
3787
4900
|
applied = applyJsonPatch(base, p.diff, {
|
|
@@ -3825,6 +4938,8 @@ var Coordinator = class {
|
|
|
3825
4938
|
opAbstainDeclare(p) {
|
|
3826
4939
|
const miss = missing(p, ["workspace", "from", "task_id", "reason"]);
|
|
3827
4940
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4941
|
+
const bad = paramsError(p, [["category", "string"]]);
|
|
4942
|
+
if (bad) return bad;
|
|
3828
4943
|
const ws = this.workspaces.get(p.workspace);
|
|
3829
4944
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3830
4945
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3834,7 +4949,7 @@ var Coordinator = class {
|
|
|
3834
4949
|
if (task.state !== "review_requested") {
|
|
3835
4950
|
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
3836
4951
|
}
|
|
3837
|
-
const notReviewer = this.requireReviewer(task, p.from);
|
|
4952
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
3838
4953
|
if (notReviewer) return notReviewer;
|
|
3839
4954
|
const now = this.now();
|
|
3840
4955
|
task.review.decisions.push({
|
|
@@ -3866,6 +4981,8 @@ var Coordinator = class {
|
|
|
3866
4981
|
return { error: rpcError(E.PARAMS, "new_task must be an object") };
|
|
3867
4982
|
}
|
|
3868
4983
|
const nt = p.new_task;
|
|
4984
|
+
const bad = paramsError(p, [["new_task.kind", "string"]]);
|
|
4985
|
+
if (bad) return bad;
|
|
3869
4986
|
const assignee = nt.assignee;
|
|
3870
4987
|
if (typeof assignee !== "string" || !ws.members.has(assignee)) {
|
|
3871
4988
|
return { error: rpcError(E.PARAMS, "Escalation assignee not in workspace") };
|
|
@@ -3900,9 +5017,10 @@ var Coordinator = class {
|
|
|
3900
5017
|
history: [{ ts: now, from: p.from, state: "created", note: `escalated from ${orig.id}` }],
|
|
3901
5018
|
paused: false
|
|
3902
5019
|
};
|
|
3903
|
-
if (orig.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
|
|
5020
|
+
if (orig.review_required || orig.review !== void 0 || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
|
|
3904
5021
|
newTask.review_required = true;
|
|
3905
5022
|
}
|
|
5023
|
+
newTask.prior_authors = successorAuthors(orig, p.from);
|
|
3906
5024
|
ws.tasks.set(newId, newTask);
|
|
3907
5025
|
orig.state = "escalated";
|
|
3908
5026
|
orig.updated_at = now;
|
|
@@ -3919,8 +5037,8 @@ function memberToDict(m) {
|
|
|
3919
5037
|
joined: m.joined
|
|
3920
5038
|
};
|
|
3921
5039
|
if (m.display_name) out.display_name = m.display_name;
|
|
3922
|
-
if (m.capabilities) out.capabilities = m.capabilities;
|
|
3923
|
-
if (m.scopes) out.scopes = m.scopes;
|
|
5040
|
+
if (m.capabilities && Object.keys(m.capabilities).length) out.capabilities = m.capabilities;
|
|
5041
|
+
if (m.scopes?.length) out.scopes = m.scopes;
|
|
3924
5042
|
if (m.keys.length) {
|
|
3925
5043
|
out.jwks = { keys: m.keys.filter((k) => !k.revoked_at && !k.valid_until).map((k) => k.jwk) };
|
|
3926
5044
|
out.key_history = m.keys;
|
|
@@ -3932,7 +5050,6 @@ function memberToDict(m) {
|
|
|
3932
5050
|
}
|
|
3933
5051
|
|
|
3934
5052
|
// src/policy.ts
|
|
3935
|
-
var CRIT2 = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
3936
5053
|
function makeDefaultPolicy(seniorTarget) {
|
|
3937
5054
|
const routingPolicy = (task, candidates) => {
|
|
3938
5055
|
const eligible = candidates;
|
|
@@ -3950,15 +5067,8 @@ function makeDefaultPolicy(seniorTarget) {
|
|
|
3950
5067
|
};
|
|
3951
5068
|
};
|
|
3952
5069
|
const reviewDepthPolicy = (_task, hints) => {
|
|
3953
|
-
const
|
|
3954
|
-
|
|
3955
|
-
try {
|
|
3956
|
-
conf = Number(hints.confidence ?? 1);
|
|
3957
|
-
} catch {
|
|
3958
|
-
conf = 1;
|
|
3959
|
-
}
|
|
3960
|
-
if (isNaN(conf)) conf = 1;
|
|
3961
|
-
const r = CRIT2[crit] ?? 1;
|
|
5070
|
+
const conf = confidenceOf(hints);
|
|
5071
|
+
const r = criticalityOf(hints);
|
|
3962
5072
|
if (r >= 3) return {
|
|
3963
5073
|
depth: "full",
|
|
3964
5074
|
rationale: { policy_id: "default", summary: "criticality=critical: full review" }
|
|
@@ -3983,15 +5093,8 @@ function makeDefaultPolicy(seniorTarget) {
|
|
|
3983
5093
|
};
|
|
3984
5094
|
};
|
|
3985
5095
|
const escalationPolicy = (_task, hints) => {
|
|
3986
|
-
const
|
|
3987
|
-
|
|
3988
|
-
try {
|
|
3989
|
-
conf = Number(hints.confidence ?? 1);
|
|
3990
|
-
} catch {
|
|
3991
|
-
conf = 1;
|
|
3992
|
-
}
|
|
3993
|
-
if (isNaN(conf)) conf = 1;
|
|
3994
|
-
const r = CRIT2[crit] ?? 1;
|
|
5096
|
+
const conf = confidenceOf(hints);
|
|
5097
|
+
const r = criticalityOf(hints);
|
|
3995
5098
|
const escalate = r >= 3 || r >= 2 && conf < 0.6;
|
|
3996
5099
|
return {
|
|
3997
5100
|
escalate,
|
|
@@ -4011,6 +5114,19 @@ function wrapMcpToolCall(coord, workspace, options) {
|
|
|
4011
5114
|
if (!workspace) throw new Error("workspace is required");
|
|
4012
5115
|
if (!options.caller) throw new Error("caller is required");
|
|
4013
5116
|
if (!options.tool) throw new Error("tool is required");
|
|
5117
|
+
if (options.reviewRequired) {
|
|
5118
|
+
const described = coord.dispatch({
|
|
5119
|
+
jsonrpc: "2.0",
|
|
5120
|
+
id: `wrap-${options.tool}-describe`,
|
|
5121
|
+
method: "workspace.describe",
|
|
5122
|
+
params: { workspace, from: options.caller }
|
|
5123
|
+
});
|
|
5124
|
+
if (described.error) throw new Error(`workspace.describe failed: ${JSON.stringify(described.error)}`);
|
|
5125
|
+
const members = described.result.members;
|
|
5126
|
+
if (!members.some((m) => m.type === "human" && m.uri !== options.caller)) {
|
|
5127
|
+
throw new Error("review_required needs a human member other than the caller to review the result; nothing was recorded");
|
|
5128
|
+
}
|
|
5129
|
+
}
|
|
4014
5130
|
const inputHash = contentHash(options.args);
|
|
4015
5131
|
const outputHash = contentHash(options.result);
|
|
4016
5132
|
const kind = options.taskKind ?? `mcp_call:${options.tool}`;
|
|
@@ -4029,6 +5145,7 @@ function wrapMcpToolCall(coord, workspace, options) {
|
|
|
4029
5145
|
input: options.args
|
|
4030
5146
|
};
|
|
4031
5147
|
if (options.routingHints) createParams.routing_hints = { ...options.routingHints };
|
|
5148
|
+
if (options.reviewRequired) createParams.review_required = true;
|
|
4032
5149
|
const createResp = coord.dispatch({
|
|
4033
5150
|
jsonrpc: "2.0",
|
|
4034
5151
|
id: `wrap-${options.tool}-create`,
|
|
@@ -4058,7 +5175,7 @@ function wrapMcpToolCall(coord, workspace, options) {
|
|
|
4058
5175
|
method: "task.complete",
|
|
4059
5176
|
params: completeParams
|
|
4060
5177
|
});
|
|
4061
|
-
if (completeResp.error) throw new Error(`task.complete failed: ${JSON.stringify(completeResp.error)}`);
|
|
5178
|
+
if (completeResp.error) throw new Error(`task.complete failed for ${taskId}: ${JSON.stringify(completeResp.error)}`);
|
|
4062
5179
|
return { task_id: taskId, input_hash: inputHash, output_hash: outputHash };
|
|
4063
5180
|
}
|
|
4064
5181
|
function wrapA2aMessageExchange(coord, workspace, options) {
|