ghostbus 0.5.0 → 0.5.2

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2 — 2026-10-11
4
+
5
+ Security hardening — the pre-pilot pass. A cold source review scored GhostBus 7.4/10 with security maturity its lowest mark (5.5/10, "authentication enforcement and deployment hardening need attention"). That was the right call to answer before any hosted workspace carries a stranger's work — or their money:
6
+
7
+ - **Hosted: per-agent token enforcement is now the DEFAULT** (`src/hosted-server.mjs`) — every workspace bus is created with enforcement on, no env var needed. The only opt-out is an explicit `GHOSTBUS_REQUIRE_TOKENS=0`, and using it prints a loud three-line stderr warning at startup (with enforcement off, anyone holding a workspace key can act under any agent name). Header comment updated to match.
8
+ - **Hosted: admin-key floor** — the hosted server already refused to start without `GHOSTBUS_ADMIN_KEY`; it now also refuses keys shorter than 16 characters (exit 2). The admin key is operator root (creates/deletes workspaces, master key inside all of them) and gets a floor to match. Storage/rotation guidance (root-only file, chmod 600, rotate by restart) is documented in `docs/hosted.md`.
9
+ - **Single relay: exposure is never silent** (`src/http-server.mjs`) — Node's `listen()` binds all network interfaces, not just loopback. Starting the relay without `GHOSTBUS_KEY` now prints a loud warning saying exactly that; with a key but token enforcement off, a one-line warning names the impersonation trade-off. Semantics unchanged: local trust is still the relay's default model.
10
+ - **Docs** — new "Security & deployment hardening" section in `docs/hosted.md`: enforcement default + opt-out, HTTPS REQUIRED for any public deployment (reverse proxy or a *named* Cloudflare Tunnel; a quick tunnel is explicitly not for paid use), admin-key storage/rotation, per-workspace key handling, scheduled export backups. README hosted section points at it.
11
+ - **Tests** — the two existing hosted checks now present the tokens issued at registration (they exercise the enforced path, as real clients now must), and four new checks cover the pass: default-on enforcement over real HTTP (no token → 400, wrong token → 400, issued token → 200, header and body forms), the `=0` opt-out + its startup warning, the short-admin-key refusal, and the relay's no-key warning. All four fail against the 0.5.1 servers (47 passed, 4 failed) and pass with the change.
12
+ - **Scope boundary, honestly** — what did NOT change: core bus behavior, the local stdio/CLI trust model (tokens stay opt-in there via `GHOSTBUS_REQUIRE_TOKENS=1`), the single relay's opt-in posture beyond the new warnings, storage (memory/JSON file; a networked multi-writer store is still future work, not faked here), and the serverless pack (its `GHOSTBUS_REQUIRE_TOKENS` env remains opt-in). The hosted `open` surfaces — board page, `/health`, `/probe` — were audited, not changed: every workspace is always created with a key and no data route bypasses it; the three open paths serve the static board shell, liveness, and counts-only probe data, the same posture as the single relay, and are documented as such.
13
+ - 51 checks in the main suite (was 47) + 6 serverless, all passing; both demos green.
14
+
15
+ ## 0.5.1 — 2026-10-09
16
+
17
+ Reliability fix — the operation-level race above the v0.4.1 FileStore fix:
18
+
19
+ - **Per-Bus mutation mutex** — every public operation on a `GhostBus` (register/heartbeat, message send, task create/claim/complete/comment, file put/delete, capsule/context writes, token ops, snapshot restore, and reads) now runs its whole load → mutate → save cycle inside a per-instance promise-chain mutex (`_serialized` in src/core.mjs), made reentrant via AsyncLocalStorage so operations that call other operations (updateCapsule → getCapsule, restore → status) can't self-deadlock. Before this fix, concurrent calls on ONE bus instance interleaved their cycles against the shared store and silently lost each other's updates: a 4-writer × 12-round probe through the public Bus API ended with one writer's file stuck at an early round and another's missing entirely, with zero errors reported. A throwing operation still releases the chain (try/finally), so one failure can never wedge the bus. Public API and behavior are unchanged.
20
+ - **Scope boundary, honestly** — the mutex serializes operations within one Bus instance in one process. It is not a distributed lock: separate processes sharing one store file still coordinate only at single-operation granularity (each op remains an atomic read-modify-write with the v0.4.1 temp-file fix), and the serverless pack (deploy/netlify) builds a fresh bus per request over an external KV, so cross-invocation interleavings there remain bounded by the KV layer, not by this mutex.
21
+ - **Regression tests in the main suite** — three new checks: concurrent writers lose no updates (4 writers × 12 rounds, verified on disk via a fresh instance), concurrent mixed ops (tasks + messages + files at once) all land with unique task ids, and a throwing operation releases the mutex (with a wedge timeout). All three fail on the pre-fix core (44 passed, 3 failed) and pass with the fix.
22
+ - 47 checks in the main suite (was 44) + 6 serverless, all passing; both demos green.
23
+
3
24
  ## 0.5.0 — 2026-10-08
4
25
 
5
26
  The operator release — running a hosted bus day-to-day:
package/PLAN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GhostBus — Agent-to-Agent Message Bus / Shared Workspace MCP
2
2
 
3
- **Status:** PUBLISHED — v0.5.0 live Oct 8, 2026 (github.com/littlestjames82-sys/ghostbus). v0.5.0 is the operator release: hosted workspace key rotation (old key dies immediately), full-state export/import for backup + host migration (fresh key on import, keys never inside exports), core snapshot/restore + CLI export/import, and the FileStore race regression test in the main suite — 44/44 + 6/6. v0.4.0 added the hosted multi-workspace server + Docker + Netlify serverless pack (41/41 + 6/6 tests). v0.4.1 fixed the FileStore temp-file race (unique temp per save + serialized saves; stress 0 failures vs 59/60 failing before), shipped the docker-compose + one-shot VPS setup assets, and passed a full hosted smoke test. Still not deployed to a public host — that waits on Ryan's VPS choice. npm: name `ghostbus` verified available; publish rides the Trusted-Publishing workflow after Ryan's one-time npm setup (see LAUNCH.md).
3
+ **Status:** PUBLISHED — v0.5.1 live Oct 9, 2026 (github.com/littlestjames82-sys/ghostbus). v0.5.1 is a reliability patch: a per-Bus mutation mutex in the core serializes every operation's load → mutate → save cycle, closing the silent lost-update race under concurrent writers on one bus instance — 47/47 + 6/6 with three new concurrency regression checks (all red on the pre-fix core). v0.5.0 is the operator release: hosted workspace key rotation (old key dies immediately), full-state export/import for backup + host migration (fresh key on import, keys never inside exports), core snapshot/restore + CLI export/import, and the FileStore race regression test in the main suite — 44/44 + 6/6. v0.4.0 added the hosted multi-workspace server + Docker + Netlify serverless pack (41/41 + 6/6 tests). v0.4.1 fixed the FileStore temp-file race (unique temp per save + serialized saves; stress 0 failures vs 59/60 failing before), shipped the docker-compose + one-shot VPS setup assets, and passed a full hosted smoke test. Still not deployed to a public host — that waits on Ryan's VPS choice. npm: name `ghostbus` verified available; publish rides the Trusted-Publishing workflow after Ryan's one-time npm setup (see LAUNCH.md).
4
4
  **Origin:** Generalizes Ghost Bridge (Ryan's private Muse ↔ Antigravity relay, Level 3, live since Oct 6) into a standalone product any agents can join. Ghost Bridge stays private; GhostBus is the clean, general version — same proven patterns, no Ryan-specific context, no studio data in the box.
5
5
 
6
6
  ## The problem (one line)
package/README.md CHANGED
@@ -6,12 +6,24 @@ Agents today work alone in separate windows — when two of them need to coopera
6
6
 
7
7
  Zero dependencies. Node 18+. MIT.
8
8
 
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install ghostbus
13
+ ```
14
+
15
+ GhostBus is published on npm as [`ghostbus`](https://www.npmjs.com/package/ghostbus) (v0.5.0) — zero dependencies, Node 18+. You can also run it straight from a clone of this repository, as the quick start below shows.
16
+
17
+ ## Status
18
+
19
+ Built and in use: 29 MCP tools across presence, messages, tasks, a shared workspace, and a provenance log; local stdio servers, an HTTP relay, and a hosted multi-workspace server are all included in this repo, along with a live web board, SSE event pushes, per-agent tokens, and a terminal CLI. See `CHANGELOG.md` for release history and `docs/` for the client setup and hosted-operator guides.
20
+
9
21
  ## Quick start
10
22
 
11
23
  ```bash
12
- git clone <this repo> ghostbus && cd ghostbus
24
+ git clone https://github.com/littlestjames82-sys/ghostbus.git ghostbus && cd ghostbus
13
25
  node examples/two-agent-demo.mjs # watch a planner and a builder cooperate
14
- node test.mjs # 40 checks: core, tokens, capsules, dependencies, sync, persistence, MCP stdio, HTTP, SSE, CLI
26
+ node test.mjs # 51 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security
15
27
  node examples/three-agent-team.mjs # planner + builder + reviewer, no human relaying
16
28
  ```
17
29
 
@@ -46,6 +58,8 @@ GHOSTBUS_ADMIN_KEY=pick-a-long-secret node src/hosted-server.mjs --port 8388
46
58
 
47
59
  One process serves many **isolated** workspaces under `/w/<id>/` — each with its own key (issued once at creation, hash-only in the registry), board, REST, MCP, SSE and probe. The admin API creates/lists/deletes workspaces, **rotates keys** (old key dies instantly), and **exports/imports** full workspace state for backup and host migration — imports always mint a fresh key. A Dockerfile and a serverless Netlify/Blobs pack are included. Operator guide: `docs/hosted.md`.
48
60
 
61
+ Hosted mode is the deployment that serves strangers, so it is hardened by default (v0.5.2): per-agent tokens are **enforced** in every workspace (`GHOSTBUS_REQUIRE_TOKENS=0` opts out, loudly), the admin key must be at least 16 characters, and any public deployment belongs behind HTTPS. Details: the "Security & deployment hardening" section of `docs/hosted.md`.
62
+
49
63
  Same tools over `POST /mcp`, plus a REST mirror under `/api/*`. `/health` and a counts-only `/probe` stay open for monitoring and wake hooks. A **live web board** is served at `/`, and agents can subscribe to **pushed events** over SSE (`GET /api/stream?agent=<name>`) instead of polling. Set `GHOSTBUS_REQUIRE_TOKENS=1` to enforce the per-agent tokens issued at registration. There's also a terminal CLI (`src/cli.mjs`) and full client setup docs in `docs/client-setup.md`.
50
64
 
51
65
  ## The 29 tools
@@ -77,6 +91,19 @@ Same tools over `POST /mcp`, plus a REST mirror under `/api/*`. `/health` and a
77
91
 
78
92
  GhostBus generalizes a private relay that has run in production for one studio's own agents since Oct 2026 (task handoffs, shared project capsules, a live board). This is the clean, general version of that proven pattern. Built by Ghost Developer Studio.
79
93
 
94
+ ## From Ghost Developer Studio
95
+
96
+ GhostBus is one part of the studio's agent-infrastructure family:
97
+
98
+ - **Visual roadmap board** — where every studio product stands, in one picture: [ghost-roadmaps](https://github.com/littlestjames82-sys/ghost-roadmaps)
99
+ - **GhostGuard** — the governance layer for autonomous systems: [ghostguard](https://github.com/littlestjames82-sys/ghostguard)
100
+ - **Ghost Bridge** — MCP bridge + self-hostable relay for handing tasks to a personal agent, with scoped keys and approvals: [ghost-bridge](https://github.com/littlestjames82-sys/ghost-bridge)
101
+ - **Ghost Hands** — governed, recorded, replayable agent hands for the web: [ghost-hands](https://github.com/littlestjames82-sys/ghost-hands)
102
+ - **Agent Seatbelt** — deterministic guardrails that gate what an AI coding agent does before it does it: [agent-seatbelt](https://github.com/littlestjames82-sys/agent-seatbelt)
103
+ - **Ghost Developer Studio storefront** — [storefront](https://github.com/littlestjames82-sys/storefront)
104
+
105
+ Built in public by Ryan Cotten / Ghost Developer Studio.
106
+
80
107
  ## License
81
108
 
82
109
  MIT — see `LICENSE`.
package/docs/hosted.md CHANGED
@@ -53,20 +53,65 @@ Everything from the single-workspace relay works under the prefix:
53
53
 
54
54
  - Board: `https://host/w/acme/` (asks for the workspace key in-page)
55
55
  - REST: `https://host/w/acme/api/*` with header `x-bus-key: <workspace key>`
56
- - MCP: `POST https://host/w/acme/mcp` (same header; plus `x-agent-token` when token mode is on)
56
+ - MCP: `POST https://host/w/acme/mcp` (same header; plus `x-agent-token` — per-agent token enforcement is ON by default in hosted mode, see below)
57
57
  - Probe (open, counts only): `GET https://host/w/acme/probe`
58
58
  - Push: SSE at `/w/acme/api/stream?agent=…`, long-poll at `/w/acme/api/wait?agent=…`
59
59
 
60
60
  The admin key also works as a master key inside any workspace — treat it like root: operators only, never hand it to agents.
61
61
 
62
- Set `GHOSTBUS_REQUIRE_TOKENS=1` to enforce per-agent tokens in every workspace, exactly as on the single relay.
62
+ ## Security & deployment hardening
63
+
64
+ The hosted server is the one GhostBus deployment that serves people other than
65
+ the operator, so its defaults are stricter than the local relay's (v0.5.2):
66
+
67
+ - **Per-agent tokens are enforced by default** in every workspace: an agent
68
+ registers once, receives its token once, and must present it
69
+ (`x-agent-token` header or `token` field) for every act. A stolen agent
70
+ *name* alone cannot impersonate it. `GHOSTBUS_REQUIRE_TOKENS=0` is the only
71
+ opt-out — the server prints a loud startup warning when it is used, because
72
+ with enforcement off, anyone holding a workspace key can act under any
73
+ agent name. Don't disable it for a public or paid deployment.
74
+ - **HTTPS is REQUIRED for any public deployment.** The server itself speaks
75
+ plain HTTP; terminate TLS in front of it — a reverse proxy with automatic
76
+ certificates (Caddy, nginx + certbot) or a **named** Cloudflare Tunnel with
77
+ a stable hostname. A Cloudflare *quick* tunnel (`trycloudflare.com`) is
78
+ fine for a demo and **not** for paid use: its URL changes on restart and it
79
+ carries no uptime expectation. Never expose the plain-HTTP port directly.
80
+ - **Admin key hygiene.** The admin key is operator root: it creates and
81
+ deletes workspaces, rotates their keys, exports their state, and works as a
82
+ master key inside every workspace. The server refuses to start with a key
83
+ shorter than 16 characters; use 32+ random hex. Store it in a root-only
84
+ file, never in the repo, a ticket, or a chat log:
85
+ ```bash
86
+ head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > /root/ghostbus-admin-key
87
+ chmod 600 /root/ghostbus-admin-key
88
+ GHOSTBUS_ADMIN_KEY=$(cat /root/ghostbus-admin-key) node src/hosted-server.mjs
89
+ ```
90
+ (The shipped `deploy/docker/vps-setup.sh` already generates it this way.)
91
+ To rotate the admin key, restart the server with a new one — existing
92
+ workspace keys are unaffected (they live in the registry as hashes, not
93
+ derived from the admin key).
94
+ - **Per-workspace keys.** Each workspace key is shown exactly once, at
95
+ creation (or import); only its SHA-256 is stored. Hand it to that
96
+ workspace's owner over a secure channel, never publish it, and if one
97
+ leaks, rotate it — `POST /api/workspaces/<id>/rotate-key` kills the old
98
+ key immediately (see above).
99
+ - **Backups are a schedule, not a feature.** The data dir *is* the state
100
+ (`registry.json` + one `<id>.json` per workspace), so back it up on a
101
+ timer, and per-workspace exports give you portable copies:
102
+ ```bash
103
+ # nightly, e.g. from cron — plus a plain copy of the data dir
104
+ curl -s localhost:8388/api/workspaces/acme/export -H 'x-bus-key: <admin>' > backups/acme-$(date +%F).json
105
+ ```
106
+ Drill a restore (delete + import) before you need one; a restore lands in
107
+ the workspace's provenance log as `workspace.restore`.
63
108
 
64
109
  ## Option B — serverless on Netlify (Blobs)
65
110
 
66
111
  `deploy/netlify/` contains a Netlify Function implementing the same multi-workspace semantics over Netlify Blobs (strong consistency):
67
112
 
68
113
  1. Create a Netlify site using `deploy/netlify/` as the site root (or copy `functions/` + `netlify.toml` into a site).
69
- 2. Set site env: `GHOSTBUS_ADMIN_KEY` (required), optionally `GHOSTBUS_REQUIRE_TOKENS=1`.
114
+ 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.)
70
115
  3. Add `@netlify/blobs` to that site's dependencies (`npm i @netlify/blobs` in the site root) and deploy.
71
116
 
72
117
  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.
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.0"
47
+ "version": "0.5.2"
48
48
  }
package/src/core.mjs CHANGED
@@ -12,8 +12,9 @@
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import crypto from 'node:crypto';
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
15
16
 
16
- export const GHOSTBUS_VERSION = '0.5.0';
17
+ export const GHOSTBUS_VERSION = '0.5.2';
17
18
  const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
18
19
  export const TASK_STATUSES = ['queued', 'claimed', 'needs-approval', 'done', 'cancelled'];
19
20
  const CLAIM_LEASE_MS = 15 * 60 * 1000;
@@ -82,12 +83,44 @@ export class GhostBus {
82
83
  // Registration issues the token once; only its sha256 is stored.
83
84
  this.requireTokens = !!opts.requireTokens;
84
85
  this.listeners = new Set(); // live event subscribers (SSE relay)
86
+ // Mutation mutex (v0.5.1): a promise chain serializing every public
87
+ // operation's load -> mutate -> save cycle on THIS instance. Without it,
88
+ // concurrent calls interleave their cycles against the shared store and
89
+ // silently lose each other's updates (one op's reload discards another's
90
+ // not-yet-saved state). The AsyncLocalStorage marker makes the chain
91
+ // reentrant: public methods that call other public methods (updateCapsule
92
+ // -> getCapsule, restore -> status, heartbeat -> listAgents) run inline
93
+ // instead of deadlocking on their own queue.
94
+ this._chain = Promise.resolve();
95
+ this._als = new AsyncLocalStorage();
96
+ }
97
+
98
+ /**
99
+ * Run `fn` inside this bus's serialized section. Callers queue FIFO on the
100
+ * promise chain; a throwing operation still releases the chain (finally),
101
+ * so one failure can never wedge the bus. Scope: per Bus instance, in this
102
+ * process — it cannot span processes sharing one store file (each op there
103
+ * is still a single atomic read-modify-write) nor serverless invocations.
104
+ */
105
+ async _serialized(fn) {
106
+ if (this._als.getStore() === this) return fn();
107
+ const prev = this._chain;
108
+ let release;
109
+ const gate = new Promise((resolve) => { release = resolve; });
110
+ this._chain = prev.then(() => gate);
111
+ await prev;
112
+ try {
113
+ return await this._als.run(this, fn);
114
+ } finally {
115
+ release();
116
+ }
85
117
  }
86
118
 
87
119
  /** Subscribe to provenance events as they happen. Returns an unsubscribe fn. */
88
120
  onEvent(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
89
121
 
90
- async init() {
122
+ async init() { return this._serialized(() => this._init()); }
123
+ async _init() {
91
124
  // Shared (file) stores are re-read on every call: several processes
92
125
  // (stdio servers, the CLI, a relay) can then share ONE workspace file
93
126
  // and actually see each other's writes. Each mutation is a fresh
@@ -136,7 +169,8 @@ export class GhostBus {
136
169
  }
137
170
 
138
171
  // ---------- agents ----------
139
- async registerAgent({ name, role = '', capabilities = [], token = null }) {
172
+ async registerAgent(...args) { return this._serialized(() => this._registerAgent(...args)); }
173
+ async _registerAgent({ name, role = '', capabilities = [], token = null }) {
140
174
  await this.init();
141
175
  if (!name || typeof name !== 'string') throw new Error('agent name required');
142
176
  name = name.trim();
@@ -163,7 +197,8 @@ export class GhostBus {
163
197
  return { agent: this._publicAgent(agent), rejoined: false, token: rawToken, tokenNote: 'Store this token — it is shown once and only its hash is kept. Present it as `token` (tools) or x-agent-token (HTTP) when the bus runs with requireTokens.' };
164
198
  }
165
199
 
166
- async rotateAgentToken({ name, token = null }) {
200
+ async rotateAgentToken(...args) { return this._serialized(() => this._rotateAgentToken(...args)); }
201
+ async _rotateAgentToken({ name, token = null }) {
167
202
  await this.init();
168
203
  const a = this._requireAgent(name, token);
169
204
  const rawToken = crypto.randomBytes(24).toString('hex');
@@ -173,16 +208,19 @@ export class GhostBus {
173
208
  return { agent: this._publicAgent(a), token: rawToken, tokenNote: 'New token — the old one no longer works. Shown once.' };
174
209
  }
175
210
 
176
- async heartbeat({ name, token = null }) {
211
+ async heartbeat(...args) { return this._serialized(() => this._heartbeat(...args)); }
212
+ async _heartbeat({ name, token = null }) {
177
213
  await this.init();
178
214
  const a = this._requireAgent(name, token);
179
215
  return { agent: this._publicAgent(a), agents: await this.listAgents() };
180
216
  }
181
217
 
182
- async listAgents() { await this.init(); return this.state.agents.map(a => this._publicAgent(a)); }
218
+ async listAgents(...args) { return this._serialized(() => this._listAgents(...args)); }
219
+ async _listAgents() { await this.init(); return this.state.agents.map(a => this._publicAgent(a)); }
183
220
 
184
221
  // ---------- messages ----------
185
- async sendMessage({ from, to = '*', body, channel = 'general', threadId = null, token = null }) {
222
+ async sendMessage(...args) { return this._serialized(() => this._sendMessage(...args)); }
223
+ async _sendMessage({ from, to = '*', body, channel = 'general', threadId = null, token = null }) {
186
224
  await this.init();
187
225
  this._requireAgent(from, token);
188
226
  if (to !== '*') this._existsAgent(to);
@@ -201,7 +239,8 @@ export class GhostBus {
201
239
  }
202
240
 
203
241
  /** Messages visible to `agent`: addressed to it, broadcast, or sent by it. Unread-first filtering optional. */
204
- async inbox(agentName, { unreadOnly = false, limit = 50, markRead = true, token = null } = {}) {
242
+ async inbox(...args) { return this._serialized(() => this._inbox(...args)); }
243
+ async _inbox(agentName, { unreadOnly = false, limit = 50, markRead = true, token = null } = {}) {
205
244
  await this.init();
206
245
  this._requireAgent(agentName, token);
207
246
  let msgs = this.state.messages.filter(m =>
@@ -216,7 +255,8 @@ export class GhostBus {
216
255
  return clone(msgs);
217
256
  }
218
257
 
219
- async thread(id) {
258
+ async thread(...args) { return this._serialized(() => this._thread(...args)); }
259
+ async _thread(id) {
220
260
  await this.init();
221
261
  const root = this.state.messages.find(m => m.id === Number(id));
222
262
  if (!root) throw new Error(`message not found: ${id}`);
@@ -224,7 +264,8 @@ export class GhostBus {
224
264
  }
225
265
 
226
266
  // ---------- tasks ----------
227
- async createTask({ from, title, body = '', assignee = null, priority = 'normal', needsApproval = false, blockedBy = [], token = null }) {
267
+ async createTask(...args) { return this._serialized(() => this._createTask(...args)); }
268
+ async _createTask({ from, title, body = '', assignee = null, priority = 'normal', needsApproval = false, blockedBy = [], token = null }) {
228
269
  await this.init();
229
270
  this._requireAgent(from, token);
230
271
  if (assignee) this._existsAgent(assignee);
@@ -257,7 +298,8 @@ export class GhostBus {
257
298
  }
258
299
  }
259
300
 
260
- async listTasks({ status = null, assignee = null } = {}) {
301
+ async listTasks(...args) { return this._serialized(() => this._listTasks(...args)); }
302
+ async _listTasks({ status = null, assignee = null } = {}) {
261
303
  await this.init();
262
304
  this._expireClaims(); await this._save();
263
305
  let tasks = this.state.tasks;
@@ -266,7 +308,8 @@ export class GhostBus {
266
308
  return clone(tasks.sort((a, b) => b.id - a.id));
267
309
  }
268
310
 
269
- async approveTask(id, { by, token = null }) {
311
+ async approveTask(...args) { return this._serialized(() => this._approveTask(...args)); }
312
+ async _approveTask(id, { by, token = null }) {
270
313
  await this.init();
271
314
  this._requireAgent(by, token);
272
315
  const task = this._task(id);
@@ -279,7 +322,8 @@ export class GhostBus {
279
322
  }
280
323
 
281
324
  /** Claim is exclusive: a second claim while a lease is live fails loudly. */
282
- async claimTask(id, { by, token = null }) {
325
+ async claimTask(...args) { return this._serialized(() => this._claimTask(...args)); }
326
+ async _claimTask(id, { by, token = null }) {
283
327
  await this.init();
284
328
  this._requireAgent(by, token);
285
329
  this._expireClaims();
@@ -298,7 +342,8 @@ export class GhostBus {
298
342
  return clone(task);
299
343
  }
300
344
 
301
- async completeTask(id, { by, result = '', token = null }) {
345
+ async completeTask(...args) { return this._serialized(() => this._completeTask(...args)); }
346
+ async _completeTask(id, { by, result = '', token = null }) {
302
347
  await this.init();
303
348
  this._requireAgent(by, token);
304
349
  const task = this._task(id);
@@ -311,7 +356,8 @@ export class GhostBus {
311
356
  return clone(task);
312
357
  }
313
358
 
314
- async cancelTask(id, { by, token = null }) {
359
+ async cancelTask(...args) { return this._serialized(() => this._cancelTask(...args)); }
360
+ async _cancelTask(id, { by, token = null }) {
315
361
  await this.init();
316
362
  this._requireAgent(by, token);
317
363
  const task = this._task(id);
@@ -330,7 +376,8 @@ export class GhostBus {
330
376
  }
331
377
 
332
378
  // ---------- shared workspace: files + context + board ----------
333
- async putFile({ by, path: p, text, token = null }) {
379
+ async putFile(...args) { return this._serialized(() => this._putFile(...args)); }
380
+ async _putFile({ by, path: p, text, token = null }) {
334
381
  await this.init();
335
382
  this._requireAgent(by, token);
336
383
  if (!p || typeof text !== 'string') throw new Error('path and text required');
@@ -343,7 +390,8 @@ export class GhostBus {
343
390
  return { path: p, bytes: Buffer.byteLength(text) };
344
391
  }
345
392
 
346
- async getFile(p, { withHistory = false } = {}) {
393
+ async getFile(...args) { return this._serialized(() => this._getFile(...args)); }
394
+ async _getFile(p, { withHistory = false } = {}) {
347
395
  await this.init();
348
396
  const f = this.state.files[p];
349
397
  if (!f) throw new Error(`file not found: ${p}`);
@@ -351,19 +399,22 @@ export class GhostBus {
351
399
  return { path: p, ...clone(rest), versions: (history || []).length + 1, ...(withHistory ? { history: clone(history || []) } : {}) };
352
400
  }
353
401
 
354
- async fileHistory(p) {
402
+ async fileHistory(...args) { return this._serialized(() => this._fileHistory(...args)); }
403
+ async _fileHistory(p) {
355
404
  await this.init();
356
405
  const f = this.state.files[p];
357
406
  if (!f) throw new Error(`file not found: ${p}`);
358
407
  return { path: p, current: { text: f.text, updatedBy: f.updatedBy, updatedAt: f.updatedAt }, history: clone(f.history || []) };
359
408
  }
360
409
 
361
- async listFiles() {
410
+ async listFiles(...args) { return this._serialized(() => this._listFiles(...args)); }
411
+ async _listFiles() {
362
412
  await this.init();
363
413
  return Object.entries(this.state.files).map(([p, f]) => ({ path: p, bytes: Buffer.byteLength(f.text), updatedBy: f.updatedBy, updatedAt: f.updatedAt }));
364
414
  }
365
415
 
366
- async putContext({ by, context, token = null }) {
416
+ async putContext(...args) { return this._serialized(() => this._putContext(...args)); }
417
+ async _putContext({ by, context, token = null }) {
367
418
  await this.init();
368
419
  this._requireAgent(by, token);
369
420
  if (!context || typeof context !== 'object' || Array.isArray(context)) throw new Error('context must be an object');
@@ -373,13 +424,15 @@ export class GhostBus {
373
424
  return { ok: true };
374
425
  }
375
426
 
376
- async getContext() {
427
+ async getContext(...args) { return this._serialized(() => this._getContext(...args)); }
428
+ async _getContext() {
377
429
  await this.init();
378
430
  if (!this.state.context) throw new Error('no context published yet');
379
431
  return clone(this.state.context);
380
432
  }
381
433
 
382
- async addTaskComment(id, { by, body, token = null }) {
434
+ async addTaskComment(...args) { return this._serialized(() => this._addTaskComment(...args)); }
435
+ async _addTaskComment(id, { by, body, token = null }) {
383
436
  await this.init();
384
437
  this._requireAgent(by, token);
385
438
  if (!body || !String(body).trim()) throw new Error('comment body required');
@@ -394,7 +447,8 @@ export class GhostBus {
394
447
  return clone(task);
395
448
  }
396
449
 
397
- async deleteFile({ by, path: p, token = null }) {
450
+ async deleteFile(...args) { return this._serialized(() => this._deleteFile(...args)); }
451
+ async _deleteFile({ by, path: p, token = null }) {
398
452
  await this.init();
399
453
  this._requireAgent(by, token);
400
454
  if (!this.state.files[p]) throw new Error(`file not found: ${p}`);
@@ -404,7 +458,8 @@ export class GhostBus {
404
458
  return { path: p, deleted: true };
405
459
  }
406
460
 
407
- async listChannels() {
461
+ async listChannels(...args) { return this._serialized(() => this._listChannels(...args)); }
462
+ async _listChannels() {
408
463
  await this.init();
409
464
  const counts = {};
410
465
  for (const m of this.state.messages) counts[m.channel] = (counts[m.channel] || 0) + 1;
@@ -412,7 +467,8 @@ export class GhostBus {
412
467
  }
413
468
 
414
469
  /** Full-text search across messages, tasks, and shared files. */
415
- async search(query, { limit = 20 } = {}) {
470
+ async search(...args) { return this._serialized(() => this._search(...args)); }
471
+ async _search(query, { limit = 20 } = {}) {
416
472
  await this.init();
417
473
  const q = String(query || '').toLowerCase().trim();
418
474
  if (q.length < 2) throw new Error('search query needs at least 2 characters');
@@ -437,7 +493,8 @@ export class GhostBus {
437
493
  }
438
494
 
439
495
  /** Read a project capsule, creating it from the template on first touch when create=true. */
440
- async getCapsule(slug, { create = false, title = '', by = null, token = null } = {}) {
496
+ async getCapsule(...args) { return this._serialized(() => this._getCapsule(...args)); }
497
+ async _getCapsule(slug, { create = false, title = '', by = null, token = null } = {}) {
441
498
  await this.init();
442
499
  const pth = this._capsulePath(slug);
443
500
  if (!this.state.files[pth]) {
@@ -451,7 +508,8 @@ export class GhostBus {
451
508
  }
452
509
 
453
510
  /** Replace one capsule section (State / Decisions / Next) or append to the Session log. */
454
- async updateCapsule(slug, { by, section, text, token = null }) {
511
+ async updateCapsule(...args) { return this._serialized(() => this._updateCapsule(...args)); }
512
+ async _updateCapsule(slug, { by, section, text, token = null }) {
455
513
  await this.init();
456
514
  this._requireAgent(by, token);
457
515
  await this.getCapsule(slug, { create: true, by, token });
@@ -477,13 +535,15 @@ export class GhostBus {
477
535
  return this.getFile(pth);
478
536
  }
479
537
 
480
- async listCapsules() {
538
+ async listCapsules(...args) { return this._serialized(() => this._listCapsules(...args)); }
539
+ async _listCapsules() {
481
540
  await this.init();
482
541
  return Object.keys(this.state.files).filter(p => p.startsWith('capsules/') && p.endsWith('.md')).map(p => p.slice('capsules/'.length, -3)).sort();
483
542
  }
484
543
 
485
544
  /** A human-readable board rendered from live state — the shared picture at a glance. */
486
- async board() {
545
+ async board(...args) { return this._serialized(() => this._board(...args)); }
546
+ async _board() {
487
547
  await this.init();
488
548
  this._expireClaims();
489
549
  const s = this.state;
@@ -511,18 +571,21 @@ export class GhostBus {
511
571
 
512
572
  /** Catch-up sync: every event after `sinceSeq`, plus the current head seq.
513
573
  * An agent that was offline replays exactly what it missed — nothing more. */
514
- async sync({ sinceSeq = 0, limit = 500 } = {}) {
574
+ async sync(...args) { return this._serialized(() => this._sync(...args)); }
575
+ async _sync({ sinceSeq = 0, limit = 500 } = {}) {
515
576
  await this.init();
516
577
  const evs = this.state.events.filter(e => e.seq > Number(sinceSeq));
517
578
  return { events: clone(evs.slice(0, Math.min(limit, 1000))), headSeq: this.state.seq, truncated: evs.length > Math.min(limit, 1000) };
518
579
  }
519
580
 
520
- async events({ limit = 50 } = {}) {
581
+ async events(...args) { return this._serialized(() => this._events(...args)); }
582
+ async _events({ limit = 50 } = {}) {
521
583
  await this.init();
522
584
  return clone(this.state.events.slice(-Math.min(limit, 500)));
523
585
  }
524
586
 
525
- async status() {
587
+ async status(...args) { return this._serialized(() => this._status(...args)); }
588
+ async _status() {
526
589
  await this.init();
527
590
  this._expireClaims();
528
591
  const s = this.state;
@@ -540,7 +603,8 @@ export class GhostBus {
540
603
  /** Full-state snapshot for backup / migration. The state holds agent token
541
604
  * HASHES only, never plaintext secrets; workspace keys live outside the
542
605
  * state (hosted registry) and are never part of a snapshot. */
543
- async snapshot() {
606
+ async snapshot(...args) { return this._serialized(() => this._snapshot(...args)); }
607
+ async _snapshot() {
544
608
  await this.init();
545
609
  return clone(this.state);
546
610
  }
@@ -549,7 +613,8 @@ export class GhostBus {
549
613
  * The snapshot is shape-validated, the workspace identity is preserved
550
614
  * unless the snapshot carries one, and the restore itself is recorded
551
615
  * in the provenance log. Returns the post-restore status. */
552
- async restore(snapshot, { by = 'operator' } = {}) {
616
+ async restore(...args) { return this._serialized(() => this._restore(...args)); }
617
+ async _restore(snapshot, { by = 'operator' } = {}) {
553
618
  await this.init();
554
619
  const bad = validateSnapshot(snapshot);
555
620
  if (bad) throw Object.assign(new Error(`invalid snapshot: ${bad}`), { code: 'BAD_SNAPSHOT' });
@@ -18,7 +18,11 @@
18
18
  * GET /api/workspaces/<id>/export -> full-state export envelope (backup/migrate)
19
19
  * POST /api/workspaces/import {id?, name?, state|export} -> {id, name, key (once)}
20
20
  * - Registry + one data file per workspace live in the data dir.
21
- * Set GHOSTBUS_REQUIRE_TOKENS=1 to enforce per-agent tokens in all workspaces.
21
+ * - Per-agent tokens are ENFORCED in all workspaces by default (v0.5.2):
22
+ * hosted mode serves strangers, so a stolen agent name must not be enough
23
+ * to act. GHOSTBUS_REQUIRE_TOKENS=0 is the only opt-out and starts with a
24
+ * loud warning. GHOSTBUS_ADMIN_KEY is required and must be at least 16
25
+ * characters — it is the operator root key (see docs/hosted.md).
22
26
  */
23
27
  import http from 'node:http';
24
28
  import fs from 'node:fs';
@@ -32,12 +36,25 @@ const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i
32
36
  const port = Number(argVal('--port') || process.env.GHOSTBUS_PORT || 8388);
33
37
  const dataDir = argVal('--data-dir') || process.env.GHOSTBUS_DATA_DIR || './ghostbus-hosted';
34
38
  const ADMIN_KEY = process.env.GHOSTBUS_ADMIN_KEY || '';
35
- const REQUIRE_TOKENS = process.env.GHOSTBUS_REQUIRE_TOKENS === '1';
39
+ // v0.5.2: enforcement is the hosted DEFAULT. Only an explicit
40
+ // GHOSTBUS_REQUIRE_TOKENS=0 turns it off (anything else, including unset,
41
+ // means on) — the hosted server is the one deployment that serves people
42
+ // other than the operator, so the safe posture cannot be opt-in.
43
+ const REQUIRE_TOKENS = process.env.GHOSTBUS_REQUIRE_TOKENS !== '0';
36
44
 
37
45
  if (!ADMIN_KEY) {
38
46
  process.stderr.write('GHOSTBUS_ADMIN_KEY is required for the hosted server (workspace creation must not be open).\n');
39
47
  process.exit(2);
40
48
  }
49
+ if (ADMIN_KEY.length < 16) {
50
+ process.stderr.write('GHOSTBUS_ADMIN_KEY is too short: use at least 16 characters (32+ random hex recommended). The admin key is the operator root key — it creates/deletes workspaces and acts as a master key inside every one of them.\n');
51
+ process.exit(2);
52
+ }
53
+ if (!REQUIRE_TOKENS) {
54
+ process.stderr.write('WARNING: GHOSTBUS_REQUIRE_TOKENS=0 — per-agent token enforcement is DISABLED in every workspace.\n');
55
+ process.stderr.write('WARNING: anyone holding a workspace key can act under ANY agent name in that workspace (impersonation).\n');
56
+ process.stderr.write('WARNING: hosted mode serves strangers; run with enforcement on unless you have a specific reason not to.\n');
57
+ }
41
58
 
42
59
  fs.mkdirSync(dataDir, { recursive: true });
43
60
  const registryPath = path.join(dataDir, 'registry.json');
@@ -23,8 +23,9 @@ const storePath = argVal('--store') || process.env.GHOSTBUS_STORE || './ghostbus
23
23
  const wsName = argVal('--name') || process.env.GHOSTBUS_WORKSPACE_NAME || 'GhostBus Workspace';
24
24
  const KEY = process.env.GHOSTBUS_KEY || '';
25
25
 
26
+ const REQUIRE_TOKENS = process.env.GHOSTBUS_REQUIRE_TOKENS === '1';
26
27
  const store = storePath === ':memory:' ? new MemoryStore() : new FileStore(storePath);
27
- const bus = await createBus(store, { name: wsName }, { requireTokens: process.env.GHOSTBUS_REQUIRE_TOKENS === '1' });
28
+ const bus = await createBus(store, { name: wsName }, { requireTokens: REQUIRE_TOKENS });
28
29
  const handle = createBusHandler({ id: 'default', bus, key: KEY });
29
30
 
30
31
  const server = http.createServer((req, res) => {
@@ -33,4 +34,14 @@ const server = http.createServer((req, res) => {
33
34
  });
34
35
  server.listen(port, () => {
35
36
  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
+ // Node's listen() with no host binds ALL network interfaces, not just
38
+ // loopback — the 127.0.0.1 above is the local URL, not a bind restriction.
39
+ // Local trust is the relay's default model and stays unchanged, but an
40
+ // unkeyed or tokenless relay must never be network-reachable *silently*.
41
+ if (!KEY) {
42
+ process.stderr.write('WARNING: GHOSTBUS_KEY is not set — this relay is OPEN: anyone who can reach this port can read and write the workspace.\n');
43
+ process.stderr.write('WARNING: the relay listens on all network interfaces, not just localhost. Set GHOSTBUS_KEY (a long secret) before exposing it beyond this machine, or firewall the port.\n');
44
+ } else if (!REQUIRE_TOKENS) {
45
+ process.stderr.write('WARNING: per-agent tokens are not enforced (GHOSTBUS_REQUIRE_TOKENS is not 1): anyone holding the workspace key can act under any agent name. The relay listens on all network interfaces — set GHOSTBUS_REQUIRE_TOKENS=1 if untrusted agents share the key.\n');
46
+ }
36
47
  });