@staix/agent-hub 0.20.0 → 0.21.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.
Files changed (175) hide show
  1. package/CHANGELOG.md +59 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +2 -2
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +37 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +565 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +14 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  37. package/docs/verification/claude-permission-transport-native.py +71 -14
  38. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  39. package/docs/verified.json +15 -15
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +1016 -280
  43. package/src/adapters/acp.ts +79 -3
  44. package/src/adapters/channel-peer.ts +4 -0
  45. package/src/adapters/claude-channel.ts +13 -6
  46. package/src/adapters/claude-implementer.ts +60 -7
  47. package/src/adapters/codex-appserver.ts +77 -4
  48. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  49. package/src/adapters/conformance/kit.ts +3 -9
  50. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  51. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  52. package/src/adapters/definitions/native-config-policy.ts +3 -2
  53. package/src/adapters/definitions/native-context.ts +12 -0
  54. package/src/adapters/external-metadata.ts +48 -0
  55. package/src/adapters/external-modules.ts +63 -3
  56. package/src/adapters/jcode.ts +6 -2
  57. package/src/adapters/local-worker.ts +40 -25
  58. package/src/adapters/native-proof-ports.ts +10 -0
  59. package/src/adapters/registry-types.ts +4 -0
  60. package/src/broker/protocol.ts +203 -0
  61. package/src/broker/server.ts +314 -0
  62. package/src/broker/store.ts +548 -0
  63. package/src/cli/bench.ts +3 -2
  64. package/src/cli/broker.ts +58 -0
  65. package/src/cli/conductor-recovery.ts +142 -0
  66. package/src/cli/configured-acp-entry.ts +26 -6
  67. package/src/cli/console-state.ts +59 -17
  68. package/src/cli/console.ts +17 -2
  69. package/src/cli/facts-hook.ts +3 -1
  70. package/src/cli/help.ts +28 -16
  71. package/src/cli/identity.ts +23 -10
  72. package/src/cli/init.ts +34 -7
  73. package/src/cli/intake-output.ts +33 -0
  74. package/src/cli/main.ts +314 -71
  75. package/src/cli/output.ts +41 -1
  76. package/src/cli/recovery-package.ts +18 -1
  77. package/src/cli/role-holders.ts +35 -0
  78. package/src/cli/room.ts +304 -0
  79. package/src/cli/say-input.ts +16 -0
  80. package/src/cli/schedule-output.ts +49 -0
  81. package/src/cli/sdlc-output.ts +44 -6
  82. package/src/cli/smoke-pty-bridge.ts +33 -0
  83. package/src/cli/smoke-pty.ts +145 -0
  84. package/src/cli/smoke-run.ts +139 -0
  85. package/src/cli/smoke-target.ts +130 -0
  86. package/src/cli/source-output.ts +29 -0
  87. package/src/cli/task-output.ts +3 -2
  88. package/src/cli/upgrade-runtime.ts +98 -16
  89. package/src/cli/upgrade.ts +66 -7
  90. package/src/hub/ask.ts +76 -12
  91. package/src/hub/board.ts +132 -22
  92. package/src/hub/bus.ts +399 -56
  93. package/src/hub/child-process.ts +2 -0
  94. package/src/hub/ci-watch.ts +42 -20
  95. package/src/hub/claude-failure-stage.ts +85 -0
  96. package/src/hub/claude-shell-wrapper.ts +11 -5
  97. package/src/hub/conductor.ts +17 -4
  98. package/src/hub/config-apply.ts +32 -5
  99. package/src/hub/config-reload.ts +7 -5
  100. package/src/hub/config-trust.ts +7 -1
  101. package/src/hub/context-window.ts +33 -3
  102. package/src/hub/control-client.ts +14 -2
  103. package/src/hub/corpus-config.ts +34 -0
  104. package/src/hub/corpus-evidence.ts +184 -0
  105. package/src/hub/corpus.ts +373 -0
  106. package/src/hub/cost-governance.ts +26 -0
  107. package/src/hub/daemon.ts +1150 -171
  108. package/src/hub/deliverable-files.ts +8 -2
  109. package/src/hub/delivery-journal.ts +123 -17
  110. package/src/hub/dogfood-profile.ts +32 -5
  111. package/src/hub/dogfood-read.ts +6 -2
  112. package/src/hub/envelope.ts +11 -0
  113. package/src/hub/eval-gate.ts +131 -0
  114. package/src/hub/events.ts +1 -1
  115. package/src/hub/external-instance.ts +69 -0
  116. package/src/hub/facts.ts +15 -5
  117. package/src/hub/folder-trigger.ts +223 -0
  118. package/src/hub/hub-tools.ts +2 -1
  119. package/src/hub/inference.ts +20 -5
  120. package/src/hub/instance-config.ts +69 -3
  121. package/src/hub/instance-execution.ts +6 -2
  122. package/src/hub/instances.ts +32 -4
  123. package/src/hub/intake.ts +125 -0
  124. package/src/hub/learning.ts +85 -0
  125. package/src/hub/lifecycle.ts +7 -3
  126. package/src/hub/limits.ts +12 -5
  127. package/src/hub/native-permission-request.ts +77 -0
  128. package/src/hub/operational-authority.ts +63 -5
  129. package/src/hub/operational-settings.ts +76 -0
  130. package/src/hub/operational-view.ts +1 -1
  131. package/src/hub/packs.ts +132 -0
  132. package/src/hub/path-policy.ts +7 -0
  133. package/src/hub/peer-doing.ts +130 -0
  134. package/src/hub/peers.ts +12 -0
  135. package/src/hub/queue-backpressure.ts +58 -0
  136. package/src/hub/read-tools.ts +2 -0
  137. package/src/hub/recovery-authority.ts +151 -0
  138. package/src/hub/recovery-policy.ts +220 -0
  139. package/src/hub/research.ts +24 -14
  140. package/src/hub/restart.ts +7 -0
  141. package/src/hub/routing.ts +54 -11
  142. package/src/hub/schedules.ts +266 -0
  143. package/src/hub/sdlc-merge.ts +38 -14
  144. package/src/hub/sdlc-metrics.ts +32 -10
  145. package/src/hub/sdlc-queue.ts +7 -1
  146. package/src/hub/sdlc-release.ts +1 -1
  147. package/src/hub/sdlc.ts +37 -7
  148. package/src/hub/security-baseline.ts +38 -0
  149. package/src/hub/settings.ts +6 -1
  150. package/src/hub/shipped-packs.ts +8 -0
  151. package/src/hub/snapshots.ts +72 -6
  152. package/src/hub/source-read.ts +160 -0
  153. package/src/hub/sources.ts +311 -0
  154. package/src/hub/tasks.ts +910 -110
  155. package/src/hub/workflow.ts +1 -1
  156. package/src/local/doc-read.ts +69 -0
  157. package/src/local/proxy.ts +4 -13
  158. package/src/local/tools.ts +37 -3
  159. package/src/local/web-fetch-config.ts +43 -0
  160. package/src/local/web-fetch.ts +183 -0
  161. package/src/memory/brief.ts +85 -13
  162. package/src/memory/capture.ts +1 -1
  163. package/src/rooms/application.ts +114 -0
  164. package/src/rooms/boundary.ts +103 -0
  165. package/src/rooms/commands.ts +294 -0
  166. package/src/rooms/crypto.ts +371 -0
  167. package/src/rooms/link.ts +584 -0
  168. package/src/rooms/policy.ts +82 -0
  169. package/src/rooms/remote-peer.ts +80 -0
  170. package/src/rooms/service.ts +426 -0
  171. package/src/rooms/status.ts +146 -0
  172. package/src/rooms/store.ts +1210 -0
  173. package/src/rooms/wire.ts +465 -0
  174. package/src/ui/index.html +91 -4
  175. package/templates/packs/example.pack.json +13 -0
@@ -0,0 +1,548 @@
1
+ import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+ import { realPath } from "../hub/project.ts";
5
+ import { decodeBase64url, encodeBase64url } from "../rooms/crypto.ts";
6
+ import {
7
+ decodeFrame, positiveU64, publicFrameWire, publicRosterWire, verifyPublicFrame, verifyPublicRoster,
8
+ type VerifiedPublicFrame, type VerifiedPublicRoster,
9
+ } from "../rooms/wire.ts";
10
+
11
+ export const BROKER_LIMIT_CEILINGS = Object.freeze({
12
+ rooms: 64, queuedPerRoom: 200, queuedGlobal: 1000, packetBytes: 32 * 1024 * 1024,
13
+ queuedBytesPerRoom: 4 * 1024 * 1024, queueTtlMs: 24 * 60 * 60 * 1000,
14
+ });
15
+ export interface BrokerLimits {
16
+ rooms: number;
17
+ queuedPerRoom: number;
18
+ queuedGlobal: number;
19
+ packetBytes: number;
20
+ queuedBytesPerRoom: number;
21
+ queueTtlMs: number;
22
+ }
23
+ export const DEFAULT_BROKER_LIMITS: Readonly<BrokerLimits> = Object.freeze({ ...BROKER_LIMIT_CEILINGS });
24
+ export type BrokerStoreCode = "invalid" | "busy" | "corrupt" | "unsupported" | "limit" | "untrusted" | "conflict" | "full" | "expired";
25
+ export class BrokerStoreError extends Error {
26
+ constructor(readonly code: BrokerStoreCode) { super(`Broker store: ${code}`); this.name = "BrokerStoreError"; }
27
+ }
28
+ function fail(code: BrokerStoreCode): never { throw new BrokerStoreError(code); }
29
+ const SCHEMA_VERSION = 2;
30
+ const GRACE_MS = 60_000;
31
+ const DB_NAME = "broker.db";
32
+ // Exclusive ownership cannot be shared; keep accidental second starts fail-fast.
33
+ const BROKER_DB_BUSY_TIMEOUT_MS = 1;
34
+
35
+ interface StoredRoom {
36
+ room: string; owner: string; currentEpoch: string; currentDigest: string; currentWire: Uint8Array;
37
+ previousEpoch: string | null; previousDigest: string | null; previousWire: Uint8Array | null; previousExpiresAt: number | null;
38
+ }
39
+ interface StoredFrame { room: string; sender: string; seq: string; digest: string; wire: Uint8Array; recipients: string; createdAt: number; expiresAt: number; epoch: string }
40
+ interface StoredFloor { room: string; sender: string; seq: string; digest: string }
41
+ export interface BrokerRoomState { current: VerifiedPublicRoster; previous?: { roster: VerifiedPublicRoster; expiresAt: number } }
42
+ export interface BrokerStoreStats { rooms: number; queuedFrames: number; queuedBytes: number }
43
+
44
+ function safeErrorCode(error: unknown): BrokerStoreCode {
45
+ const text = String(error).toLowerCase();
46
+ if (text.includes("busy") || text.includes("locked")) return "busy";
47
+ if (text.includes("malformed") || text.includes("not a database") || text.includes("corrupt")) return "corrupt";
48
+ if (text.includes("no such table") || text.includes("no such column") || text.includes("schema")) return "unsupported";
49
+ return "invalid";
50
+ }
51
+ function positiveLimits(input: Partial<BrokerLimits> | undefined): BrokerLimits {
52
+ const limits = { ...DEFAULT_BROKER_LIMITS, ...input };
53
+ for (const key of Object.keys(BROKER_LIMIT_CEILINGS) as (keyof BrokerLimits)[]) {
54
+ const value = limits[key];
55
+ if (!Number.isSafeInteger(value) || value <= 0 || value > BROKER_LIMIT_CEILINGS[key]) fail("limit");
56
+ }
57
+ return limits;
58
+ }
59
+ function validWire(wire: unknown): wire is Uint8Array { return wire instanceof Uint8Array && wire.length > 0; }
60
+ function parseMembers(value: readonly { fingerprint: string; signingKey: string; agreementKey: string }[]): Map<string, string> {
61
+ return new Map(value.map(member => [member.fingerprint, `${member.signingKey}:${member.agreementKey}`]));
62
+ }
63
+ function checkedProofRoster(proof: VerifiedPublicRoster): Uint8Array {
64
+ let wire: Uint8Array;
65
+ try { wire = publicRosterWire(proof); } catch { return fail("untrusted"); }
66
+ if (!validWire(wire) || typeof proof.room !== "string" || typeof proof.owner !== "string" || typeof proof.epoch !== "string" || typeof proof.digest !== "string" || !Array.isArray(proof.members)) fail("untrusted");
67
+ return wire;
68
+ }
69
+ function checkedProofFrame(proof: VerifiedPublicFrame): Uint8Array {
70
+ let wire: Uint8Array;
71
+ try { wire = publicFrameWire(proof); } catch { return fail("untrusted"); }
72
+ if (!validWire(wire) || typeof proof.room !== "string" || typeof proof.epoch !== "string" || typeof proof.sender !== "string" || typeof proof.seq !== "string" || typeof proof.digest !== "string" || typeof proof.rosterDigest !== "string" || !Array.isArray(proof.recipients)) fail("untrusted");
73
+ return wire;
74
+ }
75
+ function sameKeys(a: VerifiedPublicRoster, b: VerifiedPublicRoster, fingerprints: Iterable<string>): boolean {
76
+ const left = parseMembers(a.members), right = parseMembers(b.members);
77
+ for (const fingerprint of fingerprints) if (!left.has(fingerprint) || left.get(fingerprint) !== right.get(fingerprint)) return false;
78
+ return true;
79
+ }
80
+ function parseRecipients(value: string): string[] {
81
+ try {
82
+ const recipients: unknown = JSON.parse(value);
83
+ if (!Array.isArray(recipients) || recipients.some(x => typeof x !== "string") || new Set(recipients).size !== recipients.length) fail("corrupt");
84
+ return recipients as string[];
85
+ } catch (error) {
86
+ if (error instanceof BrokerStoreError) throw error;
87
+ return fail("corrupt");
88
+ }
89
+ }
90
+ function validDigest(value: string): boolean {
91
+ try { return encodeBase64url(decodeBase64url(value, 32)) === value; }
92
+ catch { return false; }
93
+ }
94
+
95
+ /** Durable ciphertext-only queue and monotonic public roster pins for one broker process. */
96
+ export class BrokerStore {
97
+ private readonly db: Database;
98
+ private readonly limits: BrokerLimits;
99
+ private readonly clock: () => number;
100
+ private readonly rooms = new Map<string, BrokerRoomState>();
101
+ private closed = false;
102
+
103
+ private constructor(db: Database, limits: BrokerLimits, clock: () => number) {
104
+ this.db = db; this.limits = limits; this.clock = clock;
105
+ }
106
+
107
+ private static validateSchema(db: Database, version: number): void {
108
+ const expected = ["rooms", "sender_floors", "queued_frames"];
109
+ const found = db.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map(x => x.name).sort();
110
+ if (found.join(",") !== expected.slice().sort().join(",")) fail("unsupported");
111
+ const queuedColumns = ["room", "sender", "seq", "digest", "wire", "recipients", "created_at", "expires_at", ...(version >= 2 ? ["epoch"] : [])];
112
+ const columns: Record<string, string[]> = {
113
+ rooms: ["room", "owner", "current_epoch", "current_digest", "current_wire", "previous_epoch", "previous_digest", "previous_wire", "previous_expires_at"],
114
+ sender_floors: ["room", "sender", "seq", "digest"],
115
+ queued_frames: queuedColumns,
116
+ };
117
+ for (const [table, names] of Object.entries(columns)) {
118
+ const actual = db.query<{ name: string }, []>(`PRAGMA table_info(${table})`).all().map(column => column.name);
119
+ if (actual.join(",") !== names.join(",")) fail("unsupported");
120
+ }
121
+ const queuedSql = db.query<{ sql: string | null }, []>("SELECT sql FROM sqlite_master WHERE type='table' AND name='queued_frames'").get()?.sql;
122
+ if (!queuedSql || /WITHOUT\s+ROWID/i.test(queuedSql)) fail("unsupported");
123
+ try { db.query<{ rowid: number }, []>("SELECT rowid FROM queued_frames LIMIT 0").all(); }
124
+ catch { fail("unsupported"); }
125
+ if (version >= 2) {
126
+ const indexes = db.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='queued_frames'").all().map(row => row.name);
127
+ if (!indexes.includes("queued_frames_room") || !indexes.includes("queued_frames_expiry")) fail("unsupported");
128
+ const roomIndex = db.query<{ name: string }, []>("PRAGMA index_info(queued_frames_room)").all().map(row => row.name);
129
+ const expiryIndex = db.query<{ name: string }, []>("PRAGMA index_info(queued_frames_expiry)").all().map(row => row.name);
130
+ if (roomIndex[0] !== "room" || expiryIndex[0] !== "expires_at") fail("unsupported");
131
+ }
132
+ }
133
+
134
+ static async open(input: { stateDir: string; limits?: Partial<BrokerLimits>; clock?: () => number; create?: boolean }): Promise<BrokerStore> {
135
+ const limits = positiveLimits(input.limits), clock = input.clock ?? Date.now, create = input.create ?? true;
136
+ let dir: string;
137
+ try {
138
+ if (existsSync(input.stateDir)) {
139
+ if (lstatSync(input.stateDir).isSymbolicLink() || !statSync(input.stateDir).isDirectory()) fail("invalid");
140
+ dir = realPath(input.stateDir);
141
+ const entries = readdirSync(dir);
142
+ if (entries.length > 0 && entries.some(name => ![DB_NAME, `${DB_NAME}-journal`, `${DB_NAME}-wal`, `${DB_NAME}-shm`].includes(name))) fail("invalid");
143
+ if (entries.length > 0 && !entries.includes(DB_NAME)) fail("corrupt");
144
+ if ((statSync(dir).mode & 0o777) !== 0o700) {
145
+ if (entries.length > 0) fail("invalid");
146
+ chmodSync(dir, 0o700);
147
+ }
148
+ } else {
149
+ if (!create) fail("invalid");
150
+ mkdirSync(input.stateDir, { recursive: true, mode: 0o700 });
151
+ dir = realPath(input.stateDir);
152
+ chmodSync(dir, 0o700);
153
+ }
154
+ } catch (error) {
155
+ if (error instanceof BrokerStoreError) throw error;
156
+ fail("invalid");
157
+ }
158
+ const file = join(dir, DB_NAME);
159
+ let createdFile = false;
160
+ try {
161
+ if (existsSync(file)) {
162
+ if (lstatSync(file).isSymbolicLink() || !statSync(file).isFile()) fail("invalid");
163
+ chmodSync(file, 0o600);
164
+ } else {
165
+ if (!create) fail("invalid");
166
+ try {
167
+ const descriptor = openSync(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
168
+ closeSync(descriptor);
169
+ createdFile = true;
170
+ } catch (error) {
171
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
172
+ }
173
+ if (lstatSync(file).isSymbolicLink() || !statSync(file).isFile()) fail("invalid");
174
+ chmodSync(file, 0o600);
175
+ }
176
+ } catch (error) {
177
+ if (error instanceof BrokerStoreError) throw error;
178
+ fail("invalid");
179
+ }
180
+ let db: Database | undefined;
181
+ try {
182
+ db = new Database(file, { create, strict: true });
183
+ db.run(`PRAGMA busy_timeout = ${BROKER_DB_BUSY_TIMEOUT_MS}`);
184
+ db.exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=EXTRA; PRAGMA locking_mode=EXCLUSIVE;");
185
+ db.exec("BEGIN EXCLUSIVE;");
186
+ const existing = db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0;
187
+ if (![0, 1, SCHEMA_VERSION].includes(existing)) fail("unsupported");
188
+ if (existing === 0) {
189
+ if (!createdFile) fail("corrupt");
190
+ const hasUserTables = db.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
191
+ if (hasUserTables.length) fail("unsupported");
192
+ db.exec(`
193
+ CREATE TABLE rooms (
194
+ room TEXT PRIMARY KEY, owner TEXT NOT NULL, current_epoch TEXT NOT NULL, current_digest TEXT NOT NULL,
195
+ current_wire BLOB NOT NULL, previous_epoch TEXT, previous_digest TEXT, previous_wire BLOB,
196
+ previous_expires_at INTEGER, CHECK ((previous_epoch IS NULL AND previous_digest IS NULL AND previous_wire IS NULL AND previous_expires_at IS NULL)
197
+ OR (previous_epoch IS NOT NULL AND previous_digest IS NOT NULL AND previous_wire IS NOT NULL AND previous_expires_at IS NOT NULL))
198
+ ) WITHOUT ROWID;
199
+ CREATE TABLE sender_floors (
200
+ room TEXT NOT NULL, sender TEXT NOT NULL, seq TEXT NOT NULL, digest TEXT NOT NULL,
201
+ PRIMARY KEY (room, sender), FOREIGN KEY (room) REFERENCES rooms(room) ON DELETE CASCADE
202
+ ) WITHOUT ROWID;
203
+ CREATE TABLE queued_frames (
204
+ room TEXT NOT NULL, sender TEXT NOT NULL, seq TEXT NOT NULL, digest TEXT NOT NULL,
205
+ wire BLOB NOT NULL, recipients TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, epoch TEXT NOT NULL,
206
+ PRIMARY KEY (room, sender, seq), FOREIGN KEY (room) REFERENCES rooms(room) ON DELETE CASCADE
207
+ );
208
+ CREATE INDEX queued_frames_room ON queued_frames(room);
209
+ CREATE INDEX queued_frames_expiry ON queued_frames(expires_at);
210
+ PRAGMA user_version=${SCHEMA_VERSION};
211
+ `);
212
+ } else if (existing === 1) {
213
+ this.validateSchema(db, 1);
214
+ db.exec("ALTER TABLE queued_frames ADD COLUMN epoch TEXT NOT NULL DEFAULT '0';");
215
+ const legacy = db.query<{ room: string; sender: string; seq: string; wire: Uint8Array }, []>("SELECT room,sender,seq,wire FROM queued_frames").all();
216
+ for (const row of legacy) {
217
+ if (!(row.wire instanceof Uint8Array)) fail("corrupt");
218
+ let frame: ReturnType<typeof decodeFrame>;
219
+ try { frame = decodeFrame(row.wire); } catch { fail("corrupt"); }
220
+ if (frame.room !== row.room || frame.sender !== row.sender || frame.seq !== row.seq) fail("corrupt");
221
+ db.query("UPDATE queued_frames SET epoch=? WHERE room=? AND sender=? AND seq=?").run(frame.epoch, row.room, row.sender, row.seq);
222
+ }
223
+ db.exec("CREATE INDEX IF NOT EXISTS queued_frames_room ON queued_frames(room); CREATE INDEX queued_frames_expiry ON queued_frames(expires_at); PRAGMA user_version=2;");
224
+ this.validateSchema(db, 2);
225
+ } else {
226
+ this.validateSchema(db, SCHEMA_VERSION);
227
+ }
228
+ if (db.query<{ quick_check: string }, []>("PRAGMA quick_check").all().some(row => row.quick_check !== "ok")) fail("corrupt");
229
+ db.exec("COMMIT;");
230
+ chmodSync(file, 0o600);
231
+ const store = new BrokerStore(db, limits, clock);
232
+ await store.restore();
233
+ return store;
234
+ } catch (error) {
235
+ try { db?.close(); } catch { /* close after failed open */ }
236
+ if (error instanceof BrokerStoreError) throw error;
237
+ fail(safeErrorCode(error));
238
+ }
239
+ }
240
+
241
+ private assertOpen(): void { if (this.closed) fail("invalid"); }
242
+ private transaction<T>(fn: () => T): T {
243
+ this.assertOpen();
244
+ this.db.exec("BEGIN IMMEDIATE;");
245
+ try { const result = fn(); this.db.exec("COMMIT;"); return result; }
246
+ catch (error) { try { this.db.exec("ROLLBACK;"); } catch { /* preserve original fixed error */ } throw error; }
247
+ }
248
+ private async restore(): Promise<void> {
249
+ let rows: StoredRoom[];
250
+ try { rows = this.db.query<StoredRoom, []>("SELECT room, owner, current_epoch AS currentEpoch, current_digest AS currentDigest, current_wire AS currentWire, previous_epoch AS previousEpoch, previous_digest AS previousDigest, previous_wire AS previousWire, previous_expires_at AS previousExpiresAt FROM rooms ORDER BY room").all(); }
251
+ catch { fail("corrupt"); }
252
+ if (rows.length > this.limits.rooms) fail("corrupt");
253
+ for (const row of rows) {
254
+ try {
255
+ if (!(row.currentWire instanceof Uint8Array) || row.currentWire.length === 0 || (row.previousWire !== null && !(row.previousWire instanceof Uint8Array))) fail("corrupt");
256
+ const current = await verifyPublicRoster({ wire: row.currentWire, pin: { room: row.room, owner: row.owner, minEpoch: row.currentEpoch, digest: row.currentDigest } });
257
+ if (current.digest !== row.currentDigest || current.epoch !== row.currentEpoch) fail("corrupt");
258
+ let previous: BrokerRoomState["previous"];
259
+ if (row.previousWire !== null) {
260
+ if (row.previousEpoch === null || row.previousDigest === null || row.previousExpiresAt === null) fail("corrupt");
261
+ const restoreNow = this.now();
262
+ if (!Number.isSafeInteger(row.previousExpiresAt) || row.previousExpiresAt <= 0 || row.previousExpiresAt > restoreNow + GRACE_MS) fail("corrupt");
263
+ const prior = await verifyPublicRoster({ wire: row.previousWire, pin: { room: row.room, owner: row.owner, minEpoch: row.previousEpoch, digest: row.previousDigest } });
264
+ if (BigInt(prior.epoch) + 1n !== BigInt(current.epoch) || prior.owner !== current.owner || prior.digest !== row.previousDigest) fail("corrupt");
265
+ previous = { roster: prior, expiresAt: row.previousExpiresAt };
266
+ } else if (row.previousEpoch !== null || row.previousDigest !== null || row.previousExpiresAt !== null) fail("corrupt");
267
+ this.rooms.set(row.room, previous ? { current, previous } : { current });
268
+ } catch (error) {
269
+ if (error instanceof BrokerStoreError) throw error;
270
+ fail("corrupt");
271
+ }
272
+ }
273
+ const floors = this.db.query<StoredFloor, []>("SELECT room,sender,seq,digest FROM sender_floors").all();
274
+ const floorBySender = new Map<string, StoredFloor>();
275
+ if (floors.length > this.limits.rooms * 64) fail("corrupt");
276
+ for (const floor of floors) {
277
+ const state = this.rooms.get(floor.room);
278
+ if (!state || !state.current.members.some(member => member.fingerprint === floor.sender) || !validDigest(floor.digest)) fail("corrupt");
279
+ try { positiveU64(floor.seq); } catch { fail("corrupt"); }
280
+ const key = `${floor.room}\0${floor.sender}`;
281
+ if (floorBySender.has(key)) fail("corrupt");
282
+ floorBySender.set(key, floor);
283
+ }
284
+ const queued = this.db.query<StoredFrame, []>("SELECT room,sender,seq,digest,wire,recipients,created_at AS createdAt,expires_at AS expiresAt,epoch FROM queued_frames").all();
285
+ for (const row of queued) {
286
+ try {
287
+ const state = this.rooms.get(row.room);
288
+ if (!state || !(row.wire instanceof Uint8Array) || !Number.isSafeInteger(row.createdAt) || !Number.isSafeInteger(row.expiresAt) ||
289
+ row.createdAt < 0 || row.expiresAt <= row.createdAt || row.expiresAt - row.createdAt > BROKER_LIMIT_CEILINGS.queueTtlMs) fail("corrupt");
290
+ const frameHeader = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(row.wire)) as { epoch?: unknown };
291
+ if (frameHeader.epoch !== row.epoch) fail("corrupt");
292
+ const roster = row.epoch === state.current.epoch ? state.current : state.previous?.roster;
293
+ if (!roster) fail("corrupt");
294
+ const proof = await verifyPublicFrame({ wire: row.wire, roster });
295
+ const recipients = parseRecipients(row.recipients);
296
+ if (proof.room !== row.room || proof.epoch !== row.epoch || proof.sender !== row.sender || proof.seq !== row.seq || proof.digest !== row.digest ||
297
+ !validDigest(row.digest) || recipients.length === 0 || recipients.some(recipient => !proof.recipients.includes(recipient))) fail("corrupt");
298
+ try { positiveU64(row.seq); } catch { fail("corrupt"); }
299
+ const floor = floorBySender.get(`${row.room}\0${row.sender}`);
300
+ if (!floor || BigInt(floor.seq) < BigInt(row.seq) || (floor.seq === row.seq && floor.digest !== row.digest)) fail("corrupt");
301
+ } catch (error) {
302
+ if (error instanceof BrokerStoreError) throw error;
303
+ fail("corrupt");
304
+ }
305
+ }
306
+ // Recover only structurally valid public/ciphertext queue rows, then enforce the current caps.
307
+ this.sweep();
308
+ const stats = this.stats();
309
+ if (stats.queuedFrames > this.limits.queuedGlobal || stats.queuedBytes > this.limits.packetBytes) fail("corrupt");
310
+ for (const room of this.rooms.keys()) if (this.countRoom(room) > this.limits.queuedPerRoom) fail("corrupt");
311
+ }
312
+
313
+ current(room: string): BrokerRoomState | undefined {
314
+ this.assertOpen();
315
+ const now = this.now();
316
+ const state = this.rooms.get(room);
317
+ const previous = state?.previous && state.previous.expiresAt > now ? state.previous : undefined;
318
+ return state ? { current: state.current, ...(previous ? { previous: { ...previous } } : {}) } : undefined;
319
+ }
320
+
321
+ install(proof: VerifiedPublicRoster, authorizedOwner: string): { changed: boolean; revoked: string[] } {
322
+ this.assertOpen();
323
+ const wire = checkedProofRoster(proof);
324
+ if (authorizedOwner !== proof.owner) fail("untrusted");
325
+ const now = this.now();
326
+ const before = this.rooms.get(proof.room);
327
+ if (before) {
328
+ if (proof.owner !== before.current.owner) fail("untrusted");
329
+ const cmp = BigInt(proof.epoch) - BigInt(before.current.epoch);
330
+ if (cmp === 0n) {
331
+ if (proof.digest !== before.current.digest) fail("conflict");
332
+ return { changed: false, revoked: [] };
333
+ }
334
+ if (cmp < 0n) fail("conflict");
335
+ } else if (this.rooms.size >= this.limits.rooms) fail("full");
336
+
337
+ const adjacent = before !== undefined && BigInt(proof.epoch) === BigInt(before.current.epoch) + 1n;
338
+ const graceExpiry = now + GRACE_MS;
339
+ if (adjacent && !Number.isSafeInteger(graceExpiry)) fail("invalid");
340
+ const nextState: BrokerRoomState = { current: proof, ...(adjacent && before ? { previous: { roster: before.current, expiresAt: graceExpiry } } : {}) };
341
+ const priorKeys = before ? parseMembers(before.current.members) : new Map<string, string>();
342
+ const nextKeys = parseMembers(proof.members);
343
+ const revoked = before ? [...priorKeys.keys()].filter(id => !nextKeys.has(id) || nextKeys.get(id) !== priorKeys.get(id)).sort() : [];
344
+ this.transaction(() => {
345
+ if (before) {
346
+ this.db.query("UPDATE rooms SET current_epoch=?, current_digest=?, current_wire=?, previous_epoch=?, previous_digest=?, previous_wire=?, previous_expires_at=? WHERE room=?")
347
+ .run(proof.epoch, proof.digest, wire, nextState.previous?.roster.epoch ?? null, nextState.previous?.roster.digest ?? null,
348
+ nextState.previous ? publicRosterWire(nextState.previous.roster) : null, nextState.previous?.expiresAt ?? null, proof.room);
349
+ } else {
350
+ this.db.query("INSERT INTO rooms(room,owner,current_epoch,current_digest,current_wire) VALUES(?,?,?,?,?)").run(proof.room, proof.owner, proof.epoch, proof.digest, wire);
351
+ }
352
+ for (const fingerprint of priorKeys.keys()) if (!nextKeys.has(fingerprint) || nextKeys.get(fingerprint) !== priorKeys.get(fingerprint)) {
353
+ this.db.query("DELETE FROM sender_floors WHERE room=? AND sender=?").run(proof.room, fingerprint);
354
+ }
355
+ this.purgeInvalidRows(proof.room, nextState, now);
356
+ });
357
+ this.rooms.set(proof.room, nextState);
358
+ return { changed: true, revoked };
359
+ }
360
+
361
+ enqueue(proof: VerifiedPublicFrame, authorizedSender: string): { digest: string; duplicate: boolean } {
362
+ this.assertOpen();
363
+ const wire = checkedProofFrame(proof);
364
+ if (authorizedSender !== proof.sender) fail("untrusted");
365
+ this.sweep();
366
+ const byteLength = wire.byteLength;
367
+ let recipients: string[] = [];
368
+ let result: { digest: string; duplicate: boolean } | undefined;
369
+ let expiredPrevious = false;
370
+ this.transaction(() => {
371
+ const writeNow = this.now();
372
+ let state = this.rooms.get(proof.room);
373
+ this.db.query("DELETE FROM queued_frames WHERE expires_at<=?").run(writeNow);
374
+ if (state?.previous && state.previous.expiresAt <= writeNow) {
375
+ this.db.query("DELETE FROM queued_frames WHERE room=? AND epoch=?").run(proof.room, state.previous.roster.epoch);
376
+ this.db.query("UPDATE rooms SET previous_epoch=NULL,previous_digest=NULL,previous_wire=NULL,previous_expires_at=NULL WHERE room=?").run(proof.room);
377
+ expiredPrevious = true;
378
+ state = { current: state.current };
379
+ }
380
+ const roster = this.rosterForProof(state, proof, writeNow);
381
+ if (!state || !roster || !roster.members.some(member => member.fingerprint === proof.sender) || proof.recipients.length === 0 ||
382
+ proof.recipients.some(recipient => !roster.members.some(member => member.fingerprint === recipient))) fail("untrusted");
383
+ const currentKeys = parseMembers(state.current.members);
384
+ const proofKeys = parseMembers(roster.members);
385
+ recipients = [...new Set(proof.recipients)].filter(recipient => currentKeys.has(recipient) && currentKeys.get(recipient) === proofKeys.get(recipient)).sort();
386
+ if (!recipients.length) fail("untrusted");
387
+ // A retained older packet is a known accepted outcome even after a newer
388
+ // sequence advanced the compact sender floor. Never recreate or rewrite it.
389
+ const retained = this.db.query<{ digest: string }, [string, string, string]>("SELECT digest FROM queued_frames WHERE room=? AND sender=? AND seq=?").get(proof.room, proof.sender, proof.seq);
390
+ if (retained) {
391
+ if (retained.digest !== proof.digest) fail("conflict");
392
+ result = { digest: proof.digest, duplicate: true };
393
+ return;
394
+ }
395
+ const floor = this.db.query<{ seq: string; digest: string }, [string, string]>("SELECT seq,digest FROM sender_floors WHERE room=? AND sender=?").get(proof.room, proof.sender);
396
+ if (floor) {
397
+ const order = BigInt(proof.seq) - BigInt(floor.seq);
398
+ if (order === 0n) {
399
+ if (floor.digest !== proof.digest) fail("conflict");
400
+ result = { digest: proof.digest, duplicate: true };
401
+ return;
402
+ }
403
+ if (order < 0n) fail("conflict");
404
+ }
405
+ const counts = this.db.query<{ count: number; bytes: number }, []>("SELECT COUNT(*) AS count, COALESCE(SUM(length(wire)),0) AS bytes FROM queued_frames").get();
406
+ const room = this.db.query<{ count: number; bytes: number }, [string]>("SELECT COUNT(*) AS count, COALESCE(SUM(length(wire)),0) AS bytes FROM queued_frames WHERE room=?").get(proof.room);
407
+ if (byteLength > this.limits.packetBytes || byteLength > this.limits.queuedBytesPerRoom || (room?.count ?? 0) >= this.limits.queuedPerRoom ||
408
+ (room?.bytes ?? 0) + byteLength > this.limits.queuedBytesPerRoom || (counts?.count ?? 0) >= this.limits.queuedGlobal ||
409
+ (counts?.bytes ?? 0) + byteLength > this.limits.packetBytes) fail("full");
410
+ const expiresAt = writeNow + this.limits.queueTtlMs;
411
+ if (!Number.isSafeInteger(expiresAt)) fail("invalid");
412
+ this.db.query("INSERT INTO sender_floors(room,sender,seq,digest) VALUES(?,?,?,?) ON CONFLICT(room,sender) DO UPDATE SET seq=excluded.seq,digest=excluded.digest")
413
+ .run(proof.room, proof.sender, proof.seq, proof.digest);
414
+ this.db.query("INSERT INTO queued_frames(room,sender,seq,digest,wire,recipients,created_at,expires_at,epoch) VALUES(?,?,?,?,?,?,?,?,?)")
415
+ .run(proof.room, proof.sender, proof.seq, proof.digest, wire, JSON.stringify(recipients), writeNow, expiresAt, proof.epoch);
416
+ result = { digest: proof.digest, duplicate: false };
417
+ });
418
+ if (expiredPrevious) {
419
+ const state = this.rooms.get(proof.room);
420
+ if (state) this.rooms.set(proof.room, { current: state.current });
421
+ }
422
+ if (result) return result;
423
+ return fail("invalid");
424
+ }
425
+
426
+ next(room: string, recipient: string): { digest: string; wire: Uint8Array } | undefined {
427
+ this.assertOpen(); let now = 0;
428
+ let state = this.rooms.get(room);
429
+ if (!state) return undefined;
430
+ this.transaction(() => { now = this.now(); state = this.expireRoom(room, now); });
431
+ if (state) this.rooms.set(room, state);
432
+ if (!state?.current.members.some(member => member.fingerprint === recipient)) return undefined;
433
+ const previous = state.previous && state.previous.expiresAt > now ? state.previous.roster.epoch : state.current.epoch;
434
+ const rows = this.db.query<{ id: number; digest: string; recipients: string }, [string, number, string, string]>(
435
+ "SELECT rowid AS id,digest,recipients FROM queued_frames WHERE room=? AND expires_at>? AND epoch IN (?,?) ORDER BY rowid",
436
+ ).all(room, now, state.current.epoch, previous);
437
+ for (const row of rows) {
438
+ if (!parseRecipients(row.recipients).includes(recipient)) continue;
439
+ const packet = this.db.query<{ wire: Uint8Array }, [number]>("SELECT wire FROM queued_frames WHERE rowid=?").get(row.id);
440
+ if (packet?.wire instanceof Uint8Array) return { digest: row.digest, wire: new Uint8Array(packet.wire) };
441
+ }
442
+ return undefined;
443
+ }
444
+
445
+ ack(room: string, recipient: string, digest: string): void {
446
+ this.assertOpen();
447
+ let now = 0;
448
+ let state = this.rooms.get(room);
449
+ if (!state) fail("untrusted");
450
+ this.transaction(() => {
451
+ now = this.now();
452
+ state = this.expireRoom(room, now);
453
+ if (!state?.current.members.some(member => member.fingerprint === recipient)) fail("untrusted");
454
+ const epochs = state.previous && state.previous.expiresAt > now ? [state.current.epoch, state.previous.roster.epoch] : [state.current.epoch, state.current.epoch];
455
+ const rows = this.db.query<{ rowid: number; sender: string; seq: string; digest: string; recipients: string; expiresAt: number; epoch: string }, [string, string, number, string, string]>(
456
+ "SELECT rowid,digest,sender,seq,recipients,expires_at AS expiresAt,epoch FROM queued_frames WHERE room=? AND digest=? AND expires_at>? AND epoch IN (?,?) LIMIT 1",
457
+ ).all(room, digest, now, epochs[0]!, epochs[1]!);
458
+ const row = rows[0]; if (!row) return;
459
+ const recipients = parseRecipients(row.recipients);
460
+ if (!recipients.includes(recipient)) return;
461
+ const remaining = recipients.filter(id => id !== recipient);
462
+ if (remaining.length) this.db.query("UPDATE queued_frames SET recipients=? WHERE rowid=?").run(JSON.stringify(remaining), row.rowid);
463
+ else this.db.query("DELETE FROM queued_frames WHERE rowid=?").run(row.rowid);
464
+ });
465
+ if (state) this.rooms.set(room, state);
466
+ }
467
+
468
+ sweep(): void {
469
+ this.assertOpen(); const now = this.now();
470
+ const expired: string[] = [];
471
+ this.transaction(() => {
472
+ this.db.query("DELETE FROM queued_frames WHERE expires_at<=?").run(now);
473
+ for (const [room, state] of this.rooms) {
474
+ if (state.previous && state.previous.expiresAt <= now) {
475
+ this.db.query("DELETE FROM queued_frames WHERE room=? AND epoch=?").run(room, state.previous.roster.epoch);
476
+ this.db.query("UPDATE rooms SET previous_epoch=NULL,previous_digest=NULL,previous_wire=NULL,previous_expires_at=NULL WHERE room=?").run(room);
477
+ expired.push(room);
478
+ }
479
+ }
480
+ });
481
+ for (const room of expired) {
482
+ const state = this.rooms.get(room);
483
+ if (state) this.rooms.set(room, { current: state.current });
484
+ }
485
+ }
486
+
487
+ stats(): BrokerStoreStats {
488
+ this.assertOpen();
489
+ const row = this.db.query<{ count: number; bytes: number }, []>("SELECT COUNT(*) AS count, COALESCE(SUM(length(wire)),0) AS bytes FROM queued_frames").get();
490
+ const rooms = this.db.query<{ count: number }, []>("SELECT COUNT(*) AS count FROM rooms").get();
491
+ return { rooms: rooms?.count ?? 0, queuedFrames: row?.count ?? 0, queuedBytes: row?.bytes ?? 0 };
492
+ }
493
+
494
+ dropRoom(room: string): { removed: boolean } {
495
+ this.assertOpen();
496
+ try { if (encodeBase64url(decodeBase64url(room, 32)) !== room) fail("invalid"); }
497
+ catch (error) { if (error instanceof BrokerStoreError) throw error; fail("invalid"); }
498
+ let removed = false;
499
+ this.transaction(() => {
500
+ if (!this.db.query<{ room: string }, [string]>("SELECT room FROM rooms WHERE room=?").get(room)) return;
501
+ this.db.query("DELETE FROM queued_frames WHERE room=?").run(room);
502
+ this.db.query("DELETE FROM sender_floors WHERE room=?").run(room);
503
+ this.db.query("DELETE FROM rooms WHERE room=?").run(room);
504
+ removed = true;
505
+ });
506
+ this.rooms.delete(room);
507
+ return { removed };
508
+ }
509
+
510
+ close(): void { if (this.closed) return; this.closed = true; this.db.close(); this.rooms.clear(); }
511
+
512
+ private now(): number { const value = this.clock(); if (!Number.isSafeInteger(value) || value < 0) fail("invalid"); return value; }
513
+ private expireRoom(room: string, now: number): BrokerRoomState | undefined {
514
+ let state = this.rooms.get(room);
515
+ this.db.query("DELETE FROM queued_frames WHERE room=? AND expires_at<=?").run(room, now);
516
+ if (state?.previous && state.previous.expiresAt <= now) {
517
+ this.db.query("DELETE FROM queued_frames WHERE room=? AND epoch=?").run(room, state.previous.roster.epoch);
518
+ this.db.query("UPDATE rooms SET previous_epoch=NULL,previous_digest=NULL,previous_wire=NULL,previous_expires_at=NULL WHERE room=?").run(room);
519
+ state = { current: state.current };
520
+ }
521
+ return state;
522
+ }
523
+ private countRoom(room: string): number { return this.db.query<{ count: number }, [string]>("SELECT COUNT(*) AS count FROM queued_frames WHERE room=?").get(room)?.count ?? 0; }
524
+ private rosterForProof(state: BrokerRoomState | undefined, proof: VerifiedPublicFrame, now: number): VerifiedPublicRoster | undefined {
525
+ if (!state) return undefined;
526
+ if (proof.epoch === state.current.epoch && proof.rosterDigest === state.current.digest) return state.current;
527
+ const previous = state.previous;
528
+ if (previous && previous.expiresAt > now && proof.epoch === previous.roster.epoch && proof.rosterDigest === previous.roster.digest &&
529
+ state.current.owner === previous.roster.owner && sameKeys(previous.roster, state.current, [proof.sender])) return previous.roster;
530
+ return undefined;
531
+ }
532
+ private purgeInvalidRows(room: string, state: BrokerRoomState, now: number): void {
533
+ const rows = this.db.query<{ rowid: number; sender: string; recipients: string; expiresAt: number; epoch: string }, [string]>(
534
+ "SELECT rowid,sender,recipients,expires_at AS expiresAt,epoch FROM queued_frames WHERE room=?",
535
+ ).all(room);
536
+ for (const row of rows) {
537
+ const recipients = parseRecipients(row.recipients);
538
+ const sender = state.current.members.find(member => member.fingerprint === row.sender);
539
+ const previous = state.previous && state.previous.expiresAt > now && row.epoch === state.previous.roster.epoch ? state.previous.roster : undefined;
540
+ const isCurrent = row.epoch === state.current.epoch;
541
+ const senderRetained = sender && (!previous || sameKeys(previous, state.current, [row.sender]));
542
+ const keep = row.expiresAt > now && senderRetained ? recipients.filter(recipient =>
543
+ state.current.members.some(member => member.fingerprint === recipient) && (!previous || sameKeys(previous, state.current, [recipient]))) : [];
544
+ if ((!isCurrent && !previous) || !keep.length) this.db.query("DELETE FROM queued_frames WHERE rowid=?").run(row.rowid);
545
+ else if (keep.length !== recipients.length) this.db.query("UPDATE queued_frames SET recipients=? WHERE rowid=?").run(JSON.stringify(keep), row.rowid);
546
+ }
547
+ }
548
+ }
package/src/cli/bench.ts CHANGED
@@ -42,7 +42,8 @@ async function git(cwd: string, args: string[]): Promise<{ code: number; out: st
42
42
  return { code, out: out.trim() };
43
43
  }
44
44
 
45
- export const defaultIo = (stateDir: string, cwd: string): BenchIo => ({
45
+ /** `peer`: the conductor running the bench (#933) proposes as itself, never through the person's console identity. */
46
+ export const defaultIo = (stateDir: string, cwd: string, peer?: string): BenchIo => ({
46
47
  sh: async (command, dir, timeoutMs, stopped) => {
47
48
  const p = spawn("sh", ["-c", command], { cwd: dir, detached: true, stdio: "ignore", env: childEnv() });
48
49
  trackGroup(p);
@@ -68,7 +69,7 @@ export const defaultIo = (stateDir: string, cwd: string): BenchIo => ({
68
69
  });
69
70
  return { code: timedOut || interrupted ? null : code ?? null, timedOut, interrupted };
70
71
  },
71
- connect: () => ControlClient.connect(stateDir, { role: "console", projectRoot: cwd }),
72
+ connect: () => ControlClient.connect(stateDir, peer ? { role: "tools", peer, projectRoot: cwd } : { role: "console", projectRoot: cwd }),
72
73
  now: () => Date.now(),
73
74
  sleep: (ms) => Bun.sleep(ms),
74
75
  log: (line) => console.log(line),
@@ -0,0 +1,58 @@
1
+ /** Person-only command; main.ts performs identity/delegation refusal before importing this module. */
2
+ import { join, resolve } from "node:path";
3
+ import { BrokerStore, BrokerStoreError } from "../broker/store.ts";
4
+ import { decodeBase64url, RoomCryptoError } from "../rooms/crypto.ts";
5
+ import { startBroker, BrokerServerError, type BrokerOptions } from "../broker/server.ts";
6
+
7
+ export const BROKER_USAGE = "usage: ahub broker serve [--hostname <host>] [--port <0..65535>] [--public-url <ws[s]://host/>] [--owner <fingerprint>]... [--state-dir <dir>] | broker room drop <room> --yes [--state-dir <dir>]";
8
+ export function brokerOptions(args: readonly string[], stateDir: string): BrokerOptions {
9
+ if (args[0] !== "serve" || (args.length - 1) % 2 !== 0) throw new Error(BROKER_USAGE);
10
+ const flags = new Map<string, string>(), owners: string[] = [];
11
+ for (let i = 1; i < args.length; i += 2) {
12
+ const key = args[i]!, value = args[i + 1];
13
+ if (!["--hostname", "--port", "--public-url", "--state-dir", "--owner"].includes(key) || !value || value.startsWith("--") || (key !== "--owner" && flags.has(key))) throw new Error(BROKER_USAGE);
14
+ if (key === "--owner") {
15
+ if (!/^[0-9a-f]{64}$/.test(value) || owners.length >= 64) throw new Error(BROKER_USAGE);
16
+ owners.push(value);
17
+ } else flags.set(key, value);
18
+ }
19
+ const portText = flags.get("--port") ?? "8788";
20
+ if (!/^(0|[1-9][0-9]{0,4})$/.test(portText) || Number(portText) > 65535) throw new Error(BROKER_USAGE);
21
+ return { stateDir: flags.has("--state-dir") ? resolve(flags.get("--state-dir")!) : join(stateDir, "broker"), hostname: flags.get("--hostname") ?? "127.0.0.1", port: Number(portText), ...(owners.length ? { owners: [...new Set(owners)] } : {}), ...(flags.has("--public-url") ? { publicUrl: flags.get("--public-url")! } : {}) };
22
+ }
23
+ export function brokerDropOptions(args: readonly string[], stateDir: string): { room: string; stateDir: string } {
24
+ const room = args[2];
25
+ if (args[0] !== "room" || args[1] !== "drop" || !room || !/^[A-Za-z0-9_-]{43}$/.test(room)) throw new Error(BROKER_USAGE);
26
+ try { decodeBase64url(room, 32); } catch { throw new Error(BROKER_USAGE); }
27
+ let yes = false, selected = join(stateDir, "broker"), hasState = false;
28
+ for (let i = 3; i < args.length; i++) {
29
+ const key = args[i];
30
+ if (key === "--yes" && !yes) yes = true;
31
+ else if (key === "--state-dir" && !hasState && args[i + 1] && !args[i + 1]!.startsWith("--")) { selected = resolve(args[++i]!); hasState = true; }
32
+ else throw new Error(BROKER_USAGE);
33
+ }
34
+ if (!yes) throw new Error(BROKER_USAGE);
35
+ return { room, stateDir: selected };
36
+ }
37
+ export async function runBrokerCommand(args: readonly string[], stateDir: string): Promise<void> {
38
+ if (args[0] === "room") {
39
+ const drop = brokerDropOptions(args, stateDir);
40
+ const store = await BrokerStore.open({ stateDir: drop.stateDir, create: false });
41
+ try { console.log(`broker room ${store.dropRoom(drop.room).removed ? "dropped" : "not found"}: ${drop.room}`); }
42
+ finally { store.close(); }
43
+ return;
44
+ }
45
+ const options = brokerOptions(args, stateDir);
46
+ const broker = await startBroker(options);
47
+ console.log(`broker listening at ${broker.publicUrl} (ciphertext relay; application delivery is not acknowledged here)`);
48
+ const stop = () => { void broker.stop().catch(() => { process.exitCode = 1; console.error("broker: stop failed"); }); };
49
+ process.once("SIGINT", stop); process.once("SIGTERM", stop);
50
+ try { await broker.closed; }
51
+ finally { process.off("SIGINT", stop); process.off("SIGTERM", stop); }
52
+ }
53
+ export function brokerCommandError(error: unknown): string {
54
+ if (error instanceof Error && error.message === BROKER_USAGE) return BROKER_USAGE;
55
+ if (error instanceof BrokerServerError || error instanceof BrokerStoreError) return `broker: ${error.code}`;
56
+ if (error instanceof RoomCryptoError && error.code === "unavailable") return "broker: crypto_unavailable";
57
+ return "broker: startup failed";
58
+ }