@brightbeamai/chap-coordinator 0.2.10 → 0.2.12

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.
@@ -75,6 +75,8 @@ interface CoordinatorOptions {
75
75
  requireSignatures?: boolean;
76
76
  enforceStepUp?: boolean;
77
77
  requireReadMembership?: boolean;
78
+ /** Largest envelope accepted (default 1 MiB), published in the descriptor. */
79
+ maxEnvelopeBytes?: number;
78
80
  onAudit?: AuditListener;
79
81
  onAutoEscalate?: (task: Task, to: ParticipantUri) => void;
80
82
  verifyOidcToken?: TokenVerifier;
@@ -75,6 +75,8 @@ interface CoordinatorOptions {
75
75
  requireSignatures?: boolean;
76
76
  enforceStepUp?: boolean;
77
77
  requireReadMembership?: boolean;
78
+ /** Largest envelope accepted (default 1 MiB), published in the descriptor. */
79
+ maxEnvelopeBytes?: number;
78
80
  onAudit?: AuditListener;
79
81
  onAutoEscalate?: (task: Task, to: ParticipantUri) => void;
80
82
  verifyOidcToken?: TokenVerifier;
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-CcT_8H5j.cjs';
2
+ export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-DiiL0sil.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-Cvkhcalw.js';
2
+ export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-BOGEBNmX.js';
3
3
  import './types.js';
4
4
  import './storage/store.js';
package/dist/index.cjs CHANGED
@@ -323,6 +323,11 @@ function isValidEnvelope(env) {
323
323
  var PatchError = class extends Error {
324
324
  };
325
325
  var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
326
+ var ARRAY_INDEX_RE = /^(0|[1-9][0-9]*)$/;
327
+ function arrayIndex(seg) {
328
+ if (!ARRAY_INDEX_RE.test(seg)) throw new PatchError(`Array index expected at ${JSON.stringify(seg)}`);
329
+ return Number(seg);
330
+ }
326
331
  function unescape(token) {
327
332
  return token.replace(/~1/g, "/").replace(/~0/g, "~");
328
333
  }
@@ -347,8 +352,8 @@ function navigate(doc, parts) {
347
352
  for (let i = 0; i < parts.length - 1; i++) {
348
353
  const part = parts[i];
349
354
  if (Array.isArray(parent)) {
350
- const idx = Number(part);
351
- if (!Number.isInteger(idx) || idx < 0 || idx >= parent.length) {
355
+ const idx = arrayIndex(part);
356
+ if (idx >= parent.length) {
352
357
  throw new PatchError(`Index out of range at /${parts.slice(0, i + 1).join("/")}`);
353
358
  }
354
359
  parent = parent[idx];
@@ -364,11 +369,7 @@ function navigate(doc, parts) {
364
369
  const last = parts[parts.length - 1];
365
370
  if (Array.isArray(parent)) {
366
371
  if (last === "-") return [parent, "-"];
367
- const idx = Number(last);
368
- if (!Number.isInteger(idx)) {
369
- throw new PatchError(`Array index expected at ${JSON.stringify(last)}`);
370
- }
371
- return [parent, idx];
372
+ return [parent, arrayIndex(last)];
372
373
  }
373
374
  return [parent, last];
374
375
  }
@@ -819,7 +820,7 @@ function registerControl(coord) {
819
820
  if (scope === "task") {
820
821
  const task = ws.tasks.get(p.task_id);
821
822
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
822
- if (task.state === "completed" || task.state === "declined" || task.state === "cancelled") {
823
+ if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
823
824
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot pause ${task.state} task`) };
824
825
  }
825
826
  task.paused = true;
@@ -876,7 +877,7 @@ function registerControl(coord) {
876
877
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
877
878
  const task = ws.tasks.get(p.task_id);
878
879
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
879
- if (task.state === "completed" || task.state === "declined" || task.state === "cancelled") {
880
+ if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
880
881
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
881
882
  }
882
883
  task.state = "cancelled";
@@ -898,7 +899,7 @@ function registerControl(coord) {
898
899
  }));
899
900
  }
900
901
  if (include.includes("open_tasks")) {
901
- state.open_tasks = Array.from(ws.tasks.values()).filter((t) => t.state !== "completed" && t.state !== "declined" && t.state !== "cancelled").map((t) => ({ id: t.id, kind: t.kind, state: t.state, assignee: t.assignee }));
902
+ state.open_tasks = Array.from(ws.tasks.values()).filter((t) => t.state !== "completed" && t.state !== "declined" && t.state !== "cancelled" && t.state !== "superseded").map((t) => ({ id: t.id, kind: t.kind, state: t.state, assignee: t.assignee }));
902
903
  }
903
904
  if (include.includes("mode_ceiling")) state.mode_ceiling = ws.mode_ceiling;
904
905
  if (include.includes("policy")) state.routing_policy_uri = ws.routing_policy_uri;
@@ -966,6 +967,13 @@ function registerControl(coord) {
966
967
  if (!ws.members.has(assignee)) {
967
968
  return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
968
969
  }
970
+ const requestedMode = newSpec.mode ?? old.mode;
971
+ if (!modeLE(requestedMode, ws.mode_ceiling)) {
972
+ return { error: rpcError(
973
+ E.MODE_CEILING_EXCEEDED,
974
+ `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
975
+ ) };
976
+ }
969
977
  const now = coord.now();
970
978
  const newId = coord.ids.taskId();
971
979
  const newTask = {
@@ -977,7 +985,7 @@ function registerControl(coord) {
977
985
  input: newSpec.input ?? {},
978
986
  created_at: now,
979
987
  updated_at: now,
980
- mode: newSpec.mode ?? old.mode,
988
+ mode: requestedMode,
981
989
  supersedes: old.id,
982
990
  history: [{
983
991
  ts: now,
@@ -987,6 +995,8 @@ function registerControl(coord) {
987
995
  }],
988
996
  paused: false
989
997
  };
998
+ if (ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") newTask.review_required = true;
999
+ else if ("review_required" in newSpec) newTask.review_required = !!newSpec.review_required;
990
1000
  ws.tasks.set(newId, newTask);
991
1001
  old.state = "superseded";
992
1002
  old.superseded_by = newId;
@@ -1740,6 +1750,8 @@ var READ_ONLY_METHODS = /* @__PURE__ */ new Set([
1740
1750
  "audit.verify_chain",
1741
1751
  "audit.verify_receipt"
1742
1752
  ]);
1753
+ var MAX_IDEMPOTENCY_KEYS = 1e4;
1754
+ var DEFAULT_MAX_ENVELOPE_BYTES = 1048576;
1743
1755
  function nowIso(clockMs) {
1744
1756
  const d = clockMs === void 0 ? /* @__PURE__ */ new Date() : new Date(clockMs);
1745
1757
  return d.toISOString().replace(/\.(\d{3})Z$/, ".$1Z");
@@ -1758,6 +1770,25 @@ function tagsError(p) {
1758
1770
  function linkHash(envelope, prev) {
1759
1771
  return sha256Hex(Buffer.concat([canonicalize(envelope), Buffer.from(prev, "utf-8")]));
1760
1772
  }
1773
+ function reviewRuleSupported(rule) {
1774
+ if (rule === "any_one_approves" || rule === "all_approve") return true;
1775
+ if (rule.startsWith("quorum:")) {
1776
+ const arg = rule.slice("quorum:".length);
1777
+ return /^[0-9]+$/.test(arg) && parseInt(arg, 10) >= 1;
1778
+ }
1779
+ return false;
1780
+ }
1781
+ function reviewSatisfied(review) {
1782
+ const approvers = new Set(review.decisions.filter((d) => d.kind === "approve").map((d) => d.reviewer));
1783
+ const rule = review.rule || "any_one_approves";
1784
+ if (rule === "all_approve") {
1785
+ const named = review.requested_to.filter((r) => !(r.startsWith("workspace:") || r.startsWith("group:")));
1786
+ if (named.length === 0) return approvers.size >= 1;
1787
+ return named.every((r) => approvers.has(r));
1788
+ }
1789
+ if (rule.startsWith("quorum:")) return approvers.size >= parseInt(rule.slice("quorum:".length), 10);
1790
+ return approvers.size >= 1;
1791
+ }
1761
1792
  function reply(env, body) {
1762
1793
  const out = { jsonrpc: "2.0", id: env.id };
1763
1794
  if (body.error !== void 0) out.error = body.error;
@@ -1983,6 +2014,18 @@ var Coordinator = class {
1983
2014
  if (!isValidEnvelope(envelope) || !envelope.method) {
1984
2015
  return reply(envelope, { error: rpcError(E.REQUEST, "Invalid JSON-RPC 2.0 request") });
1985
2016
  }
2017
+ const maxBytes = this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
2018
+ let size = 0;
2019
+ try {
2020
+ size = canonicalize(envelope).length;
2021
+ } catch {
2022
+ }
2023
+ if (size > maxBytes) {
2024
+ return reply(envelope, { error: rpcError(
2025
+ E.REQUEST,
2026
+ `Envelope exceeds max_envelope_bytes (${size} > ${maxBytes})`
2027
+ ) });
2028
+ }
1986
2029
  const method = envelope.method;
1987
2030
  const rawParams = envelope.params;
1988
2031
  if (rawParams !== void 0 && rawParams !== null && (typeof rawParams !== "object" || Array.isArray(rawParams))) {
@@ -2007,7 +2050,7 @@ var Coordinator = class {
2007
2050
  const stale = this.checkStepUp(params);
2008
2051
  if (stale) return reply(envelope, { error: stale });
2009
2052
  }
2010
- if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.")) {
2053
+ if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.") || method.startsWith("whisper.")) {
2011
2054
  const wsId2 = params.workspace;
2012
2055
  const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2013
2056
  if (ws) {
@@ -2116,6 +2159,9 @@ var Coordinator = class {
2116
2159
  if (member.keys.some((k) => k.kid === kid && k.revoked_at !== void 0 && now >= k.revoked_at)) {
2117
2160
  return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
2118
2161
  }
2162
+ if (member.keys.some((k) => k.kid === kid && k.valid_until !== void 0 && now >= k.valid_until)) {
2163
+ return rpcError(E.SIG_KEY_NOT_FOUND, `Key ${kid} is no longer valid`);
2164
+ }
2119
2165
  const key = this.lookupKey(member, kid, ts);
2120
2166
  if (!key) {
2121
2167
  const revoked = member.keys.find((k) => k.kid === kid && k.revoked_at);
@@ -2131,9 +2177,8 @@ var Coordinator = class {
2131
2177
  return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
2132
2178
  }
2133
2179
  return null;
2134
- } catch (e) {
2135
- const msg = e instanceof Error ? e.message : String(e);
2136
- return rpcError(E.INTERNAL, `Signature check error: ${msg}`);
2180
+ } catch {
2181
+ return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
2137
2182
  }
2138
2183
  }
2139
2184
  /** Lookup the key valid for a given participant at a given time. */
@@ -2257,6 +2302,7 @@ var Coordinator = class {
2257
2302
  state: ws.state,
2258
2303
  mode: ws.mode,
2259
2304
  mode_ceiling: ws.mode_ceiling,
2305
+ max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
2260
2306
  step_up_window_sec: ws.step_up_window_sec,
2261
2307
  profiles: ws.profiles,
2262
2308
  members: Array.from(ws.members.values()).map(memberToDict),
@@ -2397,10 +2443,14 @@ var Coordinator = class {
2397
2443
  history: [{ ts: now, from: p.from, state: "created" }],
2398
2444
  paused: false
2399
2445
  };
2400
- if (task.mode === "trial") task.review_required = true;
2446
+ if (ws.profiles.some((pr) => pr.startsWith("modes/")) && task.mode === "trial") task.review_required = true;
2401
2447
  else if ("review_required" in p) task.review_required = !!p.review_required;
2402
2448
  ws.tasks.set(taskId, task);
2403
- if (typeof p.idempotency_key === "string") ws.idempotency_keys[p.idempotency_key] = taskId;
2449
+ if (typeof p.idempotency_key === "string") {
2450
+ ws.idempotency_keys[p.idempotency_key] = taskId;
2451
+ const keys = Object.keys(ws.idempotency_keys);
2452
+ if (keys.length > MAX_IDEMPOTENCY_KEYS) delete ws.idempotency_keys[keys[0]];
2453
+ }
2404
2454
  return { result: { task_id: taskId, state: "created" } };
2405
2455
  }
2406
2456
  opTaskUpdate(p) {
@@ -2414,7 +2464,7 @@ var Coordinator = class {
2414
2464
  const legal = {
2415
2465
  created: ["in_progress", "declined", "paused"],
2416
2466
  in_progress: ["in_progress", "completed", "declined", "review_requested", "paused"],
2417
- review_requested: ["in_progress", "completed", "declined"],
2467
+ review_requested: ["in_progress"],
2418
2468
  paused: ["in_progress", "cancelled"]
2419
2469
  };
2420
2470
  if (!legal[task.state]?.includes(newState)) {
@@ -2440,6 +2490,30 @@ var Coordinator = class {
2440
2490
  if (task.state !== "created" && task.state !== "in_progress") {
2441
2491
  return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
2442
2492
  }
2493
+ if (task.review_required) {
2494
+ const now = this.now();
2495
+ task.pending_artefact = p.output;
2496
+ if (!task.review) {
2497
+ const producer = p.from;
2498
+ const eligible = [...ws.members.keys()].filter((uri) => uri !== producer && uri !== task.assignee);
2499
+ if (eligible.length === 0) {
2500
+ return { error: rpcError(
2501
+ E.NOT_AUTHORISED,
2502
+ "Task requires review but has no eligible reviewer (needs a member other than its assignee and completer)"
2503
+ ) };
2504
+ }
2505
+ task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
2506
+ }
2507
+ task.state = "review_requested";
2508
+ task.updated_at = now;
2509
+ task.history.push({
2510
+ ts: now,
2511
+ from: p.from,
2512
+ state: "review_requested",
2513
+ note: "review required; opened on task.complete"
2514
+ });
2515
+ return { result: { state: "review_requested", review_id: task.id } };
2516
+ }
2443
2517
  task.output = p.output;
2444
2518
  if (typeof p.confidence === "number" || typeof p.confidence === "string") task.confidence = p.confidence;
2445
2519
  task.state = "completed";
@@ -2491,6 +2565,9 @@ var Coordinator = class {
2491
2565
  if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
2492
2566
  const now = this.now();
2493
2567
  const rule = p.rule || "any_one_approves";
2568
+ if (!reviewRuleSupported(rule)) {
2569
+ return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
2570
+ }
2494
2571
  if (task.state === "review_requested" && task.review) {
2495
2572
  if (contentHash(task.pending_artefact) !== contentHash(p.artefact)) {
2496
2573
  return { error: rpcError(
@@ -2587,8 +2664,10 @@ var Coordinator = class {
2587
2664
  tags: p.tags
2588
2665
  });
2589
2666
  if (kind === "approve") {
2590
- task.output = task.pending_artefact;
2591
- task.state = "completed";
2667
+ if (reviewSatisfied(task.review)) {
2668
+ task.output = task.pending_artefact;
2669
+ task.state = "completed";
2670
+ }
2592
2671
  } else {
2593
2672
  task.state = p.request_revision ? "in_progress" : "declined";
2594
2673
  }