@brightbeamai/chap-coordinator 0.2.8 → 0.2.10
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 +11 -13
- package/dist/{api-C3nkEk-B.d.cts → api-CcT_8H5j.d.cts} +14 -0
- package/dist/{api-CmSvlIqO.d.ts → api-Cvkhcalw.d.ts} +14 -0
- package/dist/api.d.cts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/index.cjs +169 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +169 -19
- package/dist/index.js.map +1 -1
- package/dist/jsonrpc.cjs +2 -0
- package/dist/jsonrpc.cjs.map +1 -1
- package/dist/jsonrpc.d.cts +2 -0
- package/dist/jsonrpc.d.ts +2 -0
- package/dist/jsonrpc.js +2 -0
- package/dist/jsonrpc.js.map +1 -1
- package/dist/methods.cjs.map +1 -1
- package/dist/methods.d.cts +23 -2
- package/dist/methods.d.ts +23 -2
- package/dist/storage/sqlite.cjs +5 -2
- package/dist/storage/sqlite.cjs.map +1 -1
- package/dist/storage/sqlite.js +4 -9
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/storage/store.cjs.map +1 -1
- package/dist/storage/store.d.cts +26 -1
- package/dist/storage/store.d.ts +26 -1
- package/dist/storage/store.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +3 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.js.map +1 -1
- package/package.json +79 -27
package/README.md
CHANGED
|
@@ -9,19 +9,11 @@ surface, so the two interoperate on the same JSON-RPC 2.0 wire.
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
This package is currently distributed alongside the spec repo rather
|
|
13
|
-
than published to npm. To use it directly from source in another
|
|
14
|
-
TypeScript project:
|
|
15
|
-
|
|
16
12
|
```bash
|
|
17
|
-
|
|
18
|
-
cd packages/coordinator
|
|
19
|
-
npm pack
|
|
20
|
-
# In your project
|
|
21
|
-
npm install ./brightbeamai-chap-coordinator-*.tgz # the .tgz that npm pack just produced
|
|
13
|
+
npm install @brightbeamai/chap-coordinator
|
|
22
14
|
```
|
|
23
15
|
|
|
24
|
-
Node
|
|
16
|
+
Node 20+ required. Zero external runtime dependencies; uses Node's
|
|
25
17
|
built-in `crypto` for Ed25519 and JCS.
|
|
26
18
|
|
|
27
19
|
## Companion packages
|
|
@@ -33,7 +25,8 @@ A2A ecosystems:
|
|
|
33
25
|
- [`@brightbeamai/chap-coordinator-mcp`](https://github.com/BrightbeamAI/chap/tree/main/packages/coordinator-mcp) wraps a Coordinator
|
|
34
26
|
as an MCP server. Every CHAP method becomes an MCP tool named
|
|
35
27
|
`chap.<method>`. Reference stdio server at
|
|
36
|
-
`reference/mcp-server-ts/`. Spec target: MCP
|
|
28
|
+
`reference/mcp-server-ts/`. Spec target: MCP 2026-07-28, serving
|
|
29
|
+
2025-11-25 clients as well.
|
|
37
30
|
- [`@brightbeamai/chap-coordinator-a2a`](https://github.com/BrightbeamAI/chap/tree/main/packages/coordinator-a2a) wraps a Coordinator
|
|
38
31
|
as an A2A agent. Every CHAP method becomes an `AgentSkill` on the
|
|
39
32
|
Agent Card. Reference Express server at `reference/a2a-server-ts/`.
|
|
@@ -223,7 +216,12 @@ task's `assignee`.
|
|
|
223
216
|
|
|
224
217
|
Audit entries are hash-linked when the profile is active.
|
|
225
218
|
`audit.verify_chain` replays the chain locally and confirms
|
|
226
|
-
`prev_hash` continuity.
|
|
219
|
+
`prev_hash` continuity. The verdict names the range it covered:
|
|
220
|
+
`status` is `verified` with `ok: true` only when every entry was
|
|
221
|
+
checked, and `not_evaluated` with `ok: false` when part of the log
|
|
222
|
+
lies outside the chain, which happens when the profile is added to a
|
|
223
|
+
workspace that already has entries. A broken chain is an error. Read
|
|
224
|
+
`status`, or read `ok` and fail closed.
|
|
227
225
|
|
|
228
226
|
`audit.submit_to_scitt` builds a SCITT-shaped signed statement for
|
|
229
227
|
each audit entry in the requested range and passes it to a
|
|
@@ -247,7 +245,7 @@ statements so the caller can submit out-of-band.
|
|
|
247
245
|
npm test
|
|
248
246
|
```
|
|
249
247
|
|
|
250
|
-
|
|
248
|
+
The suite covers Core, every profile, JCS and
|
|
251
249
|
Ed25519 conformance vectors, signed-envelope verification, OIDC/VC
|
|
252
250
|
binding, the inward wrap helpers, and an end-to-end composition test
|
|
253
251
|
exercising every method handler in one workspace.
|
|
@@ -74,6 +74,7 @@ interface CoordinatorOptions {
|
|
|
74
74
|
enableChain?: boolean;
|
|
75
75
|
requireSignatures?: boolean;
|
|
76
76
|
enforceStepUp?: boolean;
|
|
77
|
+
requireReadMembership?: boolean;
|
|
77
78
|
onAudit?: AuditListener;
|
|
78
79
|
onAutoEscalate?: (task: Task, to: ParticipantUri) => void;
|
|
79
80
|
verifyOidcToken?: TokenVerifier;
|
|
@@ -174,6 +175,19 @@ declare class Coordinator {
|
|
|
174
175
|
private opTaskComplete;
|
|
175
176
|
private opAuditRead;
|
|
176
177
|
private opReviewRequest;
|
|
178
|
+
/**
|
|
179
|
+
* Verify an optional `approved_artefact_digest` against the artefact under
|
|
180
|
+
* review (review/1.0 S3.2).
|
|
181
|
+
*
|
|
182
|
+
* The digest sits in params, so it is inside whatever the envelope signature
|
|
183
|
+
* covers: the decision then attests the content the reviewer saw rather than
|
|
184
|
+
* a task reference, and a relying party can check it without trusting the
|
|
185
|
+
* Coordinator that produced it. Absent, behaviour is exactly as before.
|
|
186
|
+
*
|
|
187
|
+
* On mismatch nothing is recorded and no state changes, so a client that
|
|
188
|
+
* computes the wrong digest refuses only its own decision.
|
|
189
|
+
*/
|
|
190
|
+
private checkArtefactDigest;
|
|
177
191
|
private opDecide;
|
|
178
192
|
private opDecideOverride;
|
|
179
193
|
private opAbstainDeclare;
|
|
@@ -74,6 +74,7 @@ interface CoordinatorOptions {
|
|
|
74
74
|
enableChain?: boolean;
|
|
75
75
|
requireSignatures?: boolean;
|
|
76
76
|
enforceStepUp?: boolean;
|
|
77
|
+
requireReadMembership?: boolean;
|
|
77
78
|
onAudit?: AuditListener;
|
|
78
79
|
onAutoEscalate?: (task: Task, to: ParticipantUri) => void;
|
|
79
80
|
verifyOidcToken?: TokenVerifier;
|
|
@@ -174,6 +175,19 @@ declare class Coordinator {
|
|
|
174
175
|
private opTaskComplete;
|
|
175
176
|
private opAuditRead;
|
|
176
177
|
private opReviewRequest;
|
|
178
|
+
/**
|
|
179
|
+
* Verify an optional `approved_artefact_digest` against the artefact under
|
|
180
|
+
* review (review/1.0 S3.2).
|
|
181
|
+
*
|
|
182
|
+
* The digest sits in params, so it is inside whatever the envelope signature
|
|
183
|
+
* covers: the decision then attests the content the reviewer saw rather than
|
|
184
|
+
* a task reference, and a relying party can check it without trusting the
|
|
185
|
+
* Coordinator that produced it. Absent, behaviour is exactly as before.
|
|
186
|
+
*
|
|
187
|
+
* On mismatch nothing is recorded and no state changes, so a client that
|
|
188
|
+
* computes the wrong digest refuses only its own decision.
|
|
189
|
+
*/
|
|
190
|
+
private checkArtefactDigest;
|
|
177
191
|
private opDecide;
|
|
178
192
|
private opDecideOverride;
|
|
179
193
|
private opAbstainDeclare;
|
package/dist/api.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import './methods.cjs';
|
|
2
|
-
export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-
|
|
2
|
+
export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-CcT_8H5j.cjs';
|
|
3
3
|
import './types.cjs';
|
|
4
4
|
import './storage/store.cjs';
|
package/dist/api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import './methods.js';
|
|
2
|
-
export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-
|
|
2
|
+
export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-Cvkhcalw.js';
|
|
3
3
|
import './types.js';
|
|
4
4
|
import './storage/store.js';
|
package/dist/index.cjs
CHANGED
|
@@ -210,12 +210,7 @@ var IdFactory = class {
|
|
|
210
210
|
return this.counter * 0x9E3779B97F4A7C15n & (1n << 80n) - 1n;
|
|
211
211
|
}
|
|
212
212
|
const buf = new Uint8Array(10);
|
|
213
|
-
|
|
214
|
-
crypto.getRandomValues(buf);
|
|
215
|
-
} else {
|
|
216
|
-
const nodeCrypto = require("crypto");
|
|
217
|
-
buf.set(nodeCrypto.randomBytes(10));
|
|
218
|
-
}
|
|
213
|
+
crypto.getRandomValues(buf);
|
|
219
214
|
let n = 0n;
|
|
220
215
|
for (const b of buf) n = n << 8n | BigInt(b);
|
|
221
216
|
return n;
|
|
@@ -264,6 +259,7 @@ var E = {
|
|
|
264
259
|
NOT_AUTHORISED: -32011,
|
|
265
260
|
PATCH_FAILED: -32012,
|
|
266
261
|
REVIEW_LAPSED: -32013,
|
|
262
|
+
REVIEW_ALREADY_OPEN: -32014,
|
|
267
263
|
// whisper/1.0 profile (profiles/whisper.md S6)
|
|
268
264
|
WHISPER_ALREADY_ANSWERED: -32020,
|
|
269
265
|
WHISPER_LAPSED: -32021,
|
|
@@ -290,6 +286,7 @@ var E = {
|
|
|
290
286
|
SIG_KEY_NOT_FOUND: -32071,
|
|
291
287
|
SIG_KEY_REVOKED: -32072,
|
|
292
288
|
SIG_ROTATION_KEY_MISMATCH: -32073,
|
|
289
|
+
SIG_ARTEFACT_DIGEST_MISMATCH: -32074,
|
|
293
290
|
// audit-scitt/1.0 profile (profiles/audit-scitt.md S8)
|
|
294
291
|
SCITT_UNREACHABLE: -32080,
|
|
295
292
|
SCITT_STATEMENT_REJECTED: -32081,
|
|
@@ -757,6 +754,12 @@ function registerAuditScitt(coord) {
|
|
|
757
754
|
if (!(ws.chain_enabled || coord.options.enableChain)) {
|
|
758
755
|
return { error: rpcError(E.PARAMS, "Chain not enabled for this workspace") };
|
|
759
756
|
}
|
|
757
|
+
if (p.from_seq !== void 0 || p.to_seq !== void 0) {
|
|
758
|
+
return { error: rpcError(
|
|
759
|
+
E.PARAMS,
|
|
760
|
+
"Range verification is not implemented; omit from_seq and to_seq to verify the whole log."
|
|
761
|
+
) };
|
|
762
|
+
}
|
|
760
763
|
let start = ws.audit.findIndex((e) => e.prev_hash != null);
|
|
761
764
|
if (start < 0) start = ws.audit.length;
|
|
762
765
|
const errors = [];
|
|
@@ -768,16 +771,35 @@ function registerAuditScitt(coord) {
|
|
|
768
771
|
}
|
|
769
772
|
prev = sha256Hex(Buffer.concat([canonicalize(e.envelope), Buffer.from(expectedPrev, "utf-8")]));
|
|
770
773
|
}
|
|
771
|
-
const storedHead = ws.chain_head
|
|
774
|
+
const storedHead = ws.chain_head === void 0 || ws.chain_head === null ? ZERO_HASH : ws.chain_head;
|
|
772
775
|
if (prev !== storedHead) {
|
|
773
776
|
errors.push("chain_head mismatch: replay does not match stored head");
|
|
774
777
|
}
|
|
775
778
|
if (errors.length) {
|
|
776
779
|
return { error: rpcError(E.PARAMS, errors.join("; ")) };
|
|
777
780
|
}
|
|
781
|
+
const entriesTotal = ws.audit.length;
|
|
782
|
+
const entriesChecked = entriesTotal - start;
|
|
783
|
+
const entriesUnchecked = start;
|
|
784
|
+
if (entriesUnchecked > 0) {
|
|
785
|
+
return { result: {
|
|
786
|
+
status: "not_evaluated",
|
|
787
|
+
ok: false,
|
|
788
|
+
reason: "unchained_prefix",
|
|
789
|
+
entries_total: entriesTotal,
|
|
790
|
+
entries_checked: entriesChecked,
|
|
791
|
+
entries_unchecked: entriesUnchecked,
|
|
792
|
+
checked_from_seq: entriesChecked > 0 ? ws.audit[start].seq : null,
|
|
793
|
+
chain_head: storedHead
|
|
794
|
+
} };
|
|
795
|
+
}
|
|
778
796
|
return { result: {
|
|
797
|
+
status: "verified",
|
|
779
798
|
ok: true,
|
|
780
|
-
|
|
799
|
+
entries_total: entriesTotal,
|
|
800
|
+
entries_checked: entriesChecked,
|
|
801
|
+
entries_unchecked: 0,
|
|
802
|
+
checked_from_seq: entriesChecked > 0 ? ws.audit[start].seq : null,
|
|
781
803
|
chain_head: storedHead
|
|
782
804
|
} };
|
|
783
805
|
});
|
|
@@ -1558,6 +1580,16 @@ function registerSecuritySigned(coord) {
|
|
|
1558
1580
|
for (const f of ["target_uri", "kid"]) {
|
|
1559
1581
|
if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
|
|
1560
1582
|
}
|
|
1583
|
+
const sender = p.from;
|
|
1584
|
+
if (sender !== p.target_uri) {
|
|
1585
|
+
const caller = sender ? ws.members.get(sender) : void 0;
|
|
1586
|
+
if (!caller || caller.role !== "admin") {
|
|
1587
|
+
return { error: rpcError(
|
|
1588
|
+
E.NOT_AUTHORISED,
|
|
1589
|
+
"Revoking another member's key requires the admin role"
|
|
1590
|
+
) };
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1561
1593
|
const member = ws.members.get(p.target_uri);
|
|
1562
1594
|
if (!member) return { error: rpcError(E.SIG_KEY_NOT_FOUND, `Unknown target: ${p.target_uri}`) };
|
|
1563
1595
|
const key = member.keys.find((k) => k.kid === p.kid);
|
|
@@ -1649,7 +1681,8 @@ function registerWhisper(coord) {
|
|
|
1649
1681
|
prompt.answer_option = answerOption;
|
|
1650
1682
|
prompt.answer_text = answerText;
|
|
1651
1683
|
prompt.comment = p.comment;
|
|
1652
|
-
|
|
1684
|
+
p.task_id = prompt.task_id;
|
|
1685
|
+
return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
|
|
1653
1686
|
});
|
|
1654
1687
|
coord.checkWhisperLapses = (workspaceId, now) => {
|
|
1655
1688
|
const ws = coord.workspaces.get(workspaceId);
|
|
@@ -1673,6 +1706,11 @@ function registerWhisper(coord) {
|
|
|
1673
1706
|
ts: coord.now(),
|
|
1674
1707
|
kind: "whisper_lapsed",
|
|
1675
1708
|
whisper_id: prompt.id,
|
|
1709
|
+
// Carry the task so a task-filtered audit.read surfaces the
|
|
1710
|
+
// lapse. A lapsed whisper applies its default with no human
|
|
1711
|
+
// input, so it is the case most in need of being visible on
|
|
1712
|
+
// the task's thread.
|
|
1713
|
+
task_id: prompt.task_id,
|
|
1676
1714
|
default_applied: prompt.default_if_lapsed
|
|
1677
1715
|
}
|
|
1678
1716
|
};
|
|
@@ -1874,6 +1912,7 @@ var Coordinator = class {
|
|
|
1874
1912
|
mode_ceiling: ws.mode_ceiling,
|
|
1875
1913
|
routing_policy_uri: ws.routing_policy_uri,
|
|
1876
1914
|
step_up_window_sec: ws.step_up_window_sec,
|
|
1915
|
+
min_acr: ws.min_acr,
|
|
1877
1916
|
members: Array.from(ws.members.values()),
|
|
1878
1917
|
tasks: Array.from(ws.tasks.values()),
|
|
1879
1918
|
overrides: Array.from(ws.overrides.values()),
|
|
@@ -1882,6 +1921,7 @@ var Coordinator = class {
|
|
|
1882
1921
|
handoffs: Array.from(ws.handoffs.values()),
|
|
1883
1922
|
snapshots: Array.from(ws.snapshots.values()),
|
|
1884
1923
|
route_decisions: Array.from(ws.route_decisions.values()),
|
|
1924
|
+
idempotency_keys: ws.idempotency_keys,
|
|
1885
1925
|
audit: ws.audit,
|
|
1886
1926
|
chain_head: ws.chain_head,
|
|
1887
1927
|
chain_enabled: ws.chain_enabled
|
|
@@ -1904,6 +1944,7 @@ var Coordinator = class {
|
|
|
1904
1944
|
mode_ceiling: w.mode_ceiling ?? "production",
|
|
1905
1945
|
routing_policy_uri: w.routing_policy_uri,
|
|
1906
1946
|
step_up_window_sec: w.step_up_window_sec ?? 300,
|
|
1947
|
+
min_acr: w.min_acr,
|
|
1907
1948
|
members: /* @__PURE__ */ new Map(),
|
|
1908
1949
|
tasks: /* @__PURE__ */ new Map(),
|
|
1909
1950
|
overrides: /* @__PURE__ */ new Map(),
|
|
@@ -1912,6 +1953,7 @@ var Coordinator = class {
|
|
|
1912
1953
|
handoffs: /* @__PURE__ */ new Map(),
|
|
1913
1954
|
snapshots: /* @__PURE__ */ new Map(),
|
|
1914
1955
|
route_decisions: /* @__PURE__ */ new Map(),
|
|
1956
|
+
idempotency_keys: w.idempotency_keys ?? {},
|
|
1915
1957
|
audit: w.audit ?? [],
|
|
1916
1958
|
chain_head: w.chain_head,
|
|
1917
1959
|
chain_enabled: !!w.chain_enabled
|
|
@@ -1950,6 +1992,16 @@ var Coordinator = class {
|
|
|
1950
1992
|
if (this.options.requireSignatures && method !== "participant.join" && method !== "workspace.create") {
|
|
1951
1993
|
const sigErr = this.verifySignature(envelope);
|
|
1952
1994
|
if (sigErr) return reply(envelope, { error: sigErr });
|
|
1995
|
+
if (method === "participant.rotate_key") {
|
|
1996
|
+
const signingKid = envelope.sig.split(":")[1];
|
|
1997
|
+
const oldKid = params.old_kid;
|
|
1998
|
+
if (oldKid !== void 0 && signingKid !== oldKid) {
|
|
1999
|
+
return reply(envelope, { error: rpcError(
|
|
2000
|
+
E.SIG_ROTATION_KEY_MISMATCH,
|
|
2001
|
+
"Rotation must be signed with the old key"
|
|
2002
|
+
) });
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
1953
2005
|
}
|
|
1954
2006
|
if (this.options.enforceStepUp && PRIVILEGED_METHODS.has(method)) {
|
|
1955
2007
|
const stale = this.checkStepUp(params);
|
|
@@ -1980,6 +2032,16 @@ var Coordinator = class {
|
|
|
1980
2032
|
}
|
|
1981
2033
|
}
|
|
1982
2034
|
}
|
|
2035
|
+
if (method === "task.create") {
|
|
2036
|
+
const wsId2 = params.workspace;
|
|
2037
|
+
const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
|
|
2038
|
+
const key = params.idempotency_key;
|
|
2039
|
+
if (ws && typeof key === "string") {
|
|
2040
|
+
const existingId = ws.idempotency_keys[key];
|
|
2041
|
+
const task = existingId ? ws.tasks.get(existingId) : void 0;
|
|
2042
|
+
if (task) return reply(envelope, { result: { task_id: existingId, state: task.state } });
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
1983
2045
|
const handler = this.handlers.get(method);
|
|
1984
2046
|
if (!handler) {
|
|
1985
2047
|
return reply(envelope, { error: rpcError(E.METHOD, `Unknown method: ${method}`) });
|
|
@@ -2093,7 +2155,13 @@ var Coordinator = class {
|
|
|
2093
2155
|
const ws = this.workspaces.get(wsId);
|
|
2094
2156
|
if (!ws) return null;
|
|
2095
2157
|
const member = ws.members.get(sender);
|
|
2096
|
-
if (!member
|
|
2158
|
+
if (!member) return null;
|
|
2159
|
+
if (member.type !== "human" && member.oidc_sub === void 0) return null;
|
|
2160
|
+
if (member.oidc_auth_time === void 0) {
|
|
2161
|
+
return rpcError(E.OIDC_STEP_UP_REQUIRED, "Step-up authentication required", {
|
|
2162
|
+
window_sec: ws.step_up_window_sec
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2097
2165
|
const nowSec = Math.floor(Date.now() / 1e3);
|
|
2098
2166
|
const age = nowSec - member.oidc_auth_time;
|
|
2099
2167
|
if (age > ws.step_up_window_sec) {
|
|
@@ -2102,6 +2170,12 @@ var Coordinator = class {
|
|
|
2102
2170
|
age_sec: age
|
|
2103
2171
|
});
|
|
2104
2172
|
}
|
|
2173
|
+
if (ws.min_acr !== void 0 && member.oidc_acr !== ws.min_acr) {
|
|
2174
|
+
return rpcError(E.OIDC_STEP_UP_REQUIRED, "Step-up required: acr below the workspace minimum", {
|
|
2175
|
+
required_acr: ws.min_acr,
|
|
2176
|
+
acr: member.oidc_acr
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2105
2179
|
return null;
|
|
2106
2180
|
}
|
|
2107
2181
|
// ==========================================================
|
|
@@ -2151,6 +2225,7 @@ var Coordinator = class {
|
|
|
2151
2225
|
mode_ceiling: p.mode_ceiling ?? "production",
|
|
2152
2226
|
routing_policy_uri: p.routing_policy_uri,
|
|
2153
2227
|
step_up_window_sec: typeof p.step_up_window_sec === "number" ? p.step_up_window_sec : 300,
|
|
2228
|
+
min_acr: p.min_acr,
|
|
2154
2229
|
members: /* @__PURE__ */ new Map(),
|
|
2155
2230
|
tasks: /* @__PURE__ */ new Map(),
|
|
2156
2231
|
overrides: /* @__PURE__ */ new Map(),
|
|
@@ -2159,6 +2234,7 @@ var Coordinator = class {
|
|
|
2159
2234
|
handoffs: /* @__PURE__ */ new Map(),
|
|
2160
2235
|
snapshots: /* @__PURE__ */ new Map(),
|
|
2161
2236
|
route_decisions: /* @__PURE__ */ new Map(),
|
|
2237
|
+
idempotency_keys: {},
|
|
2162
2238
|
audit: [],
|
|
2163
2239
|
chain_enabled: chainEnabled,
|
|
2164
2240
|
chain_head: chainEnabled ? ZERO_HASH : void 0
|
|
@@ -2171,6 +2247,10 @@ var Coordinator = class {
|
|
|
2171
2247
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
2172
2248
|
const ws = this.workspaces.get(p.workspace);
|
|
2173
2249
|
if (!ws) return { error: rpcError(E.PARAMS, `Unknown workspace: ${p.workspace}`) };
|
|
2250
|
+
if (this.options.requireReadMembership) {
|
|
2251
|
+
const notMember = this.requireMember(ws, p.from);
|
|
2252
|
+
if (notMember) return notMember;
|
|
2253
|
+
}
|
|
2174
2254
|
return { result: {
|
|
2175
2255
|
id: ws.id,
|
|
2176
2256
|
created: ws.created,
|
|
@@ -2188,10 +2268,15 @@ var Coordinator = class {
|
|
|
2188
2268
|
} };
|
|
2189
2269
|
}
|
|
2190
2270
|
opWorkspaceSetProfiles(p) {
|
|
2191
|
-
const miss = missing(p, ["workspace", "profiles"]);
|
|
2271
|
+
const miss = missing(p, ["workspace", "from", "profiles"]);
|
|
2192
2272
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
2193
2273
|
const ws = this.workspaces.get(p.workspace);
|
|
2194
2274
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
2275
|
+
const notMember = this.requireMember(ws, p.from);
|
|
2276
|
+
if (notMember) return notMember;
|
|
2277
|
+
if (ws.members.get(p.from).role !== "admin") {
|
|
2278
|
+
return { error: rpcError(E.NOT_AUTHORISED, "workspace.set_profiles requires the admin role") };
|
|
2279
|
+
}
|
|
2195
2280
|
const newProfiles = [...p.profiles];
|
|
2196
2281
|
if (!newProfiles.some((pr) => pr.startsWith("core/"))) newProfiles.push("core/1.0");
|
|
2197
2282
|
ws.profiles = newProfiles;
|
|
@@ -2229,6 +2314,7 @@ var Coordinator = class {
|
|
|
2229
2314
|
member.oidc_sub = claims.sub;
|
|
2230
2315
|
const at = claims.auth_time;
|
|
2231
2316
|
if (typeof at === "number") member.oidc_auth_time = at;
|
|
2317
|
+
member.oidc_acr = claims.acr;
|
|
2232
2318
|
const cnf = claims.cnf;
|
|
2233
2319
|
const cnfJwk = cnf?.jwk;
|
|
2234
2320
|
if (cnfJwk && typeof cnfJwk.kid === "string") {
|
|
@@ -2257,6 +2343,7 @@ var Coordinator = class {
|
|
|
2257
2343
|
if (existing) {
|
|
2258
2344
|
if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
|
|
2259
2345
|
if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
|
|
2346
|
+
if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
|
|
2260
2347
|
if (member.vc_holder !== void 0) existing.vc_holder = member.vc_holder;
|
|
2261
2348
|
for (const k of attested) {
|
|
2262
2349
|
if (!existing.keys.some((x) => x.kid === k.kid)) existing.keys.push(k);
|
|
@@ -2277,6 +2364,8 @@ var Coordinator = class {
|
|
|
2277
2364
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
2278
2365
|
const ws = this.workspaces.get(p.workspace);
|
|
2279
2366
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
2367
|
+
const notMember = this.requireMember(ws, p.from);
|
|
2368
|
+
if (notMember) return notMember;
|
|
2280
2369
|
const assignee = p.assignee || p.to;
|
|
2281
2370
|
if (!assignee || !ws.members.has(assignee)) {
|
|
2282
2371
|
return { error: rpcError(E.PARAMS, "Assignee not in workspace") };
|
|
@@ -2311,11 +2400,14 @@ var Coordinator = class {
|
|
|
2311
2400
|
if (task.mode === "trial") task.review_required = true;
|
|
2312
2401
|
else if ("review_required" in p) task.review_required = !!p.review_required;
|
|
2313
2402
|
ws.tasks.set(taskId, task);
|
|
2403
|
+
if (typeof p.idempotency_key === "string") ws.idempotency_keys[p.idempotency_key] = taskId;
|
|
2314
2404
|
return { result: { task_id: taskId, state: "created" } };
|
|
2315
2405
|
}
|
|
2316
2406
|
opTaskUpdate(p) {
|
|
2317
2407
|
const ws = this.workspaces.get(p.workspace);
|
|
2318
2408
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
2409
|
+
const notMember = this.requireMember(ws, p.from);
|
|
2410
|
+
if (notMember) return notMember;
|
|
2319
2411
|
const task = ws.tasks.get(p.task_id);
|
|
2320
2412
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2321
2413
|
const newState = p.state;
|
|
@@ -2358,6 +2450,10 @@ var Coordinator = class {
|
|
|
2358
2450
|
opAuditRead(p) {
|
|
2359
2451
|
const ws = this.workspaces.get(p.workspace);
|
|
2360
2452
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
2453
|
+
if (this.options.requireReadMembership) {
|
|
2454
|
+
const notMember = this.requireMember(ws, p.from);
|
|
2455
|
+
if (notMember) return notMember;
|
|
2456
|
+
}
|
|
2361
2457
|
const range = p.range ?? {};
|
|
2362
2458
|
const filter = p.filter ?? {};
|
|
2363
2459
|
const fromSeq = range.from_seq ?? 0;
|
|
@@ -2394,12 +2490,38 @@ var Coordinator = class {
|
|
|
2394
2490
|
const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
|
|
2395
2491
|
if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
|
|
2396
2492
|
const now = this.now();
|
|
2493
|
+
const rule = p.rule || "any_one_approves";
|
|
2494
|
+
if (task.state === "review_requested" && task.review) {
|
|
2495
|
+
if (contentHash(task.pending_artefact) !== contentHash(p.artefact)) {
|
|
2496
|
+
return { error: rpcError(
|
|
2497
|
+
E.REVIEW_ALREADY_OPEN,
|
|
2498
|
+
"A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
|
|
2499
|
+
) };
|
|
2500
|
+
}
|
|
2501
|
+
if (rule !== task.review.rule) {
|
|
2502
|
+
return { error: rpcError(
|
|
2503
|
+
E.REVIEW_ALREADY_OPEN,
|
|
2504
|
+
`Cannot change the decision rule of an open review (currently ${task.review.rule})`
|
|
2505
|
+
) };
|
|
2506
|
+
}
|
|
2507
|
+
for (const r of reviewers) {
|
|
2508
|
+
if (!task.review.requested_to.includes(r)) task.review.requested_to.push(r);
|
|
2509
|
+
}
|
|
2510
|
+
if (p.deadline !== void 0) task.review.deadline = p.deadline;
|
|
2511
|
+
task.updated_at = now;
|
|
2512
|
+
return { result: {
|
|
2513
|
+
state: "review_requested",
|
|
2514
|
+
review_id: task.id,
|
|
2515
|
+
amended: true,
|
|
2516
|
+
requested_to: [...task.review.requested_to]
|
|
2517
|
+
} };
|
|
2518
|
+
}
|
|
2397
2519
|
task.state = "review_requested";
|
|
2398
2520
|
task.updated_at = now;
|
|
2399
2521
|
task.review = {
|
|
2400
2522
|
requested_at: now,
|
|
2401
2523
|
requested_to: reviewers,
|
|
2402
|
-
rule
|
|
2524
|
+
rule,
|
|
2403
2525
|
deadline: p.deadline,
|
|
2404
2526
|
decisions: []
|
|
2405
2527
|
};
|
|
@@ -2407,6 +2529,37 @@ var Coordinator = class {
|
|
|
2407
2529
|
task.pending_artefact = p.artefact;
|
|
2408
2530
|
return { result: { state: "review_requested", review_id: task.id } };
|
|
2409
2531
|
}
|
|
2532
|
+
/**
|
|
2533
|
+
* Verify an optional `approved_artefact_digest` against the artefact under
|
|
2534
|
+
* review (review/1.0 S3.2).
|
|
2535
|
+
*
|
|
2536
|
+
* The digest sits in params, so it is inside whatever the envelope signature
|
|
2537
|
+
* covers: the decision then attests the content the reviewer saw rather than
|
|
2538
|
+
* a task reference, and a relying party can check it without trusting the
|
|
2539
|
+
* Coordinator that produced it. Absent, behaviour is exactly as before.
|
|
2540
|
+
*
|
|
2541
|
+
* On mismatch nothing is recorded and no state changes, so a client that
|
|
2542
|
+
* computes the wrong digest refuses only its own decision.
|
|
2543
|
+
*/
|
|
2544
|
+
checkArtefactDigest(p, task) {
|
|
2545
|
+
const declared = p.approved_artefact_digest;
|
|
2546
|
+
if (declared === void 0 || declared === null) return null;
|
|
2547
|
+
if (typeof declared !== "string") {
|
|
2548
|
+
return { error: rpcError(E.PARAMS, "approved_artefact_digest must be a string") };
|
|
2549
|
+
}
|
|
2550
|
+
if (task.pending_artefact === void 0) {
|
|
2551
|
+
return { error: rpcError(E.PARAMS, "No artefact under review to bind a digest to") };
|
|
2552
|
+
}
|
|
2553
|
+
const actual = contentHash(task.pending_artefact);
|
|
2554
|
+
if (declared !== actual) {
|
|
2555
|
+
return { error: rpcError(
|
|
2556
|
+
E.SIG_ARTEFACT_DIGEST_MISMATCH,
|
|
2557
|
+
"approved_artefact_digest does not match the artefact under review",
|
|
2558
|
+
{ declared, actual }
|
|
2559
|
+
) };
|
|
2560
|
+
}
|
|
2561
|
+
return null;
|
|
2562
|
+
}
|
|
2410
2563
|
opDecide(p, kind) {
|
|
2411
2564
|
const miss = missing(p, ["workspace", "from", "task_id"]);
|
|
2412
2565
|
if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
|
|
@@ -2423,6 +2576,8 @@ var Coordinator = class {
|
|
|
2423
2576
|
}
|
|
2424
2577
|
const notReviewer = this.requireReviewer(task, p.from);
|
|
2425
2578
|
if (notReviewer) return notReviewer;
|
|
2579
|
+
const digestError = this.checkArtefactDigest(p, task);
|
|
2580
|
+
if (digestError) return digestError;
|
|
2426
2581
|
const now = this.now();
|
|
2427
2582
|
task.review.decisions.push({
|
|
2428
2583
|
reviewer: p.from,
|
|
@@ -2457,6 +2612,8 @@ var Coordinator = class {
|
|
|
2457
2612
|
}
|
|
2458
2613
|
const notReviewer = this.requireReviewer(task, p.from);
|
|
2459
2614
|
if (notReviewer) return notReviewer;
|
|
2615
|
+
const digestError = this.checkArtefactDigest(p, task);
|
|
2616
|
+
if (digestError) return digestError;
|
|
2460
2617
|
const base = task.pending_artefact;
|
|
2461
2618
|
if (base === void 0) return { error: rpcError(E.PARAMS, "No base artefact for override") };
|
|
2462
2619
|
let applied;
|