ghostbus 0.5.5 → 0.5.7

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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.7 — 2026-10-11
4
+
5
+ Security hardening, round 6 — the credential lifecycle, and the serverless pack's quiet drift. This round audited how every credential is born, stored, leaked (or not), and killed. The Node hosted server's core answers were mostly already right and are now pinned by tests; the real findings were a rotation path that only existed for one client type, and a serverless pack that had silently fallen behind four separate hardening decisions from earlier rounds.
6
+
7
+ - **Agent tokens are rotatable over plain REST (fixed gap)** — rotation existed in the core and as the `bus_rotate_token` MCP tool, but the HTTP handler had no route for it: a pure-REST agent (curl, a webhook, a non-MCP client) whose token leaked had no way to rotate it. `POST /api/agents/rotate-token` now exists on the relay, the hosted server, and the serverless pack — current token authorizes, new token returns once, old hash is replaced before the reply. Rotation still requires the current token on an enforced bus, so a stranger who knows only an agent's name cannot rotate its owner out.
8
+ - **Rotation immediacy, verified end to end** — workspace-key rotation already replaced the registry hash and refreshed the cached meta in the same request (round 2's design); round 6 pins it over real HTTP on the hosted server and in the serverless lib: the old credential's *very next* request fails, the new one works. Agent-token rotation is pinned the same way (core, REST, serverless). Serverless rotation is effective on the next request by construction — a fresh bus is built from the KV per request, so there is no cache that could keep an old key alive.
9
+ - **Delivered credentials verified, not assumed** — workspace keys and agent tokens are `crypto.randomBytes(24)` in every issuance path (hosted create/import/rotate, core register/rotate, serverless create/import/rotate): 48 lowercase hex chars, 192 bits, now asserted by format checks in both suites. The admin key stays operator-chosen with the ≥16-character startup floor — and that floor now exists in the serverless pack too (it previously accepted anything non-empty).
10
+ - **Leak audit: clean, now pinned** — every surface a raw credential could escape through was checked: API responses (credentials appear only in their one-time issuance replies), the board payload (the key lives in local storage, travels as a header — round 4), error messages, server stderr in normal operation including the v0.5.3 audit lines (re-verified after rounds 4–5), and the provenance/event log — which matters most, because `/api/events` and `bus_sync` are readable by *every* workspace member: the log records that a rotation happened and never the token. Export envelopes carry agent `tokenHash` values and nothing else: SHA-256 of a 192-bit random token is not a practical offline brute-force target, so an export is a backup, not a credential — the suite asserts the hashes' shape and the absence of every live credential it issued.
11
+ - **Serverless parity closed (the round's biggest find)** — the Netlify pack shares the core but had drifted: (1) per-agent token enforcement defaulted **off** (wrapper opt-in) while the Node hosted server has enforced by default since v0.5.2; (2) the admin key had no minimum length; (3) workspace **rotate-key, export, and import did not exist** — a leaked serverless workspace key could only be answered by deleting the workspace. All four are aligned: enforcement is the pack's default too (`GHOSTBUS_REQUIRE_TOKENS=0` is the only opt-out, same spelling as Node), construction refuses a short admin key, and the three admin routes mirror the Node semantics (deep snapshot validation at import, fresh key on import, audit lines in the function log). The lib test grew **6 → 12 checks** to hold the line — enforcement default, REST + workspace rotation immediacy, the round-3 reserved-file-name refusal and round-4 limit clamps flowing through the shared core, timing-safe compares, export→import across two independent cores.
12
+ - **Documented, deliberately not built** — two things this round chose to write down instead of code. **No credential expiry:** nothing expires on a timer; the lifecycle is issue-once / hash-at-rest / rotate-on-suspicion, with the trade-off stated where operators will read it (`SECURITY.md`, "Credential lifecycle"). **Single-scope authorization:** a workspace key is full access to its one workspace (token minting included), an agent token is that agent, the admin key is root — there are no read-only keys, and that is a stated pilot-stage design decision with its reasoning, not a gap dressed as one (`SECURITY.md`, "Authorization model"). Two serverless divergences also remain, documented rather than hidden: no app-level failed-auth throttle in the pack (stateless per request; the platform's traffic layer plus 192-bit keys carry it) and platform-bounded request bodies instead of the app-level 1 MB cap.
13
+ - **Tests** — main suite **70 checks** (was 69): one hosted check covering REST rotation immediacy, delivered-credential formats, events-log and export hash-only assertions, and stderr secret absence. Serverless lib **12 checks** (was 6). Red→green: the pre-fix probe showed a tokenless act succeeding on the serverless default (200 vs Node's 400), rotate/export routes absent, a 5-char admin key accepted, and REST rotation 404 — all closed. Full suite, serverless, and both demos green.
14
+ - **Scope boundary, honestly** — no API shapes changed for existing clients; the one new route is additive. The single relay / stdio / CLI local trust model is untouched. Credential expiry and scoped keys were audited and consciously deferred with their reasoning recorded — if either ever ships, it ships as a feature, not as a silent default change.
15
+
16
+ ## 0.5.6 — 2026-10-11
17
+
18
+ Security hardening, round 5 — load & abuse resilience: what happens when many agents share one process for a long time. This round built the first repeatable load bench before changing anything, and the honest headline is that load broke no correctness property — zero errors, zero lost writes, exact reconciliation at every size. What it (and the audit around it) found instead: an unbounded subscription surface, missing server timeouts whose platform defaults do not do what their names suggest, and a measured, structural scaling limit that is now documented instead of discovered by operators.
19
+
20
+ - **Load bench (`scripts/load-bench.mjs`, new)** — boots the real hosted server with per-agent tokens enforced and drives N agents through mixed operations (register, heartbeat, send, inbox, task create→claim→complete, file put), reporting ops, errors, and p50/p95 latency per op class, plus a hard reconciliation: messages sent == messages stored, tasks created == tasks completed, files put == files stored. Baseline on the development machine (Node 24): **25 agents × 30 s → 2,506 ops, ~83 ops/s, 0 errors**, p50 ~280–360 ms / p95 ~390–595 ms per op class, reconciled exactly (650 messages, 300/300 tasks, 300 files); **25 agents × 60 s → 3,438 ops, ~57 ops/s, 0 errors**, also exact. Repeat 30 s runs on this shared VM varied between ~55 and ~112 ops/s with machine load; the invariants across every run were 0 errors and exact reconciliation. A reduced smoke (6 agents × 3 s) runs in the suite. One bench disclosure: at the shipped 300 req/min per-IP limiter, a single-IP bench measures the limiter, not the bus, so the bench raises that one knob for itself (printed in its output); every other limit is at its shipped default.
21
+ - **What the load actually showed** — throughput declines as a workspace's state grows (~83 → ~57 ops/s across the captured 30 s/60 s pair as the state file grew ~0.9 → ~1.2 MB, p95 latency roughly doubling) because every operation loads and re-saves the whole state file (O(state) per op) and all operations serialize on the per-workspace lock: with 25 agents queued, most of the p50 is queue wait. Memory plateaued (RSS oscillated in a ~140–260 MB band under sustained load — garbage collection keeping up with per-op state copies), it did not grow with op count. No fix is claimed for the scaling shape: it is the single-file store's documented trade-off, now with numbers, in `docs/hosted.md` ("Capacity & limits"). Trims (10k messages / 5k events / per-file history) held throughout.
22
+ - **SSE subscriptions were unbounded (fixed)** — before this round, any number of `/api/stream` connections could be held per workspace, each one a permanent bus listener plus a 25 s ping timer, reaped only on socket close. There are now caps, checked before the stream opens: **100 per workspace** (`GHOSTBUS_MAX_STREAMS_PER_WORKSPACE`) and **1000 per server** (`GHOSTBUS_MAX_STREAMS_TOTAL`), with a clean `429` JSON refusal over the cap. Cleanup is now idempotent across close/error/write-failure paths, and the suite proves the cap, that a freed slot reopens, and that bus listener counts return to exactly zero after stream churn — no listener leaks.
23
+ - **Explicit server timeouts on both servers** — slow-loris room is closed. Both HTTP servers now take commented deadlines (defaults: headers 10 s, request body 30 s, keep-alive 5 s; overridable via `GHOSTBUS_HEADERS_TIMEOUT_MS` / `GHOSTBUS_REQUEST_TIMEOUT_MS`). The implementation notes are in the code because the platform defaults mislead: Node's own header enforcement was probed to leave a partial-headers socket alive past 15 s in this configuration, and its `requestTimeout` semantics proved unsafe to reason about for long-lived responses, so that property is pinned to 0 and both deadlines are enforced by a small connection-level guard that only ever destroys a socket whose request is genuinely incomplete. The guard's first cut armed its body timer on `req.complete` — which stays false for a bodyless GET nobody reads, exactly the `/api/stream` shape — and killed live streams in testing; the shipped guard arms only on a *declared* body (Content-Length / Transfer-Encoding). The suite proves all of it at tightened deadlines (400 ms / 800 ms): a partial-headers socket dies at the header deadline, a slow body drip dies at the body deadline, and an SSE stream plus a 2 s long-poll outlive both.
24
+ - **Recovery drill (new suite proof)** — while three agents write against a hosted FileStore workspace, the server is SIGKILLed mid-load and restarted on the same data directory. Assertions: the state file parses (temp-file + atomic rename means a kill strands at most an inert `.tmp`), every operation already answered HTTP 200 is present after restart, each agent's stored messages form a contiguous prefix of its attempts (nothing half-applied), acked files are present, and the restarted server's counts agree with the file. This is the durability claim for the JSON store, stated at its true scope: single file, one writer process, last-writer semantics per op.
25
+ - **Long-poll pile-up (new suite proof)** — 25 concurrent `/api/wait` holders all wake with the event on a single broadcast (none hang, none miss it), and 10 idle waits all resolve on their own timeout with `waited: true` and no events, with no unhandled rejections in the server log.
26
+ - **Limiter hygiene** — the per-IP/workspace rate limiter is now tunable (`GHOSTBUS_RATE_LIMIT_PER_MIN`, default unchanged at 300) and its over-limit message reports the live value; the limiter map and the hosted failed-auth map prune stale buckets opportunistically instead of only ever growing under address churn.
27
+ - **Tests** — five new checks (69 in the main suite, was 64): bench smoke with exact reconciliation, server timeouts (loris + drip + SSE/long-poll survival), SSE caps + reaping, wait pile-up, and the SIGKILL recovery drill. The suite now takes ~22 s because three of the new checks are real-time proofs (a 3 s bench window, a 2 s long-poll, a 1.2 s kill drill) — that time is the evidence, not overhead. Serverless stays 6/6 and both demos pass.
28
+ - **Scope boundary, honestly** — what did NOT change: the storage format and every API shape are untouched; the local trust model is untouched; the bench figures are one machine's evidence, not a hosted SLA; and the networked multi-writer store remains future work — this round measured the single-file store's limits rather than relitigating them.
29
+
3
30
  ## 0.5.5 — 2026-10-11
4
31
 
5
32
  Security hardening, round 4 — the client side of the wire. Rounds 2–3 hardened what the server stores and how it authenticates; this round fuzzed what a client can *send*: MCP tool arguments over stdio JSON-RPC, board payloads end-to-end, and raw HTTP parameters. One real crash, a class of silent state corruption, and a set of numeric edge cases were found and fixed. Several surfaces audited clean and are now pinned by tests instead — the entry says which, as usual.
package/README.md CHANGED
@@ -23,7 +23,7 @@ Built and in use: 29 MCP tools across presence, messages, tasks, a shared worksp
23
23
  ```bash
24
24
  git clone https://github.com/littlestjames82-sys/ghostbus.git ghostbus && cd ghostbus
25
25
  node examples/two-agent-demo.mjs # watch a planner and a builder cooperate
26
- node test.mjs # 64 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security, hostile input, input fuzz
26
+ node test.mjs # 70 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security, hostile input, input fuzz, load & resilience, token lifecycle
27
27
  node examples/three-agent-team.mjs # planner + builder + reviewer, no human relaying
28
28
  ```
29
29
 
package/docs/hosted.md CHANGED
@@ -47,6 +47,20 @@ The same snapshot format works without a server: `ghostbus-cli export backup.jso
47
47
  and `ghostbus-cli --store other.json import backup.json`. Restores are recorded
48
48
  in the workspace's provenance log (`workspace.restore`).
49
49
 
50
+ Agent tokens rotate the same way — immediately, old credential dead on the
51
+ next request — via the `bus_rotate_token` MCP tool or, since v0.5.7, plain
52
+ REST (presenting the current token):
53
+
54
+ ```bash
55
+ curl -X POST localhost:8388/w/acme/api/agents/rotate-token \
56
+ -H 'x-bus-key: <workspace key>' -H 'x-agent-token: <current agent token>' \
57
+ -d '{"agent":"builder"}'
58
+ ```
59
+
60
+ The full credential lifecycle (issue / storage / rotation / the no-expiry
61
+ posture) and the single-scope authorization model are documented in
62
+ `SECURITY.md` at the repo root.
63
+
50
64
  ### Agent flow
51
65
 
52
66
  Everything from the single-workspace relay works under the prefix:
@@ -189,15 +203,113 @@ including MCP clients and any agent driving one:
189
203
  and referers. `/health` and `/probe` return exactly their documented
190
204
  counts-only shapes.
191
205
 
206
+ Round 6 (v0.5.7) audited the credential lifecycle and stopped the
207
+ serverless pack from drifting away from the rules above:
208
+
209
+ - **Rotation is immediate everywhere, and now reachable everywhere.**
210
+ Workspace-key rotation replaced the registry hash before replying
211
+ (with the cached meta refreshed) — pinned by test: the old key's next
212
+ request is a 401. Agent tokens were rotatable only through MCP; the
213
+ REST route (`POST /api/agents/rotate-token`) exists now, on both
214
+ servers and in the serverless pack. Nothing expires on a timer; the
215
+ posture and the rotation runbook are in `SECURITY.md`.
216
+ - **Delivered credentials verified, not assumed.** Workspace keys and
217
+ agent tokens are `crypto.randomBytes(24)` — 48 hex chars, 192 bits —
218
+ asserted by format checks in the suite. Only SHA-256 hashes are ever
219
+ stored, and the leak audit covered API responses, the board payload,
220
+ error messages, the provenance/event log (readable by every workspace
221
+ member — a token there would be a cross-agent compromise; rotation
222
+ events name the act, never the token), server stderr in normal
223
+ operation, and export envelopes: hashes only, everywhere.
224
+ - **Serverless parity closed.** The pack had quietly drifted: token
225
+ enforcement defaulted OFF (the Node server enforces by default), the
226
+ admin key had no 16-character floor, and rotate-key / export / import
227
+ did not exist — a leaked serverless workspace key could only be
228
+ answered by deleting the workspace. All aligned now, and the lib test
229
+ grew from 6 to 12 checks to keep them aligned (enforcement default,
230
+ rotation immediacy, deep snapshot validation at import, the round-3/4
231
+ core fixes flowing through the shared code).
232
+ - **Two serverless divergences remain — documented, not silent.** There
233
+ is no application-level failed-auth throttle in the pack: serverless
234
+ instances are stateless per request, so the Node server's in-memory
235
+ per-IP counter has no equivalent; the mitigations are the platform's
236
+ own traffic layer plus 192-bit keys and timing-safe compares (online
237
+ guessing is not a practical attack on either). Request bodies are
238
+ likewise bounded by the platform's function payload limits rather
239
+ than the app-level 1 MB cap. Admin actions ARE audit-logged in the
240
+ pack since v0.5.7 — the same one-line JSON records, in the function
241
+ log.
242
+
243
+ ## Capacity & limits (measured, v0.5.6)
244
+
245
+ First published load figures, from `scripts/load-bench.mjs` — a repeatable
246
+ bench that boots the real hosted server (per-agent tokens enforced) and
247
+ drives N agents through mixed register/heartbeat/send/inbox/task/file
248
+ operations, then reconciles the final state **exactly** (messages sent ==
249
+ messages stored, tasks created == tasks completed, files put == files
250
+ stored). One development machine (Node 24, local disk); treat these as
251
+ evidence about the shape of the limits, not as a hosted SLA.
252
+
253
+ | run | ops | throughput | errors | latency (p50 / p95) |
254
+ |---|---|---|---|---|
255
+ | 25 agents × 30 s | 2,506 | ~83 ops/s | 0 | ~280–360 ms / ~390–595 ms per op class |
256
+ | 25 agents × 60 s | 3,438 | ~57 ops/s | 0 | ~315–470 ms / ~910–1,870 ms per op class |
257
+
258
+ Repeat runs vary with machine load — five 30 s runs on the shared
259
+ development VM landed between ~55 and ~112 ops/s. What did not vary, in
260
+ any run: **0 errors, and exact reconciliation.**
261
+
262
+ What the numbers mean:
263
+
264
+ - **Correctness holds under concurrency.** Zero errors and exact
265
+ reconciliation at every size — the per-workspace lock serializes
266
+ operations, so nothing is lost or double-applied.
267
+ - **Throughput is bounded by design, and declines as a workspace grows.**
268
+ Every operation loads and re-saves the workspace's whole state file, so
269
+ per-operation cost is O(state size), and operations queue behind each
270
+ other: with 25 agents active, most of the ~300 ms p50 is queue wait, not
271
+ work. The 60 s run is slower per op than the 30 s run because its state
272
+ file grew from ~0.9 MB to ~1.2 MB during the run. History trims (10k
273
+ messages, 5k events, per-file history) bound how large that cost can
274
+ get, but a hot workspace is a real cost center — split hot work into
275
+ more workspaces rather than feeding one forever.
276
+ - **Memory plateaus.** Under sustained load the server's RSS climbed,
277
+ then oscillated in a ~140–260 MB band (garbage collection keeping up
278
+ with per-operation state copies). It did not grow linearly with the
279
+ operation count; no leak was found.
280
+ - **Durability under a hard kill is proven, not assumed.** The suite's
281
+ recovery drill SIGKILLs the server mid-load and restarts on the same
282
+ data directory: the state file parses (writes are temp-file + atomic
283
+ rename, so a kill can strand at most an inert `.tmp` file), every
284
+ operation the server had already answered with HTTP 200 is present, and
285
+ each agent's stored messages form a contiguous prefix of what it
286
+ attempted — nothing half-applied.
287
+
288
+ Abuse limits an operator can tune (environment variables):
289
+
290
+ | variable | default | meaning |
291
+ |---|---|---|
292
+ | `GHOSTBUS_MAX_STREAMS_PER_WORKSPACE` | 100 | concurrent `/api/stream` subscribers per workspace; over the cap → `429` |
293
+ | `GHOSTBUS_MAX_STREAMS_TOTAL` | 1000 | concurrent streams across the whole server (hosted) |
294
+ | `GHOSTBUS_HEADERS_TIMEOUT_MS` | 10000 | deadline for a client to deliver complete request headers |
295
+ | `GHOSTBUS_REQUEST_TIMEOUT_MS` | 30000 | deadline to deliver a declared request body (never bounds a response — SSE and long-poll outlive it by design) |
296
+ | `GHOSTBUS_RATE_LIMIT_PER_MIN` | 300 | per-IP, per-workspace request budget (the v0.4 limiter, now tunable) |
297
+
298
+ When to scale out: one process serving a handful of team-sized workspaces
299
+ is the design point. If a single workspace's sustained op rate or state
300
+ size becomes the bottleneck, the honest answer today is "more, smaller
301
+ workspaces" — the networked multi-writer store that would lift the
302
+ single-file ceiling is documented future work, not a flag you can flip.
303
+
192
304
  ## Option B — serverless on Netlify (Blobs)
193
305
 
194
306
  `deploy/netlify/` contains a Netlify Function implementing the same multi-workspace semantics over Netlify Blobs (strong consistency):
195
307
 
196
308
  1. Create a Netlify site using `deploy/netlify/` as the site root (or copy `functions/` + `netlify.toml` into a site).
197
- 2. Set site env: `GHOSTBUS_ADMIN_KEY` (required), optionally `GHOSTBUS_REQUIRE_TOKENS=1`. (The serverless pack keeps token enforcement opt-in via this env; the Node hosted server above enforces by default since v0.5.2.)
309
+ 2. Set site env: `GHOSTBUS_ADMIN_KEY` (required, at least 16 characters — the pack refuses to construct without one, same floor as the Node server). Per-agent token enforcement is ON by default here too (since v0.5.7, matching the Node hosted server); `GHOSTBUS_REQUIRE_TOKENS=0` is the only opt-out.
198
310
  3. Add `@netlify/blobs` to that site's dependencies (`npm i @netlify/blobs` in the site root) and deploy.
199
311
 
200
- The routing, admin API, per-workspace keys, and REST/MCP surface match Option A, with two honest differences: **no board page, no SSE, no long-poll** (serverless invocations can't hold them) — agents poll `/w/<id>/probe` + inbox or use `bus_sync`. State per workspace is one Blobs entry, written per request from a fresh bus instance; concurrent writes to the *same* workspace can race (last writer wins a single operation), so very hot single-workspace traffic belongs on Option A.
312
+ The routing, admin API (create / list / delete / **rotate-key / export / import** — parity since v0.5.7), per-workspace keys, and REST/MCP surface match Option A, with two honest differences: **no board page, no SSE, no long-poll** (serverless invocations can't hold them) — agents poll `/w/<id>/probe` + inbox or use `bus_sync`. Two smaller divergences are documented in the Round 6 notes above (no app-level failed-auth throttle; platform-bounded request bodies). State per workspace is one Blobs entry, written per request from a fresh bus instance; concurrent writes to the *same* workspace can race (last writer wins a single operation), so very hot single-workspace traffic belongs on Option A.
201
313
 
202
314
  The serverless logic (`deploy/netlify/functions/lib.mjs`) is unit-tested with an in-memory KV through real Web Request/Response objects: `node deploy/netlify/test-lib.mjs`.
203
315
 
package/package.json CHANGED
@@ -44,5 +44,5 @@
44
44
  "hosted": "node src/hosted-server.mjs"
45
45
  },
46
46
  "type": "module",
47
- "version": "0.5.5"
47
+ "version": "0.5.7"
48
48
  }
package/src/core.mjs CHANGED
@@ -14,7 +14,7 @@ import path from 'node:path';
14
14
  import crypto from 'node:crypto';
15
15
  import { AsyncLocalStorage } from 'node:async_hooks';
16
16
 
17
- export const GHOSTBUS_VERSION = '0.5.5';
17
+ export const GHOSTBUS_VERSION = '0.5.7';
18
18
  const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
19
19
  // Timing-safe equality for hex digests (v0.5.3): secret comparisons must not
20
20
  // short-circuit on the first differing character the way === does.
package/src/handler.mjs CHANGED
@@ -75,16 +75,117 @@ export const HTML_SECURITY_HEADERS = {
75
75
 
76
76
  const TOO_LARGE = Symbol('body-too-large');
77
77
 
78
+ // Per-IP request limiter (per workspace — one handler serves one workspace).
79
+ // The default (300/min) is unchanged since v0.3; GHOSTBUS_RATE_LIMIT_PER_MIN
80
+ // is an operator knob for two legitimate cases: many agents behind one NAT
81
+ // address sharing a single source IP, and load benchmarking, where the
82
+ // limiter — not the bus — would otherwise be the first ceiling (v0.5.6).
83
+ const rateLimitPerMin = () => {
84
+ const n = Number(process.env.GHOSTBUS_RATE_LIMIT_PER_MIN);
85
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 300;
86
+ };
87
+
88
+ // Server timeouts (v0.5.6). Three different clocks, three different jobs:
89
+ // - headersMs bounds how long a client may take to deliver complete request
90
+ // HEADERS (the classic slow-loris drip).
91
+ // - requestMs bounds how long it may take to deliver the request BODY after
92
+ // that. It must never bound a response: /api/stream and /api/wait are
93
+ // long-lived BY DESIGN.
94
+ // - keepAliveMs is Node's own idle keep-alive lifetime.
95
+ // Why this is not just "set the three Node properties": Node's own
96
+ // enforcement of both knobs was probed on Node 24 before this design was
97
+ // chosen, and neither is a deterministic drop-in for the deadlines above.
98
+ // - server.headersTimeout IS set (it only governs the header phase), but
99
+ // its enforcement rides on Node's socket-timeout machinery: with the
100
+ // default server.timeout of 0, a partial-headers socket was probed to
101
+ // survive 15 s at headersTimeout=1000.
102
+ // - server.requestTimeout is pinned to 0: its semantics proved impossible
103
+ // to reason about safely for long-lived responses (a handler that
104
+ // writes constantly masks its clock entirely), and the deadline it is
105
+ // documented for — receiving the request — is exactly the one the
106
+ // guard below enforces deterministically instead.
107
+ // The guard: arm the header deadline on connect, clear it when headers
108
+ // land, bound a *declared* body from there, re-arm while the socket sits
109
+ // between keep-alive requests. server.timeout stays 0 on purpose — socket
110
+ // *inactivity* is not a deadline here, because a long-poll legitimately
111
+ // idles for up to 55 s and an SSE stream between events for longer.
112
+ export const SERVER_TIMEOUT_DEFAULTS = { headersMs: 10_000, requestMs: 30_000, keepAliveMs: 5_000 };
113
+ export function applyServerTimeouts(server, opts = {}) {
114
+ const headersMs = opts.headersMs ?? SERVER_TIMEOUT_DEFAULTS.headersMs;
115
+ const requestMs = opts.requestMs ?? SERVER_TIMEOUT_DEFAULTS.requestMs;
116
+ const keepAliveMs = opts.keepAliveMs ?? SERVER_TIMEOUT_DEFAULTS.keepAliveMs;
117
+ server.headersTimeout = headersMs;
118
+ server.requestTimeout = 0; // see above — Node's clock kills live streams; the guard owns this deadline
119
+ server.keepAliveTimeout = keepAliveMs;
120
+ server.timeout = 0; // see above — inactivity is not the deadline
121
+ server.on('connection', (socket) => {
122
+ const armHeaderDeadline = () => {
123
+ clearTimeout(socket._gbHeaderTimer);
124
+ socket._gbHeaderTimer = setTimeout(() => socket.destroy(), headersMs);
125
+ socket._gbHeaderTimer.unref?.();
126
+ };
127
+ socket._gbArmHeaderDeadline = armHeaderDeadline;
128
+ armHeaderDeadline();
129
+ socket.on('close', () => clearTimeout(socket._gbHeaderTimer));
130
+ });
131
+ server.on('request', (req, res) => {
132
+ const socket = req.socket;
133
+ clearTimeout(socket._gbHeaderTimer); // headers are in — that deadline is met
134
+ clearTimeout(socket._gbBodyTimer);
135
+ // Bound the body ONLY when one was declared. req.complete cannot be
136
+ // the trigger: for a bodyless GET whose stream nobody reads (exactly
137
+ // the /api/stream and /api/wait shape) it stays false indefinitely, so
138
+ // a timer armed on it would destroy healthy long-lived exchanges —
139
+ // the first cut of this guard did precisely that to SSE streams.
140
+ const declaresBody = Number(req.headers['content-length'] || 0) > 0 || 'transfer-encoding' in req.headers;
141
+ if (declaresBody && !req.complete) {
142
+ socket._gbBodyTimer = setTimeout(() => socket.destroy(), requestMs);
143
+ socket._gbBodyTimer.unref?.();
144
+ const clear = () => clearTimeout(socket._gbBodyTimer);
145
+ req.on('end', clear);
146
+ req.on('close', clear);
147
+ res.on('finish', clear);
148
+ res.on('close', clear);
149
+ }
150
+ // Between keep-alive requests the socket owes us a fresh set of
151
+ // headers: re-arm the header deadline when this exchange finishes so a
152
+ // stalled *next* request is bounded too. A still-open response (SSE)
153
+ // never emits 'finish', and its request completed on arrival, so no
154
+ // timer is ever pending against a live stream or long-poll.
155
+ res.on('finish', () => { if (!socket.destroyed && socket._gbArmHeaderDeadline) socket._gbArmHeaderDeadline(); });
156
+ res.on('close', () => clearTimeout(socket._gbHeaderTimer));
157
+ });
158
+ return server;
159
+ }
160
+
161
+ // SSE stream caps (v0.5.6): before this round a workspace's /api/stream
162
+ // subscribers were bounded by nothing but the request limiter — each one
163
+ // holds a bus listener, a socket, and a 25 s ping interval, forever, and a
164
+ // buggy client reconnect-looping could pile them up without limit. Caps are
165
+ // generous against real teams (an agent holds at most a stream or two) and
166
+ // refuse cleanly with 429 past them. The per-process total spans every
167
+ // handler in the process, which is what protects the hosted server.
168
+ const streamCap = (envName, def) => {
169
+ const n = Number(process.env[envName]);
170
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : def;
171
+ };
172
+ let activeStreamsTotal = 0;
173
+
78
174
  export function createBusHandler({ id = 'default', bus, key = '' }) {
79
175
  const buckets = new Map();
176
+ const rateLimit = rateLimitPerMin();
80
177
  const rateOk = (req) => {
81
178
  const ip = (req.socket && req.socket.remoteAddress) || 'unknown';
82
179
  const t = Date.now();
180
+ // Opportunistic sweep: one bucket per source IP per minute would
181
+ // otherwise accumulate forever on a long-lived server (v0.5.6).
182
+ if (buckets.size > 4096) { for (const [k, v] of buckets) if (t - v.start > 60_000) buckets.delete(k); }
83
183
  let b = buckets.get(ip);
84
184
  if (!b || t - b.start > 60_000) { b = { start: t, count: 0 }; buckets.set(ip, b); }
85
185
  b.count += 1;
86
- return b.count <= 300;
186
+ return b.count <= rateLimit;
87
187
  };
188
+ let activeStreams = 0; // this workspace's live /api/stream subscribers
88
189
  const keyOk = (req) => {
89
190
  if (!key) return true;
90
191
  const given = req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
@@ -99,7 +200,7 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
99
200
  res.writeHead(status, { 'content-type': type }); res.end(body);
100
201
  };
101
202
  if (!rateOk(req) && url.pathname !== '/health' && url.pathname !== '/probe') {
102
- return send(429, { error: 'rate limit: 300 requests/min per IP per workspace' });
203
+ return send(429, { error: `rate limit: ${rateLimit} requests/min per IP per workspace` });
103
204
  }
104
205
  const send413 = () => {
105
206
  try {
@@ -124,15 +225,34 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
124
225
  if (req.method === 'GET' && url.pathname === '/api/stream') {
125
226
  const agent = url.searchParams.get('agent');
126
227
  if (!agent) return send(400, { error: 'agent query param required' });
228
+ // Stream caps (v0.5.6): refuse BEFORE opening the stream — a 429 the
229
+ // client can back off from, not a socket that dies later.
230
+ const maxPerWorkspace = streamCap('GHOSTBUS_MAX_STREAMS_PER_WORKSPACE', 100);
231
+ const maxTotal = streamCap('GHOSTBUS_MAX_STREAMS_TOTAL', 1000);
232
+ if (activeStreams >= maxPerWorkspace) return send(429, { error: `too many event streams for this workspace (limit ${maxPerWorkspace})` });
233
+ if (activeStreamsTotal >= maxTotal) return send(429, { error: `too many event streams on this server (limit ${maxTotal})` });
234
+ activeStreams += 1; activeStreamsTotal += 1;
127
235
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
128
236
  res.write(`event: ready\ndata: ${JSON.stringify({ agent, workspace: id, version: GHOSTBUS_VERSION })}\n\n`);
237
+ let cleaned = false;
129
238
  const off = bus.onEvent((ev) => {
130
239
  const forMe = ev.type === 'message.send' ? (ev.to === agent || ev.to === '*' || ev.actor === agent) : true;
131
240
  if (!forMe) return;
132
- res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
241
+ try { res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`); } catch { cleanup(); }
133
242
  });
134
- const ping = setInterval(() => res.write(': ping\n\n'), 25000);
135
- req.on('close', () => { clearInterval(ping); off(); });
243
+ const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch { cleanup(); } }, 25000);
244
+ // Reaping (v0.5.6): whichever side of the connection dies first —
245
+ // request close, response close, or a write into a dead socket —
246
+ // the listener, the ping, and both cap slots are released exactly
247
+ // once, so churn can neither leak listeners nor strand cap slots.
248
+ function cleanup() {
249
+ if (cleaned) return; cleaned = true;
250
+ clearInterval(ping); off();
251
+ activeStreams -= 1; activeStreamsTotal -= 1;
252
+ }
253
+ req.on('close', cleanup);
254
+ res.on('close', cleanup);
255
+ res.on('error', cleanup);
136
256
  return;
137
257
  }
138
258
 
@@ -186,6 +306,13 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
186
306
  if (req.method === 'GET' && url.pathname === '/api/status') return send(200, await bus.status());
187
307
  if (req.method === 'GET' && url.pathname === '/api/agents') return send(200, { agents: await bus.listAgents() });
188
308
  if (req.method === 'POST' && url.pathname === '/api/agents') return send(200, await bus.registerAgent({ name: safeBody.agent, role: safeBody.role, capabilities: safeBody.capabilities, token: agentTokenOf(req, safeBody) }));
309
+ // Agent-token rotation over plain REST (v0.5.7): until this route the
310
+ // only rotation path was the MCP tool — a pure-REST agent whose token
311
+ // leaked had no way to rotate it. Same semantics as the core/MCP path:
312
+ // the current token authorizes (on an enforced bus), the new token is
313
+ // returned once, and the old hash is replaced before the reply, so the
314
+ // old token is dead the moment this returns.
315
+ if (req.method === 'POST' && url.pathname === '/api/agents/rotate-token') return send(200, await bus.rotateAgentToken({ name: safeBody.agent, token: agentTokenOf(req, safeBody) }));
189
316
  if (req.method === 'POST' && url.pathname === '/api/messages') return send(200, await bus.sendMessage({ from: safeBody.agent, to: safeBody.to ?? '*', body: safeBody.body, channel: safeBody.channel ?? 'general', threadId: safeBody.threadId ?? null, token: agentTokenOf(req, safeBody) }));
190
317
  if (req.method === 'GET' && url.pathname === '/api/inbox') return send(200, { messages: await bus.inbox(q.agent, { unreadOnly: q.unread === '1', limit: Number(q.limit || 50), token: agentTokenOf(req, {}, q) }) });
191
318
  if (req.method === 'POST' && url.pathname === '/api/heartbeat') return send(200, await bus.heartbeat({ name: safeBody.agent, token: agentTokenOf(req, safeBody) }));
@@ -31,13 +31,18 @@
31
31
  * route matching (regex), workspace resolution, and the registry itself
32
32
  * when loaded: ids become file names inside the data dir, so a planted or
33
33
  * hand-edited registry entry must never turn one into a path outside it.
34
+ * - v0.5.7: the credential lifecycle was audited end to end (rotation is
35
+ * immediate and is now reachable over plain REST for agent tokens, not
36
+ * just MCP) and the serverless pack was realigned with this server —
37
+ * enforcement default, admin-key floor, rotate/export/import. See
38
+ * SECURITY.md ("Credential lifecycle") and docs/hosted.md round 6.
34
39
  */
35
40
  import http from 'node:http';
36
41
  import fs from 'node:fs';
37
42
  import path from 'node:path';
38
43
  import crypto from 'node:crypto';
39
44
  import { createBus, FileStore, GHOSTBUS_VERSION, validateSnapshot } from './core.mjs';
40
- import { createBusHandler, keyMatches, hashMatches, readJsonBody, MAX_BODY_BYTES, HTML_SECURITY_HEADERS } from './handler.mjs';
45
+ import { createBusHandler, keyMatches, hashMatches, readJsonBody, MAX_BODY_BYTES, HTML_SECURITY_HEADERS, applyServerTimeouts, SERVER_TIMEOUT_DEFAULTS } from './handler.mjs';
41
46
 
42
47
  const argv = process.argv.slice(2);
43
48
  const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
@@ -145,6 +150,9 @@ const authThrottled = (ip) => {
145
150
  };
146
151
  const noteAuthFail = (ip) => {
147
152
  const t = Date.now();
153
+ // Opportunistic sweep (v0.5.6): one entry per attacking IP per window
154
+ // would otherwise accumulate forever on a long-lived server.
155
+ if (authFails.size > 4096) { for (const [k, v] of authFails) if (t - v.start > AUTH_FAIL_WINDOW_MS) authFails.delete(k); }
148
156
  let b = authFails.get(ip);
149
157
  if (!b || t - b.start > AUTH_FAIL_WINDOW_MS) { b = { start: t, count: 0 }; authFails.set(ip, b); }
150
158
  b.count += 1;
@@ -316,6 +324,15 @@ const server = http.createServer(async (req, res) => {
316
324
  }
317
325
  });
318
326
 
327
+ // Explicit server timeouts (v0.5.6): slow-loris header/body deadlines, set
328
+ // here rather than inherited from Node defaults — see applyServerTimeouts
329
+ // in handler.mjs for the scoping (long-lived streams are never bounded).
330
+ const timeoutOpt = (envName, def) => { const n = Number(process.env[envName]); return Number.isFinite(n) && n > 0 ? Math.floor(n) : def; };
331
+ applyServerTimeouts(server, {
332
+ headersMs: timeoutOpt('GHOSTBUS_HEADERS_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.headersMs),
333
+ requestMs: timeoutOpt('GHOSTBUS_REQUEST_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.requestMs),
334
+ });
335
+
319
336
  server.listen(port, () => {
320
337
  process.stderr.write(`ghostbus HOSTED v${GHOSTBUS_VERSION} · http://127.0.0.1:${port} · data=${dataDir} · workspaces=${Object.keys(loadRegistry().workspaces).length} · agentTokens=${REQUIRE_TOKENS ? 'enforced' : 'issued, not enforced'}\n`);
321
338
  });
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import http from 'node:http';
16
16
  import { createBus, MemoryStore, FileStore, GHOSTBUS_VERSION } from './core.mjs';
17
- import { createBusHandler } from './handler.mjs';
17
+ import { createBusHandler, applyServerTimeouts, SERVER_TIMEOUT_DEFAULTS } from './handler.mjs';
18
18
 
19
19
  const argv = process.argv.slice(2);
20
20
  const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
@@ -32,6 +32,14 @@ const server = http.createServer((req, res) => {
32
32
  const url = new URL(req.url, 'http://localhost');
33
33
  handle(req, res, url).catch((e) => { process.stderr.write(`ghostbus relay: internal error: ${(e && e.stack) || e}\n`); try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal error' })); } catch {} });
34
34
  });
35
+ // Explicit server timeouts (v0.5.6): slow-loris header/body deadlines, set
36
+ // here rather than inherited from Node defaults — see applyServerTimeouts
37
+ // in handler.mjs for the scoping (long-lived streams are never bounded).
38
+ const timeoutOpt = (envName, def) => { const n = Number(process.env[envName]); return Number.isFinite(n) && n > 0 ? Math.floor(n) : def; };
39
+ applyServerTimeouts(server, {
40
+ headersMs: timeoutOpt('GHOSTBUS_HEADERS_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.headersMs),
41
+ requestMs: timeoutOpt('GHOSTBUS_REQUEST_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.requestMs),
42
+ });
35
43
  server.listen(port, () => {
36
44
  process.stderr.write(`ghostbus http relay v${GHOSTBUS_VERSION} · http://127.0.0.1:${port} · store=${storePath} · auth=${KEY ? 'workspace key required' : 'open (set GHOSTBUS_KEY)'}\n`);
37
45
  // Node's listen() with no host binds ALL network interfaces, not just