@brightbeamai/chap-coordinator 0.2.12 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/dist/{api-DiiL0sil.d.cts → api-BkvwN42r.d.cts} +40 -0
- package/dist/{api-BOGEBNmX.d.ts → api-Ddy9fqkY.d.ts} +40 -0
- package/dist/api.d.cts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/index.cjs +667 -143
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -7
- package/dist/index.d.ts +24 -7
- package/dist/index.js +663 -143
- package/dist/index.js.map +1 -1
- package/dist/methods.cjs.map +1 -1
- package/dist/methods.d.cts +1 -1
- package/dist/methods.d.ts +1 -1
- package/dist/patch.cjs +22 -6
- package/dist/patch.cjs.map +1 -1
- package/dist/patch.js +22 -6
- package/dist/patch.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +28 -9
- package/dist/types.d.ts +28 -9
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -35,8 +35,12 @@ __export(src_exports, {
|
|
|
35
35
|
deriveKeypair: () => deriveKeypair,
|
|
36
36
|
deriveSeed: () => deriveSeed,
|
|
37
37
|
diffJsonPatch: () => diffJsonPatch,
|
|
38
|
+
entryCall: () => entryCall,
|
|
39
|
+
entryRecord: () => entryRecord,
|
|
40
|
+
isRefusal: () => isRefusal,
|
|
38
41
|
isValidEnvelope: () => isValidEnvelope,
|
|
39
42
|
jwkFromPrivateKey: () => jwkFromPrivateKey,
|
|
43
|
+
linkHash: () => linkHash,
|
|
40
44
|
makeApi: () => makeApi,
|
|
41
45
|
makeDefaultPolicy: () => makeDefaultPolicy,
|
|
42
46
|
modeLE: () => modeLE,
|
|
@@ -57,7 +61,21 @@ var import_node_crypto = require("crypto");
|
|
|
57
61
|
var ZERO_HASH = "sha256:" + "0".repeat(64);
|
|
58
62
|
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.';
|
|
59
63
|
var NUMBER_RANGE_ERROR = "CHAP canonical integers must be within the safe-integer range (abs value <= Number.MAX_SAFE_INTEGER); represent larger numbers as strings.";
|
|
64
|
+
var LONE_SURROGATE_ERROR = "CHAP canonical strings must be valid Unicode (I-JSON / RFC 7493); unpaired UTF-16 surrogates are not permitted.";
|
|
65
|
+
function assertNoLoneSurrogate(s) {
|
|
66
|
+
for (let i = 0; i < s.length; i++) {
|
|
67
|
+
const c = s.charCodeAt(i);
|
|
68
|
+
if (c >= 55296 && c <= 56319) {
|
|
69
|
+
const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0;
|
|
70
|
+
if (next < 56320 || next > 57343) throw new Error(LONE_SURROGATE_ERROR);
|
|
71
|
+
i++;
|
|
72
|
+
} else if (c >= 56320 && c <= 57343) {
|
|
73
|
+
throw new Error(LONE_SURROGATE_ERROR);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
60
77
|
function canonicalString(s) {
|
|
78
|
+
assertNoLoneSurrogate(s);
|
|
61
79
|
return JSON.stringify(s);
|
|
62
80
|
}
|
|
63
81
|
function canonicalNumber(n) {
|
|
@@ -106,6 +124,55 @@ function contentHash(content) {
|
|
|
106
124
|
return sha256Hex(canonicalize(content));
|
|
107
125
|
}
|
|
108
126
|
|
|
127
|
+
// src/catalogue.ts
|
|
128
|
+
var OWNING_PROFILE = Object.freeze({
|
|
129
|
+
"abstain.declare": "review/1.0",
|
|
130
|
+
"audit.read": "core/1.0",
|
|
131
|
+
"audit.submit_to_scitt": "audit-scitt/1.0",
|
|
132
|
+
"audit.verify_chain": "audit-scitt/1.0",
|
|
133
|
+
"audit.verify_receipt": "audit-scitt/1.0",
|
|
134
|
+
"control.cancel": "control/1.0",
|
|
135
|
+
"control.pause": "control/1.0",
|
|
136
|
+
"control.resume": "control/1.0",
|
|
137
|
+
"control.rollback": "control/1.0",
|
|
138
|
+
"control.set_mode_ceiling": "control/1.0",
|
|
139
|
+
"control.snapshot": "control/1.0",
|
|
140
|
+
"control.supersede": "control/1.0",
|
|
141
|
+
"decide.approve": "review/1.0",
|
|
142
|
+
"decide.override": "review/1.0",
|
|
143
|
+
"decide.reject": "review/1.0",
|
|
144
|
+
"deliberate.close": "deliberation/1.0",
|
|
145
|
+
"deliberate.comment": "deliberation/1.0",
|
|
146
|
+
"deliberate.open": "deliberation/1.0",
|
|
147
|
+
"deliberate.vote": "deliberation/1.0",
|
|
148
|
+
"escalate.auto": "routing/1.0",
|
|
149
|
+
"escalate.raise": "review/1.0",
|
|
150
|
+
"handoff.accept": "handoff/1.0",
|
|
151
|
+
"handoff.decline": "handoff/1.0",
|
|
152
|
+
"handoff.propose": "handoff/1.0",
|
|
153
|
+
"participant.join": "core/1.0",
|
|
154
|
+
"participant.leave": "core/1.0",
|
|
155
|
+
"participant.revoke_key": "core/1.0",
|
|
156
|
+
"participant.rotate_key": "core/1.0",
|
|
157
|
+
"review.depth": "routing/1.0",
|
|
158
|
+
"review.request": "review/1.0",
|
|
159
|
+
"task.complete": "core/1.0",
|
|
160
|
+
"task.create": "core/1.0",
|
|
161
|
+
"task.route": "routing/1.0",
|
|
162
|
+
"task.update": "core/1.0",
|
|
163
|
+
"whisper.answer": "whisper/1.0",
|
|
164
|
+
"whisper.ask": "whisper/1.0",
|
|
165
|
+
"workspace.create": "core/1.0",
|
|
166
|
+
"workspace.describe": "core/1.0",
|
|
167
|
+
"workspace.set_profiles": "core/1.0"
|
|
168
|
+
});
|
|
169
|
+
var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
|
|
170
|
+
"workspace.describe",
|
|
171
|
+
"audit.read",
|
|
172
|
+
"audit.verify_chain",
|
|
173
|
+
"audit.verify_receipt"
|
|
174
|
+
]);
|
|
175
|
+
|
|
109
176
|
// src/crypto.ts
|
|
110
177
|
var import_node_crypto2 = require("crypto");
|
|
111
178
|
var ED25519_OID = Buffer.from("302a300506032b6570032100", "hex");
|
|
@@ -319,11 +386,84 @@ function isValidEnvelope(env) {
|
|
|
319
386
|
return e.jsonrpc === "2.0";
|
|
320
387
|
}
|
|
321
388
|
|
|
389
|
+
// src/audit.ts
|
|
390
|
+
function entryRecord(entry) {
|
|
391
|
+
if (entry.outcome != null) return { outcome: entry.outcome, request: entry.request };
|
|
392
|
+
return entry.envelope;
|
|
393
|
+
}
|
|
394
|
+
function entryCall(entry) {
|
|
395
|
+
return entry.envelope ?? entry.request ?? void 0;
|
|
396
|
+
}
|
|
397
|
+
function isRefusal(entry) {
|
|
398
|
+
return entry.outcome != null;
|
|
399
|
+
}
|
|
400
|
+
function isCall(value) {
|
|
401
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
402
|
+
const v = value;
|
|
403
|
+
return v.jsonrpc === "2.0" && typeof v.method === "string";
|
|
404
|
+
}
|
|
405
|
+
function entryIsWellFormed(entry) {
|
|
406
|
+
const accepted = entry.envelope != null;
|
|
407
|
+
const refused = entry.request != null;
|
|
408
|
+
if (accepted === refused) return false;
|
|
409
|
+
if (accepted) return isCall(entry.envelope) && entry.outcome == null;
|
|
410
|
+
const o = entry.outcome;
|
|
411
|
+
return isCall(entry.request) && o != null && typeof o === "object" && o.status === "refused" && Number.isInteger(o.code);
|
|
412
|
+
}
|
|
413
|
+
function isSigned(call2) {
|
|
414
|
+
return call2 !== null && typeof call2 === "object" && typeof call2.sig === "string";
|
|
415
|
+
}
|
|
416
|
+
function signedDigest(call2) {
|
|
417
|
+
const signed = { ...call2 };
|
|
418
|
+
delete signed.sig;
|
|
419
|
+
return sha256Hex(canonicalize(signed));
|
|
420
|
+
}
|
|
421
|
+
function linkHash(record, prev) {
|
|
422
|
+
return sha256Hex(Buffer.concat([canonicalize(record), Buffer.from(prev, "utf-8")]));
|
|
423
|
+
}
|
|
424
|
+
var UNRECORDED_CODES = /* @__PURE__ */ new Set([
|
|
425
|
+
E.PARSE,
|
|
426
|
+
E.REQUEST,
|
|
427
|
+
E.PARAMS,
|
|
428
|
+
E.INTERNAL,
|
|
429
|
+
E.SIG_VERIFY_FAILED,
|
|
430
|
+
E.SIG_KEY_NOT_FOUND,
|
|
431
|
+
E.SIG_KEY_REVOKED,
|
|
432
|
+
E.SIG_ROTATION_KEY_MISMATCH
|
|
433
|
+
]);
|
|
434
|
+
var UNRECORDED_METHODS = /* @__PURE__ */ new Set(["workspace.create", "participant.join"]);
|
|
435
|
+
function refusalIsRecorded(method, error, privileged) {
|
|
436
|
+
if (!Number.isInteger(error.code)) return false;
|
|
437
|
+
if (UNRECORDED_METHODS.has(method)) return false;
|
|
438
|
+
if (UNRECORDED_CODES.has(error.code)) return false;
|
|
439
|
+
if (error.code === E.METHOD) {
|
|
440
|
+
const data = error.data;
|
|
441
|
+
return typeof data?.profile === "string" && privileged.has(method);
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
322
446
|
// src/patch.ts
|
|
323
447
|
var PatchError = class extends Error {
|
|
324
448
|
};
|
|
325
449
|
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
326
450
|
var ARRAY_INDEX_RE = /^(0|[1-9][0-9]*)$/;
|
|
451
|
+
function jsonType(value) {
|
|
452
|
+
if (value === null) return "null";
|
|
453
|
+
if (Array.isArray(value)) return "array";
|
|
454
|
+
switch (typeof value) {
|
|
455
|
+
case "object":
|
|
456
|
+
return "object";
|
|
457
|
+
case "string":
|
|
458
|
+
return "string";
|
|
459
|
+
case "number":
|
|
460
|
+
return "number";
|
|
461
|
+
case "boolean":
|
|
462
|
+
return "boolean";
|
|
463
|
+
default:
|
|
464
|
+
return "null";
|
|
465
|
+
}
|
|
466
|
+
}
|
|
327
467
|
function arrayIndex(seg) {
|
|
328
468
|
if (!ARRAY_INDEX_RE.test(seg)) throw new PatchError(`Array index expected at ${JSON.stringify(seg)}`);
|
|
329
469
|
return Number(seg);
|
|
@@ -363,7 +503,7 @@ function navigate(doc, parts) {
|
|
|
363
503
|
}
|
|
364
504
|
parent = parent[part];
|
|
365
505
|
} else {
|
|
366
|
-
throw new PatchError(`Cannot traverse into ${
|
|
506
|
+
throw new PatchError(`Cannot traverse into ${jsonType(parent)} at ${JSON.stringify(part)}`);
|
|
367
507
|
}
|
|
368
508
|
}
|
|
369
509
|
const last = parts[parts.length - 1];
|
|
@@ -433,7 +573,7 @@ function applyOne(doc, op) {
|
|
|
433
573
|
} else if (parent !== null && typeof parent === "object") {
|
|
434
574
|
parent[key] = op.value;
|
|
435
575
|
} else {
|
|
436
|
-
throw new PatchError(`Cannot add into ${
|
|
576
|
+
throw new PatchError(`Cannot add into ${jsonType(parent)}`);
|
|
437
577
|
}
|
|
438
578
|
return doc;
|
|
439
579
|
}
|
|
@@ -443,7 +583,7 @@ function applyOne(doc, op) {
|
|
|
443
583
|
if (parts.length === 0) return op.value;
|
|
444
584
|
const [parent, key] = navigate(doc, parts);
|
|
445
585
|
if (Array.isArray(parent)) {
|
|
446
|
-
const idx = key;
|
|
586
|
+
const idx = key === "-" ? -1 : key;
|
|
447
587
|
if (idx < 0 || idx >= parent.length) {
|
|
448
588
|
throw new PatchError(`Path not found for replace: ${path}`);
|
|
449
589
|
}
|
|
@@ -454,7 +594,7 @@ function applyOne(doc, op) {
|
|
|
454
594
|
}
|
|
455
595
|
parent[key] = op.value;
|
|
456
596
|
} else {
|
|
457
|
-
throw new PatchError(`Cannot replace in ${
|
|
597
|
+
throw new PatchError(`Cannot replace in ${jsonType(parent)}`);
|
|
458
598
|
}
|
|
459
599
|
return doc;
|
|
460
600
|
}
|
|
@@ -463,7 +603,7 @@ function applyOne(doc, op) {
|
|
|
463
603
|
if (parts.length === 0) throw new PatchError("Cannot remove root.");
|
|
464
604
|
const [parent, key] = navigate(doc, parts);
|
|
465
605
|
if (Array.isArray(parent)) {
|
|
466
|
-
const idx = key;
|
|
606
|
+
const idx = key === "-" ? -1 : key;
|
|
467
607
|
if (idx < 0 || idx >= parent.length) {
|
|
468
608
|
throw new PatchError(`Index out of range for remove: ${path}`);
|
|
469
609
|
}
|
|
@@ -474,7 +614,7 @@ function applyOne(doc, op) {
|
|
|
474
614
|
}
|
|
475
615
|
delete parent[key];
|
|
476
616
|
} else {
|
|
477
|
-
throw new PatchError(`Cannot remove from ${
|
|
617
|
+
throw new PatchError(`Cannot remove from ${jsonType(parent)}`);
|
|
478
618
|
}
|
|
479
619
|
return doc;
|
|
480
620
|
}
|
|
@@ -682,7 +822,8 @@ function modeLE(a, ceiling) {
|
|
|
682
822
|
}
|
|
683
823
|
|
|
684
824
|
// src/profiles/audit_scitt.ts
|
|
685
|
-
|
|
825
|
+
var MALFORMED = "sha256:malformed";
|
|
826
|
+
function buildStatement(workspaceId, record, issuer) {
|
|
686
827
|
return {
|
|
687
828
|
protected: {
|
|
688
829
|
alg: -8,
|
|
@@ -690,9 +831,9 @@ function buildStatement(workspaceId, envelope, issuer) {
|
|
|
690
831
|
iss: issuer,
|
|
691
832
|
kid: "scitt-issuer",
|
|
692
833
|
cwt_claims: { sub: workspaceId, iat: null },
|
|
693
|
-
"content-type": "application/chap+json;version=0.
|
|
834
|
+
"content-type": "application/chap+json;version=0.3"
|
|
694
835
|
},
|
|
695
|
-
payload: canonicalize(
|
|
836
|
+
payload: canonicalize(record).toString("utf-8"),
|
|
696
837
|
signature: "<deployment-supplied>"
|
|
697
838
|
};
|
|
698
839
|
}
|
|
@@ -705,7 +846,7 @@ function registerAuditScitt(coord) {
|
|
|
705
846
|
const toSeq = range.to_seq ?? ws.audit.length;
|
|
706
847
|
const issuer = p.issuer ?? "service:coordinator";
|
|
707
848
|
if (!coord.options.scittSubmitter) {
|
|
708
|
-
const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, e
|
|
849
|
+
const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, entryRecord(e), issuer));
|
|
709
850
|
return { result: {
|
|
710
851
|
statements,
|
|
711
852
|
note: "No scittSubmitter configured; submit these out-of-band"
|
|
@@ -713,7 +854,7 @@ function registerAuditScitt(coord) {
|
|
|
713
854
|
}
|
|
714
855
|
const receipts = [];
|
|
715
856
|
for (const entry of ws.audit.slice(fromSeq, toSeq)) {
|
|
716
|
-
const statement = buildStatement(ws.id, entry
|
|
857
|
+
const statement = buildStatement(ws.id, entryRecord(entry), issuer);
|
|
717
858
|
let receipt;
|
|
718
859
|
try {
|
|
719
860
|
receipt = coord.options.scittSubmitter(statement);
|
|
@@ -765,12 +906,20 @@ function registerAuditScitt(coord) {
|
|
|
765
906
|
if (start < 0) start = ws.audit.length;
|
|
766
907
|
const errors = [];
|
|
767
908
|
let prev = ZERO_HASH;
|
|
909
|
+
let resync = false;
|
|
768
910
|
for (const e of ws.audit.slice(start)) {
|
|
769
|
-
const expectedPrev = prev;
|
|
911
|
+
const expectedPrev = resync && typeof e.prev_hash === "string" ? e.prev_hash : prev;
|
|
912
|
+
resync = false;
|
|
770
913
|
if (e.prev_hash !== expectedPrev) {
|
|
771
914
|
errors.push(`seq ${e.seq}: prev_hash mismatch`);
|
|
772
915
|
}
|
|
773
|
-
|
|
916
|
+
if (!entryIsWellFormed(e)) {
|
|
917
|
+
errors.push(`seq ${e.seq}: malformed entry`);
|
|
918
|
+
prev = MALFORMED;
|
|
919
|
+
resync = true;
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
prev = linkHash(entryRecord(e), expectedPrev);
|
|
774
923
|
}
|
|
775
924
|
const storedHead = ws.chain_head === void 0 || ws.chain_head === null ? ZERO_HASH : ws.chain_head;
|
|
776
925
|
if (prev !== storedHead) {
|
|
@@ -825,6 +974,7 @@ function registerControl(coord) {
|
|
|
825
974
|
}
|
|
826
975
|
task.paused = true;
|
|
827
976
|
const prior = task.state;
|
|
977
|
+
if (prior !== "paused") task.paused_from = prior;
|
|
828
978
|
task.state = "paused";
|
|
829
979
|
task.updated_at = coord.now();
|
|
830
980
|
task.history.push({ ts: task.updated_at, from: p.from, state: "paused", note: `was ${prior}` });
|
|
@@ -858,10 +1008,12 @@ function registerControl(coord) {
|
|
|
858
1008
|
return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot resume ${task.state} task`) };
|
|
859
1009
|
}
|
|
860
1010
|
task.paused = false;
|
|
861
|
-
task.
|
|
1011
|
+
const restored = task.paused_from ?? "in_progress";
|
|
1012
|
+
task.paused_from = void 0;
|
|
1013
|
+
task.state = restored;
|
|
862
1014
|
task.updated_at = coord.now();
|
|
863
|
-
task.history.push({ ts: task.updated_at, from: p.from, state:
|
|
864
|
-
return { result: { scope: "task", task_id: task.id, state:
|
|
1015
|
+
task.history.push({ ts: task.updated_at, from: p.from, state: restored });
|
|
1016
|
+
return { result: { scope: "task", task_id: task.id, state: restored } };
|
|
865
1017
|
}
|
|
866
1018
|
if (scope === "participant") {
|
|
867
1019
|
const uri = p.participant_uri || p.uri;
|
|
@@ -888,36 +1040,56 @@ function registerControl(coord) {
|
|
|
888
1040
|
coord.handlers.set("control.snapshot", (p) => {
|
|
889
1041
|
const ws = coord.workspaces.get(p.workspace);
|
|
890
1042
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
891
|
-
|
|
1043
|
+
if (Array.isArray(p.include) && p.include.length === 0) {
|
|
1044
|
+
return { error: rpcError(E.PARAMS, "include must not be empty") };
|
|
1045
|
+
}
|
|
1046
|
+
const include = Array.isArray(p.include) ? [...p.include] : ["members", "open_tasks", "mode_ceiling"];
|
|
892
1047
|
const state = {};
|
|
893
1048
|
if (include.includes("members")) {
|
|
894
|
-
|
|
1049
|
+
const members = Array.from(ws.members.values()).map((m) => ({
|
|
895
1050
|
uri: m.uri,
|
|
896
1051
|
type: m.type,
|
|
897
|
-
role: m.role,
|
|
898
|
-
scopes: m.scopes
|
|
1052
|
+
...m.role ? { role: m.role } : {},
|
|
1053
|
+
...m.scopes?.length ? { scopes: m.scopes } : {}
|
|
899
1054
|
}));
|
|
1055
|
+
if (members.length) state.members = members;
|
|
900
1056
|
}
|
|
901
1057
|
if (include.includes("open_tasks")) {
|
|
902
|
-
|
|
1058
|
+
const tasks = Array.from(ws.tasks.values()).filter((t) => t.state !== "completed" && t.state !== "declined" && t.state !== "cancelled" && t.state !== "superseded").map((t) => ({
|
|
1059
|
+
id: t.id,
|
|
1060
|
+
kind: t.kind,
|
|
1061
|
+
state: t.state,
|
|
1062
|
+
...t.assignee ? { assignee: t.assignee } : {}
|
|
1063
|
+
}));
|
|
1064
|
+
if (tasks.length) state.open_tasks = tasks;
|
|
903
1065
|
}
|
|
904
1066
|
if (include.includes("mode_ceiling")) state.mode_ceiling = ws.mode_ceiling;
|
|
905
|
-
if (include.includes("policy")) state.routing_policy_uri = ws.routing_policy_uri;
|
|
1067
|
+
if (include.includes("policy") && ws.routing_policy_uri) state.routing_policy_uri = ws.routing_policy_uri;
|
|
906
1068
|
if (include.includes("audit") || include.includes("all")) state.audit_seq = ws.audit.length;
|
|
1069
|
+
const content = structuredClone({
|
|
1070
|
+
workspace: ws.id,
|
|
1071
|
+
audit_seq: ws.audit.length,
|
|
1072
|
+
include,
|
|
1073
|
+
state,
|
|
1074
|
+
...p.label ? { label: p.label } : {}
|
|
1075
|
+
});
|
|
907
1076
|
const snapId = coord.ids.artefactId();
|
|
908
1077
|
const snap = {
|
|
909
1078
|
id: snapId,
|
|
910
1079
|
kind: "snapshot",
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
label: p.label,
|
|
916
|
-
include,
|
|
917
|
-
state
|
|
1080
|
+
produced_at: coord.now(),
|
|
1081
|
+
produced_by: p.from,
|
|
1082
|
+
content_hash: contentHash(content),
|
|
1083
|
+
content
|
|
918
1084
|
};
|
|
919
1085
|
ws.snapshots.set(snapId, snap);
|
|
920
|
-
return {
|
|
1086
|
+
return {
|
|
1087
|
+
result: {
|
|
1088
|
+
snapshot_artefact_id: snapId,
|
|
1089
|
+
audit_seq: snap.content.audit_seq,
|
|
1090
|
+
artefact: structuredClone(snap)
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
921
1093
|
});
|
|
922
1094
|
coord.handlers.set("control.rollback", (p) => {
|
|
923
1095
|
const ws = coord.workspaces.get(p.workspace);
|
|
@@ -926,31 +1098,34 @@ function registerControl(coord) {
|
|
|
926
1098
|
if (!snapId) return { error: rpcError(E.PARAMS, "to_snapshot_artefact_id is required") };
|
|
927
1099
|
const snap = ws.snapshots.get(snapId);
|
|
928
1100
|
if (!snap) return { error: rpcError(E.CONTROL_SNAPSHOT_NOT_FOUND, `Unknown snapshot: ${snapId}`) };
|
|
929
|
-
|
|
1101
|
+
if (Array.isArray(p.what_to_restore) && p.what_to_restore.length === 0) {
|
|
1102
|
+
return { error: rpcError(E.PARAMS, "what_to_restore must not be empty") };
|
|
1103
|
+
}
|
|
1104
|
+
const what = p.what_to_restore ?? snap.content.include;
|
|
930
1105
|
const restored = [];
|
|
931
|
-
if (what.includes("mode_ceiling") && snap.state.mode_ceiling) {
|
|
932
|
-
ws.mode_ceiling = snap.state.mode_ceiling;
|
|
1106
|
+
if (what.includes("mode_ceiling") && snap.content.state.mode_ceiling) {
|
|
1107
|
+
ws.mode_ceiling = snap.content.state.mode_ceiling;
|
|
933
1108
|
restored.push("mode_ceiling");
|
|
934
1109
|
}
|
|
935
|
-
if (what.includes("members") && Array.isArray(snap.state.members)) {
|
|
1110
|
+
if (what.includes("members") && Array.isArray(snap.content.state.members)) {
|
|
936
1111
|
const snapByUri = /* @__PURE__ */ new Map();
|
|
937
|
-
for (const m of snap.state.members) {
|
|
1112
|
+
for (const m of snap.content.state.members) {
|
|
938
1113
|
snapByUri.set(m.uri, m);
|
|
939
1114
|
}
|
|
940
1115
|
for (const [uri, snapM] of snapByUri) {
|
|
941
1116
|
if (ws.members.has(uri)) {
|
|
942
1117
|
const cur = ws.members.get(uri);
|
|
943
1118
|
cur.role = snapM.role ?? cur.role;
|
|
944
|
-
cur.scopes = snapM.scopes;
|
|
1119
|
+
cur.scopes = Array.isArray(snapM.scopes) ? [...snapM.scopes] : void 0;
|
|
945
1120
|
}
|
|
946
1121
|
}
|
|
947
1122
|
restored.push("members");
|
|
948
1123
|
}
|
|
949
1124
|
return { result: {
|
|
950
1125
|
rolled_back_to: snapId,
|
|
951
|
-
audit_seq: snap.audit_seq,
|
|
1126
|
+
audit_seq: snap.content.audit_seq,
|
|
952
1127
|
restored,
|
|
953
|
-
reason: p.reason
|
|
1128
|
+
...p.reason ? { reason: p.reason } : {}
|
|
954
1129
|
} };
|
|
955
1130
|
});
|
|
956
1131
|
coord.handlers.set("control.supersede", (p) => {
|
|
@@ -964,16 +1139,25 @@ function registerControl(coord) {
|
|
|
964
1139
|
return { error: rpcError(E.PARAMS, "successor_task must include kind") };
|
|
965
1140
|
}
|
|
966
1141
|
const assignee = newSpec.assignee || old.assignee;
|
|
967
|
-
if (!ws.members.has(assignee)) {
|
|
1142
|
+
if (typeof assignee !== "string" || !ws.members.has(assignee)) {
|
|
968
1143
|
return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
|
|
969
1144
|
}
|
|
970
|
-
|
|
1145
|
+
if (ws.members.get(assignee).paused) {
|
|
1146
|
+
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
|
|
1147
|
+
}
|
|
1148
|
+
if (newSpec.mode !== void 0 && newSpec.mode !== null && typeof newSpec.mode !== "string") {
|
|
1149
|
+
return { error: rpcError(E.PARAMS, "successor_task.mode must be a string") };
|
|
1150
|
+
}
|
|
1151
|
+
const requestedMode = newSpec.mode || old.mode;
|
|
971
1152
|
if (!modeLE(requestedMode, ws.mode_ceiling)) {
|
|
972
1153
|
return { error: rpcError(
|
|
973
1154
|
E.MODE_CEILING_EXCEEDED,
|
|
974
1155
|
`Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
|
|
975
1156
|
) };
|
|
976
1157
|
}
|
|
1158
|
+
if (newSpec.review_required !== void 0 && newSpec.review_required !== null && typeof newSpec.review_required !== "boolean") {
|
|
1159
|
+
return { error: rpcError(E.PARAMS, "successor_task.review_required must be a boolean") };
|
|
1160
|
+
}
|
|
977
1161
|
const now = coord.now();
|
|
978
1162
|
const newId = coord.ids.taskId();
|
|
979
1163
|
const newTask = {
|
|
@@ -995,8 +1179,11 @@ function registerControl(coord) {
|
|
|
995
1179
|
}],
|
|
996
1180
|
paused: false
|
|
997
1181
|
};
|
|
998
|
-
if (ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial")
|
|
999
|
-
|
|
1182
|
+
if (old.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
|
|
1183
|
+
newTask.review_required = true;
|
|
1184
|
+
} else if ("review_required" in newSpec) {
|
|
1185
|
+
newTask.review_required = !!newSpec.review_required;
|
|
1186
|
+
}
|
|
1000
1187
|
ws.tasks.set(newId, newTask);
|
|
1001
1188
|
old.state = "superseded";
|
|
1002
1189
|
old.superseded_by = newId;
|
|
@@ -1293,8 +1480,14 @@ function registerHandoff(coord) {
|
|
|
1293
1480
|
) };
|
|
1294
1481
|
}
|
|
1295
1482
|
}
|
|
1483
|
+
if (ws.members.get(acceptor).paused) {
|
|
1484
|
+
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Acceptor ${acceptor} is paused`) };
|
|
1485
|
+
}
|
|
1486
|
+
if (Array.isArray(p.accepted_task_ids) && p.accepted_task_ids.length === 0) {
|
|
1487
|
+
return { error: rpcError(E.PARAMS, "accepted_task_ids must not be empty") };
|
|
1488
|
+
}
|
|
1296
1489
|
const validIds = new Set(ho.tasks.map((t) => t.task_id));
|
|
1297
|
-
const acceptedIds = Array.isArray(p.accepted_task_ids) ? p.accepted_task_ids : ho.tasks.map((t) => t.task_id);
|
|
1490
|
+
const acceptedIds = Array.isArray(p.accepted_task_ids) ? [...p.accepted_task_ids] : ho.tasks.map((t) => t.task_id);
|
|
1298
1491
|
for (const tid of acceptedIds) {
|
|
1299
1492
|
if (!validIds.has(tid)) {
|
|
1300
1493
|
return { error: rpcError(E.PARAMS, `Task ${tid} not in this handoff`) };
|
|
@@ -1365,6 +1558,9 @@ function registerRouting(coord) {
|
|
|
1365
1558
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
1366
1559
|
const task = ws.tasks.get(p.task_id);
|
|
1367
1560
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
1561
|
+
if (p.candidates !== void 0 && p.candidates !== null && !Array.isArray(p.candidates)) {
|
|
1562
|
+
return { error: rpcError(E.PARAMS, "candidates must be a list") };
|
|
1563
|
+
}
|
|
1368
1564
|
const candidates = p.candidates ?? [];
|
|
1369
1565
|
if (!candidates.length) {
|
|
1370
1566
|
return { error: rpcError(E.ROUTING_CANDIDATES_EMPTY, "candidates array was empty") };
|
|
@@ -1381,19 +1577,29 @@ function registerRouting(coord) {
|
|
|
1381
1577
|
return { error: rpcError(E.ROUTING_POLICY_UNREACHABLE, `routing policy error: ${msg}`) };
|
|
1382
1578
|
}
|
|
1383
1579
|
} else {
|
|
1384
|
-
const
|
|
1580
|
+
const memberOf = (c) => typeof c === "string" ? ws.members.get(c) : void 0;
|
|
1581
|
+
const eligible = candidates.filter((c) => {
|
|
1582
|
+
const m = memberOf(c);
|
|
1583
|
+
return !!m && !m.paused;
|
|
1584
|
+
});
|
|
1385
1585
|
if (!eligible.length) {
|
|
1386
|
-
return { error: rpcError(E.ROUTING_NO_ELIGIBLE_ASSIGNEE, "No candidate is a workspace member") };
|
|
1586
|
+
return { error: rpcError(E.ROUTING_NO_ELIGIBLE_ASSIGNEE, "No candidate is a workspace member who is not paused") };
|
|
1387
1587
|
}
|
|
1388
1588
|
selected = eligible[0];
|
|
1589
|
+
const alternatives = [];
|
|
1590
|
+
let chosen = false;
|
|
1591
|
+
for (const c of candidates) {
|
|
1592
|
+
const m = memberOf(c);
|
|
1593
|
+
if (!m) continue;
|
|
1594
|
+
if (m.paused) alternatives.push({ candidate: c, reason_excluded: "paused" });
|
|
1595
|
+
else if (!chosen) chosen = true;
|
|
1596
|
+
else alternatives.push({ candidate: c, reason_excluded: "not first eligible" });
|
|
1597
|
+
}
|
|
1389
1598
|
rationale = {
|
|
1390
1599
|
policy_id: "default",
|
|
1391
1600
|
hints_used: Object.keys(task.routing_hints ?? {}),
|
|
1392
1601
|
summary: "default policy: first eligible candidate",
|
|
1393
|
-
alternatives_considered:
|
|
1394
|
-
candidate: c,
|
|
1395
|
-
reason_excluded: "not first eligible"
|
|
1396
|
-
}))
|
|
1602
|
+
alternatives_considered: alternatives
|
|
1397
1603
|
};
|
|
1398
1604
|
}
|
|
1399
1605
|
if (!ws.members.has(selected)) {
|
|
@@ -1402,6 +1608,9 @@ function registerRouting(coord) {
|
|
|
1402
1608
|
`Selected ${JSON.stringify(selected)} is not a member`
|
|
1403
1609
|
) };
|
|
1404
1610
|
}
|
|
1611
|
+
if (ws.members.get(selected).paused) {
|
|
1612
|
+
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${selected} is paused`) };
|
|
1613
|
+
}
|
|
1405
1614
|
task.assignee = selected;
|
|
1406
1615
|
task.updated_at = coord.now();
|
|
1407
1616
|
task.history.push({
|
|
@@ -1519,6 +1728,18 @@ function registerRouting(coord) {
|
|
|
1519
1728
|
hints_used: ["criticality", "confidence"]
|
|
1520
1729
|
};
|
|
1521
1730
|
}
|
|
1731
|
+
if (escalate) {
|
|
1732
|
+
if (!to) return { error: rpcError(
|
|
1733
|
+
E.ROUTING_ESC_TARGET_UNAVAILABLE,
|
|
1734
|
+
"Escalation triggered but no target available"
|
|
1735
|
+
) };
|
|
1736
|
+
if (!to.startsWith("group:") && !ws.members.has(to)) {
|
|
1737
|
+
return { error: rpcError(
|
|
1738
|
+
E.ROUTING_ESC_TARGET_UNAVAILABLE,
|
|
1739
|
+
`Escalation target ${to} is not a member or group`
|
|
1740
|
+
) };
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1522
1743
|
const artId = coord.ids.artefactId();
|
|
1523
1744
|
const artefact = {
|
|
1524
1745
|
id: artId,
|
|
@@ -1535,17 +1756,7 @@ function registerRouting(coord) {
|
|
|
1535
1756
|
};
|
|
1536
1757
|
ws.route_decisions.set(artId, artefact);
|
|
1537
1758
|
if (escalate) {
|
|
1538
|
-
if (
|
|
1539
|
-
E.ROUTING_ESC_TARGET_UNAVAILABLE,
|
|
1540
|
-
"Escalation triggered but no target available"
|
|
1541
|
-
) };
|
|
1542
|
-
if (!to.startsWith("group:") && !ws.members.has(to)) {
|
|
1543
|
-
return { error: rpcError(
|
|
1544
|
-
E.ROUTING_ESC_TARGET_UNAVAILABLE,
|
|
1545
|
-
`Escalation target ${to} is not a member or group`
|
|
1546
|
-
) };
|
|
1547
|
-
}
|
|
1548
|
-
if (coord.options.onAutoEscalate) {
|
|
1759
|
+
if (coord.options.onAutoEscalate && to) {
|
|
1549
1760
|
try {
|
|
1550
1761
|
coord.options.onAutoEscalate(task, to);
|
|
1551
1762
|
} catch {
|
|
@@ -1615,6 +1826,12 @@ function registerSecuritySigned(coord) {
|
|
|
1615
1826
|
function parseIso(ts) {
|
|
1616
1827
|
return new Date(ts).getTime();
|
|
1617
1828
|
}
|
|
1829
|
+
function present(v) {
|
|
1830
|
+
if (v === void 0 || v === null || v === false || v === 0 || v === "") return false;
|
|
1831
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
1832
|
+
if (typeof v === "object") return Object.keys(v).length > 0;
|
|
1833
|
+
return true;
|
|
1834
|
+
}
|
|
1618
1835
|
function registerWhisper(coord) {
|
|
1619
1836
|
coord.handlers.set("whisper.ask", (p) => {
|
|
1620
1837
|
const ws = coord.workspaces.get(p.workspace);
|
|
@@ -1625,6 +1842,9 @@ function registerWhisper(coord) {
|
|
|
1625
1842
|
if (!ws.tasks.has(p.task_id)) {
|
|
1626
1843
|
return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
1627
1844
|
}
|
|
1845
|
+
if (p.options !== void 0 && p.options !== null && !Array.isArray(p.options)) {
|
|
1846
|
+
return { error: rpcError(E.PARAMS, "options must be a list") };
|
|
1847
|
+
}
|
|
1628
1848
|
const to = p.to;
|
|
1629
1849
|
const askee = Array.isArray(to) ? to : [to];
|
|
1630
1850
|
const id = p.whisper_id || coord.ids.artefactId();
|
|
@@ -1668,22 +1888,21 @@ function registerWhisper(coord) {
|
|
|
1668
1888
|
return { error: rpcError(E.WHISPER_LAPSED, "Whisper already lapsed") };
|
|
1669
1889
|
}
|
|
1670
1890
|
const answerOption = p.answer_option;
|
|
1671
|
-
const
|
|
1891
|
+
const hasOption = answerOption !== void 0 && answerOption !== null;
|
|
1892
|
+
const answerText = present(p.answer) ? p.answer : p.answer_text;
|
|
1672
1893
|
if (prompt.options && prompt.options.length) {
|
|
1673
|
-
if (
|
|
1894
|
+
if (!hasOption) {
|
|
1674
1895
|
return { error: rpcError(E.PARAMS, "answer_option is required when options are defined") };
|
|
1675
1896
|
}
|
|
1676
1897
|
const valid = new Set(prompt.options.map((o) => o.id));
|
|
1677
1898
|
if (!valid.has(answerOption)) {
|
|
1678
1899
|
return { error: rpcError(
|
|
1679
1900
|
E.WHISPER_OPTION_NOT_IN_SET,
|
|
1680
|
-
`Answer option ${
|
|
1901
|
+
`Answer option ${canonicalize(answerOption).toString("utf-8")} not in option set`
|
|
1681
1902
|
) };
|
|
1682
1903
|
}
|
|
1683
|
-
} else {
|
|
1684
|
-
|
|
1685
|
-
return { error: rpcError(E.PARAMS, "answer or answer_option required") };
|
|
1686
|
-
}
|
|
1904
|
+
} else if (!present(answerText) && !hasOption) {
|
|
1905
|
+
return { error: rpcError(E.PARAMS, "answer or answer_option required") };
|
|
1687
1906
|
}
|
|
1688
1907
|
prompt.state = "answered";
|
|
1689
1908
|
prompt.answered_at = coord.now();
|
|
@@ -1691,7 +1910,6 @@ function registerWhisper(coord) {
|
|
|
1691
1910
|
prompt.answer_option = answerOption;
|
|
1692
1911
|
prompt.answer_text = answerText;
|
|
1693
1912
|
prompt.comment = p.comment;
|
|
1694
|
-
p.task_id = prompt.task_id;
|
|
1695
1913
|
return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
|
|
1696
1914
|
});
|
|
1697
1915
|
coord.checkWhisperLapses = (workspaceId, now) => {
|
|
@@ -1744,14 +1962,38 @@ var PRIVILEGED_METHODS = /* @__PURE__ */ new Set([
|
|
|
1744
1962
|
"participant.rotate_key",
|
|
1745
1963
|
"participant.revoke_key"
|
|
1746
1964
|
]);
|
|
1965
|
+
var MEMBER_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
1966
|
+
"task.route",
|
|
1967
|
+
"review.depth",
|
|
1968
|
+
"escalate.auto",
|
|
1969
|
+
"participant.leave"
|
|
1970
|
+
]);
|
|
1747
1971
|
var READ_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
1748
1972
|
"workspace.describe",
|
|
1749
1973
|
"audit.read",
|
|
1750
1974
|
"audit.verify_chain",
|
|
1751
|
-
"audit.verify_receipt"
|
|
1975
|
+
"audit.verify_receipt",
|
|
1976
|
+
// Submitting the chain to a transparency service reads it and sends it
|
|
1977
|
+
// onward. Recording the submission would append to the very log being
|
|
1978
|
+
// submitted, so the receipt would attest a chain one entry shorter.
|
|
1979
|
+
"audit.submit_to_scitt"
|
|
1752
1980
|
]);
|
|
1753
1981
|
var MAX_IDEMPOTENCY_KEYS = 1e4;
|
|
1754
1982
|
var DEFAULT_MAX_ENVELOPE_BYTES = 1048576;
|
|
1983
|
+
var MAX_NESTING = 64;
|
|
1984
|
+
function withinNesting(value, limit) {
|
|
1985
|
+
const stack = [[value, 1]];
|
|
1986
|
+
while (stack.length) {
|
|
1987
|
+
const [node, depth] = stack.pop();
|
|
1988
|
+
if (depth > limit) return false;
|
|
1989
|
+
if (Array.isArray(node)) {
|
|
1990
|
+
for (const v of node) stack.push([v, depth + 1]);
|
|
1991
|
+
} else if (node !== null && typeof node === "object") {
|
|
1992
|
+
for (const v of Object.values(node)) stack.push([v, depth + 1]);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
return true;
|
|
1996
|
+
}
|
|
1755
1997
|
function nowIso(clockMs) {
|
|
1756
1998
|
const d = clockMs === void 0 ? /* @__PURE__ */ new Date() : new Date(clockMs);
|
|
1757
1999
|
return d.toISOString().replace(/\.(\d{3})Z$/, ".$1Z");
|
|
@@ -1767,9 +2009,6 @@ function tagsError(p) {
|
|
|
1767
2009
|
}
|
|
1768
2010
|
return null;
|
|
1769
2011
|
}
|
|
1770
|
-
function linkHash(envelope, prev) {
|
|
1771
|
-
return sha256Hex(Buffer.concat([canonicalize(envelope), Buffer.from(prev, "utf-8")]));
|
|
1772
|
-
}
|
|
1773
2012
|
function reviewRuleSupported(rule) {
|
|
1774
2013
|
if (rule === "any_one_approves" || rule === "all_approve") return true;
|
|
1775
2014
|
if (rule.startsWith("quorum:")) {
|
|
@@ -1795,14 +2034,35 @@ function reply(env, body) {
|
|
|
1795
2034
|
else out.result = body.result;
|
|
1796
2035
|
return out;
|
|
1797
2036
|
}
|
|
2037
|
+
function indexSigned(index, entry) {
|
|
2038
|
+
const call2 = entryCall(entry);
|
|
2039
|
+
if (!isSigned(call2)) return;
|
|
2040
|
+
let digest;
|
|
2041
|
+
try {
|
|
2042
|
+
digest = signedDigest(call2);
|
|
2043
|
+
} catch {
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
if (entry.outcome != null) index.refused.set(digest, { seq: entry.seq, code: entry.outcome.code });
|
|
2047
|
+
else index.accepted.add(digest);
|
|
2048
|
+
}
|
|
2049
|
+
var REVIEW_REQUEST_STOPPED = ["cancelled", "superseded", "paused"];
|
|
1798
2050
|
var Coordinator = class {
|
|
1799
2051
|
workspaces = /* @__PURE__ */ new Map();
|
|
1800
2052
|
options;
|
|
1801
2053
|
ids;
|
|
1802
2054
|
store;
|
|
1803
2055
|
wsVersions = /* @__PURE__ */ new Map();
|
|
2056
|
+
saveChains = /* @__PURE__ */ new Map();
|
|
1804
2057
|
clockMs;
|
|
2058
|
+
frozenNow;
|
|
1805
2059
|
auditListeners = [];
|
|
2060
|
+
/**
|
|
2061
|
+
* The signed calls on each workspace's log, by what their senders signed:
|
|
2062
|
+
* recorded refusals with their seq and code, and accepted calls. Rebuilt
|
|
2063
|
+
* from the log when missing, so a snapshot never carries it.
|
|
2064
|
+
*/
|
|
2065
|
+
signedIndexes = /* @__PURE__ */ new WeakMap();
|
|
1806
2066
|
/** Method handler registry; profiles plug into this. */
|
|
1807
2067
|
handlers = /* @__PURE__ */ new Map();
|
|
1808
2068
|
/** Custom lapse-check function (whisper/1.0). Set by the whisper profile. */
|
|
@@ -1844,42 +2104,72 @@ var Coordinator = class {
|
|
|
1844
2104
|
this.applyRecords(records);
|
|
1845
2105
|
}
|
|
1846
2106
|
applyRecords(records) {
|
|
1847
|
-
this.workspaces.clear();
|
|
1848
2107
|
this.wsVersions.clear();
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
this.wsVersions.set(r.id, r.version);
|
|
1852
|
-
}
|
|
2108
|
+
this.restore(records.map((r) => r.data));
|
|
2109
|
+
for (const r of records) this.wsVersions.set(r.id, r.version);
|
|
1853
2110
|
}
|
|
1854
2111
|
/** Persist the current snapshot of one workspace to the store. */
|
|
1855
2112
|
persist(ws) {
|
|
1856
2113
|
const next = (this.wsVersions.get(ws.id) ?? 0) + 1;
|
|
1857
2114
|
this.wsVersions.set(ws.id, next);
|
|
2115
|
+
let record;
|
|
1858
2116
|
try {
|
|
1859
2117
|
const all = this.snapshot();
|
|
1860
2118
|
const data = all.find((w) => w.id === ws.id);
|
|
1861
2119
|
if (!data) return;
|
|
1862
|
-
|
|
1863
|
-
id: ws.id,
|
|
1864
|
-
data,
|
|
1865
|
-
version: next,
|
|
1866
|
-
updated_at: this.now()
|
|
1867
|
-
});
|
|
1868
|
-
if (result instanceof Promise) {
|
|
1869
|
-
result.catch(() => {
|
|
1870
|
-
});
|
|
1871
|
-
}
|
|
2120
|
+
record = { id: ws.id, data, version: next, updated_at: this.now() };
|
|
1872
2121
|
} catch {
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
const pending = this.saveChains.get(ws.id);
|
|
2125
|
+
if (pending) {
|
|
2126
|
+
this.trackSave(pending.then(() => this.store.save(record)), record);
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
let result;
|
|
2130
|
+
try {
|
|
2131
|
+
result = this.store.save(record);
|
|
2132
|
+
} catch (err) {
|
|
2133
|
+
this.reportStoreError(err, record);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (result instanceof Promise) {
|
|
2137
|
+
this.trackSave(result, record);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
trackSave(save, record) {
|
|
2141
|
+
const guarded = save.catch((err) => this.reportStoreError(err, record));
|
|
2142
|
+
this.saveChains.set(record.id, guarded);
|
|
2143
|
+
void guarded.then(() => {
|
|
2144
|
+
if (this.saveChains.get(record.id) === guarded) {
|
|
2145
|
+
this.saveChains.delete(record.id);
|
|
2146
|
+
}
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
reportStoreError(err, record) {
|
|
2150
|
+
if (this.options.onStoreError) {
|
|
2151
|
+
try {
|
|
2152
|
+
this.options.onStoreError(err, { id: record.id, version: record.version });
|
|
2153
|
+
} catch {
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
async drainSaves() {
|
|
2158
|
+
while (this.saveChains.size) {
|
|
2159
|
+
await Promise.allSettled([...this.saveChains.values()]);
|
|
1873
2160
|
}
|
|
1874
2161
|
}
|
|
1875
2162
|
// -- lifecycle -----------------------------------------------------
|
|
1876
|
-
|
|
2163
|
+
advanceClock() {
|
|
1877
2164
|
if (this.clockMs !== void 0) {
|
|
1878
2165
|
this.clockMs += 1e3;
|
|
1879
2166
|
return nowIso(this.clockMs);
|
|
1880
2167
|
}
|
|
1881
2168
|
return nowIso();
|
|
1882
2169
|
}
|
|
2170
|
+
now() {
|
|
2171
|
+
return this.frozenNow ?? this.advanceClock();
|
|
2172
|
+
}
|
|
1883
2173
|
onAudit(listener) {
|
|
1884
2174
|
this.auditListeners.push(listener);
|
|
1885
2175
|
return () => {
|
|
@@ -1904,8 +2194,11 @@ var Coordinator = class {
|
|
|
1904
2194
|
* error-table divergence is tracked separately.)
|
|
1905
2195
|
*/
|
|
1906
2196
|
requireMember(ws, sender) {
|
|
1907
|
-
if (typeof sender !== "string"
|
|
1908
|
-
return { error: rpcError(E.NOT_AUTHORISED,
|
|
2197
|
+
if (typeof sender !== "string") {
|
|
2198
|
+
return { error: rpcError(E.NOT_AUTHORISED, "Not a workspace member: from is not a participant URI") };
|
|
2199
|
+
}
|
|
2200
|
+
if (!ws.members.has(sender)) {
|
|
2201
|
+
return { error: rpcError(E.NOT_AUTHORISED, `Not a workspace member: ${sender}`) };
|
|
1909
2202
|
}
|
|
1910
2203
|
return null;
|
|
1911
2204
|
}
|
|
@@ -2002,8 +2295,40 @@ var Coordinator = class {
|
|
|
2002
2295
|
ws.deliberations.set(x.id, x);
|
|
2003
2296
|
for (const x of w.handoffs ?? [])
|
|
2004
2297
|
ws.handoffs.set(x.id, x);
|
|
2005
|
-
for (const x of w.snapshots ?? [])
|
|
2006
|
-
|
|
2298
|
+
for (const x of w.snapshots ?? []) {
|
|
2299
|
+
const content = structuredClone(x.content ?? {
|
|
2300
|
+
workspace: x.workspace,
|
|
2301
|
+
audit_seq: x.audit_seq,
|
|
2302
|
+
include: x.include,
|
|
2303
|
+
state: x.state,
|
|
2304
|
+
...x.label ? { label: x.label } : {}
|
|
2305
|
+
});
|
|
2306
|
+
if (!x.content) {
|
|
2307
|
+
const oldState = content.state;
|
|
2308
|
+
const state = {};
|
|
2309
|
+
for (const key of ["mode_ceiling", "routing_policy_uri", "audit_seq"]) {
|
|
2310
|
+
if (oldState[key] != null && oldState[key] !== "") state[key] = oldState[key];
|
|
2311
|
+
}
|
|
2312
|
+
for (const [key, fields] of [
|
|
2313
|
+
["members", ["uri", "type", "role", "scopes"]],
|
|
2314
|
+
["open_tasks", ["id", "kind", "state", "assignee"]]
|
|
2315
|
+
]) {
|
|
2316
|
+
const items = oldState[key];
|
|
2317
|
+
if (Array.isArray(items) && items.length) {
|
|
2318
|
+
state[key] = items.map((item) => Object.fromEntries(fields.filter((field) => item[field] != null && item[field] !== "" && (!Array.isArray(item[field]) || item[field].length > 0)).map((field) => [field, item[field]])));
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
content.state = state;
|
|
2322
|
+
}
|
|
2323
|
+
ws.snapshots.set(x.id, {
|
|
2324
|
+
id: x.id,
|
|
2325
|
+
kind: "snapshot",
|
|
2326
|
+
produced_by: x.produced_by ?? x.by,
|
|
2327
|
+
produced_at: x.produced_at ?? x.ts,
|
|
2328
|
+
content_hash: x.content_hash ?? contentHash(content),
|
|
2329
|
+
content
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2007
2332
|
for (const x of w.route_decisions ?? [])
|
|
2008
2333
|
ws.route_decisions.set(x.id, x);
|
|
2009
2334
|
this.workspaces.set(ws.id, ws);
|
|
@@ -2011,14 +2336,29 @@ var Coordinator = class {
|
|
|
2011
2336
|
}
|
|
2012
2337
|
// -- dispatch ------------------------------------------------------
|
|
2013
2338
|
dispatch(envelope) {
|
|
2014
|
-
|
|
2339
|
+
this.frozenNow = this.advanceClock();
|
|
2340
|
+
try {
|
|
2341
|
+
const response = this._dispatch(envelope);
|
|
2342
|
+
if (response.error) this.recordRefusal(envelope, response.error);
|
|
2343
|
+
return response;
|
|
2344
|
+
} finally {
|
|
2345
|
+
this.frozenNow = void 0;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
_dispatch(envelope) {
|
|
2349
|
+
if (!isValidEnvelope(envelope) || typeof envelope.method !== "string" || !envelope.method) {
|
|
2015
2350
|
return reply(envelope, { error: rpcError(E.REQUEST, "Invalid JSON-RPC 2.0 request") });
|
|
2016
2351
|
}
|
|
2352
|
+
if (!withinNesting(envelope, MAX_NESTING)) {
|
|
2353
|
+
return reply(envelope, { error: rpcError(E.REQUEST, `Envelope nests deeper than ${MAX_NESTING} levels`) });
|
|
2354
|
+
}
|
|
2017
2355
|
const maxBytes = this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
|
|
2018
2356
|
let size = 0;
|
|
2357
|
+
let canonicalError;
|
|
2019
2358
|
try {
|
|
2020
2359
|
size = canonicalize(envelope).length;
|
|
2021
|
-
} catch {
|
|
2360
|
+
} catch (e) {
|
|
2361
|
+
canonicalError = e;
|
|
2022
2362
|
}
|
|
2023
2363
|
if (size > maxBytes) {
|
|
2024
2364
|
return reply(envelope, { error: rpcError(
|
|
@@ -2032,6 +2372,28 @@ var Coordinator = class {
|
|
|
2032
2372
|
return reply(envelope, { error: rpcError(E.PARAMS, "Invalid params: expected an object") });
|
|
2033
2373
|
}
|
|
2034
2374
|
const params = rawParams ?? {};
|
|
2375
|
+
const handler = this.handlers.get(method);
|
|
2376
|
+
if (!handler) {
|
|
2377
|
+
return reply(envelope, { error: rpcError(E.METHOD, `Unknown method: ${method}`) });
|
|
2378
|
+
}
|
|
2379
|
+
if (canonicalError !== void 0) {
|
|
2380
|
+
const msg = canonicalError instanceof Error ? canonicalError.message : String(canonicalError);
|
|
2381
|
+
return reply(envelope, { error: rpcError(E.PARAMS, msg) });
|
|
2382
|
+
}
|
|
2383
|
+
if (isSigned(envelope)) {
|
|
2384
|
+
const wsId2 = params.workspace;
|
|
2385
|
+
const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
|
|
2386
|
+
if (ws) {
|
|
2387
|
+
const prior = this.signedIndex(ws).refused.get(signedDigest(envelope));
|
|
2388
|
+
if (prior) {
|
|
2389
|
+
return reply(envelope, { error: rpcError(
|
|
2390
|
+
prior.code,
|
|
2391
|
+
`Refused at seq ${prior.seq}; a refused request is not evaluated again`,
|
|
2392
|
+
{ refused_at_seq: prior.seq }
|
|
2393
|
+
) });
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2035
2397
|
if (this.options.requireSignatures && method !== "participant.join" && method !== "workspace.create") {
|
|
2036
2398
|
const sigErr = this.verifySignature(envelope);
|
|
2037
2399
|
if (sigErr) return reply(envelope, { error: sigErr });
|
|
@@ -2050,7 +2412,11 @@ var Coordinator = class {
|
|
|
2050
2412
|
const stale = this.checkStepUp(params);
|
|
2051
2413
|
if (stale) return reply(envelope, { error: stale });
|
|
2052
2414
|
}
|
|
2053
|
-
|
|
2415
|
+
{
|
|
2416
|
+
const gate = this.profileGate(method, params);
|
|
2417
|
+
if (gate) return reply(envelope, { error: gate });
|
|
2418
|
+
}
|
|
2419
|
+
if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.") || method.startsWith("whisper.") || MEMBER_ONLY_METHODS.has(method)) {
|
|
2054
2420
|
const wsId2 = params.workspace;
|
|
2055
2421
|
const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
|
|
2056
2422
|
if (ws) {
|
|
@@ -2085,16 +2451,6 @@ var Coordinator = class {
|
|
|
2085
2451
|
if (task) return reply(envelope, { result: { task_id: existingId, state: task.state } });
|
|
2086
2452
|
}
|
|
2087
2453
|
}
|
|
2088
|
-
const handler = this.handlers.get(method);
|
|
2089
|
-
if (!handler) {
|
|
2090
|
-
return reply(envelope, { error: rpcError(E.METHOD, `Unknown method: ${method}`) });
|
|
2091
|
-
}
|
|
2092
|
-
try {
|
|
2093
|
-
canonicalize(envelope);
|
|
2094
|
-
} catch (e) {
|
|
2095
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2096
|
-
return reply(envelope, { error: rpcError(E.PARAMS, msg) });
|
|
2097
|
-
}
|
|
2098
2454
|
let out;
|
|
2099
2455
|
try {
|
|
2100
2456
|
out = handler(params);
|
|
@@ -2112,17 +2468,56 @@ var Coordinator = class {
|
|
|
2112
2468
|
}
|
|
2113
2469
|
// -- audit ---------------------------------------------------------
|
|
2114
2470
|
recordAudit(ws, envelope) {
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2471
|
+
this.appendEntry(ws, { envelope: JSON.parse(JSON.stringify(envelope)) });
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* SPECIFICATION 10.1: record a refused call when it is a governed attempt.
|
|
2475
|
+
* The caller must be a member of an existing workspace, the call must be a
|
|
2476
|
+
* JSON-RPC call for a method that is not a read, and the refusal must be one
|
|
2477
|
+
* `refusalIsRecorded` names. The request is kept as it arrived, so a
|
|
2478
|
+
* signature on it still verifies. Every call that reaches a recorded refusal
|
|
2479
|
+
* has passed the canonical check, so it can be hashed into the chain.
|
|
2480
|
+
*/
|
|
2481
|
+
recordRefusal(envelope, error) {
|
|
2482
|
+
if (!isValidEnvelope(envelope) || typeof envelope.method !== "string") return;
|
|
2483
|
+
const method = envelope.method;
|
|
2484
|
+
if (READ_ONLY_METHODS.has(method)) return;
|
|
2485
|
+
if (!refusalIsRecorded(method, error, PRIVILEGED_METHODS)) return;
|
|
2486
|
+
const raw = envelope.params;
|
|
2487
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
2488
|
+
const params = raw;
|
|
2489
|
+
const ws = typeof params.workspace === "string" ? this.workspaces.get(params.workspace) : void 0;
|
|
2490
|
+
if (!ws || typeof params.from !== "string" || !ws.members.has(params.from)) return;
|
|
2491
|
+
if (isSigned(envelope)) {
|
|
2492
|
+
const index = this.signedIndex(ws);
|
|
2493
|
+
const digest = signedDigest(envelope);
|
|
2494
|
+
if (index.refused.has(digest) || index.accepted.has(digest)) return;
|
|
2495
|
+
}
|
|
2496
|
+
this.appendEntry(ws, {
|
|
2497
|
+
request: JSON.parse(JSON.stringify(envelope)),
|
|
2498
|
+
outcome: { status: "refused", code: error.code }
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
/** The signed calls on this workspace's log, built from the log when missing. */
|
|
2502
|
+
signedIndex(ws) {
|
|
2503
|
+
let index = this.signedIndexes.get(ws);
|
|
2504
|
+
if (!index) {
|
|
2505
|
+
index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Set() };
|
|
2506
|
+
for (const entry of ws.audit) indexSigned(index, entry);
|
|
2507
|
+
this.signedIndexes.set(ws, index);
|
|
2508
|
+
}
|
|
2509
|
+
return index;
|
|
2510
|
+
}
|
|
2511
|
+
appendEntry(ws, record) {
|
|
2512
|
+
const entry = { seq: ws.audit.length, arrived: this.now(), ...record };
|
|
2120
2513
|
if (ws.chain_enabled || this.options.enableChain) {
|
|
2121
2514
|
const prev = ws.chain_head ?? ZERO_HASH;
|
|
2122
2515
|
entry.prev_hash = prev;
|
|
2123
|
-
ws.chain_head = linkHash(entry
|
|
2516
|
+
ws.chain_head = linkHash(entryRecord(entry), prev);
|
|
2124
2517
|
}
|
|
2125
2518
|
ws.audit.push(entry);
|
|
2519
|
+
const index = this.signedIndexes.get(ws);
|
|
2520
|
+
if (index) indexSigned(index, entry);
|
|
2126
2521
|
for (const l of this.auditListeners) {
|
|
2127
2522
|
try {
|
|
2128
2523
|
l(ws, entry);
|
|
@@ -2145,10 +2540,14 @@ var Coordinator = class {
|
|
|
2145
2540
|
const params = envelope.params ?? {};
|
|
2146
2541
|
const sender = params.from;
|
|
2147
2542
|
const wsId = params.workspace;
|
|
2148
|
-
const
|
|
2149
|
-
if (!sender || !wsId) {
|
|
2543
|
+
const rawTs = params.ts;
|
|
2544
|
+
if (typeof sender !== "string" || !sender || typeof wsId !== "string" || !wsId) {
|
|
2150
2545
|
return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: missing from/workspace");
|
|
2151
2546
|
}
|
|
2547
|
+
if (rawTs !== void 0 && rawTs !== null && typeof rawTs !== "string") {
|
|
2548
|
+
return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: ts must be a string");
|
|
2549
|
+
}
|
|
2550
|
+
const ts = rawTs ?? this.now();
|
|
2152
2551
|
const ws = this.workspaces.get(wsId);
|
|
2153
2552
|
if (!ws) {
|
|
2154
2553
|
return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: unknown workspace");
|
|
@@ -2193,10 +2592,34 @@ var Coordinator = class {
|
|
|
2193
2592
|
return null;
|
|
2194
2593
|
}
|
|
2195
2594
|
// -- step-up (identity-oidc/1.0) -----------------------------------
|
|
2595
|
+
/**
|
|
2596
|
+
* The SPECIFICATION 15.4 check, per method rather than per namespace.
|
|
2597
|
+
*
|
|
2598
|
+
* Core is always present, so a core method is never gated. The reads in
|
|
2599
|
+
* ALWAYS_AVAILABLE are never gated either: a workspace must be able to ask
|
|
2600
|
+
* what it is and to check its own chain, and `audit.verify_chain` belongs to
|
|
2601
|
+
* `audit-scitt/1.0` while chaining also turns on through an option, so
|
|
2602
|
+
* gating it would let a workspace write a hash-linked chain it is refused
|
|
2603
|
+
* permission to verify.
|
|
2604
|
+
*/
|
|
2605
|
+
profileGate(method, params) {
|
|
2606
|
+
const owner = OWNING_PROFILE[method];
|
|
2607
|
+
if (owner === void 0 || owner === "core/1.0" || ALWAYS_AVAILABLE.has(method)) return void 0;
|
|
2608
|
+
const wsId = params.workspace;
|
|
2609
|
+
const ws = typeof wsId === "string" ? this.workspaces.get(wsId) : void 0;
|
|
2610
|
+
if (!ws) return void 0;
|
|
2611
|
+
const name = owner.split("/")[0];
|
|
2612
|
+
if (ws.profiles.some((p) => p === name || p.startsWith(name + "/"))) return void 0;
|
|
2613
|
+
return rpcError(
|
|
2614
|
+
E.METHOD,
|
|
2615
|
+
`Unknown method: ${method}`,
|
|
2616
|
+
{ profile: owner, advertised: [...ws.profiles] }
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2196
2619
|
checkStepUp(params) {
|
|
2197
2620
|
const wsId = params.workspace;
|
|
2198
2621
|
const sender = params.from;
|
|
2199
|
-
if (!wsId || !sender) return null;
|
|
2622
|
+
if (typeof wsId !== "string" || typeof sender !== "string" || !wsId || !sender) return null;
|
|
2200
2623
|
const ws = this.workspaces.get(wsId);
|
|
2201
2624
|
if (!ws) return null;
|
|
2202
2625
|
const member = ws.members.get(sender);
|
|
@@ -2260,6 +2683,20 @@ var Coordinator = class {
|
|
|
2260
2683
|
if (typeof id !== "string") return { error: rpcError(E.PARAMS, "workspace must be a string id") };
|
|
2261
2684
|
if (this.workspaces.has(id)) return { error: rpcError(E.PARAMS, `workspace already exists: ${id}`) };
|
|
2262
2685
|
const profiles = Array.isArray(p.profiles) ? [...p.profiles] : [...this.options.defaultProfiles ?? ["core/1.0", "review/1.0"]];
|
|
2686
|
+
for (const [profile, enforced, option] of [
|
|
2687
|
+
["security-signed/1.0", !!this.options.requireSignatures, "requireSignatures"],
|
|
2688
|
+
["identity-oidc/1.0", this.options.verifyOidcToken !== void 0, "verifyOidcToken"]
|
|
2689
|
+
]) {
|
|
2690
|
+
const name = profile.split("/")[0];
|
|
2691
|
+
const advertised = profiles.some((x) => x === name || x.startsWith(name + "/"));
|
|
2692
|
+
if (enforced && !advertised) profiles.push(profile);
|
|
2693
|
+
else if (advertised && !enforced) {
|
|
2694
|
+
return { error: rpcError(
|
|
2695
|
+
E.PARAMS,
|
|
2696
|
+
`${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.`
|
|
2697
|
+
) };
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2263
2700
|
const chainEnabled = profiles.includes("audit-scitt/1.0") || !!this.options.enableChain;
|
|
2264
2701
|
const ws = {
|
|
2265
2702
|
id,
|
|
@@ -2309,7 +2746,10 @@ var Coordinator = class {
|
|
|
2309
2746
|
audit_count: ws.audit.length,
|
|
2310
2747
|
task_count: ws.tasks.size,
|
|
2311
2748
|
override_count: ws.overrides.size,
|
|
2312
|
-
|
|
2749
|
+
// Omitted where the workspace has no chain, which is what the Python
|
|
2750
|
+
// reference now sends too: JSON.stringify drops an undefined value, so
|
|
2751
|
+
// the two answered the same call differently on the wire.
|
|
2752
|
+
...ws.chain_head ? { evidence_head: ws.chain_head } : {},
|
|
2313
2753
|
...ws.routing_policy_uri ? { routing_policy_uri: ws.routing_policy_uri } : {}
|
|
2314
2754
|
} };
|
|
2315
2755
|
}
|
|
@@ -2343,6 +2783,8 @@ var Coordinator = class {
|
|
|
2343
2783
|
}
|
|
2344
2784
|
const uri = p.from;
|
|
2345
2785
|
const now = this.now();
|
|
2786
|
+
const existing = ws.members.get(uri);
|
|
2787
|
+
let tokenVerified = false;
|
|
2346
2788
|
const member = {
|
|
2347
2789
|
uri,
|
|
2348
2790
|
type: p.type,
|
|
@@ -2357,20 +2799,41 @@ var Coordinator = class {
|
|
|
2357
2799
|
if (this.options.verifyOidcToken && typeof p.oidc_token === "string") {
|
|
2358
2800
|
const claims = this.options.verifyOidcToken(p.oidc_token);
|
|
2359
2801
|
if (claims === null) return { error: rpcError(E.OIDC_TOKEN_INVALID, "OIDC token invalid") };
|
|
2360
|
-
|
|
2802
|
+
const boundUri = claims.chap_participant_uri;
|
|
2803
|
+
const sub = typeof claims.sub === "string" && claims.sub !== "" ? claims.sub : void 0;
|
|
2804
|
+
if (boundUri != null && boundUri !== uri) {
|
|
2805
|
+
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token is bound to another participant") };
|
|
2806
|
+
}
|
|
2807
|
+
if (existing) {
|
|
2808
|
+
if (existing.oidc_sub != null) {
|
|
2809
|
+
if (sub !== existing.oidc_sub) {
|
|
2810
|
+
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token subject does not match the member") };
|
|
2811
|
+
}
|
|
2812
|
+
} else if (boundUri !== uri) {
|
|
2813
|
+
return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this member") };
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
member.oidc_sub = sub;
|
|
2817
|
+
tokenVerified = true;
|
|
2361
2818
|
const at = claims.auth_time;
|
|
2362
2819
|
if (typeof at === "number") member.oidc_auth_time = at;
|
|
2363
|
-
member.oidc_acr = claims.acr;
|
|
2820
|
+
member.oidc_acr = typeof claims.acr === "string" ? claims.acr : void 0;
|
|
2364
2821
|
const cnf = claims.cnf;
|
|
2365
2822
|
const cnfJwk = cnf?.jwk;
|
|
2366
2823
|
if (cnfJwk && typeof cnfJwk.kid === "string") {
|
|
2367
2824
|
member.keys.push({ jwk: cnfJwk, kid: cnfJwk.kid, valid_from: now });
|
|
2368
2825
|
}
|
|
2369
2826
|
}
|
|
2370
|
-
if (this.options.verifyVc && typeof p.vc_presentation === "object" && p.vc_presentation !== null) {
|
|
2827
|
+
if (this.options.verifyVc && typeof p.vc_presentation === "object" && p.vc_presentation !== null && !Array.isArray(p.vc_presentation)) {
|
|
2371
2828
|
const subject = this.options.verifyVc(p.vc_presentation);
|
|
2372
2829
|
if (subject === null) return { error: rpcError(E.VC_VP_INVALID, "VC presentation invalid") };
|
|
2373
|
-
|
|
2830
|
+
const holder = [subject.holder, subject.id].find(
|
|
2831
|
+
(h) => typeof h === "string" && h !== ""
|
|
2832
|
+
);
|
|
2833
|
+
if (existing && (existing.vc_holder == null || holder !== existing.vc_holder)) {
|
|
2834
|
+
return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "Presentation holder does not match the member") };
|
|
2835
|
+
}
|
|
2836
|
+
member.vc_holder = holder;
|
|
2374
2837
|
const vpJwk = subject.cnf_jwk;
|
|
2375
2838
|
if (vpJwk && typeof vpJwk.kid === "string") {
|
|
2376
2839
|
member.keys.push({ jwk: vpJwk, kid: vpJwk.kid, valid_from: now });
|
|
@@ -2385,11 +2848,11 @@ var Coordinator = class {
|
|
|
2385
2848
|
}
|
|
2386
2849
|
}
|
|
2387
2850
|
}
|
|
2388
|
-
const existing = ws.members.get(uri);
|
|
2389
2851
|
if (existing) {
|
|
2390
2852
|
if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
|
|
2391
2853
|
if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
|
|
2392
2854
|
if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
|
|
2855
|
+
if (tokenVerified) existing.oidc_acr = member.oidc_acr;
|
|
2393
2856
|
if (member.vc_holder !== void 0) existing.vc_holder = member.vc_holder;
|
|
2394
2857
|
for (const k of attested) {
|
|
2395
2858
|
if (!existing.keys.some((x) => x.kid === k.kid)) existing.keys.push(k);
|
|
@@ -2413,12 +2876,15 @@ var Coordinator = class {
|
|
|
2413
2876
|
const notMember = this.requireMember(ws, p.from);
|
|
2414
2877
|
if (notMember) return notMember;
|
|
2415
2878
|
const assignee = p.assignee || p.to;
|
|
2416
|
-
if (
|
|
2879
|
+
if (typeof assignee !== "string" || !ws.members.has(assignee)) {
|
|
2417
2880
|
return { error: rpcError(E.PARAMS, "Assignee not in workspace") };
|
|
2418
2881
|
}
|
|
2419
2882
|
if (ws.members.get(assignee).paused) {
|
|
2420
2883
|
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
|
|
2421
2884
|
}
|
|
2885
|
+
if (p.mode !== void 0 && p.mode !== null && typeof p.mode !== "string") {
|
|
2886
|
+
return { error: rpcError(E.PARAMS, "mode must be a string") };
|
|
2887
|
+
}
|
|
2422
2888
|
const requestedMode = p.mode || ws.mode;
|
|
2423
2889
|
if (!modeLE(requestedMode, ws.mode_ceiling)) {
|
|
2424
2890
|
return { error: rpcError(
|
|
@@ -2426,6 +2892,9 @@ var Coordinator = class {
|
|
|
2426
2892
|
`Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
|
|
2427
2893
|
) };
|
|
2428
2894
|
}
|
|
2895
|
+
if (p.review_required !== void 0 && p.review_required !== null && typeof p.review_required !== "boolean") {
|
|
2896
|
+
return { error: rpcError(E.PARAMS, "review_required must be a boolean") };
|
|
2897
|
+
}
|
|
2429
2898
|
const taskId = this.ids.taskId();
|
|
2430
2899
|
const now = this.now();
|
|
2431
2900
|
const task = {
|
|
@@ -2462,14 +2931,20 @@ var Coordinator = class {
|
|
|
2462
2931
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2463
2932
|
const newState = p.state;
|
|
2464
2933
|
const legal = {
|
|
2465
|
-
created: ["in_progress", "declined"
|
|
2466
|
-
in_progress: ["in_progress", "completed", "declined"
|
|
2934
|
+
created: ["in_progress", "declined"],
|
|
2935
|
+
in_progress: ["in_progress", "completed", "declined"],
|
|
2467
2936
|
review_requested: ["in_progress"],
|
|
2468
|
-
paused: ["
|
|
2937
|
+
paused: ["cancelled"]
|
|
2469
2938
|
};
|
|
2470
2939
|
if (!legal[task.state]?.includes(newState)) {
|
|
2471
2940
|
return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
|
|
2472
2941
|
}
|
|
2942
|
+
if (newState === "completed" && task.review_required) {
|
|
2943
|
+
return { error: rpcError(
|
|
2944
|
+
E.PARAMS,
|
|
2945
|
+
"This task requires review. Submit the output with task.complete, which opens the review; a reviewer decision completes it."
|
|
2946
|
+
) };
|
|
2947
|
+
}
|
|
2473
2948
|
task.state = newState;
|
|
2474
2949
|
task.updated_at = this.now();
|
|
2475
2950
|
task.history.push({
|
|
@@ -2492,18 +2967,18 @@ var Coordinator = class {
|
|
|
2492
2967
|
}
|
|
2493
2968
|
if (task.review_required) {
|
|
2494
2969
|
const now = this.now();
|
|
2495
|
-
task.pending_artefact = p.output;
|
|
2496
2970
|
if (!task.review) {
|
|
2497
2971
|
const producer = p.from;
|
|
2498
|
-
const eligible = [...ws.members.
|
|
2972
|
+
const eligible = [...ws.members.entries()].filter(([uri, member]) => uri !== producer && uri !== task.assignee && member.type === "human").map(([uri]) => uri);
|
|
2499
2973
|
if (eligible.length === 0) {
|
|
2500
2974
|
return { error: rpcError(
|
|
2501
2975
|
E.NOT_AUTHORISED,
|
|
2502
|
-
"Task requires review but has no eligible reviewer (needs a member other than its assignee and completer)"
|
|
2976
|
+
"Task requires review but has no eligible human reviewer (needs a human member other than its assignee and completer)"
|
|
2503
2977
|
) };
|
|
2504
2978
|
}
|
|
2505
2979
|
task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
|
|
2506
2980
|
}
|
|
2981
|
+
task.pending_artefact = p.output;
|
|
2507
2982
|
task.state = "review_requested";
|
|
2508
2983
|
task.updated_at = now;
|
|
2509
2984
|
task.history.push({
|
|
@@ -2530,23 +3005,39 @@ var Coordinator = class {
|
|
|
2530
3005
|
}
|
|
2531
3006
|
const range = p.range ?? {};
|
|
2532
3007
|
const filter = p.filter ?? {};
|
|
3008
|
+
if (filter.outcome != null && filter.outcome !== "accepted" && filter.outcome !== "refused") {
|
|
3009
|
+
return { error: rpcError(E.PARAMS, "filter.outcome must be 'accepted' or 'refused'") };
|
|
3010
|
+
}
|
|
2533
3011
|
const fromSeq = range.from_seq ?? 0;
|
|
2534
3012
|
const toSeq = range.to_seq ?? ws.audit.length;
|
|
2535
3013
|
const out = [];
|
|
2536
3014
|
for (const entry of ws.audit.slice(fromSeq, toSeq)) {
|
|
2537
|
-
const
|
|
2538
|
-
if (filter.
|
|
3015
|
+
const refused = entry.outcome != null;
|
|
3016
|
+
if (filter.outcome === "accepted" && refused) continue;
|
|
3017
|
+
if (filter.outcome === "refused" && !refused) continue;
|
|
3018
|
+
const call2 = entryCall(entry) ?? {};
|
|
3019
|
+
const ep = call2.params ?? {};
|
|
3020
|
+
if (filter.method && call2.method !== filter.method) continue;
|
|
2539
3021
|
if (filter.from && ep.from !== filter.from) continue;
|
|
2540
|
-
if (filter.task_id
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
3022
|
+
if (filter.task_id) {
|
|
3023
|
+
let taskId = ep.task_id;
|
|
3024
|
+
if (call2.method === "whisper.answer") {
|
|
3025
|
+
const prompt = ws.whispers.get(ep.whisper_id);
|
|
3026
|
+
taskId = prompt ? prompt.task_id : void 0;
|
|
3027
|
+
}
|
|
3028
|
+
if (taskId !== filter.task_id) continue;
|
|
3029
|
+
}
|
|
3030
|
+
const item = { seq: entry.seq, arrived: entry.arrived };
|
|
3031
|
+
if (refused) {
|
|
3032
|
+
item.request = entry.request;
|
|
3033
|
+
item.outcome = entry.outcome;
|
|
3034
|
+
} else {
|
|
3035
|
+
item.envelope = entry.envelope;
|
|
3036
|
+
}
|
|
2546
3037
|
if (entry.prev_hash) item.prev_hash = entry.prev_hash;
|
|
2547
3038
|
out.push(item);
|
|
2548
3039
|
}
|
|
2549
|
-
return { result: { entries: out, next_seq: toSeq } };
|
|
3040
|
+
return { result: { entries: out, next_seq: Math.min(toSeq, ws.audit.length) } };
|
|
2550
3041
|
}
|
|
2551
3042
|
// ==========================================================
|
|
2552
3043
|
// review/1.0 handlers
|
|
@@ -2560,11 +3051,15 @@ var Coordinator = class {
|
|
|
2560
3051
|
if (notMember) return notMember;
|
|
2561
3052
|
const task = ws.tasks.get(p.task_id);
|
|
2562
3053
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
3054
|
+
if (REVIEW_REQUEST_STOPPED.includes(task.state)) {
|
|
3055
|
+
return { error: rpcError(E.NOT_REVIEWABLE, `Cannot request review for task in state: ${task.state}`) };
|
|
3056
|
+
}
|
|
2563
3057
|
const to = p.to;
|
|
2564
3058
|
const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
|
|
2565
3059
|
if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
|
|
2566
3060
|
const now = this.now();
|
|
2567
|
-
const
|
|
3061
|
+
const declaredRule = p.rule;
|
|
3062
|
+
const rule = declaredRule || "any_one_approves";
|
|
2568
3063
|
if (!reviewRuleSupported(rule)) {
|
|
2569
3064
|
return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
|
|
2570
3065
|
}
|
|
@@ -2575,7 +3070,7 @@ var Coordinator = class {
|
|
|
2575
3070
|
"A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
|
|
2576
3071
|
) };
|
|
2577
3072
|
}
|
|
2578
|
-
if (
|
|
3073
|
+
if (declaredRule !== void 0 && declaredRule !== task.review.rule) {
|
|
2579
3074
|
return { error: rpcError(
|
|
2580
3075
|
E.REVIEW_ALREADY_OPEN,
|
|
2581
3076
|
`Cannot change the decision rule of an open review (currently ${task.review.rule})`
|
|
@@ -2767,16 +3262,34 @@ var Coordinator = class {
|
|
|
2767
3262
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
2768
3263
|
const notMember = this.requireMember(ws, p.from);
|
|
2769
3264
|
if (notMember) return notMember;
|
|
2770
|
-
const
|
|
3265
|
+
const origId = p.original_task_id;
|
|
3266
|
+
const orig = typeof origId === "string" ? ws.tasks.get(origId) : void 0;
|
|
2771
3267
|
if (!orig) return { error: rpcError(E.PARAMS, "Unknown original task") };
|
|
2772
3268
|
if (orig.state === "completed" || orig.state === "cancelled" || orig.state === "superseded") {
|
|
2773
3269
|
return { error: rpcError(E.PARAMS, `Cannot escalate a terminal task: ${orig.state}`) };
|
|
2774
3270
|
}
|
|
3271
|
+
if (p.new_task === null || typeof p.new_task !== "object" || Array.isArray(p.new_task)) {
|
|
3272
|
+
return { error: rpcError(E.PARAMS, "new_task must be an object") };
|
|
3273
|
+
}
|
|
2775
3274
|
const nt = p.new_task;
|
|
2776
3275
|
const assignee = nt.assignee;
|
|
2777
|
-
if (
|
|
3276
|
+
if (typeof assignee !== "string" || !ws.members.has(assignee)) {
|
|
2778
3277
|
return { error: rpcError(E.PARAMS, "Escalation assignee not in workspace") };
|
|
2779
3278
|
}
|
|
3279
|
+
const named = nt.mode;
|
|
3280
|
+
if (named !== void 0 && named !== null && typeof named !== "string") {
|
|
3281
|
+
return { error: rpcError(E.PARAMS, "new_task.mode must be a string") };
|
|
3282
|
+
}
|
|
3283
|
+
if (ws.members.get(assignee).paused) {
|
|
3284
|
+
return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
|
|
3285
|
+
}
|
|
3286
|
+
const mode = named || orig.mode;
|
|
3287
|
+
if (!modeLE(mode, ws.mode_ceiling)) {
|
|
3288
|
+
return { error: rpcError(
|
|
3289
|
+
E.MODE_CEILING_EXCEEDED,
|
|
3290
|
+
`Requested mode ${mode} exceeds ceiling ${ws.mode_ceiling}`
|
|
3291
|
+
) };
|
|
3292
|
+
}
|
|
2780
3293
|
const now = this.now();
|
|
2781
3294
|
const newId = this.ids.taskId();
|
|
2782
3295
|
const newTask = {
|
|
@@ -2788,11 +3301,14 @@ var Coordinator = class {
|
|
|
2788
3301
|
input: nt.input ?? {},
|
|
2789
3302
|
created_at: now,
|
|
2790
3303
|
updated_at: now,
|
|
2791
|
-
mode
|
|
3304
|
+
mode,
|
|
2792
3305
|
supersedes: orig.id,
|
|
2793
3306
|
history: [{ ts: now, from: p.from, state: "created", note: `escalated from ${orig.id}` }],
|
|
2794
3307
|
paused: false
|
|
2795
3308
|
};
|
|
3309
|
+
if (orig.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
|
|
3310
|
+
newTask.review_required = true;
|
|
3311
|
+
}
|
|
2796
3312
|
ws.tasks.set(newId, newTask);
|
|
2797
3313
|
orig.state = "escalated";
|
|
2798
3314
|
orig.updated_at = now;
|
|
@@ -2933,11 +3449,13 @@ function wrapMcpToolCall(coord, workspace, options) {
|
|
|
2933
3449
|
method: "task.update",
|
|
2934
3450
|
params: { workspace, from: options.caller, task_id: taskId, state: "in_progress" }
|
|
2935
3451
|
});
|
|
3452
|
+
const output = { result: options.result, citations };
|
|
3453
|
+
if (options.fulfils) output.fulfils = options.fulfils;
|
|
2936
3454
|
const completeParams = {
|
|
2937
3455
|
workspace,
|
|
2938
3456
|
from: options.caller,
|
|
2939
3457
|
task_id: taskId,
|
|
2940
|
-
output
|
|
3458
|
+
output
|
|
2941
3459
|
};
|
|
2942
3460
|
if (options.confidence !== void 0) completeParams.confidence = options.confidence;
|
|
2943
3461
|
const completeResp = coord.dispatch({
|
|
@@ -2989,11 +3507,13 @@ function wrapA2aMessageExchange(coord, workspace, options) {
|
|
|
2989
3507
|
state: "in_progress"
|
|
2990
3508
|
}
|
|
2991
3509
|
});
|
|
3510
|
+
const output = { received: options.received, citations: [citation] };
|
|
3511
|
+
if (options.fulfils) output.fulfils = options.fulfils;
|
|
2992
3512
|
const completeParams = {
|
|
2993
3513
|
workspace,
|
|
2994
3514
|
from: options.bridgeUri,
|
|
2995
3515
|
task_id: taskId,
|
|
2996
|
-
output
|
|
3516
|
+
output
|
|
2997
3517
|
};
|
|
2998
3518
|
if (options.confidence !== void 0) completeParams.confidence = options.confidence;
|
|
2999
3519
|
const completeResp = coord.dispatch({
|
|
@@ -3022,8 +3542,12 @@ function wrapA2aMessageExchange(coord, workspace, options) {
|
|
|
3022
3542
|
deriveKeypair,
|
|
3023
3543
|
deriveSeed,
|
|
3024
3544
|
diffJsonPatch,
|
|
3545
|
+
entryCall,
|
|
3546
|
+
entryRecord,
|
|
3547
|
+
isRefusal,
|
|
3025
3548
|
isValidEnvelope,
|
|
3026
3549
|
jwkFromPrivateKey,
|
|
3550
|
+
linkHash,
|
|
3027
3551
|
makeApi,
|
|
3028
3552
|
makeDefaultPolicy,
|
|
3029
3553
|
modeLE,
|