@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
@@ -72,6 +72,8 @@ export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
72
72
  instance: string;
73
73
  projectRoot: string;
74
74
  stateDir: string;
75
+ /** Host-derived private external-instance state namespace; stateDir remains the canonical daemon control directory. */
76
+ instanceStateDir?: string;
75
77
  kindConfig: unknown;
76
78
  permission: PermissionMode | "read-only";
77
79
  mcpServers: NonNullable<AcpOptions["mcpServers"]>;
@@ -207,6 +209,8 @@ export interface AdapterDefinition {
207
209
  conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
208
210
  };
209
211
  identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin"; currentChild?: true };
212
+ /** External module data-only opt-in. The verified loader derives the internal instances policy from it (#754). */
213
+ headlessInstances?: { form: "headless"; roles: readonly ("implementer" | "verifier" | "reviewer")[] };
210
214
  /** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
211
215
  instances?: { mode: "external" | "headless"; roles?: readonly string[];
212
216
  /** Each declared instance runs its own native data home (`jcode_home`) and may pass its own provider, model and profile names (#685). Fixed builtins only. */
@@ -0,0 +1,203 @@
1
+ /** Broker v1 control protocol. The broker verifies public signatures and never opens room ciphertext. */
2
+ import {
3
+ decodeBase64url, encodeBase64url, signBytes, verifyBytes,
4
+ type HubIdentity,
5
+ } from "../rooms/crypto.ts";
6
+ import {
7
+ brokerUrl, canonicalBytes, decodeRoster, publicRosterWire, verifyPublicRoster,
8
+ type OwnerPin, type VerifiedPublicRoster,
9
+ } from "../rooms/wire.ts";
10
+
11
+ export const BROKER_CONTROL_LIMITS = Object.freeze({ control: 1_048_576, challenge: 8192, nonceBytes: 32 });
12
+ export type BrokerProtocolCode = "invalid_wire" | "limit" | "untrusted" | "expired" | "authentication_failed";
13
+ export class BrokerProtocolError extends Error {
14
+ constructor(readonly code: BrokerProtocolCode) { super(`Broker protocol: ${code}`); this.name = "BrokerProtocolError"; }
15
+ }
16
+ function fail(code: BrokerProtocolCode = "invalid_wire"): never { throw new BrokerProtocolError(code); }
17
+ const decoder = new TextDecoder("utf-8", { fatal: true });
18
+ const FP = /^[0-9a-f]{64}$/;
19
+ const DIGEST = /^[A-Za-z0-9_-]{43}$/;
20
+ const B64URL = /^[A-Za-z0-9_-]+$/;
21
+
22
+ export interface BrokerChallenge {
23
+ v: 1;
24
+ type: "challenge";
25
+ room: string;
26
+ broker: string;
27
+ nonce: string;
28
+ expiresAt: number;
29
+ rosterDigest: string | null;
30
+ }
31
+ export interface BrokerAuth {
32
+ v: 1;
33
+ type: "auth";
34
+ member: string;
35
+ roster: string | null;
36
+ signature: string;
37
+ }
38
+ export interface BrokerRosterReady { v: 1; type: "roster_ready"; digest: string }
39
+ export interface BrokerReceived { v: 1; type: "received"; digest: string }
40
+ export type BrokerControl = BrokerAuth | BrokerRosterReady | BrokerReceived;
41
+
42
+ function record(x: unknown, keys: string[]): Record<string, unknown> {
43
+ if (typeof x !== "object" || x === null || Array.isArray(x) || Object.getPrototypeOf(x) !== Object.prototype) fail();
44
+ const value = x as Record<string, unknown>;
45
+ if (Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key))) fail();
46
+ return value;
47
+ }
48
+ function canonicalDecode<T>(input: Uint8Array, max: number, parse: (value: unknown) => T): T {
49
+ if (!(input instanceof Uint8Array) || input.length === 0 || input.length > max) fail("limit");
50
+ let raw: unknown;
51
+ try { raw = JSON.parse(decoder.decode(input)); } catch { fail(); }
52
+ const value = parse(raw), bytes = canonicalBytes(value);
53
+ if (bytes.length !== input.length || bytes.some((byte, i) => input[i] !== byte)) fail();
54
+ return value;
55
+ }
56
+ function exactText(x: unknown, pattern: RegExp): string {
57
+ if (typeof x !== "string" || !pattern.test(x)) fail();
58
+ return x;
59
+ }
60
+ function roomId(x: unknown): string {
61
+ if (typeof x !== "string" || x.length !== 43 || !B64URL.test(x)) fail();
62
+ try { decodeBase64url(x, 32); } catch { fail(); }
63
+ return x;
64
+ }
65
+ function digestText(x: unknown): string {
66
+ const value = exactText(x, DIGEST);
67
+ try { decodeBase64url(value, 32); } catch { fail(); }
68
+ return value;
69
+ }
70
+ function timestamp(x: unknown): number {
71
+ if (typeof x !== "number" || !Number.isSafeInteger(x) || x <= 0) fail();
72
+ return x;
73
+ }
74
+ function nullableDigest(x: unknown): string | null { return x === null ? null : digestText(x); }
75
+ function brokerAudience(value: unknown): string {
76
+ try {
77
+ const normalized = brokerUrl(value), url = new URL(normalized);
78
+ if (url.pathname !== "/" || url.search) fail();
79
+ return normalized;
80
+ } catch { return fail(); }
81
+ }
82
+ function parseChallenge(x: unknown): BrokerChallenge {
83
+ const o = record(x, ["v", "type", "room", "broker", "nonce", "expiresAt", "rosterDigest"]);
84
+ if (o.v !== 1 || o.type !== "challenge") fail();
85
+ const nonce = exactText(o.nonce, B64URL);
86
+ try { decodeBase64url(nonce, BROKER_CONTROL_LIMITS.nonceBytes); } catch { fail(); }
87
+ return { v: 1, type: "challenge", room: roomId(o.room), broker: brokerAudience(o.broker), nonce,
88
+ expiresAt: timestamp(o.expiresAt), rosterDigest: nullableDigest(o.rosterDigest) };
89
+ }
90
+ function parseAuth(x: unknown): BrokerAuth {
91
+ const o = record(x, ["v", "type", "member", "roster", "signature"]);
92
+ if (o.v !== 1 || o.type !== "auth") fail();
93
+ let roster: string | null = null;
94
+ if (o.roster !== null) {
95
+ roster = exactText(o.roster, B64URL);
96
+ if (roster.length > Math.ceil(524288 * 4 / 3)) fail("limit");
97
+ let wire: Uint8Array;
98
+ try { wire = decodeBase64url(roster); } catch { fail(); }
99
+ decodeRoster(wire);
100
+ }
101
+ const signature = exactText(o.signature, B64URL);
102
+ try { decodeBase64url(signature, 64); } catch { fail(); }
103
+ return { v: 1, type: "auth", member: exactText(o.member, FP), roster, signature };
104
+ }
105
+ function parseControl(x: unknown): BrokerControl {
106
+ if (typeof x !== "object" || x === null || Array.isArray(x)) fail();
107
+ const type = (x as Record<string, unknown>).type;
108
+ if (type === "auth") return parseAuth(x);
109
+ if (type === "roster_ready" || type === "received") {
110
+ const o = record(x, ["v", "type", "digest"]);
111
+ if (o.v !== 1 || o.type !== type) fail();
112
+ return { v: 1, type: type as "roster_ready" | "received", digest: digestText(o.digest) };
113
+ }
114
+ fail();
115
+ }
116
+ function challengeBytes(challenge: BrokerChallenge, member: string, initialRosterDigest: string | null): Uint8Array {
117
+ return canonicalBytes(["agent-hub.rooms.broker.v1", "auth", challenge, member, initialRosterDigest]);
118
+ }
119
+ function validateChallenge(challenge: BrokerChallenge): BrokerChallenge {
120
+ return parseChallenge(challenge);
121
+ }
122
+
123
+ export function decodeBrokerChallenge(input: Uint8Array): BrokerChallenge {
124
+ return canonicalDecode(input, BROKER_CONTROL_LIMITS.challenge, parseChallenge);
125
+ }
126
+ export function encodeBrokerChallenge(challenge: BrokerChallenge): Uint8Array {
127
+ return canonicalBytes(validateChallenge(challenge));
128
+ }
129
+ export function decodeBrokerControl(input: Uint8Array): BrokerControl {
130
+ return canonicalDecode(input, BROKER_CONTROL_LIMITS.control, parseControl);
131
+ }
132
+ export function encodeBrokerControl(control: BrokerControl): Uint8Array {
133
+ return canonicalBytes(parseControl(control));
134
+ }
135
+
136
+ export async function createBrokerAuth(input: {
137
+ challenge: BrokerChallenge;
138
+ identity: HubIdentity;
139
+ broker: string;
140
+ room: string;
141
+ initialRoster?: Uint8Array;
142
+ }): Promise<Uint8Array> {
143
+ const challenge = validateChallenge(input.challenge);
144
+ if (brokerAudience(input.broker) !== challenge.broker || roomId(input.room) !== challenge.room) fail("untrusted");
145
+ const member = input.identity.public.fingerprint;
146
+ if (!FP.test(member)) fail("untrusted");
147
+ let rosterWire: string | null = null;
148
+ let rosterDigest: string | null = null;
149
+ if (input.initialRoster !== undefined) {
150
+ if (challenge.rosterDigest !== null) fail("untrusted");
151
+ if (!(input.initialRoster instanceof Uint8Array)) fail();
152
+ const initialRoster = Uint8Array.from(input.initialRoster);
153
+ const decoded = decodeRoster(initialRoster);
154
+ const pin: OwnerPin = { room: decoded.room, owner: decoded.owner, minEpoch: "0" };
155
+ const proof = await verifyPublicRoster({ wire: initialRoster, pin });
156
+ if (proof.room !== challenge.room || proof.owner !== member || !proof.members.some(m => m.fingerprint === member)) fail("untrusted");
157
+ rosterWire = encodeBase64url(initialRoster);
158
+ rosterDigest = proof.digest;
159
+ } else {
160
+ if (challenge.rosterDigest === null) fail("untrusted");
161
+ rosterDigest = null;
162
+ }
163
+ const body = { v: 1 as const, type: "auth" as const, member, roster: rosterWire };
164
+ const auth = { ...body, signature: encodeBase64url(await signBytes(input.identity, challengeBytes(challenge, member, rosterDigest))) };
165
+ const wire = canonicalBytes(auth);
166
+ parseAuth(auth);
167
+ return wire;
168
+ }
169
+
170
+ /** Verify the challenge signature and resolve the public roster used for membership. */
171
+ export async function verifyBrokerAuth(input: {
172
+ wire: Uint8Array;
173
+ challenge: BrokerChallenge;
174
+ roster?: VerifiedPublicRoster;
175
+ }): Promise<{ member: string; roster: VerifiedPublicRoster; initial: boolean }> {
176
+ const challenge = validateChallenge(input.challenge);
177
+ const auth = canonicalDecode(input.wire, BROKER_CONTROL_LIMITS.control, parseAuth);
178
+ let roster: VerifiedPublicRoster;
179
+ let signedRosterDigest: string | null;
180
+ let initial: boolean;
181
+ if (challenge.rosterDigest === null) {
182
+ if (auth.roster === null || input.roster !== undefined) fail("untrusted");
183
+ let rosterWire: Uint8Array;
184
+ try { rosterWire = decodeBase64url(auth.roster); } catch { fail(); }
185
+ const decoded = decodeRoster(rosterWire);
186
+ roster = await verifyPublicRoster({ wire: rosterWire, pin: { room: decoded.room, owner: decoded.owner, minEpoch: "0" } });
187
+ if (roster.room !== challenge.room || roster.owner !== auth.member) fail("untrusted");
188
+ signedRosterDigest = roster.digest;
189
+ initial = true;
190
+ } else {
191
+ if (auth.roster !== null || !input.roster) fail("untrusted");
192
+ roster = input.roster;
193
+ publicRosterWire(roster);
194
+ if (roster.room !== challenge.room || roster.digest !== challenge.rosterDigest) fail("untrusted");
195
+ signedRosterDigest = null;
196
+ initial = false;
197
+ }
198
+ const member = roster.members.find(candidate => candidate.fingerprint === auth.member);
199
+ if (!member) fail("untrusted");
200
+ const signature = decodeBase64url(auth.signature, 64);
201
+ if (!await verifyBytes(member, challengeBytes(challenge, auth.member, signedRosterDigest), signature)) fail("authentication_failed");
202
+ return { member: auth.member, roster, initial };
203
+ }
@@ -0,0 +1,314 @@
1
+ /** Standalone blind relay. Only public proofs and sealed wire bytes cross this boundary. */
2
+ import type { ServerWebSocket } from "bun";
3
+ import { assertRoomCryptoReady, decodeBase64url, encodeBase64url, randomBytes } from "../rooms/crypto.ts";
4
+ import { brokerUrl, canonicalBytes, decodeFrame, decodeRoster, publicRosterWire, verifyPublicFrame, verifyPublicRoster, type VerifiedPublicRoster } from "../rooms/wire.ts";
5
+ import { BROKER_CONTROL_LIMITS, decodeBrokerControl, encodeBrokerChallenge, verifyBrokerAuth, type BrokerChallenge } from "./protocol.ts";
6
+ import { BrokerStore, BrokerStoreError, type BrokerLimits } from "./store.ts";
7
+
8
+ export const BROKER_SOCKET_LIMITS = Object.freeze({ connections: 128, verifications: 16, authMs: 10_000, rosterAckMs: 30_000, backpressureBytes: 1_048_576, queuedMessages: 8, queuedBytes: 2_097_152, globalQueuedBytes: 33_554_432 });
9
+ export type BrokerEvent = "started" | "stopped" | "connected" | "buffered" | "disconnected" | "refused" | "roster_installed";
10
+ export interface BrokerPorts {
11
+ clock?: () => number;
12
+ log?: (event: BrokerEvent, ids: Readonly<{ room?: string; member?: string; code?: string }>) => void;
13
+ /** Trusted test/host seam: wrappers must return real X1-minted proofs. Never read from config. */
14
+ verifyFrame?: typeof verifyPublicFrame;
15
+ }
16
+ export interface BrokerOptions {
17
+ stateDir: string;
18
+ hostname?: string;
19
+ port?: number;
20
+ publicUrl?: string;
21
+ /** Controls first-roster admission only; existing roster membership remains authoritative. */
22
+ owners?: readonly string[];
23
+ limits?: Partial<BrokerLimits>;
24
+ }
25
+ interface Client {
26
+ ws?: ServerWebSocket<Client>;
27
+ room: string;
28
+ challenge: BrokerChallenge;
29
+ phase: "challenge" | "authenticating" | "roster" | "ready" | "closed";
30
+ busy: boolean;
31
+ inbox: Uint8Array[];
32
+ inboxBytes: number;
33
+ member?: string;
34
+ key?: string;
35
+ rosterDigest?: string;
36
+ rosterDeadline?: number;
37
+ inFlight?: string;
38
+ }
39
+ export interface BrokerRuntime {
40
+ readonly port: number;
41
+ readonly hostname: string;
42
+ readonly publicUrl: string;
43
+ readonly closed: Promise<void>;
44
+ stats(): ReturnType<BrokerStore["stats"]> & { connections: number; authenticated: number; verifications: number };
45
+ sweep(): void;
46
+ stop(): Promise<void>;
47
+ }
48
+ export class BrokerServerError extends Error {
49
+ constructor(readonly code: string) { super(`Broker: ${code}`); this.name = "BrokerServerError"; }
50
+ }
51
+ function roomId(value: string): string {
52
+ if (!/^[A-Za-z0-9_-]{43}$/.test(value)) throw new BrokerServerError("invalid_room");
53
+ try { decodeBase64url(value, 32); } catch { throw new BrokerServerError("invalid_room"); }
54
+ return value;
55
+ }
56
+ function audience(value: string): string {
57
+ let parsed: URL;
58
+ try { parsed = new URL(brokerUrl(value)); } catch { throw new BrokerServerError("invalid_public_url"); }
59
+ if (parsed.pathname !== "/" || parsed.search) throw new BrokerServerError("invalid_public_url");
60
+ return parsed.href;
61
+ }
62
+ const identityKey = (member: { signingKey: string; agreementKey: string }) => `${member.signingKey}:${member.agreementKey}`;
63
+ function errorCode(error: unknown): string {
64
+ if (error instanceof BrokerStoreError) {
65
+ if (error.code === "full" || error.code === "limit") return "queue_full";
66
+ if (error.code === "conflict") return "replay_refused";
67
+ if (error.code === "untrusted" || error.code === "expired") return error.code;
68
+ return "storage_error";
69
+ }
70
+ if (error instanceof BrokerServerError) return error.code;
71
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
72
+ return typeof code === "string" && ["invalid_wire", "limit", "untrusted", "wrong_epoch", "authentication_failed", "expired"].includes(code) ? code : "invalid_wire";
73
+ }
74
+
75
+ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {}): Promise<BrokerRuntime> {
76
+ // This must precede filesystem, database and listener effects.
77
+ await assertRoomCryptoReady();
78
+ const hostname = options.hostname ?? "127.0.0.1", requestedPort = options.port ?? 8788;
79
+ if (!/^[A-Za-z0-9.:%-]+$/.test(hostname) || !Number.isSafeInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) throw new BrokerServerError("invalid_listen");
80
+ const loopback = hostname === "127.0.0.1" || hostname === "::1";
81
+ if (!loopback && options.publicUrl === undefined) throw new BrokerServerError("public_url_required");
82
+ const configuredAudience = options.publicUrl === undefined ? undefined : audience(options.publicUrl);
83
+ if (!loopback && configuredAudience && new URL(configuredAudience).protocol !== "wss:") throw new BrokerServerError("public_url_required");
84
+ let owners: ReadonlySet<string> | undefined;
85
+ if (options.owners !== undefined) {
86
+ if (!Array.isArray(options.owners) || options.owners.length > 64 || options.owners.some(owner => typeof owner !== "string" || !/^[0-9a-f]{64}$/.test(owner))) throw new BrokerServerError("invalid_owner");
87
+ owners = new Set(options.owners);
88
+ }
89
+ if ((!loopback || configuredAudience?.startsWith("wss:")) && !owners?.size) throw new BrokerServerError("owner_allowlist_required");
90
+ const now = () => {
91
+ const value = (ports.clock ?? Date.now)();
92
+ if (!Number.isSafeInteger(value) || value < 0) throw new BrokerServerError("invalid_clock");
93
+ return value;
94
+ };
95
+ const store = await BrokerStore.open({ stateDir: options.stateDir, ...(options.limits ? { limits: options.limits } : {}), clock: now });
96
+ const clients = new Set<Client>(), members = new Map<string, Client>();
97
+ const memberKey = (client: Client) => `${client.room}|${client.member}`;
98
+ let stopping = false, verifications = 0, queuedBytes = 0, publicUrl = configuredAudience ?? "";
99
+ let resolveClosed!: () => void;
100
+ const closed = new Promise<void>(resolve => { resolveClosed = resolve; });
101
+ const emit = (event: BrokerEvent, client?: Client, code?: string) => {
102
+ try { ports.log?.(event, { ...(client ? { room: client.room, ...(client.member ? { member: client.member } : {}) } : {}), ...(code ? { code } : {}) }); } catch { /* diagnostics never change admission or delivery */ }
103
+ };
104
+ function close(client: Client, code: string): void {
105
+ if (client.phase === "closed") return;
106
+ client.phase = "closed";
107
+ queuedBytes -= client.inboxBytes; client.inboxBytes = 0; client.inbox.length = 0;
108
+ if (client.member && members.get(memberKey(client)) === client) members.delete(memberKey(client));
109
+ if (client.ws) { try { client.ws.close(4403, code); } catch { /* already closed */ } }
110
+ else clients.delete(client);
111
+ }
112
+ function send(client: Client, wire: Uint8Array): boolean {
113
+ if (stopping || client.phase === "closed" || !client.ws) return false;
114
+ try {
115
+ if (client.ws.send(wire) !== 0) return true; // -1 is queued; never resend on drain.
116
+ } catch { /* a closed transport cannot receive */ }
117
+ close(client, "backpressure"); return false;
118
+ }
119
+ const control = (client: Client, value: unknown) => send(client, canonicalBytes(value));
120
+ function refused(client: Client, code: string, terminal = true): void {
121
+ emit("refused", client, code);
122
+ control(client, { v: 1, type: "error", code });
123
+ if (terminal) close(client, code);
124
+ }
125
+ function active(client: Client): VerifiedPublicRoster {
126
+ if (stopping || client.phase === "closed" || !client.member || members.get(memberKey(client)) !== client) throw new BrokerServerError("state_changed");
127
+ const roster = store.current(client.room)?.current;
128
+ const member = roster?.members.find(row => row.fingerprint === client.member);
129
+ if (!roster || !member || identityKey(member) !== client.key) throw new BrokerServerError("untrusted");
130
+ return roster;
131
+ }
132
+ function sendRoster(client: Client): void {
133
+ const roster = active(client);
134
+ client.phase = "roster"; client.rosterDigest = roster.digest;
135
+ client.rosterDeadline = now() + BROKER_SOCKET_LIMITS.rosterAckMs;
136
+ if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest });
137
+ }
138
+ function pump(client: Client): void {
139
+ if (stopping || client.phase === "closed" || !client.member) return;
140
+ active(client);
141
+ const next = store.next(client.room, client.member);
142
+ if (client.inFlight && next?.digest === client.inFlight) return;
143
+ client.inFlight = undefined; // the previous packet may have expired or been purged.
144
+ if (client.phase !== "ready" || !next) return;
145
+ if (send(client, next.wire)) client.inFlight = next.digest;
146
+ }
147
+ function safePump(client: Client): void {
148
+ try { pump(client); } catch (error) { refused(client, errorCode(error)); }
149
+ }
150
+ function announce(room: string): void {
151
+ for (const client of clients) if (client.room === room && client.member && client.phase !== "closed") {
152
+ try { sendRoster(client); } catch (error) { refused(client, errorCode(error)); }
153
+ }
154
+ }
155
+ async function authenticate(client: Client, bytes: Uint8Array): Promise<void> {
156
+ if (client.phase !== "challenge" || now() >= client.challenge.expiresAt) throw new BrokerServerError("expired");
157
+ client.phase = "authenticating"; // consume this socket's one-use challenge before any await.
158
+ const before = store.current(client.room)?.current;
159
+ const auth = await verifyBrokerAuth({ wire: bytes, challenge: client.challenge, ...(before ? { roster: before } : {}) });
160
+ if (stopping || client.phase !== "authenticating" || now() >= client.challenge.expiresAt) throw new BrokerServerError("expired");
161
+ if (auth.initial) {
162
+ if (owners && !owners.has(auth.member)) throw new BrokerServerError("owner_not_allowed");
163
+ store.install(auth.roster, auth.member);
164
+ }
165
+ else if (store.current(client.room)?.current.digest !== auth.roster.digest) throw new BrokerServerError("state_changed");
166
+ const current = store.current(client.room)?.current;
167
+ const member = current?.members.find(row => row.fingerprint === auth.member);
168
+ if (!current || !member || current.digest !== auth.roster.digest) throw new BrokerServerError("untrusted");
169
+ client.member = auth.member; client.key = identityKey(member);
170
+ const previous = members.get(memberKey(client));
171
+ if (previous && previous !== client) close(previous, "replaced");
172
+ members.set(memberKey(client), client);
173
+ client.phase = "roster";
174
+ sendRoster(client);
175
+ emit("connected", client);
176
+ }
177
+ async function message(client: Client, bytes: Uint8Array): Promise<void> {
178
+ if (client.phase === "challenge") return authenticate(client, bytes);
179
+ const current = active(client);
180
+ let type: unknown;
181
+ try { type = (JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as { type?: unknown }).type; } catch { throw new BrokerServerError("invalid_wire"); }
182
+ if (type === "roster") {
183
+ if (client.member !== current.owner) throw new BrokerServerError("not_owner");
184
+ const decoded = decodeRoster(bytes);
185
+ const proof = await verifyPublicRoster({ wire: bytes, pin: { room: current.room, owner: current.owner, minEpoch: current.epoch, digest: current.digest } });
186
+ active(client); // revocation/replacement/shutdown may have happened during verification.
187
+ if (decoded.room !== client.room) throw new BrokerServerError("untrusted");
188
+ const installed = store.install(proof, client.member!);
189
+ if (installed.changed) { announce(client.room); emit("roster_installed", client); }
190
+ control(client, { v: 1, type: "roster_installed", epoch: proof.epoch, digest: proof.digest, changed: installed.changed });
191
+ return;
192
+ }
193
+ if (type === "frame") {
194
+ const header = decodeFrame(bytes), state = store.current(client.room);
195
+ const selected = state && (header.epoch === state.current.epoch ? state.current : state.previous?.roster);
196
+ if (!selected) throw new BrokerServerError("wrong_epoch");
197
+ const proof = await (ports.verifyFrame ?? verifyPublicFrame)({ wire: bytes, roster: selected });
198
+ active(client);
199
+ const result = store.enqueue(proof, client.member!); // latest membership/grace/caps checked transactionally.
200
+ control(client, { v: 1, type: "stored", digest: result.digest, seq: proof.seq, duplicate: result.duplicate });
201
+ for (const recipient of proof.recipients) {
202
+ const target = members.get(`${client.room}|${recipient}`);
203
+ if (target) safePump(target);
204
+ }
205
+ return;
206
+ }
207
+ const packet = decodeBrokerControl(bytes);
208
+ if (packet.type === "auth") throw new BrokerServerError("challenge_used");
209
+ if (packet.type === "roster_ready") {
210
+ const latest = active(client);
211
+ if (packet.digest !== client.rosterDigest || packet.digest !== latest.digest) { sendRoster(client); return; }
212
+ client.phase = "ready"; client.rosterDeadline = undefined;
213
+ safePump(client); return;
214
+ }
215
+ if (packet.digest !== client.inFlight) { refused(client, "unexpected_ack", false); return; }
216
+ active(client);
217
+ store.ack(client.room, client.member!, packet.digest);
218
+ client.inFlight = undefined;
219
+ control(client, { v: 1, type: "queue_ack", digest: packet.digest });
220
+ safePump(client);
221
+ }
222
+ function schedule(client: Client): void {
223
+ if (stopping || client.phase === "closed" || client.busy || !client.inbox.length || verifications >= BROKER_SOCKET_LIMITS.verifications) return;
224
+ const bytes = client.inbox.shift()!;
225
+ client.inboxBytes -= bytes.length; queuedBytes -= bytes.length;
226
+ client.busy = true; verifications++;
227
+ void message(client, bytes).catch(error => {
228
+ if (!stopping && client.phase !== "closed") {
229
+ const code = errorCode(error);
230
+ refused(client, code, !["queue_full", "replay_refused"].includes(code));
231
+ }
232
+ }).finally(() => {
233
+ client.busy = false; verifications--;
234
+ // Bounded per-socket FIFO allows transport ACKs during an awaited outgoing
235
+ // signature check, without concurrent mutation of a socket's auth state.
236
+ for (const waiting of clients) schedule(waiting);
237
+ });
238
+ }
239
+ function receive(client: Client, data: string | Buffer): void {
240
+ if (stopping || client.phase === "closed") return;
241
+ if (typeof data === "string") { refused(client, "binary_required"); return; }
242
+ if (data.byteLength > BROKER_CONTROL_LIMITS.control) { refused(client, "limit"); return; }
243
+ if (client.inbox.length >= BROKER_SOCKET_LIMITS.queuedMessages || client.inboxBytes + data.byteLength > BROKER_SOCKET_LIMITS.queuedBytes || queuedBytes + data.byteLength > BROKER_SOCKET_LIMITS.globalQueuedBytes) { refused(client, "busy"); return; }
244
+ const bytes = Uint8Array.from(data);
245
+ client.inbox.push(bytes); client.inboxBytes += bytes.length; queuedBytes += bytes.length;
246
+ if (client.busy) emit("buffered", client);
247
+ schedule(client);
248
+ }
249
+ let server: ReturnType<typeof Bun.serve<Client>>;
250
+ try {
251
+ server = Bun.serve<Client>({
252
+ hostname, port: requestedPort, maxRequestBodySize: 8192,
253
+ fetch(req, host) {
254
+ try {
255
+ if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
256
+ const url = new URL(req.url);
257
+ if (req.method === "GET" && url.pathname === "/health" && !url.search) return Response.json({ v: 1, status: "ok" });
258
+ if (stopping || req.method !== "GET" || url.search || !url.pathname.startsWith("/v1/rooms/")) return new Response("not found", { status: 404 });
259
+ const room = roomId(url.pathname.slice("/v1/rooms/".length));
260
+ if (clients.size >= BROKER_SOCKET_LIMITS.connections) return new Response("busy", { status: 503 });
261
+ const current = store.current(room)?.current;
262
+ const client: Client = { room, challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
263
+ clients.add(client);
264
+ if (host.upgrade(req, { data: client })) return;
265
+ clients.delete(client); return new Response("websocket required", { status: 400 });
266
+ } catch { return new Response("invalid request", { status: 400 }); }
267
+ },
268
+ error() { return new Response("broker error", { status: 500 }); },
269
+ websocket: {
270
+ maxPayloadLength: BROKER_CONTROL_LIMITS.control,
271
+ backpressureLimit: BROKER_SOCKET_LIMITS.backpressureBytes,
272
+ closeOnBackpressureLimit: true, perMessageDeflate: false, idleTimeout: 60, sendPings: true,
273
+ open(ws) { const client = ws.data; client.ws = ws; if (client.phase === "closed") { ws.close(4403, "expired"); return; } send(client, encodeBrokerChallenge(client.challenge)); },
274
+ message(ws, data) { receive(ws.data, data); },
275
+ drain(ws) { if (!stopping && ws.data.phase !== "closed") safePump(ws.data); },
276
+ close(ws) {
277
+ const client = ws.data;
278
+ close(client, "disconnected"); clients.delete(client); emit("disconnected", client);
279
+ },
280
+ },
281
+ });
282
+ } catch { store.close(); throw new BrokerServerError("listen_failed"); }
283
+ publicUrl ||= audience(`ws://${hostname === "::1" ? "[::1]" : hostname}:${server.port}/`);
284
+ let cachedStats = store.stats();
285
+ const stats = () => {
286
+ if (!stopping) cachedStats = store.stats();
287
+ return { ...cachedStats, connections: clients.size, authenticated: members.size, verifications };
288
+ };
289
+ function sweep(): void {
290
+ if (stopping) return;
291
+ try { store.sweep(); } catch { for (const client of clients) close(client, "storage_error"); return; }
292
+ for (const client of clients) {
293
+ if (client.phase === "closed") continue;
294
+ if ((client.phase === "challenge" || client.phase === "authenticating") && now() >= client.challenge.expiresAt) close(client, "expired");
295
+ else if (client.rosterDeadline !== undefined && now() >= client.rosterDeadline) close(client, "roster_timeout");
296
+ else if (client.member) safePump(client);
297
+ }
298
+ }
299
+ const timer = setInterval(sweep, 1000);
300
+ let stoppingPromise: Promise<void> | undefined;
301
+ const stop = () => stoppingPromise ??= (async () => {
302
+ if (stopping) return;
303
+ try { cachedStats = store.stats(); } catch { /* still close a failed store */ }
304
+ stopping = true; clearInterval(timer);
305
+ for (const client of clients) close(client, "stopped");
306
+ try { await server.stop(true); }
307
+ finally {
308
+ try { store.close(); }
309
+ finally { emit("stopped"); resolveClosed(); }
310
+ }
311
+ })();
312
+ emit("started");
313
+ return Object.freeze({ port: server.port!, hostname, publicUrl, closed, stats, sweep, stop });
314
+ }