@brightbeamai/chap-coordinator 0.3.3 → 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 +1578 -276
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -6
- package/dist/index.d.ts +20 -6
- package/dist/index.js +1577 -276
- 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/patch.cjs +195 -61
- package/dist/patch.cjs.map +1 -1
- package/dist/patch.d.cts +16 -2
- package/dist/patch.d.ts +16 -2
- package/dist/patch.js +194 -61
- package/dist/patch.js.map +1 -1
- 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.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
3
|
var ZERO_HASH = "sha256:" + "0".repeat(64);
|
|
4
4
|
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.';
|
|
5
|
-
var NUMBER_RANGE_ERROR = "CHAP canonical integers must be within the safe-integer range (abs value <=
|
|
5
|
+
var NUMBER_RANGE_ERROR = "CHAP canonical integers must be within the safe-integer range (abs value <= 2^53 - 1); represent larger numbers as strings.";
|
|
6
6
|
var LONE_SURROGATE_ERROR = "CHAP canonical strings must be valid Unicode (I-JSON / RFC 7493); unpaired UTF-16 surrogates are not permitted.";
|
|
7
7
|
function assertNoLoneSurrogate(s) {
|
|
8
8
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -23,7 +23,7 @@ function canonicalString(s) {
|
|
|
23
23
|
function canonicalNumber(n) {
|
|
24
24
|
if (!Number.isFinite(n)) {
|
|
25
25
|
throw new Error(
|
|
26
|
-
"Non-finite numbers are not permitted in a CHAP canonical value"
|
|
26
|
+
"Non-finite numbers are not permitted in a CHAP canonical value."
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
if (!Number.isInteger(n)) {
|
|
@@ -96,6 +96,7 @@ var OWNING_PROFILE = Object.freeze({
|
|
|
96
96
|
"participant.leave": "core/1.0",
|
|
97
97
|
"participant.revoke_key": "core/1.0",
|
|
98
98
|
"participant.rotate_key": "core/1.0",
|
|
99
|
+
"review.acknowledge": "review/1.0",
|
|
99
100
|
"review.depth": "routing/1.0",
|
|
100
101
|
"review.request": "review/1.0",
|
|
101
102
|
"task.complete": "core/1.0",
|
|
@@ -106,6 +107,7 @@ var OWNING_PROFILE = Object.freeze({
|
|
|
106
107
|
"whisper.ask": "whisper/1.0",
|
|
107
108
|
"workspace.create": "core/1.0",
|
|
108
109
|
"workspace.describe": "core/1.0",
|
|
110
|
+
"workspace.invite": "core/1.0",
|
|
109
111
|
"workspace.set_profiles": "core/1.0"
|
|
110
112
|
});
|
|
111
113
|
var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
|
|
@@ -114,6 +116,20 @@ var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
|
|
|
114
116
|
"audit.verify_chain",
|
|
115
117
|
"audit.verify_receipt"
|
|
116
118
|
]);
|
|
119
|
+
var PROFILE_VERSIONS = Object.freeze({
|
|
120
|
+
"audit-scitt": "1.0",
|
|
121
|
+
"control": "1.0",
|
|
122
|
+
"core": "1.0",
|
|
123
|
+
"deliberation": "1.0",
|
|
124
|
+
"handoff": "1.0",
|
|
125
|
+
"identity-oidc": "1.0",
|
|
126
|
+
"identity-vc": "1.0",
|
|
127
|
+
"modes": "1.0",
|
|
128
|
+
"review": "1.0",
|
|
129
|
+
"routing": "1.0",
|
|
130
|
+
"security-signed": "1.0",
|
|
131
|
+
"whisper": "1.0"
|
|
132
|
+
});
|
|
117
133
|
|
|
118
134
|
// src/crypto.ts
|
|
119
135
|
import {
|
|
@@ -171,21 +187,132 @@ function signEnvelope(canonical, privateKey, kid) {
|
|
|
171
187
|
const sig = nodeSign(null, canonical, privateKey);
|
|
172
188
|
return `ed25519:${kid}:${sig.toString("base64")}`;
|
|
173
189
|
}
|
|
190
|
+
function strictBase64(s) {
|
|
191
|
+
const bytes = Buffer.from(s, "base64");
|
|
192
|
+
return bytes.toString("base64") === s ? bytes : void 0;
|
|
193
|
+
}
|
|
174
194
|
function verifyEnvelope(canonical, sigTag, publicKey) {
|
|
175
195
|
if (!sigTag.startsWith("ed25519:")) return false;
|
|
176
196
|
const parts = sigTag.split(":");
|
|
177
197
|
if (parts.length < 2) return false;
|
|
178
|
-
const
|
|
198
|
+
const sig = strictBase64(parts[parts.length - 1]);
|
|
199
|
+
if (sig === void 0) return false;
|
|
179
200
|
try {
|
|
180
|
-
const sig = Buffer.from(sigB64, "base64");
|
|
181
201
|
return nodeVerify(null, canonical, publicKey, sig);
|
|
182
202
|
} catch {
|
|
183
203
|
return false;
|
|
184
204
|
}
|
|
185
205
|
}
|
|
206
|
+
function isEd25519Key(jwk) {
|
|
207
|
+
if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return false;
|
|
208
|
+
const key = jwk;
|
|
209
|
+
return key.kty === "OKP" && key.crv === "Ed25519" && typeof key.x === "string" && COORDINATE.test(key.x);
|
|
210
|
+
}
|
|
186
211
|
function publicKeyFromJwk(jwk) {
|
|
187
212
|
return publicKeyFromRaw(b64urlDecode(jwk.x));
|
|
188
213
|
}
|
|
214
|
+
function jwkThumbprint(jwk) {
|
|
215
|
+
if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return void 0;
|
|
216
|
+
const key = jwk;
|
|
217
|
+
const names = key.kty === "OKP" ? ["crv", "kty", "x"] : key.kty === "EC" ? ["crv", "kty", "x", "y"] : void 0;
|
|
218
|
+
if (!names || names.some((n) => typeof key[n] !== "string")) return void 0;
|
|
219
|
+
const members = Object.fromEntries(names.map((n) => [n, key[n]]));
|
|
220
|
+
return b64urlNoPad(createHash2("sha256").update(canonicalize(members)).digest());
|
|
221
|
+
}
|
|
222
|
+
var B64URL = /^[A-Za-z0-9_-]+$/;
|
|
223
|
+
function parseWebAuthnAssertion(part) {
|
|
224
|
+
const pieces = part.split(".");
|
|
225
|
+
if (pieces.length !== 3 || pieces.some((p) => !B64URL.test(p) || p.length % 4 === 1)) return void 0;
|
|
226
|
+
const [authenticatorData, clientDataJSON, signature] = pieces.map((p) => Buffer.from(p, "base64url"));
|
|
227
|
+
return { authenticatorData, clientDataJSON, signature };
|
|
228
|
+
}
|
|
229
|
+
var COORDINATE = /^[A-Za-z0-9_-]{43}$/;
|
|
230
|
+
function isWebAuthnKey(jwk) {
|
|
231
|
+
if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return false;
|
|
232
|
+
const key = jwk;
|
|
233
|
+
const w = key.webauthn;
|
|
234
|
+
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"));
|
|
235
|
+
}
|
|
236
|
+
var UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
237
|
+
var MAX_CLIENT_DATA_NESTING = 64;
|
|
238
|
+
function jsonDepth(text) {
|
|
239
|
+
let depth = 0;
|
|
240
|
+
let deepest = 0;
|
|
241
|
+
let inString = false;
|
|
242
|
+
for (let i = 0; i < text.length; i++) {
|
|
243
|
+
const ch = text[i];
|
|
244
|
+
if (inString) {
|
|
245
|
+
if (ch === "\\") i++;
|
|
246
|
+
else if (ch === '"') inString = false;
|
|
247
|
+
} else if (ch === '"') {
|
|
248
|
+
inString = true;
|
|
249
|
+
} else if (ch === "{" || ch === "[") {
|
|
250
|
+
deepest = Math.max(deepest, ++depth);
|
|
251
|
+
} else if (ch === "}" || ch === "]") {
|
|
252
|
+
depth--;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return deepest;
|
|
256
|
+
}
|
|
257
|
+
var UP = 1;
|
|
258
|
+
var UV = 4;
|
|
259
|
+
var BE = 8;
|
|
260
|
+
var BS = 16;
|
|
261
|
+
var AT = 64;
|
|
262
|
+
var ED = 128;
|
|
263
|
+
function webAuthnFailure(canonical, assertion, jwk) {
|
|
264
|
+
const w = jwk.webauthn;
|
|
265
|
+
if (!Array.isArray(w.origins) || w.origins.length === 0) return "WebAuthn key lists no origin";
|
|
266
|
+
let text;
|
|
267
|
+
try {
|
|
268
|
+
text = UTF8.decode(assertion.clientDataJSON);
|
|
269
|
+
} catch {
|
|
270
|
+
text = void 0;
|
|
271
|
+
}
|
|
272
|
+
if (text !== void 0 && jsonDepth(text) > MAX_CLIENT_DATA_NESTING) {
|
|
273
|
+
return `WebAuthn clientDataJSON nests deeper than ${MAX_CLIENT_DATA_NESTING} levels`;
|
|
274
|
+
}
|
|
275
|
+
let client;
|
|
276
|
+
try {
|
|
277
|
+
client = text === void 0 ? void 0 : JSON.parse(text);
|
|
278
|
+
} catch {
|
|
279
|
+
client = void 0;
|
|
280
|
+
}
|
|
281
|
+
if (client === null || typeof client !== "object" || Array.isArray(client)) {
|
|
282
|
+
return "WebAuthn clientDataJSON is not a JSON object";
|
|
283
|
+
}
|
|
284
|
+
const data = client;
|
|
285
|
+
if (data.type !== "webauthn.get") return "WebAuthn clientDataJSON type is not webauthn.get";
|
|
286
|
+
if (data.challenge !== b64urlNoPad(createHash2("sha256").update(canonical).digest())) {
|
|
287
|
+
return "WebAuthn challenge is not the digest of this request";
|
|
288
|
+
}
|
|
289
|
+
if (!w.origins.includes(data.origin)) return "WebAuthn origin is not one the key allows";
|
|
290
|
+
if (data.crossOrigin === true || data.topOrigin !== void 0) {
|
|
291
|
+
return "WebAuthn assertion was made in a cross-origin frame";
|
|
292
|
+
}
|
|
293
|
+
const auth = assertion.authenticatorData;
|
|
294
|
+
if (auth.length < 37) return "WebAuthn authenticatorData is too short";
|
|
295
|
+
if (!auth.subarray(0, 32).equals(createHash2("sha256").update(w.rp_id, "utf-8").digest())) {
|
|
296
|
+
return "WebAuthn rpIdHash does not match the key's rp_id";
|
|
297
|
+
}
|
|
298
|
+
const flags = auth[32];
|
|
299
|
+
if ((flags & (UP | UV)) !== (UP | UV)) return "WebAuthn assertion lacks user presence or verification";
|
|
300
|
+
if (flags & BS && !(flags & BE)) return "WebAuthn assertion is backed up but not backup eligible";
|
|
301
|
+
if (flags & AT) return "WebAuthn assertion carries attested credential data";
|
|
302
|
+
if (flags & ED ? auth.length === 37 : auth.length !== 37) {
|
|
303
|
+
return "WebAuthn authenticatorData does not end where its flags say";
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const key = createPublicKey({
|
|
307
|
+
key: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y },
|
|
308
|
+
format: "jwk"
|
|
309
|
+
});
|
|
310
|
+
const signed = Buffer.concat([auth, createHash2("sha256").update(assertion.clientDataJSON).digest()]);
|
|
311
|
+
return nodeVerify("sha256", signed, { key, dsaEncoding: "der" }, assertion.signature) ? null : "Signature failed verification";
|
|
312
|
+
} catch {
|
|
313
|
+
return "Signature failed verification";
|
|
314
|
+
}
|
|
315
|
+
}
|
|
189
316
|
function deriveKeypair(uri) {
|
|
190
317
|
const seed = deriveSeed(uri);
|
|
191
318
|
const privateKey = privateKeyFromSeed(seed);
|
|
@@ -302,6 +429,7 @@ var E = {
|
|
|
302
429
|
SIG_KEY_REVOKED: -32072,
|
|
303
430
|
SIG_ROTATION_KEY_MISMATCH: -32073,
|
|
304
431
|
SIG_ARTEFACT_DIGEST_MISMATCH: -32074,
|
|
432
|
+
SIG_ALREADY_ACCEPTED: -32075,
|
|
305
433
|
// audit-scitt/1.0 profile (profiles/audit-scitt.md S8)
|
|
306
434
|
SCITT_UNREACHABLE: -32080,
|
|
307
435
|
SCITT_STATEMENT_REJECTED: -32081,
|
|
@@ -334,6 +462,44 @@ function isValidEnvelope(env) {
|
|
|
334
462
|
return e.jsonrpc === "2.0";
|
|
335
463
|
}
|
|
336
464
|
|
|
465
|
+
// src/params.ts
|
|
466
|
+
var NAMES = {
|
|
467
|
+
string: "a string",
|
|
468
|
+
strings: "a list of strings",
|
|
469
|
+
boolean: "a boolean",
|
|
470
|
+
object: "an object",
|
|
471
|
+
list: "a list",
|
|
472
|
+
integer: "an integer"
|
|
473
|
+
};
|
|
474
|
+
function isKind(value, kind) {
|
|
475
|
+
switch (kind) {
|
|
476
|
+
case "string":
|
|
477
|
+
return typeof value === "string";
|
|
478
|
+
case "strings":
|
|
479
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
480
|
+
case "boolean":
|
|
481
|
+
return typeof value === "boolean";
|
|
482
|
+
case "object":
|
|
483
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
484
|
+
case "list":
|
|
485
|
+
return Array.isArray(value);
|
|
486
|
+
case "integer":
|
|
487
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function paramsError(params, checks) {
|
|
491
|
+
for (const [name, kind] of checks) {
|
|
492
|
+
let value = params;
|
|
493
|
+
for (const part of name.split(".")) {
|
|
494
|
+
value = typeof value === "object" && value !== null && !Array.isArray(value) ? value[part] : void 0;
|
|
495
|
+
}
|
|
496
|
+
if (value !== void 0 && value !== null && !isKind(value, kind)) {
|
|
497
|
+
return { error: rpcError(E.PARAMS, `${name} must be ${NAMES[kind]}`) };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
|
|
337
503
|
// src/audit.ts
|
|
338
504
|
function entryRecord(entry) {
|
|
339
505
|
if (entry.outcome != null) return { outcome: entry.outcome, request: entry.request };
|
|
@@ -377,7 +543,8 @@ var UNRECORDED_CODES = /* @__PURE__ */ new Set([
|
|
|
377
543
|
E.SIG_VERIFY_FAILED,
|
|
378
544
|
E.SIG_KEY_NOT_FOUND,
|
|
379
545
|
E.SIG_KEY_REVOKED,
|
|
380
|
-
E.SIG_ROTATION_KEY_MISMATCH
|
|
546
|
+
E.SIG_ROTATION_KEY_MISMATCH,
|
|
547
|
+
E.SIG_ALREADY_ACCEPTED
|
|
381
548
|
]);
|
|
382
549
|
var UNRECORDED_METHODS = /* @__PURE__ */ new Set(["workspace.create", "participant.join"]);
|
|
383
550
|
function refusalIsRecorded(method, error, privileged) {
|
|
@@ -500,77 +667,116 @@ function deepEqual(a, b) {
|
|
|
500
667
|
}
|
|
501
668
|
return true;
|
|
502
669
|
}
|
|
503
|
-
function
|
|
670
|
+
function addAt(doc, path, value, size, copied = false, movedFrom) {
|
|
671
|
+
const parts = splitPath(path);
|
|
672
|
+
if (parts.length === 0) {
|
|
673
|
+
if (size) {
|
|
674
|
+
size.reset(value);
|
|
675
|
+
if (copied || movedFrom !== void 0) size.carry(size);
|
|
676
|
+
}
|
|
677
|
+
return value;
|
|
678
|
+
}
|
|
679
|
+
const [parent, key] = navigate(doc, parts);
|
|
680
|
+
let newKey;
|
|
681
|
+
if (Array.isArray(parent)) {
|
|
682
|
+
if (key === "-") {
|
|
683
|
+
parent.push(value);
|
|
684
|
+
} else {
|
|
685
|
+
const idx = key;
|
|
686
|
+
if (idx < 0 || idx > parent.length) {
|
|
687
|
+
throw new PatchError(`Index out of range for add: ${path}`);
|
|
688
|
+
}
|
|
689
|
+
parent.splice(idx, 0, value);
|
|
690
|
+
}
|
|
691
|
+
} else if (parent !== null && typeof parent === "object") {
|
|
692
|
+
if (Object.prototype.hasOwnProperty.call(parent, key)) size?.take(parent[key]);
|
|
693
|
+
else newKey = key;
|
|
694
|
+
parent[key] = value;
|
|
695
|
+
} else {
|
|
696
|
+
throw new PatchError(`Cannot add into ${jsonType(parent)}`);
|
|
697
|
+
}
|
|
698
|
+
if (size) {
|
|
699
|
+
if (movedFrom === void 0) {
|
|
700
|
+
const inserted = size.insert(value, parts.length, newKey);
|
|
701
|
+
if (copied) size.carry(inserted);
|
|
702
|
+
} else {
|
|
703
|
+
if (newKey !== void 0) size.bytes += utf8Length(newKey);
|
|
704
|
+
if (parts.length > movedFrom) {
|
|
705
|
+
const moved = sizeOf(value);
|
|
706
|
+
size.depth = Math.max(size.depth, parts.length + moved.depth);
|
|
707
|
+
size.carry(moved);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return doc;
|
|
712
|
+
}
|
|
713
|
+
function removeAt(doc, path, size, moved = false) {
|
|
714
|
+
const parts = splitPath(path);
|
|
715
|
+
if (parts.length === 0) throw new PatchError("Cannot remove root.");
|
|
716
|
+
const [parent, key] = navigate(doc, parts);
|
|
717
|
+
if (Array.isArray(parent)) {
|
|
718
|
+
const idx = key === "-" ? -1 : key;
|
|
719
|
+
if (idx < 0 || idx >= parent.length) {
|
|
720
|
+
throw new PatchError(`Index out of range for remove: ${path}`);
|
|
721
|
+
}
|
|
722
|
+
if (size && !moved) size.take(parent[idx]);
|
|
723
|
+
parent.splice(idx, 1);
|
|
724
|
+
} else if (parent !== null && typeof parent === "object") {
|
|
725
|
+
if (!Object.prototype.hasOwnProperty.call(parent, key)) {
|
|
726
|
+
throw new PatchError(`Path not found for remove: ${path}`);
|
|
727
|
+
}
|
|
728
|
+
if (size) {
|
|
729
|
+
if (moved) size.bytes -= utf8Length(key);
|
|
730
|
+
else size.take(parent[key], key);
|
|
731
|
+
}
|
|
732
|
+
delete parent[key];
|
|
733
|
+
} else {
|
|
734
|
+
throw new PatchError(`Cannot remove from ${jsonType(parent)}`);
|
|
735
|
+
}
|
|
736
|
+
return doc;
|
|
737
|
+
}
|
|
738
|
+
function applyOne(doc, op, size) {
|
|
504
739
|
const kind = op.op;
|
|
505
740
|
const path = op.path ?? "";
|
|
506
741
|
if (kind === "add") {
|
|
507
742
|
if (!("value" in op)) throw new PatchError("'add' requires 'value'");
|
|
508
|
-
|
|
509
|
-
if (parts.length === 0) return op.value;
|
|
510
|
-
const [parent, key] = navigate(doc, parts);
|
|
511
|
-
if (Array.isArray(parent)) {
|
|
512
|
-
if (key === "-") {
|
|
513
|
-
parent.push(op.value);
|
|
514
|
-
} else {
|
|
515
|
-
const idx = key;
|
|
516
|
-
if (idx < 0 || idx > parent.length) {
|
|
517
|
-
throw new PatchError(`Index out of range for add: ${path}`);
|
|
518
|
-
}
|
|
519
|
-
parent.splice(idx, 0, op.value);
|
|
520
|
-
}
|
|
521
|
-
} else if (parent !== null && typeof parent === "object") {
|
|
522
|
-
parent[key] = op.value;
|
|
523
|
-
} else {
|
|
524
|
-
throw new PatchError(`Cannot add into ${jsonType(parent)}`);
|
|
525
|
-
}
|
|
526
|
-
return doc;
|
|
743
|
+
return addAt(doc, path, clone(op.value), size);
|
|
527
744
|
}
|
|
528
745
|
if (kind === "replace") {
|
|
529
746
|
if (!("value" in op)) throw new PatchError("'replace' requires 'value'");
|
|
530
747
|
const parts = splitPath(path);
|
|
531
|
-
|
|
748
|
+
const value = clone(op.value);
|
|
749
|
+
if (parts.length === 0) {
|
|
750
|
+
size?.reset(value);
|
|
751
|
+
return value;
|
|
752
|
+
}
|
|
532
753
|
const [parent, key] = navigate(doc, parts);
|
|
533
754
|
if (Array.isArray(parent)) {
|
|
534
755
|
const idx = key === "-" ? -1 : key;
|
|
535
756
|
if (idx < 0 || idx >= parent.length) {
|
|
536
757
|
throw new PatchError(`Path not found for replace: ${path}`);
|
|
537
758
|
}
|
|
538
|
-
parent[idx]
|
|
759
|
+
size?.take(parent[idx]);
|
|
760
|
+
parent[idx] = value;
|
|
539
761
|
} else if (parent !== null && typeof parent === "object") {
|
|
540
762
|
if (!Object.prototype.hasOwnProperty.call(parent, key)) {
|
|
541
763
|
throw new PatchError(`Path not found for replace: ${path}`);
|
|
542
764
|
}
|
|
543
|
-
parent[key]
|
|
765
|
+
size?.take(parent[key]);
|
|
766
|
+
parent[key] = value;
|
|
544
767
|
} else {
|
|
545
768
|
throw new PatchError(`Cannot replace in ${jsonType(parent)}`);
|
|
546
769
|
}
|
|
770
|
+
size?.insert(value, parts.length);
|
|
547
771
|
return doc;
|
|
548
772
|
}
|
|
549
773
|
if (kind === "remove") {
|
|
550
|
-
|
|
551
|
-
if (parts.length === 0) throw new PatchError("Cannot remove root.");
|
|
552
|
-
const [parent, key] = navigate(doc, parts);
|
|
553
|
-
if (Array.isArray(parent)) {
|
|
554
|
-
const idx = key === "-" ? -1 : key;
|
|
555
|
-
if (idx < 0 || idx >= parent.length) {
|
|
556
|
-
throw new PatchError(`Index out of range for remove: ${path}`);
|
|
557
|
-
}
|
|
558
|
-
parent.splice(idx, 1);
|
|
559
|
-
} else if (parent !== null && typeof parent === "object") {
|
|
560
|
-
if (!Object.prototype.hasOwnProperty.call(parent, key)) {
|
|
561
|
-
throw new PatchError(`Path not found for remove: ${path}`);
|
|
562
|
-
}
|
|
563
|
-
delete parent[key];
|
|
564
|
-
} else {
|
|
565
|
-
throw new PatchError(`Cannot remove from ${jsonType(parent)}`);
|
|
566
|
-
}
|
|
567
|
-
return doc;
|
|
774
|
+
return removeAt(doc, path, size);
|
|
568
775
|
}
|
|
569
776
|
if (kind === "copy") {
|
|
570
777
|
const src = op.from;
|
|
571
778
|
if (src === void 0) throw new PatchError("'copy' requires 'from'");
|
|
572
|
-
|
|
573
|
-
return applyOne(doc, { op: "add", path, value });
|
|
779
|
+
return addAt(doc, path, clone(getAt(doc, src)), size, true);
|
|
574
780
|
}
|
|
575
781
|
if (kind === "move") {
|
|
576
782
|
const src = op.from;
|
|
@@ -578,9 +784,9 @@ function applyOne(doc, op) {
|
|
|
578
784
|
if (path.startsWith(src + "/")) {
|
|
579
785
|
throw new PatchError("'move' cannot move a location into its own child.");
|
|
580
786
|
}
|
|
581
|
-
const value =
|
|
582
|
-
doc =
|
|
583
|
-
return
|
|
787
|
+
const value = getAt(doc, src);
|
|
788
|
+
doc = removeAt(doc, src, size, true);
|
|
789
|
+
return addAt(doc, path, value, size, false, splitPath(src).length);
|
|
584
790
|
}
|
|
585
791
|
if (kind === "test") {
|
|
586
792
|
if (!("value" in op)) throw new PatchError("'test' requires 'value'");
|
|
@@ -592,29 +798,122 @@ function applyOne(doc, op) {
|
|
|
592
798
|
}
|
|
593
799
|
var MAX_PATCH_OPS = 1e3;
|
|
594
800
|
var MAX_DOCUMENT_NODES = 1e5;
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
801
|
+
var MAX_DOCUMENT_DEPTH = 64;
|
|
802
|
+
var MAX_CARRIED_TIMES = 3;
|
|
803
|
+
var DEFAULT_MAX_PATCHED_BYTES = 1048576;
|
|
804
|
+
function utf8Length(s) {
|
|
805
|
+
const buffer = globalThis.Buffer;
|
|
806
|
+
if (typeof buffer === "function") {
|
|
807
|
+
return buffer.byteLength(s, "utf8");
|
|
808
|
+
}
|
|
809
|
+
let n = 0;
|
|
810
|
+
for (let i = 0; i < s.length; i++) {
|
|
811
|
+
const c = s.charCodeAt(i);
|
|
812
|
+
if (c < 128) n += 1;
|
|
813
|
+
else if (c < 2048) n += 2;
|
|
814
|
+
else if (c >= 55296 && c <= 56319 && i + 1 < s.length) {
|
|
815
|
+
const d = s.charCodeAt(i + 1);
|
|
816
|
+
if (d >= 56320 && d <= 57343) {
|
|
817
|
+
n += 4;
|
|
818
|
+
i++;
|
|
819
|
+
} else n += 3;
|
|
820
|
+
} else n += 3;
|
|
821
|
+
}
|
|
822
|
+
return n;
|
|
823
|
+
}
|
|
824
|
+
function sizeOf(value) {
|
|
825
|
+
if (value === null || typeof value !== "object") {
|
|
826
|
+
return { nodes: 1, bytes: typeof value === "string" ? utf8Length(value) : 0, depth: 1 };
|
|
827
|
+
}
|
|
828
|
+
let nodes = 0;
|
|
829
|
+
let bytes = 0;
|
|
830
|
+
let depth = 0;
|
|
831
|
+
const values = [value];
|
|
832
|
+
const levels = [1];
|
|
833
|
+
const visit = (x, level) => {
|
|
834
|
+
if (x !== null && typeof x === "object") {
|
|
835
|
+
values.push(x);
|
|
836
|
+
levels.push(level);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
nodes += 1;
|
|
840
|
+
if (level > depth) depth = level;
|
|
841
|
+
if (typeof x === "string") bytes += utf8Length(x);
|
|
842
|
+
};
|
|
843
|
+
while (values.length > 0) {
|
|
844
|
+
const v = values.pop();
|
|
845
|
+
const level = levels.pop();
|
|
846
|
+
nodes += 1;
|
|
847
|
+
if (level > depth) depth = level;
|
|
848
|
+
if (Array.isArray(v)) {
|
|
849
|
+
for (const x of v) visit(x, level + 1);
|
|
850
|
+
} else {
|
|
851
|
+
const o = v;
|
|
852
|
+
for (const k of Object.keys(o)) {
|
|
853
|
+
bytes += utf8Length(k);
|
|
854
|
+
visit(o[k], level + 1);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
605
857
|
}
|
|
606
|
-
return
|
|
858
|
+
return { nodes, bytes, depth };
|
|
607
859
|
}
|
|
608
|
-
|
|
860
|
+
var Size = class {
|
|
861
|
+
nodes = 0;
|
|
862
|
+
bytes = 0;
|
|
863
|
+
depth = 0;
|
|
864
|
+
/** What copies, and moves to a deeper place or onto the whole document, have carried. */
|
|
865
|
+
carriedNodes = 0;
|
|
866
|
+
carriedBytes = 0;
|
|
867
|
+
constructor(doc) {
|
|
868
|
+
this.reset(doc);
|
|
869
|
+
}
|
|
870
|
+
reset(doc) {
|
|
871
|
+
const s = sizeOf(doc);
|
|
872
|
+
this.nodes = s.nodes;
|
|
873
|
+
this.bytes = s.bytes;
|
|
874
|
+
this.depth = s.depth;
|
|
875
|
+
}
|
|
876
|
+
/** `value` goes in `segments` levels below the top, under a new `key`. */
|
|
877
|
+
insert(value, segments, key) {
|
|
878
|
+
const s = sizeOf(value);
|
|
879
|
+
this.nodes += s.nodes;
|
|
880
|
+
this.bytes += s.bytes + (key === void 0 ? 0 : utf8Length(key));
|
|
881
|
+
this.depth = Math.max(this.depth, segments + s.depth);
|
|
882
|
+
return s;
|
|
883
|
+
}
|
|
884
|
+
carry(s) {
|
|
885
|
+
this.carriedNodes += s.nodes;
|
|
886
|
+
this.carriedBytes += s.bytes;
|
|
887
|
+
}
|
|
888
|
+
/** `value` comes out, with its `key`. */
|
|
889
|
+
take(value, key) {
|
|
890
|
+
const s = sizeOf(value);
|
|
891
|
+
this.nodes -= s.nodes;
|
|
892
|
+
this.bytes -= s.bytes + (key === void 0 ? 0 : utf8Length(key));
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
function applyJsonPatch(doc, ops, limits = {}) {
|
|
896
|
+
const maxBytes = limits.maxStringBytes ?? DEFAULT_MAX_PATCHED_BYTES;
|
|
609
897
|
if (ops.length > MAX_PATCH_OPS) {
|
|
610
898
|
throw new PatchError(`Patch has too many operations (${ops.length} > ${MAX_PATCH_OPS})`);
|
|
611
899
|
}
|
|
612
900
|
let out = JSON.parse(JSON.stringify(doc));
|
|
901
|
+
const size = new Size(out);
|
|
613
902
|
for (const op of ops) {
|
|
614
|
-
out = applyOne(out, op);
|
|
615
|
-
if (
|
|
903
|
+
out = applyOne(out, op, size);
|
|
904
|
+
if (size.depth > MAX_DOCUMENT_DEPTH) size.depth = sizeOf(out).depth;
|
|
905
|
+
if (size.nodes > MAX_DOCUMENT_NODES) {
|
|
616
906
|
throw new PatchError(`Patched document exceeds the node limit (${MAX_DOCUMENT_NODES})`);
|
|
617
907
|
}
|
|
908
|
+
if (size.bytes > maxBytes) {
|
|
909
|
+
throw new PatchError(`Patched document exceeds the size limit (${maxBytes} bytes of strings and keys)`);
|
|
910
|
+
}
|
|
911
|
+
if (size.depth > MAX_DOCUMENT_DEPTH) {
|
|
912
|
+
throw new PatchError(`Patched document nests deeper than ${MAX_DOCUMENT_DEPTH} levels`);
|
|
913
|
+
}
|
|
914
|
+
if (size.carriedNodes > MAX_CARRIED_TIMES * MAX_DOCUMENT_NODES || size.carriedBytes > MAX_CARRIED_TIMES * maxBytes) {
|
|
915
|
+
throw new PatchError(`Patch copies or moves more than its limit (${MAX_CARRIED_TIMES * MAX_DOCUMENT_NODES} values or ${MAX_CARRIED_TIMES * maxBytes} bytes of strings and keys)`);
|
|
916
|
+
}
|
|
618
917
|
}
|
|
619
918
|
return out;
|
|
620
919
|
}
|
|
@@ -662,7 +961,8 @@ var COLLECTIONS = [
|
|
|
662
961
|
"deliberations",
|
|
663
962
|
"handoffs",
|
|
664
963
|
"snapshots",
|
|
665
|
-
"route_decisions"
|
|
964
|
+
"route_decisions",
|
|
965
|
+
"invitations"
|
|
666
966
|
];
|
|
667
967
|
var IDEMPOTENCY_KEYS = "idempotency_keys";
|
|
668
968
|
var StoreConflictError = class extends Error {
|
|
@@ -673,7 +973,7 @@ var StoreConflictError = class extends Error {
|
|
|
673
973
|
base;
|
|
674
974
|
constructor(workspace, held, base) {
|
|
675
975
|
super(
|
|
676
|
-
`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
|
|
976
|
+
`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.`
|
|
677
977
|
);
|
|
678
978
|
this.name = "StoreConflictError";
|
|
679
979
|
this.workspace = workspace;
|
|
@@ -750,20 +1050,24 @@ function assembleSnapshot(meta, entries, objects) {
|
|
|
750
1050
|
var MemoryStore = class {
|
|
751
1051
|
held = /* @__PURE__ */ new Map();
|
|
752
1052
|
load() {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
|
|
762
|
-
version: h.version,
|
|
763
|
-
updated_at: h.updated_at
|
|
764
|
-
});
|
|
1053
|
+
return [...this.held.keys()].map((id) => this.loadOne(id));
|
|
1054
|
+
}
|
|
1055
|
+
loadOne(id) {
|
|
1056
|
+
const h = this.held.get(id);
|
|
1057
|
+
if (!h) return void 0;
|
|
1058
|
+
const objects = [];
|
|
1059
|
+
for (const [collection, items] of h.objects) {
|
|
1060
|
+
for (const [key, value] of items) objects.push([collection, key, JSON.parse(value)]);
|
|
765
1061
|
}
|
|
766
|
-
return
|
|
1062
|
+
return {
|
|
1063
|
+
id,
|
|
1064
|
+
data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
|
|
1065
|
+
version: h.version,
|
|
1066
|
+
updated_at: h.updated_at
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
version(id) {
|
|
1070
|
+
return this.held.get(id)?.entries.length;
|
|
767
1071
|
}
|
|
768
1072
|
save(record) {
|
|
769
1073
|
this.apply(changeFromRecord(record));
|
|
@@ -890,7 +1194,8 @@ function makeApi(coord) {
|
|
|
890
1194
|
workspace: {
|
|
891
1195
|
create: (p) => c("workspace.create", p),
|
|
892
1196
|
describe: (p) => c("workspace.describe", p),
|
|
893
|
-
set_profiles: (p) => c("workspace.set_profiles", p)
|
|
1197
|
+
set_profiles: (p) => c("workspace.set_profiles", p),
|
|
1198
|
+
invite: (p) => c("workspace.invite", p)
|
|
894
1199
|
},
|
|
895
1200
|
participant: {
|
|
896
1201
|
join: (p) => c("participant.join", p),
|
|
@@ -906,6 +1211,7 @@ function makeApi(coord) {
|
|
|
906
1211
|
},
|
|
907
1212
|
review: {
|
|
908
1213
|
request: (p) => c("review.request", p),
|
|
1214
|
+
acknowledge: (p) => c("review.acknowledge", p),
|
|
909
1215
|
depth: (p) => c("review.depth", p)
|
|
910
1216
|
},
|
|
911
1217
|
decide: {
|
|
@@ -979,10 +1285,17 @@ function registerAuditScitt(coord) {
|
|
|
979
1285
|
coord.handlers.set("audit.submit_to_scitt", (p) => {
|
|
980
1286
|
const ws = coord.workspaces.get(p.workspace);
|
|
981
1287
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1288
|
+
const bad = paramsError(p, [
|
|
1289
|
+
["range", "object"],
|
|
1290
|
+
["range.from_seq", "integer"],
|
|
1291
|
+
["range.to_seq", "integer"],
|
|
1292
|
+
["issuer", "string"]
|
|
1293
|
+
]);
|
|
1294
|
+
if (bad) return bad;
|
|
982
1295
|
const range = p.range ?? {};
|
|
983
1296
|
const fromSeq = range.from_seq ?? 0;
|
|
984
1297
|
const toSeq = range.to_seq ?? ws.audit.length;
|
|
985
|
-
const issuer = p.issuer
|
|
1298
|
+
const issuer = p.issuer || "service:coordinator";
|
|
986
1299
|
if (!coord.options.scittSubmitter) {
|
|
987
1300
|
const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, entryRecord(e), issuer));
|
|
988
1301
|
return { result: {
|
|
@@ -1012,7 +1325,7 @@ function registerAuditScitt(coord) {
|
|
|
1012
1325
|
});
|
|
1013
1326
|
coord.handlers.set("audit.verify_receipt", (p) => {
|
|
1014
1327
|
const receipt = p.receipt;
|
|
1015
|
-
if (typeof receipt !== "object" || receipt === null) {
|
|
1328
|
+
if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
|
|
1016
1329
|
return { error: rpcError(E.PARAMS, "receipt must be an object") };
|
|
1017
1330
|
}
|
|
1018
1331
|
if (coord.options.verifyScittReceipt) {
|
|
@@ -1026,7 +1339,7 @@ function registerAuditScitt(coord) {
|
|
|
1026
1339
|
if (!ok) return { error: rpcError(E.SCITT_RECEIPT_INVALID, "Receipt did not verify") };
|
|
1027
1340
|
return { result: { verified: true } };
|
|
1028
1341
|
}
|
|
1029
|
-
return { error: rpcError(E.SCITT_RECEIPT_INVALID, "No
|
|
1342
|
+
return { error: rpcError(E.SCITT_RECEIPT_INVALID, "No receipt verifier is configured; receipt not verified") };
|
|
1030
1343
|
});
|
|
1031
1344
|
coord.handlers.set("audit.verify_chain", (p) => {
|
|
1032
1345
|
const ws = coord.workspaces.get(p.workspace);
|
|
@@ -1034,7 +1347,7 @@ function registerAuditScitt(coord) {
|
|
|
1034
1347
|
if (!(ws.chain_enabled || coord.options.enableChain)) {
|
|
1035
1348
|
return { error: rpcError(E.PARAMS, "Chain not enabled for this workspace") };
|
|
1036
1349
|
}
|
|
1037
|
-
if (p.from_seq
|
|
1350
|
+
if (p.from_seq != null || p.to_seq != null) {
|
|
1038
1351
|
return { error: rpcError(
|
|
1039
1352
|
E.PARAMS,
|
|
1040
1353
|
"Range verification is not implemented; omit from_seq and to_seq to verify the whole log."
|
|
@@ -1096,13 +1409,21 @@ function registerAuditScitt(coord) {
|
|
|
1096
1409
|
// src/profiles/control.ts
|
|
1097
1410
|
var VALID_MODES = /* @__PURE__ */ new Set(["shadow", "trial", "production"]);
|
|
1098
1411
|
var VALID_SCOPES = /* @__PURE__ */ new Set(["task", "participant", "workspace"]);
|
|
1412
|
+
var SCOPE_ERROR = "scope must be task, participant or workspace";
|
|
1099
1413
|
function registerControl(coord) {
|
|
1100
1414
|
coord.handlers.set("control.pause", (p) => {
|
|
1101
1415
|
const ws = coord.workspaces.get(p.workspace);
|
|
1102
1416
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1417
|
+
const bad = paramsError(p, [
|
|
1418
|
+
["scope", "string"],
|
|
1419
|
+
["participant_uri", "string"],
|
|
1420
|
+
["uri", "string"],
|
|
1421
|
+
["in_flight_policy", "string"]
|
|
1422
|
+
]);
|
|
1423
|
+
if (bad) return bad;
|
|
1103
1424
|
const scope = p.scope || "task";
|
|
1104
1425
|
if (!VALID_SCOPES.has(scope)) {
|
|
1105
|
-
return { error: rpcError(E.PARAMS,
|
|
1426
|
+
return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
|
|
1106
1427
|
}
|
|
1107
1428
|
if (scope === "task") {
|
|
1108
1429
|
const task = ws.tasks.get(p.task_id);
|
|
@@ -1120,7 +1441,8 @@ function registerControl(coord) {
|
|
|
1120
1441
|
}
|
|
1121
1442
|
if (scope === "participant") {
|
|
1122
1443
|
const uri = p.participant_uri || p.uri;
|
|
1123
|
-
if (!uri
|
|
1444
|
+
if (!uri) return { error: rpcError(E.PARAMS, "participant_uri is required") };
|
|
1445
|
+
if (!ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
|
|
1124
1446
|
ws.members.get(uri).paused = true;
|
|
1125
1447
|
return { result: {
|
|
1126
1448
|
scope: "participant",
|
|
@@ -1135,9 +1457,11 @@ function registerControl(coord) {
|
|
|
1135
1457
|
coord.handlers.set("control.resume", (p) => {
|
|
1136
1458
|
const ws = coord.workspaces.get(p.workspace);
|
|
1137
1459
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1460
|
+
const bad = paramsError(p, [["scope", "string"], ["participant_uri", "string"], ["uri", "string"]]);
|
|
1461
|
+
if (bad) return bad;
|
|
1138
1462
|
const scope = p.scope || "task";
|
|
1139
1463
|
if (!VALID_SCOPES.has(scope)) {
|
|
1140
|
-
return { error: rpcError(E.PARAMS,
|
|
1464
|
+
return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
|
|
1141
1465
|
}
|
|
1142
1466
|
if (scope === "task") {
|
|
1143
1467
|
const task = ws.tasks.get(p.task_id);
|
|
@@ -1155,7 +1479,9 @@ function registerControl(coord) {
|
|
|
1155
1479
|
}
|
|
1156
1480
|
if (scope === "participant") {
|
|
1157
1481
|
const uri = p.participant_uri || p.uri;
|
|
1158
|
-
if (!uri
|
|
1482
|
+
if (!uri) return { error: rpcError(E.PARAMS, "participant_uri is required") };
|
|
1483
|
+
if (!ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
|
|
1484
|
+
if (uri === p.from) return { error: rpcError(E.NOT_AUTHORISED, "A paused participant does not lift its own pause") };
|
|
1159
1485
|
ws.members.get(uri).paused = false;
|
|
1160
1486
|
return { result: { scope: "participant", participant_uri: uri, paused: false } };
|
|
1161
1487
|
}
|
|
@@ -1170,6 +1496,12 @@ function registerControl(coord) {
|
|
|
1170
1496
|
if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
|
|
1171
1497
|
return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
|
|
1172
1498
|
}
|
|
1499
|
+
if (openRequiredReviewAuthors(ws, task).includes(p.from)) {
|
|
1500
|
+
return { error: rpcError(
|
|
1501
|
+
E.NOT_AUTHORISED,
|
|
1502
|
+
"The authors of the work under review cannot end its review without a decision"
|
|
1503
|
+
) };
|
|
1504
|
+
}
|
|
1173
1505
|
task.state = "cancelled";
|
|
1174
1506
|
task.updated_at = coord.now();
|
|
1175
1507
|
task.history.push({ ts: task.updated_at, from: p.from, state: "cancelled", note: p.reason });
|
|
@@ -1178,6 +1510,8 @@ function registerControl(coord) {
|
|
|
1178
1510
|
coord.handlers.set("control.snapshot", (p) => {
|
|
1179
1511
|
const ws = coord.workspaces.get(p.workspace);
|
|
1180
1512
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1513
|
+
const bad = paramsError(p, [["include", "strings"], ["label", "string"]]);
|
|
1514
|
+
if (bad) return bad;
|
|
1181
1515
|
if (Array.isArray(p.include) && p.include.length === 0) {
|
|
1182
1516
|
return { error: rpcError(E.PARAMS, "include must not be empty") };
|
|
1183
1517
|
}
|
|
@@ -1232,6 +1566,14 @@ function registerControl(coord) {
|
|
|
1232
1566
|
coord.handlers.set("control.rollback", (p) => {
|
|
1233
1567
|
const ws = coord.workspaces.get(p.workspace);
|
|
1234
1568
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1569
|
+
const bad = paramsError(p, [
|
|
1570
|
+
["to_snapshot_artefact_id", "string"],
|
|
1571
|
+
["snapshot_artefact_id", "string"],
|
|
1572
|
+
["snapshot_id", "string"],
|
|
1573
|
+
["what_to_restore", "strings"],
|
|
1574
|
+
["reason", "string"]
|
|
1575
|
+
]);
|
|
1576
|
+
if (bad) return bad;
|
|
1235
1577
|
const snapId = p.to_snapshot_artefact_id || p.snapshot_artefact_id || p.snapshot_id;
|
|
1236
1578
|
if (!snapId) return { error: rpcError(E.PARAMS, "to_snapshot_artefact_id is required") };
|
|
1237
1579
|
const snap = ws.snapshots.get(snapId);
|
|
@@ -1253,7 +1595,8 @@ function registerControl(coord) {
|
|
|
1253
1595
|
for (const [uri, snapM] of snapByUri) {
|
|
1254
1596
|
if (ws.members.has(uri)) {
|
|
1255
1597
|
const cur = ws.members.get(uri);
|
|
1256
|
-
|
|
1598
|
+
const role = snapM.role ?? cur.role;
|
|
1599
|
+
if (role !== "admin" && cur.role !== "admin") cur.role = role;
|
|
1257
1600
|
cur.scopes = Array.isArray(snapM.scopes) ? [...snapM.scopes] : void 0;
|
|
1258
1601
|
}
|
|
1259
1602
|
}
|
|
@@ -1272,10 +1615,18 @@ function registerControl(coord) {
|
|
|
1272
1615
|
const oldId = p.task_id;
|
|
1273
1616
|
const old = ws.tasks.get(oldId);
|
|
1274
1617
|
if (!old) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
1618
|
+
if (openRequiredReviewAuthors(ws, old).includes(p.from)) {
|
|
1619
|
+
return { error: rpcError(
|
|
1620
|
+
E.NOT_AUTHORISED,
|
|
1621
|
+
"The authors of the work under review cannot end its review without a decision"
|
|
1622
|
+
) };
|
|
1623
|
+
}
|
|
1275
1624
|
const newSpec = p.successor_task ?? {};
|
|
1276
|
-
if (!newSpec.kind) {
|
|
1625
|
+
if (typeof newSpec.kind !== "string" || !newSpec.kind) {
|
|
1277
1626
|
return { error: rpcError(E.PARAMS, "successor_task must include kind") };
|
|
1278
1627
|
}
|
|
1628
|
+
const bad = paramsError(p, [["successor_task.assignee", "string"], ["reason", "string"]]);
|
|
1629
|
+
if (bad) return bad;
|
|
1279
1630
|
const assignee = newSpec.assignee || old.assignee;
|
|
1280
1631
|
if (typeof assignee !== "string" || !ws.members.has(assignee)) {
|
|
1281
1632
|
return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
|
|
@@ -1322,6 +1673,7 @@ function registerControl(coord) {
|
|
|
1322
1673
|
} else if ("review_required" in newSpec) {
|
|
1323
1674
|
newTask.review_required = !!newSpec.review_required;
|
|
1324
1675
|
}
|
|
1676
|
+
newTask.prior_authors = successorAuthors(old, p.from);
|
|
1325
1677
|
ws.tasks.set(newId, newTask);
|
|
1326
1678
|
old.state = "superseded";
|
|
1327
1679
|
old.superseded_by = newId;
|
|
@@ -1332,26 +1684,37 @@ function registerControl(coord) {
|
|
|
1332
1684
|
state: "superseded",
|
|
1333
1685
|
note: `-> ${newId}: ${p.reason || ""}`
|
|
1334
1686
|
});
|
|
1335
|
-
return { result: {
|
|
1687
|
+
return { result: {
|
|
1688
|
+
superseded_task_id: old.id,
|
|
1689
|
+
new_task_id: newId,
|
|
1690
|
+
...p.reason != null ? { reason: p.reason } : {}
|
|
1691
|
+
} };
|
|
1336
1692
|
});
|
|
1337
1693
|
coord.handlers.set("control.set_mode_ceiling", (p) => {
|
|
1338
1694
|
const ws = coord.workspaces.get(p.workspace);
|
|
1339
1695
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1696
|
+
const bad = paramsError(p, [["reason", "string"]]);
|
|
1697
|
+
if (bad) return bad;
|
|
1340
1698
|
const m = p.new_ceiling ?? p.mode_ceiling;
|
|
1341
1699
|
if (!VALID_MODES.has(m)) {
|
|
1342
|
-
return { error: rpcError(E.PARAMS,
|
|
1700
|
+
return { error: rpcError(E.PARAMS, "new_ceiling must be shadow, trial or production") };
|
|
1343
1701
|
}
|
|
1344
1702
|
ws.mode_ceiling = m;
|
|
1345
|
-
return { result: { mode_ceiling: ws.mode_ceiling, reason: p.reason } };
|
|
1703
|
+
return { result: { mode_ceiling: ws.mode_ceiling, ...p.reason != null ? { reason: p.reason } : {} } };
|
|
1346
1704
|
});
|
|
1347
1705
|
}
|
|
1348
1706
|
|
|
1349
1707
|
// src/profiles/deliberation.ts
|
|
1708
|
+
var RESERVED_IDS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1709
|
+
var QUORUM_ARG = /^[0-9]+$/;
|
|
1710
|
+
var THRESHOLD_ARG = /^[0-9]+(\.[0-9]+)?$/;
|
|
1350
1711
|
function parseRule(rule) {
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1712
|
+
const colon = rule.indexOf(":");
|
|
1713
|
+
if (colon >= 0) {
|
|
1714
|
+
const kind = rule.slice(0, colon);
|
|
1715
|
+
const arg = rule.slice(colon + 1);
|
|
1716
|
+
if (kind === "quorum" && QUORUM_ARG.test(arg)) return { kind, params: { n: parseInt(arg, 10) } };
|
|
1717
|
+
if ((kind === "weighted_vote" || kind === "weighted_vote_with_veto") && THRESHOLD_ARG.test(arg)) {
|
|
1355
1718
|
return { kind, params: { threshold: parseFloat(arg) } };
|
|
1356
1719
|
}
|
|
1357
1720
|
}
|
|
@@ -1436,6 +1799,23 @@ function registerDeliberation(coord) {
|
|
|
1436
1799
|
for (const f of ["from", "rule"]) {
|
|
1437
1800
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
1438
1801
|
}
|
|
1802
|
+
for (const name of ["to", "participants"]) {
|
|
1803
|
+
const v = p[name];
|
|
1804
|
+
if (v != null && typeof v !== "string" && !(Array.isArray(v) && v.every((x) => typeof x === "string"))) {
|
|
1805
|
+
return { error: rpcError(E.PARAMS, `${name} must be a string or a list of strings`) };
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
if (typeof p.rule !== "string") return { error: rpcError(E.PARAMS, "rule must be a string") };
|
|
1809
|
+
const bad = paramsError(p, [["weights", "object"], ["veto", "object"]]);
|
|
1810
|
+
if (bad) return bad;
|
|
1811
|
+
const weights = p.weights ?? {};
|
|
1812
|
+
if (!Object.values(weights).every((w) => typeof w === "number" && Number.isInteger(w))) {
|
|
1813
|
+
return { error: rpcError(E.PARAMS, "weights must map participants to integers") };
|
|
1814
|
+
}
|
|
1815
|
+
const veto = p.veto ?? {};
|
|
1816
|
+
if (!Object.values(veto).every((v) => typeof v === "boolean")) {
|
|
1817
|
+
return { error: rpcError(E.PARAMS, "veto must map participants to booleans") };
|
|
1818
|
+
}
|
|
1439
1819
|
const to = p.to ?? p.participants;
|
|
1440
1820
|
const participants = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
|
|
1441
1821
|
if (!participants.length) {
|
|
@@ -1447,6 +1827,18 @@ function registerDeliberation(coord) {
|
|
|
1447
1827
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1448
1828
|
return { error: rpcError(E.DELIB_UNKNOWN_RULE, msg) };
|
|
1449
1829
|
}
|
|
1830
|
+
const suppliedDeliberationId = p.deliberation_id;
|
|
1831
|
+
if (suppliedDeliberationId !== void 0 && suppliedDeliberationId !== null && suppliedDeliberationId !== "") {
|
|
1832
|
+
if (typeof suppliedDeliberationId !== "string") {
|
|
1833
|
+
return { error: rpcError(E.PARAMS, "deliberation_id must be a string") };
|
|
1834
|
+
}
|
|
1835
|
+
if (RESERVED_IDS.has(suppliedDeliberationId)) {
|
|
1836
|
+
return { error: rpcError(E.PARAMS, `deliberation_id cannot be ${JSON.stringify(suppliedDeliberationId)}`) };
|
|
1837
|
+
}
|
|
1838
|
+
if (ws.deliberations.has(suppliedDeliberationId)) {
|
|
1839
|
+
return { error: rpcError(E.PARAMS, `deliberation_id already in use: ${suppliedDeliberationId}`) };
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1450
1842
|
const id = p.deliberation_id || coord.ids.deliberationId();
|
|
1451
1843
|
const delib = {
|
|
1452
1844
|
id,
|
|
@@ -1474,6 +1866,8 @@ function registerDeliberation(coord) {
|
|
|
1474
1866
|
if (delib.state !== "open") {
|
|
1475
1867
|
return { error: rpcError(E.DELIB_CLOSED_OR_LAPSED, "Deliberation not open") };
|
|
1476
1868
|
}
|
|
1869
|
+
const bad = paramsError(p, [["comment", "string"], ["text", "string"]]);
|
|
1870
|
+
if (bad) return bad;
|
|
1477
1871
|
delib.comments.push({
|
|
1478
1872
|
ts: coord.now(),
|
|
1479
1873
|
from: p.from,
|
|
@@ -1496,6 +1890,8 @@ function registerDeliberation(coord) {
|
|
|
1496
1890
|
if (v !== "yea" && v !== "nay" && v !== "abstain") {
|
|
1497
1891
|
return { error: rpcError(E.PARAMS, "vote must be yea/nay/abstain") };
|
|
1498
1892
|
}
|
|
1893
|
+
const bad = paramsError(p, [["veto_invoked", "boolean"]]);
|
|
1894
|
+
if (bad) return bad;
|
|
1499
1895
|
const voter = p.from;
|
|
1500
1896
|
if (!delib.participants.includes(voter)) {
|
|
1501
1897
|
return { error: rpcError(
|
|
@@ -1529,6 +1925,7 @@ function registerDeliberation(coord) {
|
|
|
1529
1925
|
}
|
|
1530
1926
|
|
|
1531
1927
|
// src/profiles/handoff.ts
|
|
1928
|
+
var RESERVED_IDS2 = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1532
1929
|
function registerHandoff(coord) {
|
|
1533
1930
|
coord.handlers.set("handoff.propose", (p) => {
|
|
1534
1931
|
const ws = coord.workspaces.get(p.workspace);
|
|
@@ -1536,6 +1933,9 @@ function registerHandoff(coord) {
|
|
|
1536
1933
|
for (const f of ["from", "to", "tasks"]) {
|
|
1537
1934
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
1538
1935
|
}
|
|
1936
|
+
if (typeof p.to !== "string") return { error: rpcError(E.PARAMS, "to must be a string") };
|
|
1937
|
+
const bad = paramsError(p, [["context_links", "strings"]]);
|
|
1938
|
+
if (bad) return bad;
|
|
1539
1939
|
const proposer = p.from;
|
|
1540
1940
|
const recipient = p.to;
|
|
1541
1941
|
if (typeof recipient === "string" && !recipient.startsWith("group:")) {
|
|
@@ -1552,6 +1952,11 @@ function registerHandoff(coord) {
|
|
|
1552
1952
|
}
|
|
1553
1953
|
const proposed = [];
|
|
1554
1954
|
for (const t of tasksIn) {
|
|
1955
|
+
if (t === null || typeof t !== "object" || Array.isArray(t) || typeof t.task_id !== "string") {
|
|
1956
|
+
return { error: rpcError(E.PARAMS, "each entry in tasks must be an object with a string task_id") };
|
|
1957
|
+
}
|
|
1958
|
+
const badItem = paramsError(t, [["blockers", "strings"]]);
|
|
1959
|
+
if (badItem) return badItem;
|
|
1555
1960
|
const tid = t.task_id;
|
|
1556
1961
|
if (!tid || !ws.tasks.has(tid)) {
|
|
1557
1962
|
return { error: rpcError(E.PARAMS, `Unknown task in handoff: ${tid}`) };
|
|
@@ -1571,6 +1976,18 @@ function registerHandoff(coord) {
|
|
|
1571
1976
|
blockers: t.blockers ?? []
|
|
1572
1977
|
});
|
|
1573
1978
|
}
|
|
1979
|
+
const suppliedHandoffId = p.handoff_id;
|
|
1980
|
+
if (suppliedHandoffId !== void 0 && suppliedHandoffId !== null && suppliedHandoffId !== "") {
|
|
1981
|
+
if (typeof suppliedHandoffId !== "string") {
|
|
1982
|
+
return { error: rpcError(E.PARAMS, "handoff_id must be a string") };
|
|
1983
|
+
}
|
|
1984
|
+
if (RESERVED_IDS2.has(suppliedHandoffId)) {
|
|
1985
|
+
return { error: rpcError(E.PARAMS, `handoff_id cannot be ${JSON.stringify(suppliedHandoffId)}`) };
|
|
1986
|
+
}
|
|
1987
|
+
if (ws.handoffs.has(suppliedHandoffId)) {
|
|
1988
|
+
return { error: rpcError(E.PARAMS, `handoff_id already in use: ${suppliedHandoffId}`) };
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1574
1991
|
const id = p.handoff_id || coord.ids.handoffId();
|
|
1575
1992
|
const ho = {
|
|
1576
1993
|
id,
|
|
@@ -1621,6 +2038,8 @@ function registerHandoff(coord) {
|
|
|
1621
2038
|
if (ws.members.get(acceptor).paused) {
|
|
1622
2039
|
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Acceptor ${acceptor} is paused`) };
|
|
1623
2040
|
}
|
|
2041
|
+
const bad = paramsError(p, [["accepted_task_ids", "strings"]]);
|
|
2042
|
+
if (bad) return bad;
|
|
1624
2043
|
if (Array.isArray(p.accepted_task_ids) && p.accepted_task_ids.length === 0) {
|
|
1625
2044
|
return { error: rpcError(E.PARAMS, "accepted_task_ids must not be empty") };
|
|
1626
2045
|
}
|
|
@@ -1631,9 +2050,18 @@ function registerHandoff(coord) {
|
|
|
1631
2050
|
return { error: rpcError(E.PARAMS, `Task ${tid} not in this handoff`) };
|
|
1632
2051
|
}
|
|
1633
2052
|
}
|
|
2053
|
+
for (const tid of acceptedIds) {
|
|
2054
|
+
if (ws.tasks.get(tid)?.assignee !== ho.proposer) {
|
|
2055
|
+
return { error: rpcError(
|
|
2056
|
+
E.HANDOFF_TASKS_NOT_ASSIGNED_TO_PROPOSER,
|
|
2057
|
+
`Task ${tid} is not currently assigned to ${ho.proposer}`
|
|
2058
|
+
) };
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
1634
2061
|
const now = coord.now();
|
|
1635
2062
|
for (const tid of acceptedIds) {
|
|
1636
2063
|
const task = ws.tasks.get(tid);
|
|
2064
|
+
if (acceptor !== task.assignee) task.prior_authors = unionOf(task.prior_authors ?? [], heldBy(task, ho.proposer));
|
|
1637
2065
|
task.assignee = acceptor;
|
|
1638
2066
|
task.updated_at = now;
|
|
1639
2067
|
task.history.push({
|
|
@@ -1674,16 +2102,20 @@ function registerHandoff(coord) {
|
|
|
1674
2102
|
|
|
1675
2103
|
// src/profiles/routing.ts
|
|
1676
2104
|
var CRIT = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
1677
|
-
|
|
2105
|
+
var DECIMAL = /^-?[0-9]+(\.[0-9]+)?$/;
|
|
2106
|
+
function confidenceOf(hints) {
|
|
2107
|
+
const v = hints.confidence;
|
|
2108
|
+
if (typeof v === "number" && Number.isInteger(v)) return v;
|
|
2109
|
+
if (typeof v === "string" && DECIMAL.test(v)) return Number(v);
|
|
2110
|
+
return 1;
|
|
2111
|
+
}
|
|
2112
|
+
function criticalityOf(hints) {
|
|
1678
2113
|
const crit = hints.criticality ?? "medium";
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
}
|
|
1685
|
-
if (isNaN(conf)) conf = 1;
|
|
1686
|
-
const r = CRIT[crit] ?? 1;
|
|
2114
|
+
return typeof crit === "string" && Object.prototype.hasOwnProperty.call(CRIT, crit) ? CRIT[crit] : 1;
|
|
2115
|
+
}
|
|
2116
|
+
function defaultDepth(hints) {
|
|
2117
|
+
const conf = confidenceOf(hints);
|
|
2118
|
+
const r = criticalityOf(hints);
|
|
1687
2119
|
if (r >= 3) return { depth: "full", summary: "criticality=critical: full review" };
|
|
1688
2120
|
if (r === 2 && conf < 0.7) return { depth: "full", summary: "criticality=high and confidence<0.7: full review" };
|
|
1689
2121
|
if (r === 0 && conf >= 0.95) return { depth: "skip", summary: "very low criticality + very high confidence: skip" };
|
|
@@ -1749,6 +2181,8 @@ function registerRouting(coord) {
|
|
|
1749
2181
|
if (ws.members.get(selected).paused) {
|
|
1750
2182
|
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${selected} is paused`) };
|
|
1751
2183
|
}
|
|
2184
|
+
const leaving = heldBy(task, p.from);
|
|
2185
|
+
if (selected !== task.assignee && leaving.length) task.prior_authors = unionOf(task.prior_authors ?? [], leaving);
|
|
1752
2186
|
task.assignee = selected;
|
|
1753
2187
|
task.updated_at = coord.now();
|
|
1754
2188
|
task.history.push({
|
|
@@ -1779,6 +2213,8 @@ function registerRouting(coord) {
|
|
|
1779
2213
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1780
2214
|
const task = ws.tasks.get(p.task_id);
|
|
1781
2215
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2216
|
+
const bad = paramsError(p, [["artefact_routing_hints", "object"]]);
|
|
2217
|
+
if (bad) return bad;
|
|
1782
2218
|
const hints = {
|
|
1783
2219
|
...task.routing_hints ?? {},
|
|
1784
2220
|
...p.artefact_routing_hints ?? {}
|
|
@@ -1836,6 +2272,8 @@ function registerRouting(coord) {
|
|
|
1836
2272
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1837
2273
|
const task = ws.tasks.get(p.task_id);
|
|
1838
2274
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2275
|
+
const bad = paramsError(p, [["default_escalation_target", "string"]]);
|
|
2276
|
+
if (bad) return bad;
|
|
1839
2277
|
const hints = { ...task.routing_hints ?? {} };
|
|
1840
2278
|
let escalate, to, rule;
|
|
1841
2279
|
if (coord.options.escalationPolicy) {
|
|
@@ -1849,15 +2287,8 @@ function registerRouting(coord) {
|
|
|
1849
2287
|
return { error: rpcError(E.ROUTING_POLICY_UNREACHABLE, `escalation policy error: ${msg}`) };
|
|
1850
2288
|
}
|
|
1851
2289
|
} else {
|
|
1852
|
-
const
|
|
1853
|
-
|
|
1854
|
-
try {
|
|
1855
|
-
conf = Number(hints.confidence ?? 1);
|
|
1856
|
-
} catch {
|
|
1857
|
-
conf = 1;
|
|
1858
|
-
}
|
|
1859
|
-
if (isNaN(conf)) conf = 1;
|
|
1860
|
-
const r = CRIT[crit] ?? 1;
|
|
2290
|
+
const conf = confidenceOf(hints);
|
|
2291
|
+
const r = criticalityOf(hints);
|
|
1861
2292
|
escalate = r >= 3 || r >= 2 && conf < 0.6;
|
|
1862
2293
|
to = p.default_escalation_target;
|
|
1863
2294
|
rule = {
|
|
@@ -1914,6 +2345,9 @@ function registerSecuritySigned(coord) {
|
|
|
1914
2345
|
for (const f of ["from", "old_kid", "new_jwk"]) {
|
|
1915
2346
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
1916
2347
|
}
|
|
2348
|
+
for (const f of ["from", "old_kid"]) {
|
|
2349
|
+
if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
|
|
2350
|
+
}
|
|
1917
2351
|
const member = ws.members.get(p.from);
|
|
1918
2352
|
if (!member) return { error: rpcError(E.SIG_KEY_NOT_FOUND, `Unknown participant: ${p.from}`) };
|
|
1919
2353
|
const oldKid = p.old_kid;
|
|
@@ -1924,6 +2358,9 @@ function registerSecuritySigned(coord) {
|
|
|
1924
2358
|
if (!newJwk || typeof newJwk.kid !== "string") {
|
|
1925
2359
|
return { error: rpcError(E.PARAMS, "new_jwk must include kid") };
|
|
1926
2360
|
}
|
|
2361
|
+
if (member.keys.some((k) => k.kid === newJwk.kid)) {
|
|
2362
|
+
return { error: rpcError(E.PARAMS, `${p.from} already holds a key ${newJwk.kid}; a new key takes a kid of its own`) };
|
|
2363
|
+
}
|
|
1927
2364
|
const now = coord.now();
|
|
1928
2365
|
oldKey.valid_until = now;
|
|
1929
2366
|
member.keys.push({
|
|
@@ -1939,6 +2376,11 @@ function registerSecuritySigned(coord) {
|
|
|
1939
2376
|
for (const f of ["target_uri", "kid"]) {
|
|
1940
2377
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
1941
2378
|
}
|
|
2379
|
+
for (const f of ["target_uri", "kid"]) {
|
|
2380
|
+
if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
|
|
2381
|
+
}
|
|
2382
|
+
const bad = paramsError(p, [["from", "string"], ["reason", "string"]]);
|
|
2383
|
+
if (bad) return bad;
|
|
1942
2384
|
const sender = p.from;
|
|
1943
2385
|
if (sender !== p.target_uri) {
|
|
1944
2386
|
const caller = sender ? ws.members.get(sender) : void 0;
|
|
@@ -1961,6 +2403,7 @@ function registerSecuritySigned(coord) {
|
|
|
1961
2403
|
}
|
|
1962
2404
|
|
|
1963
2405
|
// src/profiles/whisper.ts
|
|
2406
|
+
var RESERVED_IDS3 = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1964
2407
|
function parseIso(ts) {
|
|
1965
2408
|
return new Date(ts).getTime();
|
|
1966
2409
|
}
|
|
@@ -1980,11 +2423,25 @@ function registerWhisper(coord) {
|
|
|
1980
2423
|
if (!ws.tasks.has(p.task_id)) {
|
|
1981
2424
|
return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
1982
2425
|
}
|
|
1983
|
-
|
|
1984
|
-
|
|
2426
|
+
const bad = paramsError(p, [["options", "list"], ["urgency", "string"]]);
|
|
2427
|
+
if (bad) return bad;
|
|
2428
|
+
if (typeof p.deadline_ms !== "number" || !Number.isInteger(p.deadline_ms)) {
|
|
2429
|
+
return { error: rpcError(E.PARAMS, "deadline_ms must be an integer") };
|
|
1985
2430
|
}
|
|
1986
2431
|
const to = p.to;
|
|
1987
2432
|
const askee = Array.isArray(to) ? to : [to];
|
|
2433
|
+
const suppliedWhisperId = p.whisper_id;
|
|
2434
|
+
if (suppliedWhisperId !== void 0 && suppliedWhisperId !== null && suppliedWhisperId !== "") {
|
|
2435
|
+
if (typeof suppliedWhisperId !== "string") {
|
|
2436
|
+
return { error: rpcError(E.PARAMS, "whisper_id must be a string") };
|
|
2437
|
+
}
|
|
2438
|
+
if (RESERVED_IDS3.has(suppliedWhisperId)) {
|
|
2439
|
+
return { error: rpcError(E.PARAMS, `whisper_id cannot be ${JSON.stringify(suppliedWhisperId)}`) };
|
|
2440
|
+
}
|
|
2441
|
+
if (ws.whispers.has(suppliedWhisperId)) {
|
|
2442
|
+
return { error: rpcError(E.PARAMS, `whisper_id already in use: ${suppliedWhisperId}`) };
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
1988
2445
|
const id = p.whisper_id || coord.ids.artefactId();
|
|
1989
2446
|
const prompt = {
|
|
1990
2447
|
id,
|
|
@@ -2032,7 +2489,7 @@ function registerWhisper(coord) {
|
|
|
2032
2489
|
if (!hasOption) {
|
|
2033
2490
|
return { error: rpcError(E.PARAMS, "answer_option is required when options are defined") };
|
|
2034
2491
|
}
|
|
2035
|
-
const valid = new Set(prompt.options.map((o) => o.id));
|
|
2492
|
+
const valid = new Set(prompt.options.filter((o) => o !== null && typeof o === "object" && !Array.isArray(o)).map((o) => o.id));
|
|
2036
2493
|
if (!valid.has(answerOption)) {
|
|
2037
2494
|
return { error: rpcError(
|
|
2038
2495
|
E.WHISPER_OPTION_NOT_IN_SET,
|
|
@@ -2050,12 +2507,13 @@ function registerWhisper(coord) {
|
|
|
2050
2507
|
prompt.comment = p.comment;
|
|
2051
2508
|
return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
|
|
2052
2509
|
});
|
|
2053
|
-
coord.checkWhisperLapses = (workspaceId, now) =>
|
|
2510
|
+
coord.checkWhisperLapses = (workspaceId, now) => coord.outsideCall(workspaceId, () => lapse(workspaceId, now));
|
|
2511
|
+
function lapse(workspaceId, now) {
|
|
2054
2512
|
const ws = coord.workspaces.get(workspaceId);
|
|
2055
2513
|
if (!ws) return [];
|
|
2056
2514
|
const cutoff = parseIso(now ?? coord.now());
|
|
2057
2515
|
const emitted = [];
|
|
2058
|
-
for (const prompt of ws.whispers
|
|
2516
|
+
for (const prompt of rawValues(ws.whispers)) {
|
|
2059
2517
|
if (prompt.state !== "pending") continue;
|
|
2060
2518
|
const asked = parseIso(prompt.asked_at);
|
|
2061
2519
|
const deadline = asked + prompt.deadline_ms;
|
|
@@ -2085,7 +2543,7 @@ function registerWhisper(coord) {
|
|
|
2085
2543
|
emitted.push(notify);
|
|
2086
2544
|
}
|
|
2087
2545
|
return emitted;
|
|
2088
|
-
}
|
|
2546
|
+
}
|
|
2089
2547
|
}
|
|
2090
2548
|
|
|
2091
2549
|
// src/coordinator.ts
|
|
@@ -2098,6 +2556,7 @@ var PRIVILEGED_METHODS = /* @__PURE__ */ new Set([
|
|
|
2098
2556
|
"control.rollback",
|
|
2099
2557
|
"control.set_mode_ceiling",
|
|
2100
2558
|
"workspace.set_profiles",
|
|
2559
|
+
"workspace.invite",
|
|
2101
2560
|
"participant.rotate_key",
|
|
2102
2561
|
"participant.revoke_key"
|
|
2103
2562
|
]);
|
|
@@ -2141,12 +2600,9 @@ function missing(params, fields) {
|
|
|
2141
2600
|
for (const f of fields) if (!(f in params)) return f;
|
|
2142
2601
|
return null;
|
|
2143
2602
|
}
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
return { error: rpcError(E.PARAMS, "tags must be a list of strings") };
|
|
2148
|
-
}
|
|
2149
|
-
return null;
|
|
2603
|
+
var MODES = /* @__PURE__ */ new Set(["shadow", "trial", "production"]);
|
|
2604
|
+
function isObject(value) {
|
|
2605
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2150
2606
|
}
|
|
2151
2607
|
function reviewRuleSupported(rule) {
|
|
2152
2608
|
if (rule === "any_one_approves" || rule === "all_approve") return true;
|
|
@@ -2167,8 +2623,214 @@ function reviewSatisfied(review) {
|
|
|
2167
2623
|
if (rule.startsWith("quorum:")) return approvers.size >= parseInt(rule.slice("quorum:".length), 10);
|
|
2168
2624
|
return approvers.size >= 1;
|
|
2169
2625
|
}
|
|
2626
|
+
function advertises(ws, name) {
|
|
2627
|
+
return ws.profiles.some((p) => p === name || p.startsWith(name + "/"));
|
|
2628
|
+
}
|
|
2629
|
+
function knownProfile(id) {
|
|
2630
|
+
const m = /^([a-z][a-z0-9-]*)\/(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(id);
|
|
2631
|
+
if (!m || !Object.prototype.hasOwnProperty.call(PROFILE_VERSIONS, m[1])) return false;
|
|
2632
|
+
return PROFILE_VERSIONS[m[1]].split(".")[0] === m[2];
|
|
2633
|
+
}
|
|
2634
|
+
var LASTING_PROFILES = ["security-signed", "identity-oidc", "identity-vc"];
|
|
2635
|
+
function profilesError(profiles) {
|
|
2636
|
+
const unknown = profiles.find((x) => !knownProfile(x));
|
|
2637
|
+
if (unknown === void 0) return null;
|
|
2638
|
+
return { error: rpcError(
|
|
2639
|
+
E.PARAMS,
|
|
2640
|
+
`Unknown profile ${JSON.stringify(unknown)}: a profile id is <name>/<major>.<minor> for a profile the catalogue lists`
|
|
2641
|
+
) };
|
|
2642
|
+
}
|
|
2643
|
+
var RESERVED_KEYS = ["__proto__", "constructor", "prototype"];
|
|
2644
|
+
var PARTICIPANT_TYPES = ["human", "agent", "service", "group", "workspace"];
|
|
2645
|
+
var ADMIN_TYPES = ["human", "service"];
|
|
2646
|
+
function participantError(type, uri, field) {
|
|
2647
|
+
if (!PARTICIPANT_TYPES.includes(type)) {
|
|
2648
|
+
return { error: rpcError(E.PARAMS, "type must be human, agent, service, group or workspace") };
|
|
2649
|
+
}
|
|
2650
|
+
if (!uri.startsWith(`${type}:`)) {
|
|
2651
|
+
return { error: rpcError(E.PARAMS, `${field} must start with "${type}:" for type ${type}`) };
|
|
2652
|
+
}
|
|
2653
|
+
if (type === "group" || type === "workspace") {
|
|
2654
|
+
return { error: rpcError(E.PARAMS, "group: and workspace: URIs name address scopes, which cannot join") };
|
|
2655
|
+
}
|
|
2656
|
+
return null;
|
|
2657
|
+
}
|
|
2658
|
+
function inForce(key, now) {
|
|
2659
|
+
return !(key.revoked_at !== void 0 && now >= key.revoked_at) && !(key.valid_until !== void 0 && now >= key.valid_until);
|
|
2660
|
+
}
|
|
2661
|
+
function sameKey(a, b) {
|
|
2662
|
+
const material = (k) => jwkThumbprint(k) ?? canonicalize(k).toString("utf-8");
|
|
2663
|
+
return a.kid === b.kid && material(a) === material(b);
|
|
2664
|
+
}
|
|
2665
|
+
function credentialKeys(cnf, nonce, joinKeys) {
|
|
2666
|
+
if (isObject(cnf)) {
|
|
2667
|
+
const kid = typeof cnf.kid === "string" && cnf.kid !== "" ? cnf.kid : jwkThumbprint(cnf);
|
|
2668
|
+
if (kid === void 0) return null;
|
|
2669
|
+
const key = { ...cnf, kid };
|
|
2670
|
+
return joinKeys.every((j) => sameKey(key, j)) ? [key] : null;
|
|
2671
|
+
}
|
|
2672
|
+
if (typeof nonce === "string") return joinKeys.every((j) => jwkThumbprint(j) === nonce) ? joinKeys : null;
|
|
2673
|
+
return void 0;
|
|
2674
|
+
}
|
|
2675
|
+
function rawInvitation(ws, uri) {
|
|
2676
|
+
return Map.prototype.get.call(ws.invitations, uri);
|
|
2677
|
+
}
|
|
2678
|
+
function awaitsFirstMember(ws) {
|
|
2679
|
+
if (ws.members.size > 0) return false;
|
|
2680
|
+
return !ws.audit.some((e) => e.outcome == null && entryCall(e)?.method === "participant.join");
|
|
2681
|
+
}
|
|
2682
|
+
function signingKeys(jwks) {
|
|
2683
|
+
const keys = isObject(jwks) && Array.isArray(jwks.keys) ? jwks.keys : [];
|
|
2684
|
+
return keys.filter((j) => isObject(j) && typeof j.kid === "string" && j.kid !== "");
|
|
2685
|
+
}
|
|
2686
|
+
function recordedForm(envelope) {
|
|
2687
|
+
if (envelope.method !== "participant.join" || !isObject(envelope.params)) return envelope;
|
|
2688
|
+
const params = { ...envelope.params };
|
|
2689
|
+
if (typeof params.oidc_token === "string") params.oidc_token = sha256Hex(params.oidc_token);
|
|
2690
|
+
if (isObject(params.vc_presentation)) params.vc_presentation = contentHash(params.vc_presentation);
|
|
2691
|
+
return { ...envelope, params };
|
|
2692
|
+
}
|
|
2693
|
+
function storeFailed() {
|
|
2694
|
+
return rpcError(E.INTERNAL, "Internal error", { reason: "store" });
|
|
2695
|
+
}
|
|
2696
|
+
function targetOf(envelope) {
|
|
2697
|
+
const params = isObject(envelope) ? envelope.params : void 0;
|
|
2698
|
+
const id = isObject(params) ? params.workspace : void 0;
|
|
2699
|
+
return typeof id === "string" && id !== "" ? id : void 0;
|
|
2700
|
+
}
|
|
2701
|
+
var KEPT_NULLS = { whispers: ["default_if_lapsed"] };
|
|
2702
|
+
function dropNulls(o, keep = []) {
|
|
2703
|
+
for (const key of Object.keys(o)) if (o[key] === null && !keep.includes(key)) delete o[key];
|
|
2704
|
+
}
|
|
2705
|
+
function shapeObject(name, item) {
|
|
2706
|
+
if (name === "route_decisions") {
|
|
2707
|
+
const extra = item.extra;
|
|
2708
|
+
delete item.extra;
|
|
2709
|
+
if (isObject(extra)) Object.assign(item, extra);
|
|
2710
|
+
item.kind = "route_decision";
|
|
2711
|
+
}
|
|
2712
|
+
dropNulls(item, KEPT_NULLS[name]);
|
|
2713
|
+
const each = (o, field, f) => {
|
|
2714
|
+
if (!Array.isArray(o[field])) return;
|
|
2715
|
+
for (const x of o[field]) {
|
|
2716
|
+
if (isObject(x)) {
|
|
2717
|
+
dropNulls(x);
|
|
2718
|
+
f?.(x);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
};
|
|
2722
|
+
if (name === "members") each(item, "keys");
|
|
2723
|
+
if (name === "tasks") {
|
|
2724
|
+
each(item, "history", (h) => {
|
|
2725
|
+
if (h.from === void 0 && typeof h.from_ === "string") h.from = h.from_;
|
|
2726
|
+
delete h.from_;
|
|
2727
|
+
});
|
|
2728
|
+
if (isObject(item.review)) {
|
|
2729
|
+
dropNulls(item.review);
|
|
2730
|
+
each(item.review, "decisions");
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
if (name === "deliberations") each(item, "votes");
|
|
2734
|
+
if (name === "handoffs") {
|
|
2735
|
+
each(item, "tasks");
|
|
2736
|
+
if (Array.isArray(item.accepted_task_ids) && item.accepted_task_ids.length === 0) delete item.accepted_task_ids;
|
|
2737
|
+
}
|
|
2738
|
+
return item;
|
|
2739
|
+
}
|
|
2740
|
+
function storedCopy(name, item) {
|
|
2741
|
+
return shapeObject(name, structuredClone(item));
|
|
2742
|
+
}
|
|
2743
|
+
function shapeSnapshot(w) {
|
|
2744
|
+
const out = { ...w };
|
|
2745
|
+
dropNulls(out);
|
|
2746
|
+
for (const name of COLLECTIONS) {
|
|
2747
|
+
if (Array.isArray(out[name])) {
|
|
2748
|
+
out[name] = out[name].map((x) => isObject(x) ? storedCopy(name, x) : x);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
return out;
|
|
2752
|
+
}
|
|
2753
|
+
function reviewRequired(ws, task) {
|
|
2754
|
+
return task.review_required === true || task.mode === "trial" && advertises(ws, "modes");
|
|
2755
|
+
}
|
|
2756
|
+
function rawMember(ws, uri) {
|
|
2757
|
+
return typeof uri === "string" ? Map.prototype.get.call(ws.members, uri) : void 0;
|
|
2758
|
+
}
|
|
2759
|
+
function unionOf(...lists) {
|
|
2760
|
+
const out = [];
|
|
2761
|
+
for (const list of lists) for (const uri of list) if (!out.includes(uri)) out.push(uri);
|
|
2762
|
+
return out;
|
|
2763
|
+
}
|
|
2764
|
+
function authorsOf(task, completer) {
|
|
2765
|
+
const authors = [task.assignee];
|
|
2766
|
+
if (typeof completer === "string" && completer !== task.assignee) authors.push(completer);
|
|
2767
|
+
return unionOf(authors, Array.isArray(task.prior_authors) ? task.prior_authors : []);
|
|
2768
|
+
}
|
|
2769
|
+
function stateBefore(history, i) {
|
|
2770
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
2771
|
+
if (history[j].state !== "paused") return history[j].state;
|
|
2772
|
+
}
|
|
2773
|
+
return void 0;
|
|
2774
|
+
}
|
|
2775
|
+
function workCompletion(history, i) {
|
|
2776
|
+
if (history[i].state !== "completed") return false;
|
|
2777
|
+
const before = stateBefore(history, i);
|
|
2778
|
+
return before !== "review_requested" && before !== "completed";
|
|
2779
|
+
}
|
|
2780
|
+
function lastCompleter(task) {
|
|
2781
|
+
for (let i = task.history.length - 1; i >= 0; i--) {
|
|
2782
|
+
if (workCompletion(task.history, i)) return task.history[i].from;
|
|
2783
|
+
}
|
|
2784
|
+
return void 0;
|
|
2785
|
+
}
|
|
2786
|
+
var OPENED_ON_COMPLETE = "review required; opened on task.complete";
|
|
2787
|
+
var NEW_ROUND_ON_COMPLETE = "a new round of the review; opened on task.complete";
|
|
2788
|
+
function storedAuthors(task) {
|
|
2789
|
+
let requester;
|
|
2790
|
+
const withRequester = (authors) => unionOf(authors, requester !== void 0 ? [requester] : []);
|
|
2791
|
+
for (let i = task.history.length - 1; i >= 0; i--) {
|
|
2792
|
+
const h = task.history[i];
|
|
2793
|
+
const opens = h.state === "review_requested" && stateBefore(task.history, i) !== "review_requested";
|
|
2794
|
+
if (opens && (h.note === OPENED_ON_COMPLETE || h.note === NEW_ROUND_ON_COMPLETE)) return withRequester(authorsOf(task, h.from));
|
|
2795
|
+
if (opens && requester === void 0) requester = h.from;
|
|
2796
|
+
if (workCompletion(task.history, i)) return withRequester(authorsOf(task, h.from));
|
|
2797
|
+
}
|
|
2798
|
+
return withRequester(authorsOf(task, void 0));
|
|
2799
|
+
}
|
|
2800
|
+
var ENDED = ["completed", "declined", "cancelled", "superseded", "escalated"];
|
|
2801
|
+
function reviewHolds(ws, task) {
|
|
2802
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2803
|
+
for (let t = task; t && !seen.has(t.id); t = typeof t.supersedes === "string" ? ws.tasks.get(t.supersedes) : void 0) {
|
|
2804
|
+
seen.add(t.id);
|
|
2805
|
+
if (!reviewRequired(ws, t)) return [];
|
|
2806
|
+
if (t.review) {
|
|
2807
|
+
const decided = t.review.decisions.some((d) => d.kind === "reject" || d.kind === "override") || reviewSatisfied(t.review);
|
|
2808
|
+
return decided ? [] : t.review.authors;
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
return [];
|
|
2812
|
+
}
|
|
2813
|
+
function openRequiredReviewAuthors(ws, task) {
|
|
2814
|
+
return ENDED.includes(task.state) ? [] : reviewHolds(ws, task);
|
|
2815
|
+
}
|
|
2816
|
+
function heldBy(task, mover) {
|
|
2817
|
+
return mover === task.assignee ? [task.assignee] : [];
|
|
2818
|
+
}
|
|
2819
|
+
function successorAuthors(old, mover) {
|
|
2820
|
+
const completer = lastCompleter(old);
|
|
2821
|
+
return unionOf(
|
|
2822
|
+
old.review ? old.review.authors : [],
|
|
2823
|
+
heldBy(old, mover),
|
|
2824
|
+
completer !== void 0 ? [completer] : [],
|
|
2825
|
+
Array.isArray(old.prior_authors) ? old.prior_authors : []
|
|
2826
|
+
);
|
|
2827
|
+
}
|
|
2828
|
+
function isScope(uri) {
|
|
2829
|
+
return typeof uri === "string" && (uri.startsWith("workspace:") || uri.startsWith("group:"));
|
|
2830
|
+
}
|
|
2170
2831
|
function reply(env, body) {
|
|
2171
|
-
const
|
|
2832
|
+
const sent = env !== null && typeof env === "object" && !Array.isArray(env) ? env.id : void 0;
|
|
2833
|
+
const out = { jsonrpc: "2.0", id: sent ?? null };
|
|
2172
2834
|
if (body.error !== void 0) out.error = body.error;
|
|
2173
2835
|
else out.result = body.result;
|
|
2174
2836
|
return out;
|
|
@@ -2183,7 +2845,7 @@ function indexSigned(index, entry) {
|
|
|
2183
2845
|
return;
|
|
2184
2846
|
}
|
|
2185
2847
|
if (entry.outcome != null) index.refused.set(digest, { seq: entry.seq, code: entry.outcome.code });
|
|
2186
|
-
else index.accepted.
|
|
2848
|
+
else if (!index.accepted.has(digest)) index.accepted.set(digest, entry.seq);
|
|
2187
2849
|
}
|
|
2188
2850
|
function isThenable(value) {
|
|
2189
2851
|
return typeof value?.then === "function";
|
|
@@ -2211,13 +2873,21 @@ var Coordinator = class {
|
|
|
2211
2873
|
unflushed = /* @__PURE__ */ new Set();
|
|
2212
2874
|
/** Workspaces with a write queued behind an async write in flight. */
|
|
2213
2875
|
flushQueued = /* @__PURE__ */ new Set();
|
|
2876
|
+
/**
|
|
2877
|
+
* Workspaces whose store copy may differ from memory after a synchronous
|
|
2878
|
+
* write failed or conflicted. Each is reloaded from the store before the
|
|
2879
|
+
* next call on it, which is refused while the reload fails.
|
|
2880
|
+
*/
|
|
2881
|
+
held = /* @__PURE__ */ new Set();
|
|
2882
|
+
/** Entries appended and not yet written, which the audit listeners hear of once they are. */
|
|
2883
|
+
unannounced = /* @__PURE__ */ new WeakMap();
|
|
2214
2884
|
clockMs;
|
|
2215
2885
|
frozenNow;
|
|
2216
2886
|
auditListeners = [];
|
|
2217
2887
|
/**
|
|
2218
2888
|
* The signed calls on each workspace's log, by what their senders signed:
|
|
2219
|
-
* recorded refusals with their seq and code, and accepted calls
|
|
2220
|
-
* from the log when missing, so a snapshot never carries it.
|
|
2889
|
+
* recorded refusals with their seq and code, and accepted calls with their
|
|
2890
|
+
* seq. Rebuilt from the log when missing, so a snapshot never carries it.
|
|
2221
2891
|
*/
|
|
2222
2892
|
signedIndexes = /* @__PURE__ */ new WeakMap();
|
|
2223
2893
|
/** Method handler registry; profiles plug into this. */
|
|
@@ -2353,6 +3023,7 @@ var Coordinator = class {
|
|
|
2353
3023
|
change = this.takeChange(ws, p);
|
|
2354
3024
|
} catch (err) {
|
|
2355
3025
|
p.replace = true;
|
|
3026
|
+
if (this.options.storeErrors !== "log") this.held.add(ws.id);
|
|
2356
3027
|
this.reportStoreError(err, { id: ws.id, version: ws.audit.length });
|
|
2357
3028
|
return;
|
|
2358
3029
|
}
|
|
@@ -2361,6 +3032,7 @@ var Coordinator = class {
|
|
|
2361
3032
|
const done = () => {
|
|
2362
3033
|
p.seq = taken.version;
|
|
2363
3034
|
if (taken.replace) p.replace = false;
|
|
3035
|
+
this.announce(ws, taken.version);
|
|
2364
3036
|
};
|
|
2365
3037
|
const failed = (err) => {
|
|
2366
3038
|
this.giveBack(ws, p, taken);
|
|
@@ -2370,13 +3042,34 @@ var Coordinator = class {
|
|
|
2370
3042
|
try {
|
|
2371
3043
|
result = this.store.apply(taken);
|
|
2372
3044
|
} catch (err) {
|
|
3045
|
+
if (err instanceof StoreConflictError || this.options.storeErrors !== "log") this.held.add(ws.id);
|
|
2373
3046
|
failed(err);
|
|
2374
3047
|
return;
|
|
2375
3048
|
}
|
|
2376
3049
|
if (isThenable(result)) return Promise.resolve(result).then(done, failed);
|
|
2377
3050
|
done();
|
|
2378
3051
|
}
|
|
2379
|
-
/**
|
|
3052
|
+
/** Tell the audit listeners of the entries now written, up to `upTo`. */
|
|
3053
|
+
announce(ws, upTo) {
|
|
3054
|
+
const queued = this.unannounced.get(ws);
|
|
3055
|
+
if (!queued?.length) return;
|
|
3056
|
+
const now = queued.filter((e) => e.seq < upTo);
|
|
3057
|
+
this.unannounced.set(ws, queued.filter((e) => e.seq >= upTo));
|
|
3058
|
+
for (const entry of now) {
|
|
3059
|
+
for (const l of this.auditListeners) {
|
|
3060
|
+
try {
|
|
3061
|
+
l(ws, entry);
|
|
3062
|
+
} catch {
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* The change since the last write, taking the recorded keys with it. Each
|
|
3069
|
+
* object is written as a copy in the shape a restore gives back, so a
|
|
3070
|
+
* coordinator restarted on the store holds what this one holds, and the
|
|
3071
|
+
* workspace's own objects are left as they are.
|
|
3072
|
+
*/
|
|
2380
3073
|
takeChange(ws, p) {
|
|
2381
3074
|
const replace = p.replace;
|
|
2382
3075
|
const objects = {};
|
|
@@ -2385,12 +3078,15 @@ var Coordinator = class {
|
|
|
2385
3078
|
if (replace) {
|
|
2386
3079
|
const all = {};
|
|
2387
3080
|
for (const [key, value] of Map.prototype.entries.call(map)) {
|
|
2388
|
-
all[key] = value;
|
|
3081
|
+
all[key] = storedCopy(name, value);
|
|
2389
3082
|
}
|
|
2390
3083
|
objects[name] = all;
|
|
2391
3084
|
} else if (map.dirty.size) {
|
|
2392
3085
|
const changed = {};
|
|
2393
|
-
for (const key of map.dirty)
|
|
3086
|
+
for (const key of map.dirty) {
|
|
3087
|
+
const value = map.peek(key);
|
|
3088
|
+
changed[key] = value === void 0 ? null : storedCopy(name, value);
|
|
3089
|
+
}
|
|
2394
3090
|
objects[name] = changed;
|
|
2395
3091
|
}
|
|
2396
3092
|
map.dirty.clear();
|
|
@@ -2442,24 +3138,28 @@ var Coordinator = class {
|
|
|
2442
3138
|
this.reportStoreError(err, { id: ws.id, version: next });
|
|
2443
3139
|
return;
|
|
2444
3140
|
}
|
|
3141
|
+
const upTo = ws.audit.length;
|
|
2445
3142
|
const pending = this.saveChains.get(ws.id);
|
|
2446
3143
|
if (pending) {
|
|
2447
|
-
this.trackSave(pending.then(() => this.store.save(record)), record);
|
|
3144
|
+
this.trackSave(pending.then(() => this.store.save(record)), record, () => this.announce(ws, upTo));
|
|
2448
3145
|
return;
|
|
2449
3146
|
}
|
|
2450
3147
|
let result;
|
|
2451
3148
|
try {
|
|
2452
3149
|
result = this.store.save(record);
|
|
2453
3150
|
} catch (err) {
|
|
3151
|
+
if (this.options.storeErrors !== "log") this.held.add(ws.id);
|
|
2454
3152
|
this.reportStoreError(err, record);
|
|
2455
3153
|
return;
|
|
2456
3154
|
}
|
|
2457
3155
|
if (isThenable(result)) {
|
|
2458
|
-
this.trackSave(Promise.resolve(result), record);
|
|
3156
|
+
this.trackSave(Promise.resolve(result), record, () => this.announce(ws, upTo));
|
|
3157
|
+
return;
|
|
2459
3158
|
}
|
|
3159
|
+
this.announce(ws, upTo);
|
|
2460
3160
|
}
|
|
2461
|
-
trackSave(save, record) {
|
|
2462
|
-
const guarded = save.
|
|
3161
|
+
trackSave(save, record, saved) {
|
|
3162
|
+
const guarded = save.then(saved, (err) => this.reportStoreError(err, record));
|
|
2463
3163
|
this.saveChains.set(record.id, guarded);
|
|
2464
3164
|
void guarded.then(() => {
|
|
2465
3165
|
if (this.saveChains.get(record.id) === guarded) {
|
|
@@ -2467,6 +3167,17 @@ var Coordinator = class {
|
|
|
2467
3167
|
}
|
|
2468
3168
|
});
|
|
2469
3169
|
}
|
|
3170
|
+
reportInternalError(err, method, workspace) {
|
|
3171
|
+
const call2 = { method, ...workspace !== void 0 ? { workspace } : {} };
|
|
3172
|
+
if (this.options.onInternalError) {
|
|
3173
|
+
try {
|
|
3174
|
+
this.options.onInternalError(err, call2);
|
|
3175
|
+
} catch {
|
|
3176
|
+
}
|
|
3177
|
+
return;
|
|
3178
|
+
}
|
|
3179
|
+
console.error(`chap coordinator: ${method} failed with an internal error:`, err);
|
|
3180
|
+
}
|
|
2470
3181
|
reportStoreError(err, record) {
|
|
2471
3182
|
if (this.options.onStoreError) {
|
|
2472
3183
|
try {
|
|
@@ -2476,7 +3187,9 @@ var Coordinator = class {
|
|
|
2476
3187
|
return;
|
|
2477
3188
|
}
|
|
2478
3189
|
const message = err instanceof Error ? err.message : String(err);
|
|
2479
|
-
|
|
3190
|
+
const line = `chap coordinator: writing workspace ${record.id} to the store failed: ${message}`;
|
|
3191
|
+
if (err instanceof StoreConflictError) console.warn(line);
|
|
3192
|
+
else console.error(line);
|
|
2480
3193
|
}
|
|
2481
3194
|
async drainSaves() {
|
|
2482
3195
|
while (this.saveChains.size) {
|
|
@@ -2527,16 +3240,25 @@ var Coordinator = class {
|
|
|
2527
3240
|
return null;
|
|
2528
3241
|
}
|
|
2529
3242
|
/**
|
|
2530
|
-
* Assert that `sender`
|
|
2531
|
-
* review-decision methods (decide.* and abstain.declare)
|
|
2532
|
-
* review
|
|
2533
|
-
*
|
|
2534
|
-
*
|
|
2535
|
-
*
|
|
3243
|
+
* Assert that `sender` may decide the task's open review. Applied to
|
|
3244
|
+
* review-decision methods (decide.* and abstain.declare) and to
|
|
3245
|
+
* review.acknowledge: to act on a review you must not be an author of the
|
|
3246
|
+
* work under review, must be a human member where the task requires
|
|
3247
|
+
* review, and must be one of the reviewers it was addressed to (the `to`
|
|
3248
|
+
* set on review.request).
|
|
3249
|
+
* Membership is the floor; this is the eligibility ceiling for decisions.
|
|
3250
|
+
* Returns an error fragment if not eligible, else null.
|
|
2536
3251
|
*/
|
|
2537
|
-
requireReviewer(task, sender) {
|
|
3252
|
+
requireReviewer(ws, task, sender) {
|
|
2538
3253
|
const review = task.review;
|
|
2539
|
-
if (!review
|
|
3254
|
+
if (!review) return null;
|
|
3255
|
+
if (typeof sender === "string" && review.authors.includes(sender)) {
|
|
3256
|
+
return { error: rpcError(E.NOT_AUTHORISED, "The author of the work under review cannot decide it") };
|
|
3257
|
+
}
|
|
3258
|
+
if (reviewRequired(ws, task) && rawMember(ws, sender)?.type !== "human") {
|
|
3259
|
+
return { error: rpcError(E.NOT_AUTHORISED, `This task requires a person's review: ${String(sender)} is not a human member`) };
|
|
3260
|
+
}
|
|
3261
|
+
if (!review.requested_to || review.requested_to.length === 0) {
|
|
2540
3262
|
return null;
|
|
2541
3263
|
}
|
|
2542
3264
|
if (review.requested_to.some((r) => typeof r === "string" && (r.startsWith("workspace:") || r.startsWith("group:")))) {
|
|
@@ -2565,23 +3287,31 @@ var Coordinator = class {
|
|
|
2565
3287
|
routing_policy_uri: ws.routing_policy_uri,
|
|
2566
3288
|
step_up_window_sec: ws.step_up_window_sec,
|
|
2567
3289
|
min_acr: ws.min_acr,
|
|
3290
|
+
signature_policy: ws.signature_policy,
|
|
3291
|
+
join_policy: ws.join_policy,
|
|
3292
|
+
review_rule: ws.review_rule,
|
|
2568
3293
|
chain_head: ws.chain_head,
|
|
2569
3294
|
chain_enabled: ws.chain_enabled
|
|
2570
3295
|
};
|
|
2571
3296
|
}
|
|
2572
|
-
/**
|
|
3297
|
+
/**
|
|
3298
|
+
* One workspace as `snapshot()` holds it: each object of its collections a
|
|
3299
|
+
* copy in the shape the store keeps. Reads nothing as a change.
|
|
3300
|
+
*/
|
|
2573
3301
|
snapshotOf(ws) {
|
|
2574
3302
|
const { chain_head, chain_enabled, ...own } = this.metaOf(ws);
|
|
3303
|
+
const stored = (name) => Array.from(rawValues(ws[name])).map((v) => storedCopy(name, v));
|
|
2575
3304
|
return {
|
|
2576
3305
|
...own,
|
|
2577
|
-
members:
|
|
2578
|
-
tasks:
|
|
2579
|
-
overrides:
|
|
2580
|
-
whispers:
|
|
2581
|
-
deliberations:
|
|
2582
|
-
handoffs:
|
|
2583
|
-
snapshots:
|
|
2584
|
-
route_decisions:
|
|
3306
|
+
members: stored("members"),
|
|
3307
|
+
tasks: stored("tasks"),
|
|
3308
|
+
overrides: stored("overrides"),
|
|
3309
|
+
whispers: stored("whispers"),
|
|
3310
|
+
deliberations: stored("deliberations"),
|
|
3311
|
+
handoffs: stored("handoffs"),
|
|
3312
|
+
snapshots: stored("snapshots"),
|
|
3313
|
+
route_decisions: stored("route_decisions"),
|
|
3314
|
+
invitations: stored("invitations"),
|
|
2585
3315
|
idempotency_keys: { ...ws.idempotency_keys },
|
|
2586
3316
|
audit: ws.audit,
|
|
2587
3317
|
chain_head,
|
|
@@ -2609,7 +3339,8 @@ var Coordinator = class {
|
|
|
2609
3339
|
}
|
|
2610
3340
|
}
|
|
2611
3341
|
/** One workspace from a snapshot entry, its maps tracked. */
|
|
2612
|
-
rebuild(
|
|
3342
|
+
rebuild(snapshot) {
|
|
3343
|
+
const w = shapeSnapshot(snapshot);
|
|
2613
3344
|
const ws = {
|
|
2614
3345
|
id: w.id,
|
|
2615
3346
|
created: w.created,
|
|
@@ -2620,6 +3351,11 @@ var Coordinator = class {
|
|
|
2620
3351
|
routing_policy_uri: w.routing_policy_uri,
|
|
2621
3352
|
step_up_window_sec: w.step_up_window_sec ?? 300,
|
|
2622
3353
|
min_acr: w.min_acr,
|
|
3354
|
+
// A workspace written before the policy existed verified every call,
|
|
3355
|
+
// and one written before the join policy existed admitted anyone.
|
|
3356
|
+
signature_policy: w.signature_policy === "humans" ? "humans" : "all",
|
|
3357
|
+
join_policy: w.join_policy === "invite" ? "invite" : "open",
|
|
3358
|
+
review_rule: typeof w.review_rule === "string" && reviewRuleSupported(w.review_rule) ? w.review_rule : void 0,
|
|
2623
3359
|
members: this.newMap(),
|
|
2624
3360
|
tasks: this.newMap(),
|
|
2625
3361
|
overrides: this.newMap(),
|
|
@@ -2628,6 +3364,7 @@ var Coordinator = class {
|
|
|
2628
3364
|
handoffs: this.newMap(),
|
|
2629
3365
|
snapshots: this.newMap(),
|
|
2630
3366
|
route_decisions: this.newMap(),
|
|
3367
|
+
invitations: this.newMap(),
|
|
2631
3368
|
idempotency_keys: w.idempotency_keys ?? {},
|
|
2632
3369
|
audit: w.audit ?? [],
|
|
2633
3370
|
chain_head: w.chain_head,
|
|
@@ -2636,9 +3373,16 @@ var Coordinator = class {
|
|
|
2636
3373
|
for (const m of w.members ?? []) {
|
|
2637
3374
|
if (!Array.isArray(m.keys)) m.keys = [];
|
|
2638
3375
|
if (m.paused === void 0) m.paused = false;
|
|
3376
|
+
const scheme = typeof m.uri === "string" ? m.uri.split(":")[0] : "";
|
|
3377
|
+
if (PARTICIPANT_TYPES.includes(scheme)) m.type = scheme;
|
|
3378
|
+
if (m.role === "admin" && !ADMIN_TYPES.includes(m.type)) m.role = "participant";
|
|
2639
3379
|
ws.members.set(m.uri, m);
|
|
2640
3380
|
}
|
|
2641
|
-
for (const t of w.tasks ?? [])
|
|
3381
|
+
for (const t of w.tasks ?? []) {
|
|
3382
|
+
if (t.review && !Array.isArray(t.review.authors)) t.review.authors = storedAuthors(t);
|
|
3383
|
+
if (t.review && !Array.isArray(t.review.acknowledgements)) t.review.acknowledgements = [];
|
|
3384
|
+
ws.tasks.set(t.id, t);
|
|
3385
|
+
}
|
|
2642
3386
|
for (const o of w.overrides ?? []) ws.overrides.set(o.id, o);
|
|
2643
3387
|
for (const x of w.whispers ?? [])
|
|
2644
3388
|
ws.whispers.set(x.id, x);
|
|
@@ -2682,20 +3426,119 @@ var Coordinator = class {
|
|
|
2682
3426
|
}
|
|
2683
3427
|
for (const x of w.route_decisions ?? [])
|
|
2684
3428
|
ws.route_decisions.set(x.id, x);
|
|
3429
|
+
for (const x of w.invitations ?? []) ws.invitations.set(x.uri, x);
|
|
2685
3430
|
return ws;
|
|
2686
3431
|
}
|
|
2687
3432
|
// -- dispatch ------------------------------------------------------
|
|
2688
3433
|
dispatch(envelope) {
|
|
2689
3434
|
this.frozenNow = this.advanceClock();
|
|
2690
3435
|
this.tracker.depth++;
|
|
3436
|
+
let response;
|
|
3437
|
+
let target;
|
|
3438
|
+
let ran = false;
|
|
2691
3439
|
try {
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
3440
|
+
target = targetOf(envelope);
|
|
3441
|
+
const refused = target === void 0 ? null : this.keepUp(target);
|
|
3442
|
+
if (refused) {
|
|
3443
|
+
response = reply(envelope, { error: refused });
|
|
3444
|
+
} else {
|
|
3445
|
+
ran = true;
|
|
3446
|
+
response = this._dispatch(envelope);
|
|
3447
|
+
if (response.error) this.recordRefusal(envelope, response.error);
|
|
3448
|
+
else if (target === void 0 && envelope.method === "workspace.create") {
|
|
3449
|
+
target = response.result?.workspace;
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
2695
3452
|
} finally {
|
|
2696
3453
|
if (--this.tracker.depth === 0) this.flushUnflushed();
|
|
2697
3454
|
this.frozenNow = void 0;
|
|
2698
3455
|
}
|
|
3456
|
+
if (ran && target !== void 0 && this.tracker.depth === 0 && this.held.has(target)) {
|
|
3457
|
+
this.reload(target);
|
|
3458
|
+
return reply(envelope, { error: storeFailed() });
|
|
3459
|
+
}
|
|
3460
|
+
return response;
|
|
3461
|
+
}
|
|
3462
|
+
/**
|
|
3463
|
+
* Work that changes a workspace outside any call, a whisper lapse check
|
|
3464
|
+
* for one, kept to the rules a call keeps (SPECIFICATION 10.3): the
|
|
3465
|
+
* workspace is first brought up to date with the store, and left alone
|
|
3466
|
+
* as long as it is held; what the work changed is written once, when it ends;
|
|
3467
|
+
* and where that write fails, the workspace goes back to what the store
|
|
3468
|
+
* holds and the work's results are dropped, since none took effect.
|
|
3469
|
+
*/
|
|
3470
|
+
outsideCall(id, work) {
|
|
3471
|
+
if (this.tracker.depth > 0) return work();
|
|
3472
|
+
if (this.keepUp(id)) return [];
|
|
3473
|
+
this.tracker.depth++;
|
|
3474
|
+
let out;
|
|
3475
|
+
try {
|
|
3476
|
+
out = work();
|
|
3477
|
+
} finally {
|
|
3478
|
+
if (--this.tracker.depth === 0) this.flushUnflushed();
|
|
3479
|
+
}
|
|
3480
|
+
if (this.held.has(id)) {
|
|
3481
|
+
this.reload(id);
|
|
3482
|
+
return [];
|
|
3483
|
+
}
|
|
3484
|
+
return out;
|
|
3485
|
+
}
|
|
3486
|
+
/**
|
|
3487
|
+
* Before a call on a workspace: where the store holds a different number of
|
|
3488
|
+
* audit entries for it than this instance last wrote, another writer has
|
|
3489
|
+
* written it, and it is reloaded. A workspace held after a failed write is
|
|
3490
|
+
* reloaded as well, and the call refused while that fails.
|
|
3491
|
+
*/
|
|
3492
|
+
keepUp(id) {
|
|
3493
|
+
if (this.held.has(id)) return this.reload(id) ? null : storeFailed();
|
|
3494
|
+
if (typeof this.store.version !== "function" || this.saveChains.has(id)) return null;
|
|
3495
|
+
let stored;
|
|
3496
|
+
try {
|
|
3497
|
+
stored = this.store.version(id);
|
|
3498
|
+
} catch {
|
|
3499
|
+
return null;
|
|
3500
|
+
}
|
|
3501
|
+
if (isThenable(stored)) return null;
|
|
3502
|
+
const ws = this.workspaces.get(id);
|
|
3503
|
+
const p = ws ? this.persisted.get(ws) : void 0;
|
|
3504
|
+
if (ws && (!p || p.replace)) return null;
|
|
3505
|
+
if (stored === p?.seq) return null;
|
|
3506
|
+
return this.reload(id) ? null : storeFailed();
|
|
3507
|
+
}
|
|
3508
|
+
/** Replace a workspace with what the store holds of it. False when it cannot be read. */
|
|
3509
|
+
reload(id) {
|
|
3510
|
+
let record;
|
|
3511
|
+
try {
|
|
3512
|
+
if (typeof this.store.loadOne === "function") {
|
|
3513
|
+
record = this.store.loadOne(id);
|
|
3514
|
+
} else {
|
|
3515
|
+
const all = this.store.load();
|
|
3516
|
+
if (isThenable(all)) return false;
|
|
3517
|
+
record = all.find((r) => r.id === id);
|
|
3518
|
+
}
|
|
3519
|
+
if (isThenable(record)) return false;
|
|
3520
|
+
} catch (err) {
|
|
3521
|
+
this.reportStoreError(err, { id, version: this.workspaces.get(id)?.audit.length ?? 0 });
|
|
3522
|
+
return false;
|
|
3523
|
+
}
|
|
3524
|
+
const old = this.workspaces.get(id);
|
|
3525
|
+
if (old) this.unflushed.delete(old);
|
|
3526
|
+
if (record) {
|
|
3527
|
+
let ws;
|
|
3528
|
+
try {
|
|
3529
|
+
ws = this.rebuild(record.data);
|
|
3530
|
+
} catch (err) {
|
|
3531
|
+
this.reportStoreError(err, { id, version: record.version });
|
|
3532
|
+
return false;
|
|
3533
|
+
}
|
|
3534
|
+
this.workspaces.set(id, ws);
|
|
3535
|
+
this.markWritten(ws);
|
|
3536
|
+
this.wsVersions.set(id, record.version);
|
|
3537
|
+
} else {
|
|
3538
|
+
this.workspaces.delete(id);
|
|
3539
|
+
}
|
|
3540
|
+
this.held.delete(id);
|
|
3541
|
+
return true;
|
|
2699
3542
|
}
|
|
2700
3543
|
_dispatch(envelope) {
|
|
2701
3544
|
if (!isValidEnvelope(envelope) || typeof envelope.method !== "string" || !envelope.method) {
|
|
@@ -2732,11 +3575,16 @@ var Coordinator = class {
|
|
|
2732
3575
|
const msg = canonicalError instanceof Error ? canonicalError.message : String(canonicalError);
|
|
2733
3576
|
return reply(envelope, { error: rpcError(E.PARAMS, msg) });
|
|
2734
3577
|
}
|
|
3578
|
+
if (params.workspace !== void 0 && (typeof params.workspace !== "string" || !params.workspace)) {
|
|
3579
|
+
return reply(envelope, { error: rpcError(E.PARAMS, "workspace must be a non-empty string") });
|
|
3580
|
+
}
|
|
2735
3581
|
if (isSigned(envelope)) {
|
|
2736
3582
|
const wsId2 = params.workspace;
|
|
2737
3583
|
const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
|
|
2738
3584
|
if (ws) {
|
|
2739
|
-
const
|
|
3585
|
+
const index = this.signedIndex(ws);
|
|
3586
|
+
const digest = signedDigest(recordedForm(envelope));
|
|
3587
|
+
const prior = index.refused.get(digest);
|
|
2740
3588
|
if (prior) {
|
|
2741
3589
|
return reply(envelope, { error: rpcError(
|
|
2742
3590
|
prior.code,
|
|
@@ -2744,15 +3592,24 @@ var Coordinator = class {
|
|
|
2744
3592
|
{ refused_at_seq: prior.seq }
|
|
2745
3593
|
) });
|
|
2746
3594
|
}
|
|
3595
|
+
const accepted = index.accepted.get(digest);
|
|
3596
|
+
if (accepted !== void 0) {
|
|
3597
|
+
return reply(envelope, { error: rpcError(
|
|
3598
|
+
E.SIG_ALREADY_ACCEPTED,
|
|
3599
|
+
`Accepted at seq ${accepted}; a signed request takes effect once`,
|
|
3600
|
+
{ accepted_at_seq: accepted }
|
|
3601
|
+
) });
|
|
3602
|
+
}
|
|
2747
3603
|
}
|
|
2748
3604
|
}
|
|
2749
|
-
|
|
3605
|
+
const target = typeof params.workspace === "string" ? this.workspaces.get(params.workspace) : void 0;
|
|
3606
|
+
if (this.requiresSignatures(target) && method !== "participant.join" && method !== "workspace.create" && !(!isSigned(envelope) && target !== void 0 && !this.mustSign(target, rawMember(target, params.from)?.type))) {
|
|
2750
3607
|
const sigErr = this.verifySignature(envelope);
|
|
2751
3608
|
if (sigErr) return reply(envelope, { error: sigErr });
|
|
2752
3609
|
if (method === "participant.rotate_key") {
|
|
2753
3610
|
const signingKid = envelope.sig.split(":")[1];
|
|
2754
3611
|
const oldKid = params.old_kid;
|
|
2755
|
-
if (oldKid
|
|
3612
|
+
if (oldKid != null && signingKid !== oldKid) {
|
|
2756
3613
|
return reply(envelope, { error: rpcError(
|
|
2757
3614
|
E.SIG_ROTATION_KEY_MISMATCH,
|
|
2758
3615
|
"Rotation must be signed with the old key"
|
|
@@ -2807,14 +3664,14 @@ var Coordinator = class {
|
|
|
2807
3664
|
try {
|
|
2808
3665
|
out = handler(ownCopy(params));
|
|
2809
3666
|
} catch (e) {
|
|
2810
|
-
|
|
2811
|
-
return reply(envelope, { error: rpcError(E.INTERNAL, "Internal error"
|
|
3667
|
+
this.reportInternalError(e, method, params.workspace);
|
|
3668
|
+
return reply(envelope, { error: rpcError(E.INTERNAL, "Internal error") });
|
|
2812
3669
|
}
|
|
2813
3670
|
if (out.error) return reply(envelope, { error: out.error });
|
|
2814
|
-
const wsId = params.workspace;
|
|
3671
|
+
const wsId = method === "workspace.create" ? out.result.workspace : params.workspace;
|
|
2815
3672
|
if (typeof wsId === "string" && !READ_ONLY_METHODS.has(method)) {
|
|
2816
3673
|
const ws = this.workspaces.get(wsId);
|
|
2817
|
-
if (ws) this.recordAudit(ws, envelope);
|
|
3674
|
+
if (ws) this.recordAudit(ws, recordedForm(envelope));
|
|
2818
3675
|
}
|
|
2819
3676
|
return reply(envelope, { result: ownCopy(out.result) });
|
|
2820
3677
|
}
|
|
@@ -2854,7 +3711,7 @@ var Coordinator = class {
|
|
|
2854
3711
|
signedIndex(ws) {
|
|
2855
3712
|
let index = this.signedIndexes.get(ws);
|
|
2856
3713
|
if (!index) {
|
|
2857
|
-
index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new
|
|
3714
|
+
index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Map() };
|
|
2858
3715
|
for (const entry of ws.audit) indexSigned(index, entry);
|
|
2859
3716
|
this.signedIndexes.set(ws, index);
|
|
2860
3717
|
}
|
|
@@ -2870,12 +3727,9 @@ var Coordinator = class {
|
|
|
2870
3727
|
ws.audit.push(entry);
|
|
2871
3728
|
const index = this.signedIndexes.get(ws);
|
|
2872
3729
|
if (index) indexSigned(index, entry);
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
} catch {
|
|
2877
|
-
}
|
|
2878
|
-
}
|
|
3730
|
+
const queued = this.unannounced.get(ws);
|
|
3731
|
+
if (queued) queued.push(entry);
|
|
3732
|
+
else this.unannounced.set(ws, [entry]);
|
|
2879
3733
|
this.persist(ws);
|
|
2880
3734
|
}
|
|
2881
3735
|
// -- signature verification (security-signed/1.0) -----------------
|
|
@@ -2885,7 +3739,12 @@ var Coordinator = class {
|
|
|
2885
3739
|
return rpcError(E.SIG_VERIFY_FAILED, "Missing top-level `sig` field");
|
|
2886
3740
|
}
|
|
2887
3741
|
const parts = sig.split(":");
|
|
2888
|
-
|
|
3742
|
+
const scheme = parts[0];
|
|
3743
|
+
if (parts.length !== 3 || scheme !== "ed25519" && scheme !== "webauthn") {
|
|
3744
|
+
return rpcError(E.SIG_VERIFY_FAILED, "Malformed signature tag");
|
|
3745
|
+
}
|
|
3746
|
+
const assertion = scheme === "webauthn" ? parseWebAuthnAssertion(parts[2]) : void 0;
|
|
3747
|
+
if (scheme === "webauthn" && !assertion) {
|
|
2889
3748
|
return rpcError(E.SIG_VERIFY_FAILED, "Malformed signature tag");
|
|
2890
3749
|
}
|
|
2891
3750
|
const kid = parts[1];
|
|
@@ -2919,6 +3778,14 @@ var Coordinator = class {
|
|
|
2919
3778
|
if (revoked) return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
|
|
2920
3779
|
return rpcError(E.SIG_KEY_NOT_FOUND, `No key ${kid} valid at ${ts} for ${sender}`);
|
|
2921
3780
|
}
|
|
3781
|
+
if (assertion) {
|
|
3782
|
+
if (!isWebAuthnKey(key.jwk)) return rpcError(E.SIG_VERIFY_FAILED, `Key ${kid} is not a WebAuthn key`);
|
|
3783
|
+
const stripped = JSON.parse(JSON.stringify(envelope));
|
|
3784
|
+
delete stripped.sig;
|
|
3785
|
+
const failure = webAuthnFailure(canonicalize(stripped), assertion, key.jwk);
|
|
3786
|
+
return failure === null ? null : rpcError(E.SIG_VERIFY_FAILED, failure);
|
|
3787
|
+
}
|
|
3788
|
+
if (!isEd25519Key(key.jwk)) return rpcError(E.SIG_VERIFY_FAILED, `Key ${kid} is not an Ed25519 key`);
|
|
2922
3789
|
try {
|
|
2923
3790
|
const stripped = JSON.parse(JSON.stringify(envelope));
|
|
2924
3791
|
delete stripped.sig;
|
|
@@ -2932,6 +3799,39 @@ var Coordinator = class {
|
|
|
2932
3799
|
return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
|
|
2933
3800
|
}
|
|
2934
3801
|
}
|
|
3802
|
+
/**
|
|
3803
|
+
* SPECIFICATION 6.5: an identity profile states that a verifier checks
|
|
3804
|
+
* people's credentials, so advertising one this coordinator has no
|
|
3805
|
+
* verifier for describes a guarantee it does not give.
|
|
3806
|
+
*/
|
|
3807
|
+
unverifiedIdentity(profiles) {
|
|
3808
|
+
const named = (name) => profiles.some((x) => x === name || x.startsWith(name + "/"));
|
|
3809
|
+
if (named("identity-oidc") && this.options.verifyOidcToken === void 0) {
|
|
3810
|
+
return rpcError(
|
|
3811
|
+
E.PARAMS,
|
|
3812
|
+
"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."
|
|
3813
|
+
);
|
|
3814
|
+
}
|
|
3815
|
+
if (named("identity-vc") && this.options.verifyVc === void 0) {
|
|
3816
|
+
return rpcError(
|
|
3817
|
+
E.PARAMS,
|
|
3818
|
+
"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."
|
|
3819
|
+
);
|
|
3820
|
+
}
|
|
3821
|
+
return null;
|
|
3822
|
+
}
|
|
3823
|
+
/** Whether a workspace requires signatures: it advertises security-signed/1.0, or this coordinator is configured to. */
|
|
3824
|
+
requiresSignatures(ws) {
|
|
3825
|
+
return !!this.options.requireSignatures || ws !== void 0 && advertises(ws, "security-signed");
|
|
3826
|
+
}
|
|
3827
|
+
/**
|
|
3828
|
+
* Whether a participant of this type signs its calls in a workspace that
|
|
3829
|
+
* requires signatures: every participant under "all", and every one but an
|
|
3830
|
+
* agent under "humans". A non-member signs.
|
|
3831
|
+
*/
|
|
3832
|
+
mustSign(ws, type) {
|
|
3833
|
+
return ws.signature_policy === "all" || type !== "agent";
|
|
3834
|
+
}
|
|
2935
3835
|
/** Lookup the key valid for a given participant at a given time. */
|
|
2936
3836
|
lookupKey(member, kid, ts) {
|
|
2937
3837
|
for (const k of member.keys) {
|
|
@@ -3005,6 +3905,7 @@ var Coordinator = class {
|
|
|
3005
3905
|
this.handlers.set("workspace.create", (p) => this.opWorkspaceCreate(p));
|
|
3006
3906
|
this.handlers.set("workspace.describe", (p) => this.opWorkspaceDescribe(p));
|
|
3007
3907
|
this.handlers.set("workspace.set_profiles", (p) => this.opWorkspaceSetProfiles(p));
|
|
3908
|
+
this.handlers.set("workspace.invite", (p) => this.opWorkspaceInvite(p));
|
|
3008
3909
|
this.handlers.set("participant.join", (p) => this.opParticipantJoin(p));
|
|
3009
3910
|
this.handlers.set("participant.leave", (p) => this.opParticipantLeave(p));
|
|
3010
3911
|
this.handlers.set("task.create", (p) => this.opTaskCreate(p));
|
|
@@ -3012,6 +3913,7 @@ var Coordinator = class {
|
|
|
3012
3913
|
this.handlers.set("task.complete", (p) => this.opTaskComplete(p));
|
|
3013
3914
|
this.handlers.set("audit.read", (p) => this.opAuditRead(p));
|
|
3014
3915
|
this.handlers.set("review.request", (p) => this.opReviewRequest(p));
|
|
3916
|
+
this.handlers.set("review.acknowledge", (p) => this.opReviewAcknowledge(p));
|
|
3015
3917
|
this.handlers.set("decide.approve", (p) => this.opDecide(p, "approve"));
|
|
3016
3918
|
this.handlers.set("decide.reject", (p) => this.opDecide(p, "reject"));
|
|
3017
3919
|
this.handlers.set("decide.override", (p) => this.opDecideOverride(p));
|
|
@@ -3031,24 +3933,59 @@ var Coordinator = class {
|
|
|
3031
3933
|
// Core method handlers
|
|
3032
3934
|
// ==========================================================
|
|
3033
3935
|
opWorkspaceCreate(p) {
|
|
3936
|
+
const built = this.buildWorkspace(p);
|
|
3937
|
+
if (built.error) return { error: built.error };
|
|
3938
|
+
this.addWorkspace(built.ws);
|
|
3939
|
+
return { result: { workspace: built.ws.id, created: built.ws.created } };
|
|
3940
|
+
}
|
|
3941
|
+
/**
|
|
3942
|
+
* The workspace a workspace.create with these params makes, or why it
|
|
3943
|
+
* cannot be made. Nothing is registered, so participant.join can judge a
|
|
3944
|
+
* join by the workspace it would make and make it only once the join
|
|
3945
|
+
* passes.
|
|
3946
|
+
*/
|
|
3947
|
+
buildWorkspace(p) {
|
|
3948
|
+
const bad = paramsError(p, [
|
|
3949
|
+
["profiles", "strings"],
|
|
3950
|
+
["mode", "string"],
|
|
3951
|
+
["mode_ceiling", "string"],
|
|
3952
|
+
["routing_policy_uri", "string"],
|
|
3953
|
+
["step_up_window_sec", "integer"],
|
|
3954
|
+
["min_acr", "string"]
|
|
3955
|
+
]);
|
|
3956
|
+
if (bad) return bad;
|
|
3957
|
+
for (const name of ["mode", "mode_ceiling"]) {
|
|
3958
|
+
if (p[name] != null && !MODES.has(p[name])) {
|
|
3959
|
+
return { error: rpcError(E.PARAMS, `${name} must be shadow, trial or production`) };
|
|
3960
|
+
}
|
|
3961
|
+
}
|
|
3962
|
+
const policy = p.signature_policy ?? this.options.signaturePolicy ?? "all";
|
|
3963
|
+
if (policy !== "all" && policy !== "humans") {
|
|
3964
|
+
return { error: rpcError(E.PARAMS, "signature_policy must be all or humans") };
|
|
3965
|
+
}
|
|
3966
|
+
const joinPolicy = p.join_policy ?? this.options.joinPolicy ?? "open";
|
|
3967
|
+
if (joinPolicy !== "open" && joinPolicy !== "invite") {
|
|
3968
|
+
return { error: rpcError(E.PARAMS, "join_policy must be open or invite") };
|
|
3969
|
+
}
|
|
3970
|
+
const reviewRule = p.review_rule ?? void 0;
|
|
3971
|
+
if (reviewRule !== void 0 && (typeof reviewRule !== "string" || !reviewRuleSupported(reviewRule))) {
|
|
3972
|
+
return { error: rpcError(E.PARAMS, "review_rule must be any_one_approves, all_approve or quorum:<n>") };
|
|
3973
|
+
}
|
|
3034
3974
|
const id = p.workspace || this.ids.workspaceId();
|
|
3035
|
-
if (typeof id !== "string") return { error: rpcError(E.PARAMS, "workspace must be a string id") };
|
|
3036
3975
|
if (this.workspaces.has(id)) return { error: rpcError(E.PARAMS, `workspace already exists: ${id}`) };
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
["identity-oidc/1.0", this.options.verifyOidcToken !== void 0, "verifyOidcToken"]
|
|
3041
|
-
]) {
|
|
3042
|
-
const name = profile.split("/")[0];
|
|
3043
|
-
const advertised = profiles.some((x) => x === name || x.startsWith(name + "/"));
|
|
3044
|
-
if (enforced && !advertised) profiles.push(profile);
|
|
3045
|
-
else if (advertised && !enforced) {
|
|
3046
|
-
return { error: rpcError(
|
|
3047
|
-
E.PARAMS,
|
|
3048
|
-
`${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.`
|
|
3049
|
-
) };
|
|
3050
|
-
}
|
|
3976
|
+
if (Array.isArray(p.profiles)) {
|
|
3977
|
+
const unknown = profilesError(p.profiles);
|
|
3978
|
+
if (unknown) return unknown;
|
|
3051
3979
|
}
|
|
3980
|
+
const profiles = Array.isArray(p.profiles) ? [...p.profiles] : [...this.options.defaultProfiles ?? ["core/1.0", "review/1.0"]];
|
|
3981
|
+
const signed = profiles.some((x) => x === "security-signed" || x.startsWith("security-signed/"));
|
|
3982
|
+
if (this.options.requireSignatures && !signed) profiles.push("security-signed/1.0");
|
|
3983
|
+
const oidc = profiles.some((x) => x === "identity-oidc" || x.startsWith("identity-oidc/"));
|
|
3984
|
+
if (this.options.verifyOidcToken !== void 0 && !oidc) profiles.push("identity-oidc/1.0");
|
|
3985
|
+
const vc = profiles.some((x) => x === "identity-vc" || x.startsWith("identity-vc/"));
|
|
3986
|
+
if (this.options.verifyVc !== void 0 && !vc) profiles.push("identity-vc/1.0");
|
|
3987
|
+
const unverified = this.unverifiedIdentity(profiles);
|
|
3988
|
+
if (unverified) return { error: unverified };
|
|
3052
3989
|
const chainEnabled = profiles.includes("audit-scitt/1.0") || !!this.options.enableChain;
|
|
3053
3990
|
const ws = {
|
|
3054
3991
|
id,
|
|
@@ -3057,9 +3994,14 @@ var Coordinator = class {
|
|
|
3057
3994
|
profiles,
|
|
3058
3995
|
mode: p.mode ?? "trial",
|
|
3059
3996
|
mode_ceiling: p.mode_ceiling ?? "production",
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3997
|
+
// A param given as null gives no value, as in the Python reference: a
|
|
3998
|
+
// null min_acr sets no minimum.
|
|
3999
|
+
routing_policy_uri: p.routing_policy_uri ?? void 0,
|
|
4000
|
+
step_up_window_sec: p.step_up_window_sec ?? 300,
|
|
4001
|
+
min_acr: p.min_acr ?? void 0,
|
|
4002
|
+
signature_policy: policy,
|
|
4003
|
+
join_policy: joinPolicy,
|
|
4004
|
+
...reviewRule !== void 0 ? { review_rule: reviewRule } : {},
|
|
3063
4005
|
members: this.newMap(),
|
|
3064
4006
|
tasks: this.newMap(),
|
|
3065
4007
|
overrides: this.newMap(),
|
|
@@ -3068,14 +4010,17 @@ var Coordinator = class {
|
|
|
3068
4010
|
handoffs: this.newMap(),
|
|
3069
4011
|
snapshots: this.newMap(),
|
|
3070
4012
|
route_decisions: this.newMap(),
|
|
4013
|
+
invitations: this.newMap(),
|
|
3071
4014
|
idempotency_keys: {},
|
|
3072
4015
|
audit: [],
|
|
3073
4016
|
chain_enabled: chainEnabled,
|
|
3074
4017
|
chain_head: chainEnabled ? ZERO_HASH : void 0
|
|
3075
4018
|
};
|
|
3076
|
-
|
|
4019
|
+
return { ws };
|
|
4020
|
+
}
|
|
4021
|
+
addWorkspace(ws) {
|
|
4022
|
+
this.workspaces.set(ws.id, ws);
|
|
3077
4023
|
this.persisted.set(ws, { seq: 0, replace: false, keys: /* @__PURE__ */ new Set() });
|
|
3078
|
-
return { result: { workspace: id, created: ws.created } };
|
|
3079
4024
|
}
|
|
3080
4025
|
opWorkspaceDescribe(p) {
|
|
3081
4026
|
const miss = missing(p, ["workspace"]);
|
|
@@ -3094,6 +4039,9 @@ var Coordinator = class {
|
|
|
3094
4039
|
mode_ceiling: ws.mode_ceiling,
|
|
3095
4040
|
max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
|
|
3096
4041
|
step_up_window_sec: ws.step_up_window_sec,
|
|
4042
|
+
signature_policy: ws.signature_policy,
|
|
4043
|
+
join_policy: ws.join_policy,
|
|
4044
|
+
review_rule: ws.review_rule ?? "any_one_approves",
|
|
3097
4045
|
profiles: ws.profiles,
|
|
3098
4046
|
members: Array.from(rawValues(ws.members)).map(memberToDict),
|
|
3099
4047
|
audit_count: ws.audit.length,
|
|
@@ -3109,6 +4057,11 @@ var Coordinator = class {
|
|
|
3109
4057
|
opWorkspaceSetProfiles(p) {
|
|
3110
4058
|
const miss = missing(p, ["workspace", "from", "profiles"]);
|
|
3111
4059
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4060
|
+
if (!Array.isArray(p.profiles) || !p.profiles.every((x) => typeof x === "string")) {
|
|
4061
|
+
return { error: rpcError(E.PARAMS, "profiles must be a list of strings") };
|
|
4062
|
+
}
|
|
4063
|
+
const unknown = profilesError(p.profiles);
|
|
4064
|
+
if (unknown) return unknown;
|
|
3112
4065
|
const ws = this.workspaces.get(p.workspace);
|
|
3113
4066
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3114
4067
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3117,6 +4070,14 @@ var Coordinator = class {
|
|
|
3117
4070
|
return { error: rpcError(E.NOT_AUTHORISED, "workspace.set_profiles requires the admin role") };
|
|
3118
4071
|
}
|
|
3119
4072
|
const newProfiles = [...p.profiles];
|
|
4073
|
+
for (const name of LASTING_PROFILES) {
|
|
4074
|
+
const held = ws.profiles.find((x) => x === name || x.startsWith(name + "/"));
|
|
4075
|
+
if (held !== void 0 && !newProfiles.some((x) => x.startsWith(name + "/"))) {
|
|
4076
|
+
return { error: rpcError(E.NOT_AUTHORISED, `${held} cannot be removed once advertised`) };
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
const unverified = this.unverifiedIdentity(newProfiles);
|
|
4080
|
+
if (unverified) return { error: unverified };
|
|
3120
4081
|
if (!newProfiles.some((pr) => pr.startsWith("core/"))) newProfiles.push("core/1.0");
|
|
3121
4082
|
ws.profiles = newProfiles;
|
|
3122
4083
|
if (newProfiles.includes("audit-scitt/1.0") && !ws.chain_enabled) {
|
|
@@ -3128,20 +4089,76 @@ var Coordinator = class {
|
|
|
3128
4089
|
opParticipantJoin(p) {
|
|
3129
4090
|
const miss = missing(p, ["workspace", "from", "type"]);
|
|
3130
4091
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4092
|
+
for (const f of ["from", "type"]) {
|
|
4093
|
+
if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
|
|
4094
|
+
}
|
|
4095
|
+
const type = p.type;
|
|
4096
|
+
const named = participantError(type, p.from, "from");
|
|
4097
|
+
if (named) return named;
|
|
4098
|
+
const bad = paramsError(p, [
|
|
4099
|
+
["role", "string"],
|
|
4100
|
+
["display_name", "string"],
|
|
4101
|
+
["scopes", "strings"],
|
|
4102
|
+
["capabilities", "object"],
|
|
4103
|
+
["jwks", "object"],
|
|
4104
|
+
["jwks.keys", "list"],
|
|
4105
|
+
["oidc_token", "string"],
|
|
4106
|
+
["vc_presentation", "object"]
|
|
4107
|
+
]);
|
|
4108
|
+
if (bad) return bad;
|
|
4109
|
+
if (p.role === "admin" && !ADMIN_TYPES.includes(type)) {
|
|
4110
|
+
return { error: rpcError(E.PARAMS, "The admin role is held by a human or a service") };
|
|
4111
|
+
}
|
|
3131
4112
|
let ws = this.workspaces.get(p.workspace);
|
|
4113
|
+
const made = ws === void 0;
|
|
3132
4114
|
if (!ws) {
|
|
3133
|
-
const
|
|
3134
|
-
if (
|
|
3135
|
-
ws =
|
|
4115
|
+
const built = this.buildWorkspace({ workspace: p.workspace });
|
|
4116
|
+
if (built.error) return { error: built.error };
|
|
4117
|
+
ws = built.ws;
|
|
3136
4118
|
}
|
|
3137
4119
|
const uri = p.from;
|
|
3138
4120
|
const now = this.now();
|
|
3139
4121
|
const existing = ws.members.get(uri);
|
|
3140
4122
|
let tokenVerified = false;
|
|
4123
|
+
let holderVerified = false;
|
|
4124
|
+
let credentialBound = false;
|
|
4125
|
+
const invitation = existing ? void 0 : rawInvitation(ws, uri);
|
|
4126
|
+
const asked = typeof p.role === "string" && p.role !== "" ? p.role : void 0;
|
|
4127
|
+
let role = asked ?? "participant";
|
|
4128
|
+
if (!existing) {
|
|
4129
|
+
const first = awaitsFirstMember(ws);
|
|
4130
|
+
if (ws.join_policy === "invite" && !invitation && !(first && ADMIN_TYPES.includes(type))) {
|
|
4131
|
+
return { error: rpcError(E.NOT_AUTHORISED, `${uri} has no invitation to this workspace`) };
|
|
4132
|
+
}
|
|
4133
|
+
if (invitation?.role !== void 0) {
|
|
4134
|
+
if (asked === "admin" && invitation.role !== "admin") {
|
|
4135
|
+
return { error: rpcError(E.NOT_AUTHORISED, "The admin role is granted by an admin") };
|
|
4136
|
+
}
|
|
4137
|
+
role = invitation.role;
|
|
4138
|
+
} else if (first && ws.join_policy === "invite") {
|
|
4139
|
+
if (asked !== void 0 && asked !== "admin") {
|
|
4140
|
+
return { error: rpcError(
|
|
4141
|
+
E.PARAMS,
|
|
4142
|
+
"The first member of a workspace that admits by invitation is its admin"
|
|
4143
|
+
) };
|
|
4144
|
+
}
|
|
4145
|
+
role = "admin";
|
|
4146
|
+
} else if (asked === "admin" && !first) {
|
|
4147
|
+
return { error: rpcError(E.NOT_AUTHORISED, "The admin role is granted by an admin") };
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
if (type === "human") {
|
|
4151
|
+
const oidc = this.options.verifyOidcToken !== void 0 && advertises(ws, "identity-oidc");
|
|
4152
|
+
const vc = this.options.verifyVc !== void 0 && advertises(ws, "identity-vc");
|
|
4153
|
+
const presented = oidc && typeof p.oidc_token === "string" || vc && isObject(p.vc_presentation);
|
|
4154
|
+
if ((oidc || vc) && !presented) {
|
|
4155
|
+
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") };
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
3141
4158
|
const member = {
|
|
3142
4159
|
uri,
|
|
3143
|
-
type
|
|
3144
|
-
role
|
|
4160
|
+
type,
|
|
4161
|
+
role,
|
|
3145
4162
|
joined: now,
|
|
3146
4163
|
display_name: p.display_name,
|
|
3147
4164
|
capabilities: p.capabilities,
|
|
@@ -3149,6 +4166,9 @@ var Coordinator = class {
|
|
|
3149
4166
|
keys: [],
|
|
3150
4167
|
paused: false
|
|
3151
4168
|
};
|
|
4169
|
+
if (invitation?.oidc_sub !== void 0 && (this.options.verifyOidcToken === void 0 || typeof p.oidc_token !== "string")) {
|
|
4170
|
+
return { error: rpcError(E.OIDC_TOKEN_INVALID, "An oidc_token is required: the invitation names a token subject") };
|
|
4171
|
+
}
|
|
3152
4172
|
if (this.options.verifyOidcToken && typeof p.oidc_token === "string") {
|
|
3153
4173
|
const claims = this.options.verifyOidcToken(p.oidc_token);
|
|
3154
4174
|
if (claims === null) return { error: rpcError(E.OIDC_TOKEN_INVALID, "OIDC token invalid") };
|
|
@@ -3157,6 +4177,9 @@ var Coordinator = class {
|
|
|
3157
4177
|
if (boundUri != null && boundUri !== uri) {
|
|
3158
4178
|
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token is bound to another participant") };
|
|
3159
4179
|
}
|
|
4180
|
+
if (invitation?.oidc_sub !== void 0 && sub !== invitation.oidc_sub) {
|
|
4181
|
+
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token subject does not match the invitation") };
|
|
4182
|
+
}
|
|
3160
4183
|
if (existing) {
|
|
3161
4184
|
if (existing.oidc_sub != null) {
|
|
3162
4185
|
if (sub !== existing.oidc_sub) {
|
|
@@ -3165,19 +4188,23 @@ var Coordinator = class {
|
|
|
3165
4188
|
} else if (boundUri !== uri) {
|
|
3166
4189
|
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this member") };
|
|
3167
4190
|
}
|
|
4191
|
+
} else if (type === "human" && boundUri !== uri && invitation?.oidc_sub === void 0) {
|
|
4192
|
+
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this participant") };
|
|
3168
4193
|
}
|
|
3169
4194
|
member.oidc_sub = sub;
|
|
3170
4195
|
tokenVerified = true;
|
|
3171
4196
|
const at = claims.auth_time;
|
|
3172
|
-
if (typeof at === "number") member.oidc_auth_time = at;
|
|
4197
|
+
if (typeof at === "number" && Number.isFinite(at) && Math.abs(at) <= Number.MAX_SAFE_INTEGER) member.oidc_auth_time = Math.floor(at);
|
|
3173
4198
|
member.oidc_acr = typeof claims.acr === "string" ? claims.acr : void 0;
|
|
3174
|
-
const cnf = claims.cnf;
|
|
3175
|
-
const
|
|
3176
|
-
if (
|
|
3177
|
-
|
|
4199
|
+
const cnf = isObject(claims.cnf) ? claims.cnf.jwk : void 0;
|
|
4200
|
+
const bound = credentialKeys(cnf, cnf == null ? claims.nonce : void 0, signingKeys(p.jwks));
|
|
4201
|
+
if (bound === null) return { error: rpcError(E.OIDC_CNF_MISMATCH, "The join's keys are not the key the token binds") };
|
|
4202
|
+
if (bound !== void 0) {
|
|
4203
|
+
credentialBound = true;
|
|
4204
|
+
for (const j of bound) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
|
|
3178
4205
|
}
|
|
3179
4206
|
}
|
|
3180
|
-
if (this.options.verifyVc &&
|
|
4207
|
+
if (this.options.verifyVc && isObject(p.vc_presentation)) {
|
|
3181
4208
|
const subject = this.options.verifyVc(p.vc_presentation);
|
|
3182
4209
|
if (subject === null) return { error: rpcError(E.VC_VP_INVALID, "VC presentation invalid") };
|
|
3183
4210
|
const holder = [subject.holder, subject.id].find(
|
|
@@ -3187,21 +4214,54 @@ var Coordinator = class {
|
|
|
3187
4214
|
return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "Presentation holder does not match the member") };
|
|
3188
4215
|
}
|
|
3189
4216
|
member.vc_holder = holder;
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
4217
|
+
holderVerified = true;
|
|
4218
|
+
const bound = credentialKeys(subject.cnf_jwk, void 0, signingKeys(p.jwks));
|
|
4219
|
+
if (bound === null) {
|
|
4220
|
+
return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "The join's keys are not the key the presentation binds") };
|
|
4221
|
+
}
|
|
4222
|
+
if (bound !== void 0) {
|
|
4223
|
+
credentialBound = true;
|
|
4224
|
+
for (const j of bound) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
4227
|
+
if (!existing && type === "human" && (tokenVerified || holderVerified) && !credentialBound && !invitation?.jwks && signingKeys(p.jwks).length > 0) {
|
|
4228
|
+
return { error: tokenVerified ? rpcError(E.OIDC_CNF_MISMATCH, "The token binds no key: a person it verifies signs with a key it binds") : rpcError(
|
|
4229
|
+
E.VC_HOLDER_BINDING_INVALID,
|
|
4230
|
+
"The presentation binds no key: a person it verifies signs with a key it binds"
|
|
4231
|
+
) };
|
|
4232
|
+
}
|
|
4233
|
+
const pinned = invitation?.jwks?.keys;
|
|
4234
|
+
if (pinned) {
|
|
4235
|
+
const strays = [...signingKeys(p.jwks), ...member.keys.map((k) => k.jwk)].filter((j) => !pinned.some((k) => sameKey(k, j)));
|
|
4236
|
+
if (strays.length) {
|
|
4237
|
+
return { error: rpcError(
|
|
4238
|
+
E.NOT_AUTHORISED,
|
|
4239
|
+
`${uri} joins with keys its invitation does not name`,
|
|
4240
|
+
{ kids: [...new Set(strays.map((j) => j.kid))] }
|
|
4241
|
+
) };
|
|
3193
4242
|
}
|
|
4243
|
+
member.keys = pinned.map((j) => ({ jwk: j, kid: j.kid, valid_from: now }));
|
|
3194
4244
|
}
|
|
3195
4245
|
const attested = [...member.keys];
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
if (j.kid && !member.keys.some((k) => k.kid === j.kid)) {
|
|
4246
|
+
if (attested.length === 0) {
|
|
4247
|
+
for (const j of signingKeys(p.jwks)) {
|
|
4248
|
+
if (!member.keys.some((k) => k.kid === j.kid)) {
|
|
3200
4249
|
member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
|
|
3201
4250
|
}
|
|
3202
4251
|
}
|
|
3203
4252
|
}
|
|
3204
4253
|
if (existing) {
|
|
4254
|
+
if (attested.length === 0) {
|
|
4255
|
+
const keys = signingKeys(p.jwks);
|
|
4256
|
+
const held = keys.some((j) => existing.keys.some((k) => sameKey(k.jwk, j)));
|
|
4257
|
+
if (keys.length > 0 && !held) {
|
|
4258
|
+
return { error: rpcError(
|
|
4259
|
+
E.NOT_AUTHORISED,
|
|
4260
|
+
`${uri} is already a member, with other keys; a new key arrives through participant.rotate_key`,
|
|
4261
|
+
{ kids: [...new Set(keys.map((j) => j.kid))] }
|
|
4262
|
+
) };
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
3205
4265
|
if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
|
|
3206
4266
|
if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
|
|
3207
4267
|
if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
|
|
@@ -3212,18 +4272,126 @@ var Coordinator = class {
|
|
|
3212
4272
|
}
|
|
3213
4273
|
return { result: { joined: true, as: uri } };
|
|
3214
4274
|
}
|
|
4275
|
+
if (this.requiresSignatures(ws) && this.mustSign(ws, type) && member.keys.length === 0) {
|
|
4276
|
+
return { error: rpcError(E.PARAMS, `${uri} must sign in this workspace, and joins with no key`) };
|
|
4277
|
+
}
|
|
4278
|
+
if (made) this.addWorkspace(ws);
|
|
3215
4279
|
ws.members.set(uri, member);
|
|
4280
|
+
if (invitation) ws.invitations.delete(uri);
|
|
3216
4281
|
return { result: { joined: true, as: uri } };
|
|
3217
4282
|
}
|
|
4283
|
+
/**
|
|
4284
|
+
* SPECIFICATION 6.3.3: an admin invites a participant. For a URI that is
|
|
4285
|
+
* not a member it records an invitation, which the invitee's join takes
|
|
4286
|
+
* up. For a member that holds no key in force it registers the
|
|
4287
|
+
* invitation's keys: the way back for a member whose keys were all revoked,
|
|
4288
|
+
* or who joined a workspace that requires signatures with none.
|
|
4289
|
+
*/
|
|
4290
|
+
opWorkspaceInvite(p) {
|
|
4291
|
+
const miss = missing(p, ["workspace", "from", "uri", "type"]);
|
|
4292
|
+
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4293
|
+
for (const f of ["uri", "type"]) {
|
|
4294
|
+
if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
|
|
4295
|
+
}
|
|
4296
|
+
const bad = paramsError(p, [
|
|
4297
|
+
["role", "string"],
|
|
4298
|
+
["jwks", "object"],
|
|
4299
|
+
["jwks.keys", "list"],
|
|
4300
|
+
["oidc_sub", "string"]
|
|
4301
|
+
]);
|
|
4302
|
+
if (bad) return bad;
|
|
4303
|
+
const ws = this.workspaces.get(p.workspace);
|
|
4304
|
+
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
4305
|
+
const notMember = this.requireMember(ws, p.from);
|
|
4306
|
+
if (notMember) return notMember;
|
|
4307
|
+
if (rawMember(ws, p.from).role !== "admin") {
|
|
4308
|
+
return { error: rpcError(E.NOT_AUTHORISED, "workspace.invite requires the admin role") };
|
|
4309
|
+
}
|
|
4310
|
+
const uri = p.uri;
|
|
4311
|
+
const type = p.type;
|
|
4312
|
+
const named = participantError(type, uri, "uri");
|
|
4313
|
+
if (named) return named;
|
|
4314
|
+
const role = typeof p.role === "string" && p.role !== "" ? p.role : void 0;
|
|
4315
|
+
if (role === "admin" && !ADMIN_TYPES.includes(type)) {
|
|
4316
|
+
return { error: rpcError(E.PARAMS, "The admin role is held by a human or a service") };
|
|
4317
|
+
}
|
|
4318
|
+
const sub = typeof p.oidc_sub === "string" ? p.oidc_sub : void 0;
|
|
4319
|
+
if (sub !== void 0 && this.options.verifyOidcToken === void 0) {
|
|
4320
|
+
return { error: rpcError(E.PARAMS, "oidc_sub names a token subject, and this Coordinator has no token verifier") };
|
|
4321
|
+
}
|
|
4322
|
+
let pinned;
|
|
4323
|
+
if (p.jwks != null) {
|
|
4324
|
+
pinned = signingKeys(p.jwks);
|
|
4325
|
+
if (pinned.length === 0) return { error: rpcError(E.PARAMS, "jwks names no key: each key needs a kid") };
|
|
4326
|
+
}
|
|
4327
|
+
const now = this.now();
|
|
4328
|
+
const member = ws.members.get(uri);
|
|
4329
|
+
if (member) {
|
|
4330
|
+
if (member.keys.some((k) => inForce(k, now))) {
|
|
4331
|
+
return { error: rpcError(
|
|
4332
|
+
E.PARAMS,
|
|
4333
|
+
`${uri} is a member and holds a key in force; its keys change through participant.rotate_key`
|
|
4334
|
+
) };
|
|
4335
|
+
}
|
|
4336
|
+
if (!pinned) {
|
|
4337
|
+
return { error: rpcError(E.PARAMS, `${uri} is a member; an invitation for a member registers the keys its jwks names`) };
|
|
4338
|
+
}
|
|
4339
|
+
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)) {
|
|
4340
|
+
return { error: rpcError(
|
|
4341
|
+
E.PARAMS,
|
|
4342
|
+
`${uri} registers a new key by joining again with a token that binds it; an invitation does not register a person's keys here`
|
|
4343
|
+
) };
|
|
4344
|
+
}
|
|
4345
|
+
if (role !== void 0 && role !== member.role || sub !== void 0 && sub !== member.oidc_sub) {
|
|
4346
|
+
return { error: rpcError(E.PARAMS, `${uri} is a member; an invitation for a member registers keys and changes nothing else`) };
|
|
4347
|
+
}
|
|
4348
|
+
const reused = pinned.find((j) => member.keys.some((k) => k.kid === j.kid));
|
|
4349
|
+
if (reused) return { error: rpcError(E.PARAMS, `Key ${reused.kid} was ${uri}'s; a new key takes a new kid`) };
|
|
4350
|
+
for (const j of pinned) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
|
|
4351
|
+
return { result: { invited: true, uri, registered: pinned.map((j) => j.kid) } };
|
|
4352
|
+
}
|
|
4353
|
+
if (pinned && (type === "human" || sub !== void 0) && (this.options.verifyOidcToken !== void 0 || this.options.verifyVc !== void 0)) {
|
|
4354
|
+
return { error: rpcError(
|
|
4355
|
+
E.PARAMS,
|
|
4356
|
+
`${uri} joins with a token that binds its key; an invitation does not register a person's keys here`
|
|
4357
|
+
) };
|
|
4358
|
+
}
|
|
4359
|
+
const invitation = {
|
|
4360
|
+
uri,
|
|
4361
|
+
type,
|
|
4362
|
+
invited_by: p.from,
|
|
4363
|
+
invited_at: now
|
|
4364
|
+
};
|
|
4365
|
+
if (role !== void 0) invitation.role = role;
|
|
4366
|
+
if (pinned) invitation.jwks = { keys: pinned };
|
|
4367
|
+
if (sub !== void 0) invitation.oidc_sub = sub;
|
|
4368
|
+
ws.invitations.set(uri, invitation);
|
|
4369
|
+
return { result: { invited: true, uri } };
|
|
4370
|
+
}
|
|
3218
4371
|
opParticipantLeave(p) {
|
|
3219
4372
|
const ws = this.workspaces.get(p.workspace);
|
|
3220
4373
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
4374
|
+
if (rawMember(ws, p.from)?.paused) {
|
|
4375
|
+
return { error: rpcError(E.NOT_AUTHORISED, "A paused participant does not leave until another member lifts its pause") };
|
|
4376
|
+
}
|
|
3221
4377
|
ws.members.delete(p.from);
|
|
3222
4378
|
return { result: { left: true } };
|
|
3223
4379
|
}
|
|
3224
4380
|
opTaskCreate(p) {
|
|
3225
4381
|
const miss = missing(p, ["workspace", "from", "kind", "input"]);
|
|
3226
4382
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4383
|
+
const bad = paramsError(p, [
|
|
4384
|
+
["assignee", "string"],
|
|
4385
|
+
["to", "string"],
|
|
4386
|
+
["mode", "string"],
|
|
4387
|
+
["review_required", "boolean"],
|
|
4388
|
+
["routing_hints", "object"],
|
|
4389
|
+
["idempotency_key", "string"]
|
|
4390
|
+
]);
|
|
4391
|
+
if (bad) return bad;
|
|
4392
|
+
if (RESERVED_KEYS.includes(p.idempotency_key)) {
|
|
4393
|
+
return { error: rpcError(E.PARAMS, "idempotency_key must not be __proto__, constructor or prototype") };
|
|
4394
|
+
}
|
|
3227
4395
|
const ws = this.workspaces.get(p.workspace);
|
|
3228
4396
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3229
4397
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3235,9 +4403,6 @@ var Coordinator = class {
|
|
|
3235
4403
|
if (ws.members.get(assignee).paused) {
|
|
3236
4404
|
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
|
|
3237
4405
|
}
|
|
3238
|
-
if (p.mode !== void 0 && p.mode !== null && typeof p.mode !== "string") {
|
|
3239
|
-
return { error: rpcError(E.PARAMS, "mode must be a string") };
|
|
3240
|
-
}
|
|
3241
4406
|
const requestedMode = p.mode || ws.mode;
|
|
3242
4407
|
if (!modeLE(requestedMode, ws.mode_ceiling)) {
|
|
3243
4408
|
return { error: rpcError(
|
|
@@ -3245,9 +4410,6 @@ var Coordinator = class {
|
|
|
3245
4410
|
`Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
|
|
3246
4411
|
) };
|
|
3247
4412
|
}
|
|
3248
|
-
if (p.review_required !== void 0 && p.review_required !== null && typeof p.review_required !== "boolean") {
|
|
3249
|
-
return { error: rpcError(E.PARAMS, "review_required must be a boolean") };
|
|
3250
|
-
}
|
|
3251
4413
|
const taskId = this.ids.taskId();
|
|
3252
4414
|
const now = this.now();
|
|
3253
4415
|
const task = {
|
|
@@ -3286,6 +4448,7 @@ var Coordinator = class {
|
|
|
3286
4448
|
if (notMember) return notMember;
|
|
3287
4449
|
const task = ws.tasks.get(p.task_id);
|
|
3288
4450
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
4451
|
+
if (typeof p.state !== "string") return { error: rpcError(E.PARAMS, "state must be a string") };
|
|
3289
4452
|
const newState = p.state;
|
|
3290
4453
|
const legal = {
|
|
3291
4454
|
created: ["in_progress", "declined"],
|
|
@@ -3296,12 +4459,42 @@ var Coordinator = class {
|
|
|
3296
4459
|
if (!legal[task.state]?.includes(newState)) {
|
|
3297
4460
|
return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
|
|
3298
4461
|
}
|
|
3299
|
-
if (newState === "completed" && task
|
|
4462
|
+
if (newState === "completed" && reviewRequired(ws, task)) {
|
|
3300
4463
|
return { error: rpcError(
|
|
3301
4464
|
E.PARAMS,
|
|
3302
4465
|
"This task requires review. Submit the output with task.complete, which opens the review; a reviewer decision completes it."
|
|
3303
4466
|
) };
|
|
3304
4467
|
}
|
|
4468
|
+
if (newState === "completed" && task.review) {
|
|
4469
|
+
return { error: rpcError(
|
|
4470
|
+
E.PARAMS,
|
|
4471
|
+
"This task holds a review. Submit the output with task.complete, which opens a new round; a reviewer decision completes it."
|
|
4472
|
+
) };
|
|
4473
|
+
}
|
|
4474
|
+
if (task.state === "review_requested" && !task.review.authors.includes(p.from)) {
|
|
4475
|
+
return { error: rpcError(
|
|
4476
|
+
E.NOT_AUTHORISED,
|
|
4477
|
+
"Only the authors of the work under review take it back from review"
|
|
4478
|
+
) };
|
|
4479
|
+
}
|
|
4480
|
+
if (newState === "cancelled" && openRequiredReviewAuthors(ws, task).includes(p.from)) {
|
|
4481
|
+
return { error: rpcError(
|
|
4482
|
+
E.NOT_AUTHORISED,
|
|
4483
|
+
"The authors of the work under review cannot end its review without a decision"
|
|
4484
|
+
) };
|
|
4485
|
+
}
|
|
4486
|
+
if (newState === "declined" && task.review && !task.review.decisions.some((d) => d.kind === "reject")) {
|
|
4487
|
+
return { error: rpcError(
|
|
4488
|
+
E.PARAMS,
|
|
4489
|
+
"This task holds a review no decision has closed; a reviewer's decision ends it"
|
|
4490
|
+
) };
|
|
4491
|
+
}
|
|
4492
|
+
if (newState === "declined" && !task.review && openRequiredReviewAuthors(ws, task).length > 0) {
|
|
4493
|
+
return { error: rpcError(
|
|
4494
|
+
E.PARAMS,
|
|
4495
|
+
"This task carries on a review no decision has closed; completing it opens the review a reviewer decides"
|
|
4496
|
+
) };
|
|
4497
|
+
}
|
|
3305
4498
|
task.state = newState;
|
|
3306
4499
|
task.updated_at = this.now();
|
|
3307
4500
|
task.history.push({
|
|
@@ -3322,32 +4515,60 @@ var Coordinator = class {
|
|
|
3322
4515
|
if (task.state !== "created" && task.state !== "in_progress") {
|
|
3323
4516
|
return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
|
|
3324
4517
|
}
|
|
3325
|
-
if (
|
|
4518
|
+
if (p.confidence != null && typeof p.confidence !== "number" && typeof p.confidence !== "string") {
|
|
4519
|
+
return { error: rpcError(E.PARAMS, "confidence must be a number or a string") };
|
|
4520
|
+
}
|
|
4521
|
+
const required = reviewRequired(ws, task);
|
|
4522
|
+
if (required && p.from !== task.assignee) {
|
|
4523
|
+
return { error: rpcError(E.NOT_AUTHORISED, "Only the assignee completes a task that requires review") };
|
|
4524
|
+
}
|
|
4525
|
+
if (required || task.review) {
|
|
3326
4526
|
const now = this.now();
|
|
4527
|
+
const authors = authorsOf(task, p.from);
|
|
4528
|
+
let note = OPENED_ON_COMPLETE;
|
|
3327
4529
|
if (!task.review) {
|
|
3328
|
-
const
|
|
3329
|
-
const eligible = [...rawEntries(ws.members)].filter(([uri, member]) => uri !== producer && uri !== task.assignee && member.type === "human").map(([uri]) => uri);
|
|
4530
|
+
const eligible = [...rawEntries(ws.members)].filter(([uri, member]) => !authors.includes(uri) && member.type === "human").map(([uri]) => uri);
|
|
3330
4531
|
if (eligible.length === 0) {
|
|
3331
4532
|
return { error: rpcError(
|
|
3332
4533
|
E.NOT_AUTHORISED,
|
|
3333
4534
|
"Task requires review but has no eligible human reviewer (needs a human member other than its assignee and completer)"
|
|
3334
4535
|
) };
|
|
3335
4536
|
}
|
|
3336
|
-
task.review = {
|
|
4537
|
+
task.review = {
|
|
4538
|
+
requested_at: now,
|
|
4539
|
+
requested_to: eligible,
|
|
4540
|
+
rule: ws.review_rule ?? "any_one_approves",
|
|
4541
|
+
decisions: [],
|
|
4542
|
+
authors,
|
|
4543
|
+
acknowledgements: []
|
|
4544
|
+
};
|
|
4545
|
+
} else {
|
|
4546
|
+
const roundAuthors = unionOf(task.review.authors, authors);
|
|
4547
|
+
const reviewers = task.review.requested_to.filter((r) => !roundAuthors.includes(r) && !(required && rawMember(ws, r) !== void 0 && rawMember(ws, r).type !== "human"));
|
|
4548
|
+
if (reviewers.length === 0) {
|
|
4549
|
+
return { error: rpcError(
|
|
4550
|
+
E.NOT_AUTHORISED,
|
|
4551
|
+
"No reviewer of this task remains once the authors of the work are left out"
|
|
4552
|
+
) };
|
|
4553
|
+
}
|
|
4554
|
+
task.review = {
|
|
4555
|
+
...task.review,
|
|
4556
|
+
requested_at: now,
|
|
4557
|
+
requested_to: reviewers,
|
|
4558
|
+
decisions: [],
|
|
4559
|
+
authors: roundAuthors,
|
|
4560
|
+
acknowledgements: []
|
|
4561
|
+
};
|
|
4562
|
+
note = NEW_ROUND_ON_COMPLETE;
|
|
3337
4563
|
}
|
|
3338
4564
|
task.pending_artefact = p.output;
|
|
3339
4565
|
task.state = "review_requested";
|
|
3340
4566
|
task.updated_at = now;
|
|
3341
|
-
task.history.push({
|
|
3342
|
-
ts: now,
|
|
3343
|
-
from: p.from,
|
|
3344
|
-
state: "review_requested",
|
|
3345
|
-
note: "review required; opened on task.complete"
|
|
3346
|
-
});
|
|
4567
|
+
task.history.push({ ts: now, from: p.from, state: "review_requested", note });
|
|
3347
4568
|
return { result: { state: "review_requested", review_id: task.id } };
|
|
3348
4569
|
}
|
|
3349
4570
|
task.output = p.output;
|
|
3350
|
-
if (
|
|
4571
|
+
if (p.confidence != null) task.confidence = p.confidence;
|
|
3351
4572
|
task.state = "completed";
|
|
3352
4573
|
task.updated_at = this.now();
|
|
3353
4574
|
task.history.push({ ts: task.updated_at, from: p.from, state: "completed" });
|
|
@@ -3360,8 +4581,16 @@ var Coordinator = class {
|
|
|
3360
4581
|
const notMember = this.requireMember(ws, p.from);
|
|
3361
4582
|
if (notMember) return notMember;
|
|
3362
4583
|
}
|
|
3363
|
-
const
|
|
3364
|
-
|
|
4584
|
+
const bad = paramsError(p, [
|
|
4585
|
+
["range.from_seq", "integer"],
|
|
4586
|
+
["range.to_seq", "integer"],
|
|
4587
|
+
["filter.method", "string"],
|
|
4588
|
+
["filter.from", "string"],
|
|
4589
|
+
["filter.task_id", "string"]
|
|
4590
|
+
]);
|
|
4591
|
+
if (bad) return bad;
|
|
4592
|
+
const range = isObject(p.range) ? p.range : {};
|
|
4593
|
+
const filter = isObject(p.filter) ? p.filter : {};
|
|
3365
4594
|
if (filter.outcome != null && filter.outcome !== "accepted" && filter.outcome !== "refused") {
|
|
3366
4595
|
return { error: rpcError(E.PARAMS, "filter.outcome must be 'accepted' or 'refused'") };
|
|
3367
4596
|
}
|
|
@@ -3412,37 +4641,73 @@ var Coordinator = class {
|
|
|
3412
4641
|
return { error: rpcError(E.NOT_REVIEWABLE, `Cannot request review for task in state: ${task.state}`) };
|
|
3413
4642
|
}
|
|
3414
4643
|
const to = p.to;
|
|
4644
|
+
if (to != null && typeof to !== "string" && !(Array.isArray(to) && to.every((r) => typeof r === "string"))) {
|
|
4645
|
+
return { error: rpcError(E.PARAMS, "to must be a string or a list of strings") };
|
|
4646
|
+
}
|
|
3415
4647
|
const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
|
|
3416
4648
|
if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
|
|
4649
|
+
const bad = paramsError(p, [["rule", "string"]]);
|
|
4650
|
+
if (bad) return bad;
|
|
3417
4651
|
const now = this.now();
|
|
3418
4652
|
const declaredRule = p.rule;
|
|
3419
4653
|
const rule = declaredRule || "any_one_approves";
|
|
3420
4654
|
if (!reviewRuleSupported(rule)) {
|
|
3421
4655
|
return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
|
|
3422
4656
|
}
|
|
3423
|
-
|
|
3424
|
-
|
|
4657
|
+
const open = task.state === "review_requested" ? task.review : void 0;
|
|
4658
|
+
if (open) {
|
|
4659
|
+
if (contentHash(task.pending_artefact ?? null) !== contentHash(p.artefact ?? null)) {
|
|
3425
4660
|
return { error: rpcError(
|
|
3426
4661
|
E.REVIEW_ALREADY_OPEN,
|
|
3427
4662
|
"A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
|
|
3428
4663
|
) };
|
|
3429
4664
|
}
|
|
3430
|
-
if (declaredRule
|
|
4665
|
+
if (declaredRule != null && declaredRule !== open.rule) {
|
|
3431
4666
|
return { error: rpcError(
|
|
3432
4667
|
E.REVIEW_ALREADY_OPEN,
|
|
3433
|
-
`Cannot change the decision rule of an open review (currently ${
|
|
4668
|
+
`Cannot change the decision rule of an open review (currently ${open.rule})`
|
|
4669
|
+
) };
|
|
4670
|
+
}
|
|
4671
|
+
}
|
|
4672
|
+
const authors = open ? open.authors : unionOf(task.review ? task.review.authors : [], authorsOf(task, lastCompleter(task)));
|
|
4673
|
+
if (reviewRequired(ws, task)) {
|
|
4674
|
+
if (!open) {
|
|
4675
|
+
return { error: rpcError(
|
|
4676
|
+
E.NOT_AUTHORISED,
|
|
4677
|
+
"This task requires a person's review, which opens on task.complete; review.request only adds reviewers to it once it is open"
|
|
4678
|
+
) };
|
|
4679
|
+
}
|
|
4680
|
+
if (authors.includes(p.from)) {
|
|
4681
|
+
return { error: rpcError(E.NOT_AUTHORISED, "The author of the work under review cannot request its review") };
|
|
4682
|
+
}
|
|
4683
|
+
const scope = reviewers.find(isScope);
|
|
4684
|
+
if (scope !== void 0) {
|
|
4685
|
+
return { error: rpcError(
|
|
4686
|
+
E.PARAMS,
|
|
4687
|
+
`A review that requires a person names its reviewers, and ${scope} is an address scope`
|
|
3434
4688
|
) };
|
|
3435
4689
|
}
|
|
3436
4690
|
for (const r of reviewers) {
|
|
3437
|
-
if (
|
|
4691
|
+
if (authors.includes(r)) {
|
|
4692
|
+
return { error: rpcError(E.NOT_AUTHORISED, `The author of the work under review cannot review it: ${r}`) };
|
|
4693
|
+
}
|
|
4694
|
+
const member = rawMember(ws, r);
|
|
4695
|
+
if (member && member.type !== "human") {
|
|
4696
|
+
return { error: rpcError(E.NOT_AUTHORISED, `This task requires a person's review: ${r} is not a human member`) };
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
}
|
|
4700
|
+
if (open) {
|
|
4701
|
+
for (const r of reviewers) {
|
|
4702
|
+
if (!open.requested_to.includes(r)) open.requested_to.push(r);
|
|
3438
4703
|
}
|
|
3439
|
-
if (p.deadline
|
|
4704
|
+
if (p.deadline != null) open.deadline = p.deadline;
|
|
3440
4705
|
task.updated_at = now;
|
|
3441
4706
|
return { result: {
|
|
3442
4707
|
state: "review_requested",
|
|
3443
4708
|
review_id: task.id,
|
|
3444
4709
|
amended: true,
|
|
3445
|
-
requested_to: [...
|
|
4710
|
+
requested_to: [...open.requested_to]
|
|
3446
4711
|
} };
|
|
3447
4712
|
}
|
|
3448
4713
|
task.state = "review_requested";
|
|
@@ -3452,12 +4717,41 @@ var Coordinator = class {
|
|
|
3452
4717
|
requested_to: reviewers,
|
|
3453
4718
|
rule,
|
|
3454
4719
|
deadline: p.deadline,
|
|
3455
|
-
decisions: []
|
|
4720
|
+
decisions: [],
|
|
4721
|
+
authors,
|
|
4722
|
+
acknowledgements: []
|
|
3456
4723
|
};
|
|
3457
4724
|
task.history.push({ ts: now, from: p.from, state: "review_requested" });
|
|
3458
4725
|
task.pending_artefact = p.artefact;
|
|
3459
4726
|
return { result: { state: "review_requested", review_id: task.id } };
|
|
3460
4727
|
}
|
|
4728
|
+
/**
|
|
4729
|
+
* review/1.0 S3.6: a reviewer says they have begun the open review. Only
|
|
4730
|
+
* someone who may decide it can, so the log's record of who took a review
|
|
4731
|
+
* up, and when, names reviewers alone. It changes nothing else: the review
|
|
4732
|
+
* stays open and the acknowledgement counts towards no rule.
|
|
4733
|
+
*/
|
|
4734
|
+
opReviewAcknowledge(p) {
|
|
4735
|
+
const miss = missing(p, ["workspace", "from", "task_id"]);
|
|
4736
|
+
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4737
|
+
const ws = this.workspaces.get(p.workspace);
|
|
4738
|
+
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
4739
|
+
const notMember = this.requireMember(ws, p.from);
|
|
4740
|
+
if (notMember) return notMember;
|
|
4741
|
+
const task = ws.tasks.get(p.task_id);
|
|
4742
|
+
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
4743
|
+
if (task.state !== "review_requested") {
|
|
4744
|
+
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
4745
|
+
}
|
|
4746
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
4747
|
+
if (notReviewer) return notReviewer;
|
|
4748
|
+
const review = task.review;
|
|
4749
|
+
if (review.acknowledgements.some((a) => a.reviewer === p.from)) {
|
|
4750
|
+
return { error: rpcError(E.PARAMS, "Already acknowledged in this round") };
|
|
4751
|
+
}
|
|
4752
|
+
review.acknowledgements.push({ reviewer: p.from, ts: this.now() });
|
|
4753
|
+
return { result: { acknowledged: true, requested_at: review.requested_at } };
|
|
4754
|
+
}
|
|
3461
4755
|
/**
|
|
3462
4756
|
* Verify an optional `approved_artefact_digest` against the artefact under
|
|
3463
4757
|
* review (review/1.0 S3.2).
|
|
@@ -3476,7 +4770,7 @@ var Coordinator = class {
|
|
|
3476
4770
|
if (typeof declared !== "string") {
|
|
3477
4771
|
return { error: rpcError(E.PARAMS, "approved_artefact_digest must be a string") };
|
|
3478
4772
|
}
|
|
3479
|
-
if (task.pending_artefact === void 0) {
|
|
4773
|
+
if (task.pending_artefact === void 0 || task.pending_artefact === null) {
|
|
3480
4774
|
return { error: rpcError(E.PARAMS, "No artefact under review to bind a digest to") };
|
|
3481
4775
|
}
|
|
3482
4776
|
const actual = contentHash(task.pending_artefact);
|
|
@@ -3492,8 +4786,8 @@ var Coordinator = class {
|
|
|
3492
4786
|
opDecide(p, kind) {
|
|
3493
4787
|
const miss = missing(p, ["workspace", "from", "task_id"]);
|
|
3494
4788
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
3495
|
-
const
|
|
3496
|
-
if (
|
|
4789
|
+
const bad = paramsError(p, [["tags", "strings"], ["request_revision", "boolean"]]);
|
|
4790
|
+
if (bad) return bad;
|
|
3497
4791
|
const ws = this.workspaces.get(p.workspace);
|
|
3498
4792
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3499
4793
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3503,7 +4797,7 @@ var Coordinator = class {
|
|
|
3503
4797
|
if (task.state !== "review_requested") {
|
|
3504
4798
|
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
3505
4799
|
}
|
|
3506
|
-
const notReviewer = this.requireReviewer(task, p.from);
|
|
4800
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
3507
4801
|
if (notReviewer) return notReviewer;
|
|
3508
4802
|
const digestError = this.checkArtefactDigest(p, task);
|
|
3509
4803
|
if (digestError) return digestError;
|
|
@@ -3530,8 +4824,8 @@ var Coordinator = class {
|
|
|
3530
4824
|
opDecideOverride(p) {
|
|
3531
4825
|
const miss = missing(p, ["workspace", "from", "task_id", "diff", "rationale"]);
|
|
3532
4826
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
3533
|
-
const
|
|
3534
|
-
if (
|
|
4827
|
+
const bad = paramsError(p, [["tags", "strings"], ["policy_refs", "strings"]]);
|
|
4828
|
+
if (bad) return bad;
|
|
3535
4829
|
const ws = this.workspaces.get(p.workspace);
|
|
3536
4830
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3537
4831
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3541,15 +4835,17 @@ var Coordinator = class {
|
|
|
3541
4835
|
if (task.state !== "review_requested") {
|
|
3542
4836
|
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
3543
4837
|
}
|
|
3544
|
-
const notReviewer = this.requireReviewer(task, p.from);
|
|
4838
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
3545
4839
|
if (notReviewer) return notReviewer;
|
|
3546
4840
|
const digestError = this.checkArtefactDigest(p, task);
|
|
3547
4841
|
if (digestError) return digestError;
|
|
3548
4842
|
const base = task.pending_artefact;
|
|
3549
|
-
if (base === void 0) return { error: rpcError(E.PARAMS, "No base artefact for override") };
|
|
4843
|
+
if (base === void 0 || base === null) return { error: rpcError(E.PARAMS, "No base artefact for override") };
|
|
3550
4844
|
let applied;
|
|
3551
4845
|
try {
|
|
3552
|
-
applied = applyJsonPatch(base, p.diff
|
|
4846
|
+
applied = applyJsonPatch(base, p.diff, {
|
|
4847
|
+
maxStringBytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES
|
|
4848
|
+
});
|
|
3553
4849
|
} catch (e) {
|
|
3554
4850
|
const msg = e instanceof Error ? e.message : String(e);
|
|
3555
4851
|
return { error: rpcError(E.PATCH_FAILED, msg) };
|
|
@@ -3588,6 +4884,8 @@ var Coordinator = class {
|
|
|
3588
4884
|
opAbstainDeclare(p) {
|
|
3589
4885
|
const miss = missing(p, ["workspace", "from", "task_id", "reason"]);
|
|
3590
4886
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4887
|
+
const bad = paramsError(p, [["category", "string"]]);
|
|
4888
|
+
if (bad) return bad;
|
|
3591
4889
|
const ws = this.workspaces.get(p.workspace);
|
|
3592
4890
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3593
4891
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3597,7 +4895,7 @@ var Coordinator = class {
|
|
|
3597
4895
|
if (task.state !== "review_requested") {
|
|
3598
4896
|
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
3599
4897
|
}
|
|
3600
|
-
const notReviewer = this.requireReviewer(task, p.from);
|
|
4898
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
3601
4899
|
if (notReviewer) return notReviewer;
|
|
3602
4900
|
const now = this.now();
|
|
3603
4901
|
task.review.decisions.push({
|
|
@@ -3629,6 +4927,8 @@ var Coordinator = class {
|
|
|
3629
4927
|
return { error: rpcError(E.PARAMS, "new_task must be an object") };
|
|
3630
4928
|
}
|
|
3631
4929
|
const nt = p.new_task;
|
|
4930
|
+
const bad = paramsError(p, [["new_task.kind", "string"]]);
|
|
4931
|
+
if (bad) return bad;
|
|
3632
4932
|
const assignee = nt.assignee;
|
|
3633
4933
|
if (typeof assignee !== "string" || !ws.members.has(assignee)) {
|
|
3634
4934
|
return { error: rpcError(E.PARAMS, "Escalation assignee not in workspace") };
|
|
@@ -3663,9 +4963,10 @@ var Coordinator = class {
|
|
|
3663
4963
|
history: [{ ts: now, from: p.from, state: "created", note: `escalated from ${orig.id}` }],
|
|
3664
4964
|
paused: false
|
|
3665
4965
|
};
|
|
3666
|
-
if (orig.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
|
|
4966
|
+
if (orig.review_required || orig.review !== void 0 || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
|
|
3667
4967
|
newTask.review_required = true;
|
|
3668
4968
|
}
|
|
4969
|
+
newTask.prior_authors = successorAuthors(orig, p.from);
|
|
3669
4970
|
ws.tasks.set(newId, newTask);
|
|
3670
4971
|
orig.state = "escalated";
|
|
3671
4972
|
orig.updated_at = now;
|
|
@@ -3682,8 +4983,8 @@ function memberToDict(m) {
|
|
|
3682
4983
|
joined: m.joined
|
|
3683
4984
|
};
|
|
3684
4985
|
if (m.display_name) out.display_name = m.display_name;
|
|
3685
|
-
if (m.capabilities) out.capabilities = m.capabilities;
|
|
3686
|
-
if (m.scopes) out.scopes = m.scopes;
|
|
4986
|
+
if (m.capabilities && Object.keys(m.capabilities).length) out.capabilities = m.capabilities;
|
|
4987
|
+
if (m.scopes?.length) out.scopes = m.scopes;
|
|
3687
4988
|
if (m.keys.length) {
|
|
3688
4989
|
out.jwks = { keys: m.keys.filter((k) => !k.revoked_at && !k.valid_until).map((k) => k.jwk) };
|
|
3689
4990
|
out.key_history = m.keys;
|
|
@@ -3695,7 +4996,6 @@ function memberToDict(m) {
|
|
|
3695
4996
|
}
|
|
3696
4997
|
|
|
3697
4998
|
// src/policy.ts
|
|
3698
|
-
var CRIT2 = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
3699
4999
|
function makeDefaultPolicy(seniorTarget) {
|
|
3700
5000
|
const routingPolicy = (task, candidates) => {
|
|
3701
5001
|
const eligible = candidates;
|
|
@@ -3713,15 +5013,8 @@ function makeDefaultPolicy(seniorTarget) {
|
|
|
3713
5013
|
};
|
|
3714
5014
|
};
|
|
3715
5015
|
const reviewDepthPolicy = (_task, hints) => {
|
|
3716
|
-
const
|
|
3717
|
-
|
|
3718
|
-
try {
|
|
3719
|
-
conf = Number(hints.confidence ?? 1);
|
|
3720
|
-
} catch {
|
|
3721
|
-
conf = 1;
|
|
3722
|
-
}
|
|
3723
|
-
if (isNaN(conf)) conf = 1;
|
|
3724
|
-
const r = CRIT2[crit] ?? 1;
|
|
5016
|
+
const conf = confidenceOf(hints);
|
|
5017
|
+
const r = criticalityOf(hints);
|
|
3725
5018
|
if (r >= 3) return {
|
|
3726
5019
|
depth: "full",
|
|
3727
5020
|
rationale: { policy_id: "default", summary: "criticality=critical: full review" }
|
|
@@ -3746,15 +5039,8 @@ function makeDefaultPolicy(seniorTarget) {
|
|
|
3746
5039
|
};
|
|
3747
5040
|
};
|
|
3748
5041
|
const escalationPolicy = (_task, hints) => {
|
|
3749
|
-
const
|
|
3750
|
-
|
|
3751
|
-
try {
|
|
3752
|
-
conf = Number(hints.confidence ?? 1);
|
|
3753
|
-
} catch {
|
|
3754
|
-
conf = 1;
|
|
3755
|
-
}
|
|
3756
|
-
if (isNaN(conf)) conf = 1;
|
|
3757
|
-
const r = CRIT2[crit] ?? 1;
|
|
5042
|
+
const conf = confidenceOf(hints);
|
|
5043
|
+
const r = criticalityOf(hints);
|
|
3758
5044
|
const escalate = r >= 3 || r >= 2 && conf < 0.6;
|
|
3759
5045
|
return {
|
|
3760
5046
|
escalate,
|
|
@@ -3774,6 +5060,19 @@ function wrapMcpToolCall(coord, workspace, options) {
|
|
|
3774
5060
|
if (!workspace) throw new Error("workspace is required");
|
|
3775
5061
|
if (!options.caller) throw new Error("caller is required");
|
|
3776
5062
|
if (!options.tool) throw new Error("tool is required");
|
|
5063
|
+
if (options.reviewRequired) {
|
|
5064
|
+
const described = coord.dispatch({
|
|
5065
|
+
jsonrpc: "2.0",
|
|
5066
|
+
id: `wrap-${options.tool}-describe`,
|
|
5067
|
+
method: "workspace.describe",
|
|
5068
|
+
params: { workspace, from: options.caller }
|
|
5069
|
+
});
|
|
5070
|
+
if (described.error) throw new Error(`workspace.describe failed: ${JSON.stringify(described.error)}`);
|
|
5071
|
+
const members = described.result.members;
|
|
5072
|
+
if (!members.some((m) => m.type === "human" && m.uri !== options.caller)) {
|
|
5073
|
+
throw new Error("review_required needs a human member other than the caller to review the result; nothing was recorded");
|
|
5074
|
+
}
|
|
5075
|
+
}
|
|
3777
5076
|
const inputHash = contentHash(options.args);
|
|
3778
5077
|
const outputHash = contentHash(options.result);
|
|
3779
5078
|
const kind = options.taskKind ?? `mcp_call:${options.tool}`;
|
|
@@ -3792,6 +5091,7 @@ function wrapMcpToolCall(coord, workspace, options) {
|
|
|
3792
5091
|
input: options.args
|
|
3793
5092
|
};
|
|
3794
5093
|
if (options.routingHints) createParams.routing_hints = { ...options.routingHints };
|
|
5094
|
+
if (options.reviewRequired) createParams.review_required = true;
|
|
3795
5095
|
const createResp = coord.dispatch({
|
|
3796
5096
|
jsonrpc: "2.0",
|
|
3797
5097
|
id: `wrap-${options.tool}-create`,
|
|
@@ -3821,7 +5121,7 @@ function wrapMcpToolCall(coord, workspace, options) {
|
|
|
3821
5121
|
method: "task.complete",
|
|
3822
5122
|
params: completeParams
|
|
3823
5123
|
});
|
|
3824
|
-
if (completeResp.error) throw new Error(`task.complete failed: ${JSON.stringify(completeResp.error)}`);
|
|
5124
|
+
if (completeResp.error) throw new Error(`task.complete failed for ${taskId}: ${JSON.stringify(completeResp.error)}`);
|
|
3825
5125
|
return { task_id: taskId, input_hash: inputHash, output_hash: outputHash };
|
|
3826
5126
|
}
|
|
3827
5127
|
function wrapA2aMessageExchange(coord, workspace, options) {
|
|
@@ -3885,6 +5185,7 @@ function wrapA2aMessageExchange(coord, workspace, options) {
|
|
|
3885
5185
|
export {
|
|
3886
5186
|
Coordinator,
|
|
3887
5187
|
CoordinatorError,
|
|
5188
|
+
DEFAULT_MAX_PATCHED_BYTES,
|
|
3888
5189
|
E,
|
|
3889
5190
|
IdFactory,
|
|
3890
5191
|
MemoryStore,
|