@brightbeamai/chap-coordinator 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -8
- package/dist/{api-59aU01Bs.d.cts → api-CjZQnj63.d.cts} +128 -14
- package/dist/{api-CxciGFHA.d.ts → api-DG0VFGVr.d.ts} +128 -14
- package/dist/api.cjs +3 -1
- package/dist/api.cjs.map +1 -1
- package/dist/api.d.cts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/api.js +3 -1
- package/dist/api.js.map +1 -1
- package/dist/index.cjs +1336 -219
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -5
- package/dist/index.d.ts +19 -5
- package/dist/index.js +1336 -219
- package/dist/index.js.map +1 -1
- package/dist/jsonrpc.cjs +1 -0
- package/dist/jsonrpc.cjs.map +1 -1
- package/dist/jsonrpc.d.cts +1 -0
- package/dist/jsonrpc.d.ts +1 -0
- package/dist/jsonrpc.js +1 -0
- package/dist/jsonrpc.js.map +1 -1
- package/dist/methods.cjs.map +1 -1
- package/dist/methods.d.cts +36 -3
- package/dist/methods.d.ts +36 -3
- package/dist/storage/sqlite.cjs +81 -19
- package/dist/storage/sqlite.cjs.map +1 -1
- package/dist/storage/sqlite.d.cts +19 -9
- package/dist/storage/sqlite.d.ts +19 -9
- package/dist/storage/sqlite.js +81 -19
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/storage/store.cjs +20 -15
- package/dist/storage/store.cjs.map +1 -1
- package/dist/storage/store.d.cts +24 -6
- package/dist/storage/store.d.ts +24 -6
- package/dist/storage/store.js +20 -15
- package/dist/storage/store.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +56 -1
- package/dist/types.d.ts +56 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/index.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) {
|
|
@@ -794,7 +961,8 @@ var COLLECTIONS = [
|
|
|
794
961
|
"deliberations",
|
|
795
962
|
"handoffs",
|
|
796
963
|
"snapshots",
|
|
797
|
-
"route_decisions"
|
|
964
|
+
"route_decisions",
|
|
965
|
+
"invitations"
|
|
798
966
|
];
|
|
799
967
|
var IDEMPOTENCY_KEYS = "idempotency_keys";
|
|
800
968
|
var StoreConflictError = class extends Error {
|
|
@@ -805,7 +973,7 @@ var StoreConflictError = class extends Error {
|
|
|
805
973
|
base;
|
|
806
974
|
constructor(workspace, held, base) {
|
|
807
975
|
super(
|
|
808
|
-
`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.`
|
|
809
977
|
);
|
|
810
978
|
this.name = "StoreConflictError";
|
|
811
979
|
this.workspace = workspace;
|
|
@@ -882,20 +1050,24 @@ function assembleSnapshot(meta, entries, objects) {
|
|
|
882
1050
|
var MemoryStore = class {
|
|
883
1051
|
held = /* @__PURE__ */ new Map();
|
|
884
1052
|
load() {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
|
|
894
|
-
version: h.version,
|
|
895
|
-
updated_at: h.updated_at
|
|
896
|
-
});
|
|
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)]);
|
|
897
1061
|
}
|
|
898
|
-
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;
|
|
899
1071
|
}
|
|
900
1072
|
save(record) {
|
|
901
1073
|
this.apply(changeFromRecord(record));
|
|
@@ -1022,7 +1194,8 @@ function makeApi(coord) {
|
|
|
1022
1194
|
workspace: {
|
|
1023
1195
|
create: (p) => c("workspace.create", p),
|
|
1024
1196
|
describe: (p) => c("workspace.describe", p),
|
|
1025
|
-
set_profiles: (p) => c("workspace.set_profiles", p)
|
|
1197
|
+
set_profiles: (p) => c("workspace.set_profiles", p),
|
|
1198
|
+
invite: (p) => c("workspace.invite", p)
|
|
1026
1199
|
},
|
|
1027
1200
|
participant: {
|
|
1028
1201
|
join: (p) => c("participant.join", p),
|
|
@@ -1038,6 +1211,7 @@ function makeApi(coord) {
|
|
|
1038
1211
|
},
|
|
1039
1212
|
review: {
|
|
1040
1213
|
request: (p) => c("review.request", p),
|
|
1214
|
+
acknowledge: (p) => c("review.acknowledge", p),
|
|
1041
1215
|
depth: (p) => c("review.depth", p)
|
|
1042
1216
|
},
|
|
1043
1217
|
decide: {
|
|
@@ -1111,10 +1285,17 @@ function registerAuditScitt(coord) {
|
|
|
1111
1285
|
coord.handlers.set("audit.submit_to_scitt", (p) => {
|
|
1112
1286
|
const ws = coord.workspaces.get(p.workspace);
|
|
1113
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;
|
|
1114
1295
|
const range = p.range ?? {};
|
|
1115
1296
|
const fromSeq = range.from_seq ?? 0;
|
|
1116
1297
|
const toSeq = range.to_seq ?? ws.audit.length;
|
|
1117
|
-
const issuer = p.issuer
|
|
1298
|
+
const issuer = p.issuer || "service:coordinator";
|
|
1118
1299
|
if (!coord.options.scittSubmitter) {
|
|
1119
1300
|
const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, entryRecord(e), issuer));
|
|
1120
1301
|
return { result: {
|
|
@@ -1144,7 +1325,7 @@ function registerAuditScitt(coord) {
|
|
|
1144
1325
|
});
|
|
1145
1326
|
coord.handlers.set("audit.verify_receipt", (p) => {
|
|
1146
1327
|
const receipt = p.receipt;
|
|
1147
|
-
if (typeof receipt !== "object" || receipt === null) {
|
|
1328
|
+
if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
|
|
1148
1329
|
return { error: rpcError(E.PARAMS, "receipt must be an object") };
|
|
1149
1330
|
}
|
|
1150
1331
|
if (coord.options.verifyScittReceipt) {
|
|
@@ -1158,7 +1339,7 @@ function registerAuditScitt(coord) {
|
|
|
1158
1339
|
if (!ok) return { error: rpcError(E.SCITT_RECEIPT_INVALID, "Receipt did not verify") };
|
|
1159
1340
|
return { result: { verified: true } };
|
|
1160
1341
|
}
|
|
1161
|
-
return { error: rpcError(E.SCITT_RECEIPT_INVALID, "No
|
|
1342
|
+
return { error: rpcError(E.SCITT_RECEIPT_INVALID, "No receipt verifier is configured; receipt not verified") };
|
|
1162
1343
|
});
|
|
1163
1344
|
coord.handlers.set("audit.verify_chain", (p) => {
|
|
1164
1345
|
const ws = coord.workspaces.get(p.workspace);
|
|
@@ -1166,7 +1347,7 @@ function registerAuditScitt(coord) {
|
|
|
1166
1347
|
if (!(ws.chain_enabled || coord.options.enableChain)) {
|
|
1167
1348
|
return { error: rpcError(E.PARAMS, "Chain not enabled for this workspace") };
|
|
1168
1349
|
}
|
|
1169
|
-
if (p.from_seq
|
|
1350
|
+
if (p.from_seq != null || p.to_seq != null) {
|
|
1170
1351
|
return { error: rpcError(
|
|
1171
1352
|
E.PARAMS,
|
|
1172
1353
|
"Range verification is not implemented; omit from_seq and to_seq to verify the whole log."
|
|
@@ -1228,13 +1409,21 @@ function registerAuditScitt(coord) {
|
|
|
1228
1409
|
// src/profiles/control.ts
|
|
1229
1410
|
var VALID_MODES = /* @__PURE__ */ new Set(["shadow", "trial", "production"]);
|
|
1230
1411
|
var VALID_SCOPES = /* @__PURE__ */ new Set(["task", "participant", "workspace"]);
|
|
1412
|
+
var SCOPE_ERROR = "scope must be task, participant or workspace";
|
|
1231
1413
|
function registerControl(coord) {
|
|
1232
1414
|
coord.handlers.set("control.pause", (p) => {
|
|
1233
1415
|
const ws = coord.workspaces.get(p.workspace);
|
|
1234
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;
|
|
1235
1424
|
const scope = p.scope || "task";
|
|
1236
1425
|
if (!VALID_SCOPES.has(scope)) {
|
|
1237
|
-
return { error: rpcError(E.PARAMS,
|
|
1426
|
+
return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
|
|
1238
1427
|
}
|
|
1239
1428
|
if (scope === "task") {
|
|
1240
1429
|
const task = ws.tasks.get(p.task_id);
|
|
@@ -1252,7 +1441,8 @@ function registerControl(coord) {
|
|
|
1252
1441
|
}
|
|
1253
1442
|
if (scope === "participant") {
|
|
1254
1443
|
const uri = p.participant_uri || p.uri;
|
|
1255
|
-
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}`) };
|
|
1256
1446
|
ws.members.get(uri).paused = true;
|
|
1257
1447
|
return { result: {
|
|
1258
1448
|
scope: "participant",
|
|
@@ -1267,9 +1457,11 @@ function registerControl(coord) {
|
|
|
1267
1457
|
coord.handlers.set("control.resume", (p) => {
|
|
1268
1458
|
const ws = coord.workspaces.get(p.workspace);
|
|
1269
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;
|
|
1270
1462
|
const scope = p.scope || "task";
|
|
1271
1463
|
if (!VALID_SCOPES.has(scope)) {
|
|
1272
|
-
return { error: rpcError(E.PARAMS,
|
|
1464
|
+
return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
|
|
1273
1465
|
}
|
|
1274
1466
|
if (scope === "task") {
|
|
1275
1467
|
const task = ws.tasks.get(p.task_id);
|
|
@@ -1287,7 +1479,9 @@ function registerControl(coord) {
|
|
|
1287
1479
|
}
|
|
1288
1480
|
if (scope === "participant") {
|
|
1289
1481
|
const uri = p.participant_uri || p.uri;
|
|
1290
|
-
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") };
|
|
1291
1485
|
ws.members.get(uri).paused = false;
|
|
1292
1486
|
return { result: { scope: "participant", participant_uri: uri, paused: false } };
|
|
1293
1487
|
}
|
|
@@ -1302,6 +1496,12 @@ function registerControl(coord) {
|
|
|
1302
1496
|
if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
|
|
1303
1497
|
return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
|
|
1304
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
|
+
}
|
|
1305
1505
|
task.state = "cancelled";
|
|
1306
1506
|
task.updated_at = coord.now();
|
|
1307
1507
|
task.history.push({ ts: task.updated_at, from: p.from, state: "cancelled", note: p.reason });
|
|
@@ -1310,6 +1510,8 @@ function registerControl(coord) {
|
|
|
1310
1510
|
coord.handlers.set("control.snapshot", (p) => {
|
|
1311
1511
|
const ws = coord.workspaces.get(p.workspace);
|
|
1312
1512
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1513
|
+
const bad = paramsError(p, [["include", "strings"], ["label", "string"]]);
|
|
1514
|
+
if (bad) return bad;
|
|
1313
1515
|
if (Array.isArray(p.include) && p.include.length === 0) {
|
|
1314
1516
|
return { error: rpcError(E.PARAMS, "include must not be empty") };
|
|
1315
1517
|
}
|
|
@@ -1364,6 +1566,14 @@ function registerControl(coord) {
|
|
|
1364
1566
|
coord.handlers.set("control.rollback", (p) => {
|
|
1365
1567
|
const ws = coord.workspaces.get(p.workspace);
|
|
1366
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;
|
|
1367
1577
|
const snapId = p.to_snapshot_artefact_id || p.snapshot_artefact_id || p.snapshot_id;
|
|
1368
1578
|
if (!snapId) return { error: rpcError(E.PARAMS, "to_snapshot_artefact_id is required") };
|
|
1369
1579
|
const snap = ws.snapshots.get(snapId);
|
|
@@ -1385,7 +1595,8 @@ function registerControl(coord) {
|
|
|
1385
1595
|
for (const [uri, snapM] of snapByUri) {
|
|
1386
1596
|
if (ws.members.has(uri)) {
|
|
1387
1597
|
const cur = ws.members.get(uri);
|
|
1388
|
-
|
|
1598
|
+
const role = snapM.role ?? cur.role;
|
|
1599
|
+
if (role !== "admin" && cur.role !== "admin") cur.role = role;
|
|
1389
1600
|
cur.scopes = Array.isArray(snapM.scopes) ? [...snapM.scopes] : void 0;
|
|
1390
1601
|
}
|
|
1391
1602
|
}
|
|
@@ -1404,10 +1615,18 @@ function registerControl(coord) {
|
|
|
1404
1615
|
const oldId = p.task_id;
|
|
1405
1616
|
const old = ws.tasks.get(oldId);
|
|
1406
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
|
+
}
|
|
1407
1624
|
const newSpec = p.successor_task ?? {};
|
|
1408
|
-
if (!newSpec.kind) {
|
|
1625
|
+
if (typeof newSpec.kind !== "string" || !newSpec.kind) {
|
|
1409
1626
|
return { error: rpcError(E.PARAMS, "successor_task must include kind") };
|
|
1410
1627
|
}
|
|
1628
|
+
const bad = paramsError(p, [["successor_task.assignee", "string"], ["reason", "string"]]);
|
|
1629
|
+
if (bad) return bad;
|
|
1411
1630
|
const assignee = newSpec.assignee || old.assignee;
|
|
1412
1631
|
if (typeof assignee !== "string" || !ws.members.has(assignee)) {
|
|
1413
1632
|
return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
|
|
@@ -1454,6 +1673,7 @@ function registerControl(coord) {
|
|
|
1454
1673
|
} else if ("review_required" in newSpec) {
|
|
1455
1674
|
newTask.review_required = !!newSpec.review_required;
|
|
1456
1675
|
}
|
|
1676
|
+
newTask.prior_authors = successorAuthors(old, p.from);
|
|
1457
1677
|
ws.tasks.set(newId, newTask);
|
|
1458
1678
|
old.state = "superseded";
|
|
1459
1679
|
old.superseded_by = newId;
|
|
@@ -1464,27 +1684,37 @@ function registerControl(coord) {
|
|
|
1464
1684
|
state: "superseded",
|
|
1465
1685
|
note: `-> ${newId}: ${p.reason || ""}`
|
|
1466
1686
|
});
|
|
1467
|
-
return { result: {
|
|
1687
|
+
return { result: {
|
|
1688
|
+
superseded_task_id: old.id,
|
|
1689
|
+
new_task_id: newId,
|
|
1690
|
+
...p.reason != null ? { reason: p.reason } : {}
|
|
1691
|
+
} };
|
|
1468
1692
|
});
|
|
1469
1693
|
coord.handlers.set("control.set_mode_ceiling", (p) => {
|
|
1470
1694
|
const ws = coord.workspaces.get(p.workspace);
|
|
1471
1695
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1696
|
+
const bad = paramsError(p, [["reason", "string"]]);
|
|
1697
|
+
if (bad) return bad;
|
|
1472
1698
|
const m = p.new_ceiling ?? p.mode_ceiling;
|
|
1473
1699
|
if (!VALID_MODES.has(m)) {
|
|
1474
|
-
return { error: rpcError(E.PARAMS,
|
|
1700
|
+
return { error: rpcError(E.PARAMS, "new_ceiling must be shadow, trial or production") };
|
|
1475
1701
|
}
|
|
1476
1702
|
ws.mode_ceiling = m;
|
|
1477
|
-
return { result: { mode_ceiling: ws.mode_ceiling, reason: p.reason } };
|
|
1703
|
+
return { result: { mode_ceiling: ws.mode_ceiling, ...p.reason != null ? { reason: p.reason } : {} } };
|
|
1478
1704
|
});
|
|
1479
1705
|
}
|
|
1480
1706
|
|
|
1481
1707
|
// src/profiles/deliberation.ts
|
|
1482
1708
|
var RESERVED_IDS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1709
|
+
var QUORUM_ARG = /^[0-9]+$/;
|
|
1710
|
+
var THRESHOLD_ARG = /^[0-9]+(\.[0-9]+)?$/;
|
|
1483
1711
|
function parseRule(rule) {
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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)) {
|
|
1488
1718
|
return { kind, params: { threshold: parseFloat(arg) } };
|
|
1489
1719
|
}
|
|
1490
1720
|
}
|
|
@@ -1569,6 +1799,23 @@ function registerDeliberation(coord) {
|
|
|
1569
1799
|
for (const f of ["from", "rule"]) {
|
|
1570
1800
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
1571
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
|
+
}
|
|
1572
1819
|
const to = p.to ?? p.participants;
|
|
1573
1820
|
const participants = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
|
|
1574
1821
|
if (!participants.length) {
|
|
@@ -1619,6 +1866,8 @@ function registerDeliberation(coord) {
|
|
|
1619
1866
|
if (delib.state !== "open") {
|
|
1620
1867
|
return { error: rpcError(E.DELIB_CLOSED_OR_LAPSED, "Deliberation not open") };
|
|
1621
1868
|
}
|
|
1869
|
+
const bad = paramsError(p, [["comment", "string"], ["text", "string"]]);
|
|
1870
|
+
if (bad) return bad;
|
|
1622
1871
|
delib.comments.push({
|
|
1623
1872
|
ts: coord.now(),
|
|
1624
1873
|
from: p.from,
|
|
@@ -1641,6 +1890,8 @@ function registerDeliberation(coord) {
|
|
|
1641
1890
|
if (v !== "yea" && v !== "nay" && v !== "abstain") {
|
|
1642
1891
|
return { error: rpcError(E.PARAMS, "vote must be yea/nay/abstain") };
|
|
1643
1892
|
}
|
|
1893
|
+
const bad = paramsError(p, [["veto_invoked", "boolean"]]);
|
|
1894
|
+
if (bad) return bad;
|
|
1644
1895
|
const voter = p.from;
|
|
1645
1896
|
if (!delib.participants.includes(voter)) {
|
|
1646
1897
|
return { error: rpcError(
|
|
@@ -1682,6 +1933,9 @@ function registerHandoff(coord) {
|
|
|
1682
1933
|
for (const f of ["from", "to", "tasks"]) {
|
|
1683
1934
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
1684
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;
|
|
1685
1939
|
const proposer = p.from;
|
|
1686
1940
|
const recipient = p.to;
|
|
1687
1941
|
if (typeof recipient === "string" && !recipient.startsWith("group:")) {
|
|
@@ -1698,6 +1952,11 @@ function registerHandoff(coord) {
|
|
|
1698
1952
|
}
|
|
1699
1953
|
const proposed = [];
|
|
1700
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;
|
|
1701
1960
|
const tid = t.task_id;
|
|
1702
1961
|
if (!tid || !ws.tasks.has(tid)) {
|
|
1703
1962
|
return { error: rpcError(E.PARAMS, `Unknown task in handoff: ${tid}`) };
|
|
@@ -1779,6 +2038,8 @@ function registerHandoff(coord) {
|
|
|
1779
2038
|
if (ws.members.get(acceptor).paused) {
|
|
1780
2039
|
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Acceptor ${acceptor} is paused`) };
|
|
1781
2040
|
}
|
|
2041
|
+
const bad = paramsError(p, [["accepted_task_ids", "strings"]]);
|
|
2042
|
+
if (bad) return bad;
|
|
1782
2043
|
if (Array.isArray(p.accepted_task_ids) && p.accepted_task_ids.length === 0) {
|
|
1783
2044
|
return { error: rpcError(E.PARAMS, "accepted_task_ids must not be empty") };
|
|
1784
2045
|
}
|
|
@@ -1789,9 +2050,18 @@ function registerHandoff(coord) {
|
|
|
1789
2050
|
return { error: rpcError(E.PARAMS, `Task ${tid} not in this handoff`) };
|
|
1790
2051
|
}
|
|
1791
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
|
+
}
|
|
1792
2061
|
const now = coord.now();
|
|
1793
2062
|
for (const tid of acceptedIds) {
|
|
1794
2063
|
const task = ws.tasks.get(tid);
|
|
2064
|
+
if (acceptor !== task.assignee) task.prior_authors = unionOf(task.prior_authors ?? [], heldBy(task, ho.proposer));
|
|
1795
2065
|
task.assignee = acceptor;
|
|
1796
2066
|
task.updated_at = now;
|
|
1797
2067
|
task.history.push({
|
|
@@ -1832,16 +2102,20 @@ function registerHandoff(coord) {
|
|
|
1832
2102
|
|
|
1833
2103
|
// src/profiles/routing.ts
|
|
1834
2104
|
var CRIT = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
1835
|
-
|
|
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) {
|
|
1836
2113
|
const crit = hints.criticality ?? "medium";
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
}
|
|
1843
|
-
if (isNaN(conf)) conf = 1;
|
|
1844
|
-
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);
|
|
1845
2119
|
if (r >= 3) return { depth: "full", summary: "criticality=critical: full review" };
|
|
1846
2120
|
if (r === 2 && conf < 0.7) return { depth: "full", summary: "criticality=high and confidence<0.7: full review" };
|
|
1847
2121
|
if (r === 0 && conf >= 0.95) return { depth: "skip", summary: "very low criticality + very high confidence: skip" };
|
|
@@ -1907,6 +2181,8 @@ function registerRouting(coord) {
|
|
|
1907
2181
|
if (ws.members.get(selected).paused) {
|
|
1908
2182
|
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${selected} is paused`) };
|
|
1909
2183
|
}
|
|
2184
|
+
const leaving = heldBy(task, p.from);
|
|
2185
|
+
if (selected !== task.assignee && leaving.length) task.prior_authors = unionOf(task.prior_authors ?? [], leaving);
|
|
1910
2186
|
task.assignee = selected;
|
|
1911
2187
|
task.updated_at = coord.now();
|
|
1912
2188
|
task.history.push({
|
|
@@ -1937,6 +2213,8 @@ function registerRouting(coord) {
|
|
|
1937
2213
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1938
2214
|
const task = ws.tasks.get(p.task_id);
|
|
1939
2215
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2216
|
+
const bad = paramsError(p, [["artefact_routing_hints", "object"]]);
|
|
2217
|
+
if (bad) return bad;
|
|
1940
2218
|
const hints = {
|
|
1941
2219
|
...task.routing_hints ?? {},
|
|
1942
2220
|
...p.artefact_routing_hints ?? {}
|
|
@@ -1994,6 +2272,8 @@ function registerRouting(coord) {
|
|
|
1994
2272
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1995
2273
|
const task = ws.tasks.get(p.task_id);
|
|
1996
2274
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2275
|
+
const bad = paramsError(p, [["default_escalation_target", "string"]]);
|
|
2276
|
+
if (bad) return bad;
|
|
1997
2277
|
const hints = { ...task.routing_hints ?? {} };
|
|
1998
2278
|
let escalate, to, rule;
|
|
1999
2279
|
if (coord.options.escalationPolicy) {
|
|
@@ -2007,15 +2287,8 @@ function registerRouting(coord) {
|
|
|
2007
2287
|
return { error: rpcError(E.ROUTING_POLICY_UNREACHABLE, `escalation policy error: ${msg}`) };
|
|
2008
2288
|
}
|
|
2009
2289
|
} else {
|
|
2010
|
-
const
|
|
2011
|
-
|
|
2012
|
-
try {
|
|
2013
|
-
conf = Number(hints.confidence ?? 1);
|
|
2014
|
-
} catch {
|
|
2015
|
-
conf = 1;
|
|
2016
|
-
}
|
|
2017
|
-
if (isNaN(conf)) conf = 1;
|
|
2018
|
-
const r = CRIT[crit] ?? 1;
|
|
2290
|
+
const conf = confidenceOf(hints);
|
|
2291
|
+
const r = criticalityOf(hints);
|
|
2019
2292
|
escalate = r >= 3 || r >= 2 && conf < 0.6;
|
|
2020
2293
|
to = p.default_escalation_target;
|
|
2021
2294
|
rule = {
|
|
@@ -2072,6 +2345,9 @@ function registerSecuritySigned(coord) {
|
|
|
2072
2345
|
for (const f of ["from", "old_kid", "new_jwk"]) {
|
|
2073
2346
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
2074
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
|
+
}
|
|
2075
2351
|
const member = ws.members.get(p.from);
|
|
2076
2352
|
if (!member) return { error: rpcError(E.SIG_KEY_NOT_FOUND, `Unknown participant: ${p.from}`) };
|
|
2077
2353
|
const oldKid = p.old_kid;
|
|
@@ -2082,6 +2358,9 @@ function registerSecuritySigned(coord) {
|
|
|
2082
2358
|
if (!newJwk || typeof newJwk.kid !== "string") {
|
|
2083
2359
|
return { error: rpcError(E.PARAMS, "new_jwk must include kid") };
|
|
2084
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
|
+
}
|
|
2085
2364
|
const now = coord.now();
|
|
2086
2365
|
oldKey.valid_until = now;
|
|
2087
2366
|
member.keys.push({
|
|
@@ -2097,6 +2376,11 @@ function registerSecuritySigned(coord) {
|
|
|
2097
2376
|
for (const f of ["target_uri", "kid"]) {
|
|
2098
2377
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
2099
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;
|
|
2100
2384
|
const sender = p.from;
|
|
2101
2385
|
if (sender !== p.target_uri) {
|
|
2102
2386
|
const caller = sender ? ws.members.get(sender) : void 0;
|
|
@@ -2139,8 +2423,10 @@ function registerWhisper(coord) {
|
|
|
2139
2423
|
if (!ws.tasks.has(p.task_id)) {
|
|
2140
2424
|
return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2141
2425
|
}
|
|
2142
|
-
|
|
2143
|
-
|
|
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") };
|
|
2144
2430
|
}
|
|
2145
2431
|
const to = p.to;
|
|
2146
2432
|
const askee = Array.isArray(to) ? to : [to];
|
|
@@ -2203,7 +2489,7 @@ function registerWhisper(coord) {
|
|
|
2203
2489
|
if (!hasOption) {
|
|
2204
2490
|
return { error: rpcError(E.PARAMS, "answer_option is required when options are defined") };
|
|
2205
2491
|
}
|
|
2206
|
-
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));
|
|
2207
2493
|
if (!valid.has(answerOption)) {
|
|
2208
2494
|
return { error: rpcError(
|
|
2209
2495
|
E.WHISPER_OPTION_NOT_IN_SET,
|
|
@@ -2221,12 +2507,13 @@ function registerWhisper(coord) {
|
|
|
2221
2507
|
prompt.comment = p.comment;
|
|
2222
2508
|
return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
|
|
2223
2509
|
});
|
|
2224
|
-
coord.checkWhisperLapses = (workspaceId, now) =>
|
|
2510
|
+
coord.checkWhisperLapses = (workspaceId, now) => coord.outsideCall(workspaceId, () => lapse(workspaceId, now));
|
|
2511
|
+
function lapse(workspaceId, now) {
|
|
2225
2512
|
const ws = coord.workspaces.get(workspaceId);
|
|
2226
2513
|
if (!ws) return [];
|
|
2227
2514
|
const cutoff = parseIso(now ?? coord.now());
|
|
2228
2515
|
const emitted = [];
|
|
2229
|
-
for (const prompt of ws.whispers
|
|
2516
|
+
for (const prompt of rawValues(ws.whispers)) {
|
|
2230
2517
|
if (prompt.state !== "pending") continue;
|
|
2231
2518
|
const asked = parseIso(prompt.asked_at);
|
|
2232
2519
|
const deadline = asked + prompt.deadline_ms;
|
|
@@ -2256,7 +2543,7 @@ function registerWhisper(coord) {
|
|
|
2256
2543
|
emitted.push(notify);
|
|
2257
2544
|
}
|
|
2258
2545
|
return emitted;
|
|
2259
|
-
}
|
|
2546
|
+
}
|
|
2260
2547
|
}
|
|
2261
2548
|
|
|
2262
2549
|
// src/coordinator.ts
|
|
@@ -2269,6 +2556,7 @@ var PRIVILEGED_METHODS = /* @__PURE__ */ new Set([
|
|
|
2269
2556
|
"control.rollback",
|
|
2270
2557
|
"control.set_mode_ceiling",
|
|
2271
2558
|
"workspace.set_profiles",
|
|
2559
|
+
"workspace.invite",
|
|
2272
2560
|
"participant.rotate_key",
|
|
2273
2561
|
"participant.revoke_key"
|
|
2274
2562
|
]);
|
|
@@ -2312,12 +2600,9 @@ function missing(params, fields) {
|
|
|
2312
2600
|
for (const f of fields) if (!(f in params)) return f;
|
|
2313
2601
|
return null;
|
|
2314
2602
|
}
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
return { error: rpcError(E.PARAMS, "tags must be a list of strings") };
|
|
2319
|
-
}
|
|
2320
|
-
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);
|
|
2321
2606
|
}
|
|
2322
2607
|
function reviewRuleSupported(rule) {
|
|
2323
2608
|
if (rule === "any_one_approves" || rule === "all_approve") return true;
|
|
@@ -2338,8 +2623,214 @@ function reviewSatisfied(review) {
|
|
|
2338
2623
|
if (rule.startsWith("quorum:")) return approvers.size >= parseInt(rule.slice("quorum:".length), 10);
|
|
2339
2624
|
return approvers.size >= 1;
|
|
2340
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
|
+
}
|
|
2341
2831
|
function reply(env, body) {
|
|
2342
|
-
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 };
|
|
2343
2834
|
if (body.error !== void 0) out.error = body.error;
|
|
2344
2835
|
else out.result = body.result;
|
|
2345
2836
|
return out;
|
|
@@ -2354,7 +2845,7 @@ function indexSigned(index, entry) {
|
|
|
2354
2845
|
return;
|
|
2355
2846
|
}
|
|
2356
2847
|
if (entry.outcome != null) index.refused.set(digest, { seq: entry.seq, code: entry.outcome.code });
|
|
2357
|
-
else index.accepted.
|
|
2848
|
+
else if (!index.accepted.has(digest)) index.accepted.set(digest, entry.seq);
|
|
2358
2849
|
}
|
|
2359
2850
|
function isThenable(value) {
|
|
2360
2851
|
return typeof value?.then === "function";
|
|
@@ -2382,13 +2873,21 @@ var Coordinator = class {
|
|
|
2382
2873
|
unflushed = /* @__PURE__ */ new Set();
|
|
2383
2874
|
/** Workspaces with a write queued behind an async write in flight. */
|
|
2384
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();
|
|
2385
2884
|
clockMs;
|
|
2386
2885
|
frozenNow;
|
|
2387
2886
|
auditListeners = [];
|
|
2388
2887
|
/**
|
|
2389
2888
|
* The signed calls on each workspace's log, by what their senders signed:
|
|
2390
|
-
* recorded refusals with their seq and code, and accepted calls
|
|
2391
|
-
* 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.
|
|
2392
2891
|
*/
|
|
2393
2892
|
signedIndexes = /* @__PURE__ */ new WeakMap();
|
|
2394
2893
|
/** Method handler registry; profiles plug into this. */
|
|
@@ -2524,6 +3023,7 @@ var Coordinator = class {
|
|
|
2524
3023
|
change = this.takeChange(ws, p);
|
|
2525
3024
|
} catch (err) {
|
|
2526
3025
|
p.replace = true;
|
|
3026
|
+
if (this.options.storeErrors !== "log") this.held.add(ws.id);
|
|
2527
3027
|
this.reportStoreError(err, { id: ws.id, version: ws.audit.length });
|
|
2528
3028
|
return;
|
|
2529
3029
|
}
|
|
@@ -2532,6 +3032,7 @@ var Coordinator = class {
|
|
|
2532
3032
|
const done = () => {
|
|
2533
3033
|
p.seq = taken.version;
|
|
2534
3034
|
if (taken.replace) p.replace = false;
|
|
3035
|
+
this.announce(ws, taken.version);
|
|
2535
3036
|
};
|
|
2536
3037
|
const failed = (err) => {
|
|
2537
3038
|
this.giveBack(ws, p, taken);
|
|
@@ -2541,13 +3042,34 @@ var Coordinator = class {
|
|
|
2541
3042
|
try {
|
|
2542
3043
|
result = this.store.apply(taken);
|
|
2543
3044
|
} catch (err) {
|
|
3045
|
+
if (err instanceof StoreConflictError || this.options.storeErrors !== "log") this.held.add(ws.id);
|
|
2544
3046
|
failed(err);
|
|
2545
3047
|
return;
|
|
2546
3048
|
}
|
|
2547
3049
|
if (isThenable(result)) return Promise.resolve(result).then(done, failed);
|
|
2548
3050
|
done();
|
|
2549
3051
|
}
|
|
2550
|
-
/**
|
|
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
|
+
*/
|
|
2551
3073
|
takeChange(ws, p) {
|
|
2552
3074
|
const replace = p.replace;
|
|
2553
3075
|
const objects = {};
|
|
@@ -2556,12 +3078,15 @@ var Coordinator = class {
|
|
|
2556
3078
|
if (replace) {
|
|
2557
3079
|
const all = {};
|
|
2558
3080
|
for (const [key, value] of Map.prototype.entries.call(map)) {
|
|
2559
|
-
all[key] = value;
|
|
3081
|
+
all[key] = storedCopy(name, value);
|
|
2560
3082
|
}
|
|
2561
3083
|
objects[name] = all;
|
|
2562
3084
|
} else if (map.dirty.size) {
|
|
2563
3085
|
const changed = {};
|
|
2564
|
-
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
|
+
}
|
|
2565
3090
|
objects[name] = changed;
|
|
2566
3091
|
}
|
|
2567
3092
|
map.dirty.clear();
|
|
@@ -2613,24 +3138,28 @@ var Coordinator = class {
|
|
|
2613
3138
|
this.reportStoreError(err, { id: ws.id, version: next });
|
|
2614
3139
|
return;
|
|
2615
3140
|
}
|
|
3141
|
+
const upTo = ws.audit.length;
|
|
2616
3142
|
const pending = this.saveChains.get(ws.id);
|
|
2617
3143
|
if (pending) {
|
|
2618
|
-
this.trackSave(pending.then(() => this.store.save(record)), record);
|
|
3144
|
+
this.trackSave(pending.then(() => this.store.save(record)), record, () => this.announce(ws, upTo));
|
|
2619
3145
|
return;
|
|
2620
3146
|
}
|
|
2621
3147
|
let result;
|
|
2622
3148
|
try {
|
|
2623
3149
|
result = this.store.save(record);
|
|
2624
3150
|
} catch (err) {
|
|
3151
|
+
if (this.options.storeErrors !== "log") this.held.add(ws.id);
|
|
2625
3152
|
this.reportStoreError(err, record);
|
|
2626
3153
|
return;
|
|
2627
3154
|
}
|
|
2628
3155
|
if (isThenable(result)) {
|
|
2629
|
-
this.trackSave(Promise.resolve(result), record);
|
|
3156
|
+
this.trackSave(Promise.resolve(result), record, () => this.announce(ws, upTo));
|
|
3157
|
+
return;
|
|
2630
3158
|
}
|
|
3159
|
+
this.announce(ws, upTo);
|
|
2631
3160
|
}
|
|
2632
|
-
trackSave(save, record) {
|
|
2633
|
-
const guarded = save.
|
|
3161
|
+
trackSave(save, record, saved) {
|
|
3162
|
+
const guarded = save.then(saved, (err) => this.reportStoreError(err, record));
|
|
2634
3163
|
this.saveChains.set(record.id, guarded);
|
|
2635
3164
|
void guarded.then(() => {
|
|
2636
3165
|
if (this.saveChains.get(record.id) === guarded) {
|
|
@@ -2638,6 +3167,17 @@ var Coordinator = class {
|
|
|
2638
3167
|
}
|
|
2639
3168
|
});
|
|
2640
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
|
+
}
|
|
2641
3181
|
reportStoreError(err, record) {
|
|
2642
3182
|
if (this.options.onStoreError) {
|
|
2643
3183
|
try {
|
|
@@ -2647,7 +3187,9 @@ var Coordinator = class {
|
|
|
2647
3187
|
return;
|
|
2648
3188
|
}
|
|
2649
3189
|
const message = err instanceof Error ? err.message : String(err);
|
|
2650
|
-
|
|
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);
|
|
2651
3193
|
}
|
|
2652
3194
|
async drainSaves() {
|
|
2653
3195
|
while (this.saveChains.size) {
|
|
@@ -2698,16 +3240,25 @@ var Coordinator = class {
|
|
|
2698
3240
|
return null;
|
|
2699
3241
|
}
|
|
2700
3242
|
/**
|
|
2701
|
-
* Assert that `sender`
|
|
2702
|
-
* review-decision methods (decide.* and abstain.declare)
|
|
2703
|
-
* review
|
|
2704
|
-
*
|
|
2705
|
-
*
|
|
2706
|
-
*
|
|
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.
|
|
2707
3251
|
*/
|
|
2708
|
-
requireReviewer(task, sender) {
|
|
3252
|
+
requireReviewer(ws, task, sender) {
|
|
2709
3253
|
const review = task.review;
|
|
2710
|
-
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) {
|
|
2711
3262
|
return null;
|
|
2712
3263
|
}
|
|
2713
3264
|
if (review.requested_to.some((r) => typeof r === "string" && (r.startsWith("workspace:") || r.startsWith("group:")))) {
|
|
@@ -2736,23 +3287,31 @@ var Coordinator = class {
|
|
|
2736
3287
|
routing_policy_uri: ws.routing_policy_uri,
|
|
2737
3288
|
step_up_window_sec: ws.step_up_window_sec,
|
|
2738
3289
|
min_acr: ws.min_acr,
|
|
3290
|
+
signature_policy: ws.signature_policy,
|
|
3291
|
+
join_policy: ws.join_policy,
|
|
3292
|
+
review_rule: ws.review_rule,
|
|
2739
3293
|
chain_head: ws.chain_head,
|
|
2740
3294
|
chain_enabled: ws.chain_enabled
|
|
2741
3295
|
};
|
|
2742
3296
|
}
|
|
2743
|
-
/**
|
|
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
|
+
*/
|
|
2744
3301
|
snapshotOf(ws) {
|
|
2745
3302
|
const { chain_head, chain_enabled, ...own } = this.metaOf(ws);
|
|
3303
|
+
const stored = (name) => Array.from(rawValues(ws[name])).map((v) => storedCopy(name, v));
|
|
2746
3304
|
return {
|
|
2747
3305
|
...own,
|
|
2748
|
-
members:
|
|
2749
|
-
tasks:
|
|
2750
|
-
overrides:
|
|
2751
|
-
whispers:
|
|
2752
|
-
deliberations:
|
|
2753
|
-
handoffs:
|
|
2754
|
-
snapshots:
|
|
2755
|
-
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"),
|
|
2756
3315
|
idempotency_keys: { ...ws.idempotency_keys },
|
|
2757
3316
|
audit: ws.audit,
|
|
2758
3317
|
chain_head,
|
|
@@ -2780,7 +3339,8 @@ var Coordinator = class {
|
|
|
2780
3339
|
}
|
|
2781
3340
|
}
|
|
2782
3341
|
/** One workspace from a snapshot entry, its maps tracked. */
|
|
2783
|
-
rebuild(
|
|
3342
|
+
rebuild(snapshot) {
|
|
3343
|
+
const w = shapeSnapshot(snapshot);
|
|
2784
3344
|
const ws = {
|
|
2785
3345
|
id: w.id,
|
|
2786
3346
|
created: w.created,
|
|
@@ -2791,6 +3351,11 @@ var Coordinator = class {
|
|
|
2791
3351
|
routing_policy_uri: w.routing_policy_uri,
|
|
2792
3352
|
step_up_window_sec: w.step_up_window_sec ?? 300,
|
|
2793
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,
|
|
2794
3359
|
members: this.newMap(),
|
|
2795
3360
|
tasks: this.newMap(),
|
|
2796
3361
|
overrides: this.newMap(),
|
|
@@ -2799,6 +3364,7 @@ var Coordinator = class {
|
|
|
2799
3364
|
handoffs: this.newMap(),
|
|
2800
3365
|
snapshots: this.newMap(),
|
|
2801
3366
|
route_decisions: this.newMap(),
|
|
3367
|
+
invitations: this.newMap(),
|
|
2802
3368
|
idempotency_keys: w.idempotency_keys ?? {},
|
|
2803
3369
|
audit: w.audit ?? [],
|
|
2804
3370
|
chain_head: w.chain_head,
|
|
@@ -2807,9 +3373,16 @@ var Coordinator = class {
|
|
|
2807
3373
|
for (const m of w.members ?? []) {
|
|
2808
3374
|
if (!Array.isArray(m.keys)) m.keys = [];
|
|
2809
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";
|
|
2810
3379
|
ws.members.set(m.uri, m);
|
|
2811
3380
|
}
|
|
2812
|
-
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
|
+
}
|
|
2813
3386
|
for (const o of w.overrides ?? []) ws.overrides.set(o.id, o);
|
|
2814
3387
|
for (const x of w.whispers ?? [])
|
|
2815
3388
|
ws.whispers.set(x.id, x);
|
|
@@ -2853,20 +3426,119 @@ var Coordinator = class {
|
|
|
2853
3426
|
}
|
|
2854
3427
|
for (const x of w.route_decisions ?? [])
|
|
2855
3428
|
ws.route_decisions.set(x.id, x);
|
|
3429
|
+
for (const x of w.invitations ?? []) ws.invitations.set(x.uri, x);
|
|
2856
3430
|
return ws;
|
|
2857
3431
|
}
|
|
2858
3432
|
// -- dispatch ------------------------------------------------------
|
|
2859
3433
|
dispatch(envelope) {
|
|
2860
3434
|
this.frozenNow = this.advanceClock();
|
|
2861
3435
|
this.tracker.depth++;
|
|
3436
|
+
let response;
|
|
3437
|
+
let target;
|
|
3438
|
+
let ran = false;
|
|
2862
3439
|
try {
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
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
|
+
}
|
|
2866
3452
|
} finally {
|
|
2867
3453
|
if (--this.tracker.depth === 0) this.flushUnflushed();
|
|
2868
3454
|
this.frozenNow = void 0;
|
|
2869
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;
|
|
2870
3542
|
}
|
|
2871
3543
|
_dispatch(envelope) {
|
|
2872
3544
|
if (!isValidEnvelope(envelope) || typeof envelope.method !== "string" || !envelope.method) {
|
|
@@ -2903,11 +3575,16 @@ var Coordinator = class {
|
|
|
2903
3575
|
const msg = canonicalError instanceof Error ? canonicalError.message : String(canonicalError);
|
|
2904
3576
|
return reply(envelope, { error: rpcError(E.PARAMS, msg) });
|
|
2905
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
|
+
}
|
|
2906
3581
|
if (isSigned(envelope)) {
|
|
2907
3582
|
const wsId2 = params.workspace;
|
|
2908
3583
|
const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
|
|
2909
3584
|
if (ws) {
|
|
2910
|
-
const
|
|
3585
|
+
const index = this.signedIndex(ws);
|
|
3586
|
+
const digest = signedDigest(recordedForm(envelope));
|
|
3587
|
+
const prior = index.refused.get(digest);
|
|
2911
3588
|
if (prior) {
|
|
2912
3589
|
return reply(envelope, { error: rpcError(
|
|
2913
3590
|
prior.code,
|
|
@@ -2915,15 +3592,24 @@ var Coordinator = class {
|
|
|
2915
3592
|
{ refused_at_seq: prior.seq }
|
|
2916
3593
|
) });
|
|
2917
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
|
+
}
|
|
2918
3603
|
}
|
|
2919
3604
|
}
|
|
2920
|
-
|
|
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))) {
|
|
2921
3607
|
const sigErr = this.verifySignature(envelope);
|
|
2922
3608
|
if (sigErr) return reply(envelope, { error: sigErr });
|
|
2923
3609
|
if (method === "participant.rotate_key") {
|
|
2924
3610
|
const signingKid = envelope.sig.split(":")[1];
|
|
2925
3611
|
const oldKid = params.old_kid;
|
|
2926
|
-
if (oldKid
|
|
3612
|
+
if (oldKid != null && signingKid !== oldKid) {
|
|
2927
3613
|
return reply(envelope, { error: rpcError(
|
|
2928
3614
|
E.SIG_ROTATION_KEY_MISMATCH,
|
|
2929
3615
|
"Rotation must be signed with the old key"
|
|
@@ -2978,14 +3664,14 @@ var Coordinator = class {
|
|
|
2978
3664
|
try {
|
|
2979
3665
|
out = handler(ownCopy(params));
|
|
2980
3666
|
} catch (e) {
|
|
2981
|
-
|
|
2982
|
-
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") });
|
|
2983
3669
|
}
|
|
2984
3670
|
if (out.error) return reply(envelope, { error: out.error });
|
|
2985
|
-
const wsId = params.workspace;
|
|
3671
|
+
const wsId = method === "workspace.create" ? out.result.workspace : params.workspace;
|
|
2986
3672
|
if (typeof wsId === "string" && !READ_ONLY_METHODS.has(method)) {
|
|
2987
3673
|
const ws = this.workspaces.get(wsId);
|
|
2988
|
-
if (ws) this.recordAudit(ws, envelope);
|
|
3674
|
+
if (ws) this.recordAudit(ws, recordedForm(envelope));
|
|
2989
3675
|
}
|
|
2990
3676
|
return reply(envelope, { result: ownCopy(out.result) });
|
|
2991
3677
|
}
|
|
@@ -3025,7 +3711,7 @@ var Coordinator = class {
|
|
|
3025
3711
|
signedIndex(ws) {
|
|
3026
3712
|
let index = this.signedIndexes.get(ws);
|
|
3027
3713
|
if (!index) {
|
|
3028
|
-
index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new
|
|
3714
|
+
index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Map() };
|
|
3029
3715
|
for (const entry of ws.audit) indexSigned(index, entry);
|
|
3030
3716
|
this.signedIndexes.set(ws, index);
|
|
3031
3717
|
}
|
|
@@ -3041,12 +3727,9 @@ var Coordinator = class {
|
|
|
3041
3727
|
ws.audit.push(entry);
|
|
3042
3728
|
const index = this.signedIndexes.get(ws);
|
|
3043
3729
|
if (index) indexSigned(index, entry);
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
} catch {
|
|
3048
|
-
}
|
|
3049
|
-
}
|
|
3730
|
+
const queued = this.unannounced.get(ws);
|
|
3731
|
+
if (queued) queued.push(entry);
|
|
3732
|
+
else this.unannounced.set(ws, [entry]);
|
|
3050
3733
|
this.persist(ws);
|
|
3051
3734
|
}
|
|
3052
3735
|
// -- signature verification (security-signed/1.0) -----------------
|
|
@@ -3056,7 +3739,12 @@ var Coordinator = class {
|
|
|
3056
3739
|
return rpcError(E.SIG_VERIFY_FAILED, "Missing top-level `sig` field");
|
|
3057
3740
|
}
|
|
3058
3741
|
const parts = sig.split(":");
|
|
3059
|
-
|
|
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) {
|
|
3060
3748
|
return rpcError(E.SIG_VERIFY_FAILED, "Malformed signature tag");
|
|
3061
3749
|
}
|
|
3062
3750
|
const kid = parts[1];
|
|
@@ -3090,6 +3778,14 @@ var Coordinator = class {
|
|
|
3090
3778
|
if (revoked) return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
|
|
3091
3779
|
return rpcError(E.SIG_KEY_NOT_FOUND, `No key ${kid} valid at ${ts} for ${sender}`);
|
|
3092
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`);
|
|
3093
3789
|
try {
|
|
3094
3790
|
const stripped = JSON.parse(JSON.stringify(envelope));
|
|
3095
3791
|
delete stripped.sig;
|
|
@@ -3103,6 +3799,39 @@ var Coordinator = class {
|
|
|
3103
3799
|
return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
|
|
3104
3800
|
}
|
|
3105
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
|
+
}
|
|
3106
3835
|
/** Lookup the key valid for a given participant at a given time. */
|
|
3107
3836
|
lookupKey(member, kid, ts) {
|
|
3108
3837
|
for (const k of member.keys) {
|
|
@@ -3176,6 +3905,7 @@ var Coordinator = class {
|
|
|
3176
3905
|
this.handlers.set("workspace.create", (p) => this.opWorkspaceCreate(p));
|
|
3177
3906
|
this.handlers.set("workspace.describe", (p) => this.opWorkspaceDescribe(p));
|
|
3178
3907
|
this.handlers.set("workspace.set_profiles", (p) => this.opWorkspaceSetProfiles(p));
|
|
3908
|
+
this.handlers.set("workspace.invite", (p) => this.opWorkspaceInvite(p));
|
|
3179
3909
|
this.handlers.set("participant.join", (p) => this.opParticipantJoin(p));
|
|
3180
3910
|
this.handlers.set("participant.leave", (p) => this.opParticipantLeave(p));
|
|
3181
3911
|
this.handlers.set("task.create", (p) => this.opTaskCreate(p));
|
|
@@ -3183,6 +3913,7 @@ var Coordinator = class {
|
|
|
3183
3913
|
this.handlers.set("task.complete", (p) => this.opTaskComplete(p));
|
|
3184
3914
|
this.handlers.set("audit.read", (p) => this.opAuditRead(p));
|
|
3185
3915
|
this.handlers.set("review.request", (p) => this.opReviewRequest(p));
|
|
3916
|
+
this.handlers.set("review.acknowledge", (p) => this.opReviewAcknowledge(p));
|
|
3186
3917
|
this.handlers.set("decide.approve", (p) => this.opDecide(p, "approve"));
|
|
3187
3918
|
this.handlers.set("decide.reject", (p) => this.opDecide(p, "reject"));
|
|
3188
3919
|
this.handlers.set("decide.override", (p) => this.opDecideOverride(p));
|
|
@@ -3202,24 +3933,59 @@ var Coordinator = class {
|
|
|
3202
3933
|
// Core method handlers
|
|
3203
3934
|
// ==========================================================
|
|
3204
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
|
+
}
|
|
3205
3974
|
const id = p.workspace || this.ids.workspaceId();
|
|
3206
|
-
if (typeof id !== "string") return { error: rpcError(E.PARAMS, "workspace must be a string id") };
|
|
3207
3975
|
if (this.workspaces.has(id)) return { error: rpcError(E.PARAMS, `workspace already exists: ${id}`) };
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
["identity-oidc/1.0", this.options.verifyOidcToken !== void 0, "verifyOidcToken"]
|
|
3212
|
-
]) {
|
|
3213
|
-
const name = profile.split("/")[0];
|
|
3214
|
-
const advertised = profiles.some((x) => x === name || x.startsWith(name + "/"));
|
|
3215
|
-
if (enforced && !advertised) profiles.push(profile);
|
|
3216
|
-
else if (advertised && !enforced) {
|
|
3217
|
-
return { error: rpcError(
|
|
3218
|
-
E.PARAMS,
|
|
3219
|
-
`${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.`
|
|
3220
|
-
) };
|
|
3221
|
-
}
|
|
3976
|
+
if (Array.isArray(p.profiles)) {
|
|
3977
|
+
const unknown = profilesError(p.profiles);
|
|
3978
|
+
if (unknown) return unknown;
|
|
3222
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 };
|
|
3223
3989
|
const chainEnabled = profiles.includes("audit-scitt/1.0") || !!this.options.enableChain;
|
|
3224
3990
|
const ws = {
|
|
3225
3991
|
id,
|
|
@@ -3228,9 +3994,14 @@ var Coordinator = class {
|
|
|
3228
3994
|
profiles,
|
|
3229
3995
|
mode: p.mode ?? "trial",
|
|
3230
3996
|
mode_ceiling: p.mode_ceiling ?? "production",
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
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 } : {},
|
|
3234
4005
|
members: this.newMap(),
|
|
3235
4006
|
tasks: this.newMap(),
|
|
3236
4007
|
overrides: this.newMap(),
|
|
@@ -3239,14 +4010,17 @@ var Coordinator = class {
|
|
|
3239
4010
|
handoffs: this.newMap(),
|
|
3240
4011
|
snapshots: this.newMap(),
|
|
3241
4012
|
route_decisions: this.newMap(),
|
|
4013
|
+
invitations: this.newMap(),
|
|
3242
4014
|
idempotency_keys: {},
|
|
3243
4015
|
audit: [],
|
|
3244
4016
|
chain_enabled: chainEnabled,
|
|
3245
4017
|
chain_head: chainEnabled ? ZERO_HASH : void 0
|
|
3246
4018
|
};
|
|
3247
|
-
|
|
4019
|
+
return { ws };
|
|
4020
|
+
}
|
|
4021
|
+
addWorkspace(ws) {
|
|
4022
|
+
this.workspaces.set(ws.id, ws);
|
|
3248
4023
|
this.persisted.set(ws, { seq: 0, replace: false, keys: /* @__PURE__ */ new Set() });
|
|
3249
|
-
return { result: { workspace: id, created: ws.created } };
|
|
3250
4024
|
}
|
|
3251
4025
|
opWorkspaceDescribe(p) {
|
|
3252
4026
|
const miss = missing(p, ["workspace"]);
|
|
@@ -3265,6 +4039,9 @@ var Coordinator = class {
|
|
|
3265
4039
|
mode_ceiling: ws.mode_ceiling,
|
|
3266
4040
|
max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
|
|
3267
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",
|
|
3268
4045
|
profiles: ws.profiles,
|
|
3269
4046
|
members: Array.from(rawValues(ws.members)).map(memberToDict),
|
|
3270
4047
|
audit_count: ws.audit.length,
|
|
@@ -3280,6 +4057,11 @@ var Coordinator = class {
|
|
|
3280
4057
|
opWorkspaceSetProfiles(p) {
|
|
3281
4058
|
const miss = missing(p, ["workspace", "from", "profiles"]);
|
|
3282
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;
|
|
3283
4065
|
const ws = this.workspaces.get(p.workspace);
|
|
3284
4066
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3285
4067
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3288,6 +4070,14 @@ var Coordinator = class {
|
|
|
3288
4070
|
return { error: rpcError(E.NOT_AUTHORISED, "workspace.set_profiles requires the admin role") };
|
|
3289
4071
|
}
|
|
3290
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 };
|
|
3291
4081
|
if (!newProfiles.some((pr) => pr.startsWith("core/"))) newProfiles.push("core/1.0");
|
|
3292
4082
|
ws.profiles = newProfiles;
|
|
3293
4083
|
if (newProfiles.includes("audit-scitt/1.0") && !ws.chain_enabled) {
|
|
@@ -3299,20 +4089,76 @@ var Coordinator = class {
|
|
|
3299
4089
|
opParticipantJoin(p) {
|
|
3300
4090
|
const miss = missing(p, ["workspace", "from", "type"]);
|
|
3301
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
|
+
}
|
|
3302
4112
|
let ws = this.workspaces.get(p.workspace);
|
|
4113
|
+
const made = ws === void 0;
|
|
3303
4114
|
if (!ws) {
|
|
3304
|
-
const
|
|
3305
|
-
if (
|
|
3306
|
-
ws =
|
|
4115
|
+
const built = this.buildWorkspace({ workspace: p.workspace });
|
|
4116
|
+
if (built.error) return { error: built.error };
|
|
4117
|
+
ws = built.ws;
|
|
3307
4118
|
}
|
|
3308
4119
|
const uri = p.from;
|
|
3309
4120
|
const now = this.now();
|
|
3310
4121
|
const existing = ws.members.get(uri);
|
|
3311
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
|
+
}
|
|
3312
4158
|
const member = {
|
|
3313
4159
|
uri,
|
|
3314
|
-
type
|
|
3315
|
-
role
|
|
4160
|
+
type,
|
|
4161
|
+
role,
|
|
3316
4162
|
joined: now,
|
|
3317
4163
|
display_name: p.display_name,
|
|
3318
4164
|
capabilities: p.capabilities,
|
|
@@ -3320,6 +4166,9 @@ var Coordinator = class {
|
|
|
3320
4166
|
keys: [],
|
|
3321
4167
|
paused: false
|
|
3322
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
|
+
}
|
|
3323
4172
|
if (this.options.verifyOidcToken && typeof p.oidc_token === "string") {
|
|
3324
4173
|
const claims = this.options.verifyOidcToken(p.oidc_token);
|
|
3325
4174
|
if (claims === null) return { error: rpcError(E.OIDC_TOKEN_INVALID, "OIDC token invalid") };
|
|
@@ -3328,6 +4177,9 @@ var Coordinator = class {
|
|
|
3328
4177
|
if (boundUri != null && boundUri !== uri) {
|
|
3329
4178
|
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token is bound to another participant") };
|
|
3330
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
|
+
}
|
|
3331
4183
|
if (existing) {
|
|
3332
4184
|
if (existing.oidc_sub != null) {
|
|
3333
4185
|
if (sub !== existing.oidc_sub) {
|
|
@@ -3336,19 +4188,23 @@ var Coordinator = class {
|
|
|
3336
4188
|
} else if (boundUri !== uri) {
|
|
3337
4189
|
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this member") };
|
|
3338
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") };
|
|
3339
4193
|
}
|
|
3340
4194
|
member.oidc_sub = sub;
|
|
3341
4195
|
tokenVerified = true;
|
|
3342
4196
|
const at = claims.auth_time;
|
|
3343
|
-
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);
|
|
3344
4198
|
member.oidc_acr = typeof claims.acr === "string" ? claims.acr : void 0;
|
|
3345
|
-
const cnf = claims.cnf;
|
|
3346
|
-
const
|
|
3347
|
-
if (
|
|
3348
|
-
|
|
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 });
|
|
3349
4205
|
}
|
|
3350
4206
|
}
|
|
3351
|
-
if (this.options.verifyVc &&
|
|
4207
|
+
if (this.options.verifyVc && isObject(p.vc_presentation)) {
|
|
3352
4208
|
const subject = this.options.verifyVc(p.vc_presentation);
|
|
3353
4209
|
if (subject === null) return { error: rpcError(E.VC_VP_INVALID, "VC presentation invalid") };
|
|
3354
4210
|
const holder = [subject.holder, subject.id].find(
|
|
@@ -3358,21 +4214,54 @@ var Coordinator = class {
|
|
|
3358
4214
|
return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "Presentation holder does not match the member") };
|
|
3359
4215
|
}
|
|
3360
4216
|
member.vc_holder = holder;
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
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 });
|
|
3364
4225
|
}
|
|
3365
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
|
+
) };
|
|
4242
|
+
}
|
|
4243
|
+
member.keys = pinned.map((j) => ({ jwk: j, kid: j.kid, valid_from: now }));
|
|
4244
|
+
}
|
|
3366
4245
|
const attested = [...member.keys];
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
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)) {
|
|
3371
4249
|
member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
|
|
3372
4250
|
}
|
|
3373
4251
|
}
|
|
3374
4252
|
}
|
|
3375
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
|
+
}
|
|
3376
4265
|
if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
|
|
3377
4266
|
if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
|
|
3378
4267
|
if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
|
|
@@ -3383,18 +4272,126 @@ var Coordinator = class {
|
|
|
3383
4272
|
}
|
|
3384
4273
|
return { result: { joined: true, as: uri } };
|
|
3385
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);
|
|
3386
4279
|
ws.members.set(uri, member);
|
|
4280
|
+
if (invitation) ws.invitations.delete(uri);
|
|
3387
4281
|
return { result: { joined: true, as: uri } };
|
|
3388
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
|
+
}
|
|
3389
4371
|
opParticipantLeave(p) {
|
|
3390
4372
|
const ws = this.workspaces.get(p.workspace);
|
|
3391
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
|
+
}
|
|
3392
4377
|
ws.members.delete(p.from);
|
|
3393
4378
|
return { result: { left: true } };
|
|
3394
4379
|
}
|
|
3395
4380
|
opTaskCreate(p) {
|
|
3396
4381
|
const miss = missing(p, ["workspace", "from", "kind", "input"]);
|
|
3397
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
|
+
}
|
|
3398
4395
|
const ws = this.workspaces.get(p.workspace);
|
|
3399
4396
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3400
4397
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3406,9 +4403,6 @@ var Coordinator = class {
|
|
|
3406
4403
|
if (ws.members.get(assignee).paused) {
|
|
3407
4404
|
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
|
|
3408
4405
|
}
|
|
3409
|
-
if (p.mode !== void 0 && p.mode !== null && typeof p.mode !== "string") {
|
|
3410
|
-
return { error: rpcError(E.PARAMS, "mode must be a string") };
|
|
3411
|
-
}
|
|
3412
4406
|
const requestedMode = p.mode || ws.mode;
|
|
3413
4407
|
if (!modeLE(requestedMode, ws.mode_ceiling)) {
|
|
3414
4408
|
return { error: rpcError(
|
|
@@ -3416,9 +4410,6 @@ var Coordinator = class {
|
|
|
3416
4410
|
`Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
|
|
3417
4411
|
) };
|
|
3418
4412
|
}
|
|
3419
|
-
if (p.review_required !== void 0 && p.review_required !== null && typeof p.review_required !== "boolean") {
|
|
3420
|
-
return { error: rpcError(E.PARAMS, "review_required must be a boolean") };
|
|
3421
|
-
}
|
|
3422
4413
|
const taskId = this.ids.taskId();
|
|
3423
4414
|
const now = this.now();
|
|
3424
4415
|
const task = {
|
|
@@ -3457,6 +4448,7 @@ var Coordinator = class {
|
|
|
3457
4448
|
if (notMember) return notMember;
|
|
3458
4449
|
const task = ws.tasks.get(p.task_id);
|
|
3459
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") };
|
|
3460
4452
|
const newState = p.state;
|
|
3461
4453
|
const legal = {
|
|
3462
4454
|
created: ["in_progress", "declined"],
|
|
@@ -3467,12 +4459,42 @@ var Coordinator = class {
|
|
|
3467
4459
|
if (!legal[task.state]?.includes(newState)) {
|
|
3468
4460
|
return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
|
|
3469
4461
|
}
|
|
3470
|
-
if (newState === "completed" && task
|
|
4462
|
+
if (newState === "completed" && reviewRequired(ws, task)) {
|
|
3471
4463
|
return { error: rpcError(
|
|
3472
4464
|
E.PARAMS,
|
|
3473
4465
|
"This task requires review. Submit the output with task.complete, which opens the review; a reviewer decision completes it."
|
|
3474
4466
|
) };
|
|
3475
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
|
+
}
|
|
3476
4498
|
task.state = newState;
|
|
3477
4499
|
task.updated_at = this.now();
|
|
3478
4500
|
task.history.push({
|
|
@@ -3493,42 +4515,60 @@ var Coordinator = class {
|
|
|
3493
4515
|
if (task.state !== "created" && task.state !== "in_progress") {
|
|
3494
4516
|
return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
|
|
3495
4517
|
}
|
|
3496
|
-
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) {
|
|
3497
4526
|
const now = this.now();
|
|
4527
|
+
const authors = authorsOf(task, p.from);
|
|
4528
|
+
let note = OPENED_ON_COMPLETE;
|
|
3498
4529
|
if (!task.review) {
|
|
3499
|
-
const
|
|
3500
|
-
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);
|
|
3501
4531
|
if (eligible.length === 0) {
|
|
3502
4532
|
return { error: rpcError(
|
|
3503
4533
|
E.NOT_AUTHORISED,
|
|
3504
4534
|
"Task requires review but has no eligible human reviewer (needs a human member other than its assignee and completer)"
|
|
3505
4535
|
) };
|
|
3506
4536
|
}
|
|
3507
|
-
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
|
+
};
|
|
3508
4545
|
} else {
|
|
3509
|
-
const
|
|
3510
|
-
const reviewers = task.review.requested_to.filter((r) => r !==
|
|
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"));
|
|
3511
4548
|
if (reviewers.length === 0) {
|
|
3512
4549
|
return { error: rpcError(
|
|
3513
4550
|
E.NOT_AUTHORISED,
|
|
3514
|
-
"
|
|
4551
|
+
"No reviewer of this task remains once the authors of the work are left out"
|
|
3515
4552
|
) };
|
|
3516
4553
|
}
|
|
3517
|
-
task.review = {
|
|
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;
|
|
3518
4563
|
}
|
|
3519
4564
|
task.pending_artefact = p.output;
|
|
3520
4565
|
task.state = "review_requested";
|
|
3521
4566
|
task.updated_at = now;
|
|
3522
|
-
task.history.push({
|
|
3523
|
-
ts: now,
|
|
3524
|
-
from: p.from,
|
|
3525
|
-
state: "review_requested",
|
|
3526
|
-
note: "review required; opened on task.complete"
|
|
3527
|
-
});
|
|
4567
|
+
task.history.push({ ts: now, from: p.from, state: "review_requested", note });
|
|
3528
4568
|
return { result: { state: "review_requested", review_id: task.id } };
|
|
3529
4569
|
}
|
|
3530
4570
|
task.output = p.output;
|
|
3531
|
-
if (
|
|
4571
|
+
if (p.confidence != null) task.confidence = p.confidence;
|
|
3532
4572
|
task.state = "completed";
|
|
3533
4573
|
task.updated_at = this.now();
|
|
3534
4574
|
task.history.push({ ts: task.updated_at, from: p.from, state: "completed" });
|
|
@@ -3541,8 +4581,16 @@ var Coordinator = class {
|
|
|
3541
4581
|
const notMember = this.requireMember(ws, p.from);
|
|
3542
4582
|
if (notMember) return notMember;
|
|
3543
4583
|
}
|
|
3544
|
-
const
|
|
3545
|
-
|
|
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 : {};
|
|
3546
4594
|
if (filter.outcome != null && filter.outcome !== "accepted" && filter.outcome !== "refused") {
|
|
3547
4595
|
return { error: rpcError(E.PARAMS, "filter.outcome must be 'accepted' or 'refused'") };
|
|
3548
4596
|
}
|
|
@@ -3593,37 +4641,73 @@ var Coordinator = class {
|
|
|
3593
4641
|
return { error: rpcError(E.NOT_REVIEWABLE, `Cannot request review for task in state: ${task.state}`) };
|
|
3594
4642
|
}
|
|
3595
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
|
+
}
|
|
3596
4647
|
const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
|
|
3597
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;
|
|
3598
4651
|
const now = this.now();
|
|
3599
4652
|
const declaredRule = p.rule;
|
|
3600
4653
|
const rule = declaredRule || "any_one_approves";
|
|
3601
4654
|
if (!reviewRuleSupported(rule)) {
|
|
3602
4655
|
return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
|
|
3603
4656
|
}
|
|
3604
|
-
|
|
3605
|
-
|
|
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)) {
|
|
3606
4660
|
return { error: rpcError(
|
|
3607
4661
|
E.REVIEW_ALREADY_OPEN,
|
|
3608
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."
|
|
3609
4663
|
) };
|
|
3610
4664
|
}
|
|
3611
|
-
if (declaredRule
|
|
4665
|
+
if (declaredRule != null && declaredRule !== open.rule) {
|
|
3612
4666
|
return { error: rpcError(
|
|
3613
4667
|
E.REVIEW_ALREADY_OPEN,
|
|
3614
|
-
`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`
|
|
3615
4688
|
) };
|
|
3616
4689
|
}
|
|
3617
4690
|
for (const r of reviewers) {
|
|
3618
|
-
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
|
+
}
|
|
3619
4698
|
}
|
|
3620
|
-
|
|
4699
|
+
}
|
|
4700
|
+
if (open) {
|
|
4701
|
+
for (const r of reviewers) {
|
|
4702
|
+
if (!open.requested_to.includes(r)) open.requested_to.push(r);
|
|
4703
|
+
}
|
|
4704
|
+
if (p.deadline != null) open.deadline = p.deadline;
|
|
3621
4705
|
task.updated_at = now;
|
|
3622
4706
|
return { result: {
|
|
3623
4707
|
state: "review_requested",
|
|
3624
4708
|
review_id: task.id,
|
|
3625
4709
|
amended: true,
|
|
3626
|
-
requested_to: [...
|
|
4710
|
+
requested_to: [...open.requested_to]
|
|
3627
4711
|
} };
|
|
3628
4712
|
}
|
|
3629
4713
|
task.state = "review_requested";
|
|
@@ -3633,12 +4717,41 @@ var Coordinator = class {
|
|
|
3633
4717
|
requested_to: reviewers,
|
|
3634
4718
|
rule,
|
|
3635
4719
|
deadline: p.deadline,
|
|
3636
|
-
decisions: []
|
|
4720
|
+
decisions: [],
|
|
4721
|
+
authors,
|
|
4722
|
+
acknowledgements: []
|
|
3637
4723
|
};
|
|
3638
4724
|
task.history.push({ ts: now, from: p.from, state: "review_requested" });
|
|
3639
4725
|
task.pending_artefact = p.artefact;
|
|
3640
4726
|
return { result: { state: "review_requested", review_id: task.id } };
|
|
3641
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
|
+
}
|
|
3642
4755
|
/**
|
|
3643
4756
|
* Verify an optional `approved_artefact_digest` against the artefact under
|
|
3644
4757
|
* review (review/1.0 S3.2).
|
|
@@ -3657,7 +4770,7 @@ var Coordinator = class {
|
|
|
3657
4770
|
if (typeof declared !== "string") {
|
|
3658
4771
|
return { error: rpcError(E.PARAMS, "approved_artefact_digest must be a string") };
|
|
3659
4772
|
}
|
|
3660
|
-
if (task.pending_artefact === void 0) {
|
|
4773
|
+
if (task.pending_artefact === void 0 || task.pending_artefact === null) {
|
|
3661
4774
|
return { error: rpcError(E.PARAMS, "No artefact under review to bind a digest to") };
|
|
3662
4775
|
}
|
|
3663
4776
|
const actual = contentHash(task.pending_artefact);
|
|
@@ -3673,8 +4786,8 @@ var Coordinator = class {
|
|
|
3673
4786
|
opDecide(p, kind) {
|
|
3674
4787
|
const miss = missing(p, ["workspace", "from", "task_id"]);
|
|
3675
4788
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
3676
|
-
const
|
|
3677
|
-
if (
|
|
4789
|
+
const bad = paramsError(p, [["tags", "strings"], ["request_revision", "boolean"]]);
|
|
4790
|
+
if (bad) return bad;
|
|
3678
4791
|
const ws = this.workspaces.get(p.workspace);
|
|
3679
4792
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3680
4793
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3684,7 +4797,7 @@ var Coordinator = class {
|
|
|
3684
4797
|
if (task.state !== "review_requested") {
|
|
3685
4798
|
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
3686
4799
|
}
|
|
3687
|
-
const notReviewer = this.requireReviewer(task, p.from);
|
|
4800
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
3688
4801
|
if (notReviewer) return notReviewer;
|
|
3689
4802
|
const digestError = this.checkArtefactDigest(p, task);
|
|
3690
4803
|
if (digestError) return digestError;
|
|
@@ -3711,8 +4824,8 @@ var Coordinator = class {
|
|
|
3711
4824
|
opDecideOverride(p) {
|
|
3712
4825
|
const miss = missing(p, ["workspace", "from", "task_id", "diff", "rationale"]);
|
|
3713
4826
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
3714
|
-
const
|
|
3715
|
-
if (
|
|
4827
|
+
const bad = paramsError(p, [["tags", "strings"], ["policy_refs", "strings"]]);
|
|
4828
|
+
if (bad) return bad;
|
|
3716
4829
|
const ws = this.workspaces.get(p.workspace);
|
|
3717
4830
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3718
4831
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3722,12 +4835,12 @@ var Coordinator = class {
|
|
|
3722
4835
|
if (task.state !== "review_requested") {
|
|
3723
4836
|
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
3724
4837
|
}
|
|
3725
|
-
const notReviewer = this.requireReviewer(task, p.from);
|
|
4838
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
3726
4839
|
if (notReviewer) return notReviewer;
|
|
3727
4840
|
const digestError = this.checkArtefactDigest(p, task);
|
|
3728
4841
|
if (digestError) return digestError;
|
|
3729
4842
|
const base = task.pending_artefact;
|
|
3730
|
-
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") };
|
|
3731
4844
|
let applied;
|
|
3732
4845
|
try {
|
|
3733
4846
|
applied = applyJsonPatch(base, p.diff, {
|
|
@@ -3771,6 +4884,8 @@ var Coordinator = class {
|
|
|
3771
4884
|
opAbstainDeclare(p) {
|
|
3772
4885
|
const miss = missing(p, ["workspace", "from", "task_id", "reason"]);
|
|
3773
4886
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
4887
|
+
const bad = paramsError(p, [["category", "string"]]);
|
|
4888
|
+
if (bad) return bad;
|
|
3774
4889
|
const ws = this.workspaces.get(p.workspace);
|
|
3775
4890
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
3776
4891
|
const notMember = this.requireMember(ws, p.from);
|
|
@@ -3780,7 +4895,7 @@ var Coordinator = class {
|
|
|
3780
4895
|
if (task.state !== "review_requested") {
|
|
3781
4896
|
return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
|
|
3782
4897
|
}
|
|
3783
|
-
const notReviewer = this.requireReviewer(task, p.from);
|
|
4898
|
+
const notReviewer = this.requireReviewer(ws, task, p.from);
|
|
3784
4899
|
if (notReviewer) return notReviewer;
|
|
3785
4900
|
const now = this.now();
|
|
3786
4901
|
task.review.decisions.push({
|
|
@@ -3812,6 +4927,8 @@ var Coordinator = class {
|
|
|
3812
4927
|
return { error: rpcError(E.PARAMS, "new_task must be an object") };
|
|
3813
4928
|
}
|
|
3814
4929
|
const nt = p.new_task;
|
|
4930
|
+
const bad = paramsError(p, [["new_task.kind", "string"]]);
|
|
4931
|
+
if (bad) return bad;
|
|
3815
4932
|
const assignee = nt.assignee;
|
|
3816
4933
|
if (typeof assignee !== "string" || !ws.members.has(assignee)) {
|
|
3817
4934
|
return { error: rpcError(E.PARAMS, "Escalation assignee not in workspace") };
|
|
@@ -3846,9 +4963,10 @@ var Coordinator = class {
|
|
|
3846
4963
|
history: [{ ts: now, from: p.from, state: "created", note: `escalated from ${orig.id}` }],
|
|
3847
4964
|
paused: false
|
|
3848
4965
|
};
|
|
3849
|
-
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") {
|
|
3850
4967
|
newTask.review_required = true;
|
|
3851
4968
|
}
|
|
4969
|
+
newTask.prior_authors = successorAuthors(orig, p.from);
|
|
3852
4970
|
ws.tasks.set(newId, newTask);
|
|
3853
4971
|
orig.state = "escalated";
|
|
3854
4972
|
orig.updated_at = now;
|
|
@@ -3865,8 +4983,8 @@ function memberToDict(m) {
|
|
|
3865
4983
|
joined: m.joined
|
|
3866
4984
|
};
|
|
3867
4985
|
if (m.display_name) out.display_name = m.display_name;
|
|
3868
|
-
if (m.capabilities) out.capabilities = m.capabilities;
|
|
3869
|
-
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;
|
|
3870
4988
|
if (m.keys.length) {
|
|
3871
4989
|
out.jwks = { keys: m.keys.filter((k) => !k.revoked_at && !k.valid_until).map((k) => k.jwk) };
|
|
3872
4990
|
out.key_history = m.keys;
|
|
@@ -3878,7 +4996,6 @@ function memberToDict(m) {
|
|
|
3878
4996
|
}
|
|
3879
4997
|
|
|
3880
4998
|
// src/policy.ts
|
|
3881
|
-
var CRIT2 = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
3882
4999
|
function makeDefaultPolicy(seniorTarget) {
|
|
3883
5000
|
const routingPolicy = (task, candidates) => {
|
|
3884
5001
|
const eligible = candidates;
|
|
@@ -3896,15 +5013,8 @@ function makeDefaultPolicy(seniorTarget) {
|
|
|
3896
5013
|
};
|
|
3897
5014
|
};
|
|
3898
5015
|
const reviewDepthPolicy = (_task, hints) => {
|
|
3899
|
-
const
|
|
3900
|
-
|
|
3901
|
-
try {
|
|
3902
|
-
conf = Number(hints.confidence ?? 1);
|
|
3903
|
-
} catch {
|
|
3904
|
-
conf = 1;
|
|
3905
|
-
}
|
|
3906
|
-
if (isNaN(conf)) conf = 1;
|
|
3907
|
-
const r = CRIT2[crit] ?? 1;
|
|
5016
|
+
const conf = confidenceOf(hints);
|
|
5017
|
+
const r = criticalityOf(hints);
|
|
3908
5018
|
if (r >= 3) return {
|
|
3909
5019
|
depth: "full",
|
|
3910
5020
|
rationale: { policy_id: "default", summary: "criticality=critical: full review" }
|
|
@@ -3929,15 +5039,8 @@ function makeDefaultPolicy(seniorTarget) {
|
|
|
3929
5039
|
};
|
|
3930
5040
|
};
|
|
3931
5041
|
const escalationPolicy = (_task, hints) => {
|
|
3932
|
-
const
|
|
3933
|
-
|
|
3934
|
-
try {
|
|
3935
|
-
conf = Number(hints.confidence ?? 1);
|
|
3936
|
-
} catch {
|
|
3937
|
-
conf = 1;
|
|
3938
|
-
}
|
|
3939
|
-
if (isNaN(conf)) conf = 1;
|
|
3940
|
-
const r = CRIT2[crit] ?? 1;
|
|
5042
|
+
const conf = confidenceOf(hints);
|
|
5043
|
+
const r = criticalityOf(hints);
|
|
3941
5044
|
const escalate = r >= 3 || r >= 2 && conf < 0.6;
|
|
3942
5045
|
return {
|
|
3943
5046
|
escalate,
|
|
@@ -3957,6 +5060,19 @@ function wrapMcpToolCall(coord, workspace, options) {
|
|
|
3957
5060
|
if (!workspace) throw new Error("workspace is required");
|
|
3958
5061
|
if (!options.caller) throw new Error("caller is required");
|
|
3959
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
|
+
}
|
|
3960
5076
|
const inputHash = contentHash(options.args);
|
|
3961
5077
|
const outputHash = contentHash(options.result);
|
|
3962
5078
|
const kind = options.taskKind ?? `mcp_call:${options.tool}`;
|
|
@@ -3975,6 +5091,7 @@ function wrapMcpToolCall(coord, workspace, options) {
|
|
|
3975
5091
|
input: options.args
|
|
3976
5092
|
};
|
|
3977
5093
|
if (options.routingHints) createParams.routing_hints = { ...options.routingHints };
|
|
5094
|
+
if (options.reviewRequired) createParams.review_required = true;
|
|
3978
5095
|
const createResp = coord.dispatch({
|
|
3979
5096
|
jsonrpc: "2.0",
|
|
3980
5097
|
id: `wrap-${options.tool}-create`,
|
|
@@ -4004,7 +5121,7 @@ function wrapMcpToolCall(coord, workspace, options) {
|
|
|
4004
5121
|
method: "task.complete",
|
|
4005
5122
|
params: completeParams
|
|
4006
5123
|
});
|
|
4007
|
-
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)}`);
|
|
4008
5125
|
return { task_id: taskId, input_hash: inputHash, output_hash: outputHash };
|
|
4009
5126
|
}
|
|
4010
5127
|
function wrapA2aMessageExchange(coord, workspace, options) {
|