@staix/agent-hub 0.19.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.
- package/CHANGELOG.md +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +8 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1228 -242
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +46 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +7 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -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
|
+
}
|