A bad ed25519 pubkey on the creator member (legacy data) made
sealTopicKeyForMember throw, which propagated up through createTopic
and made the WS topic_create handler never send a topic_created
frame. CLI saw a 5s timeout and printed 'topic create failed'.
Wraps the seal call in try/catch — topic creation succeeds even if
no copy gets sealed for the creator. They'll see GET /v1/topics/:name/key
return 404 until they re-seal (or a holder does it for them via
the phase-3 background loop).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire format:
topic_member_key.encrypted_key = base64(
<32-byte sender x25519 pubkey> || crypto_box(topic_key)
)
Embedding sender pubkey inline lets re-sealed copies (carrying a
different sender than the original creator-seal) decode the same
way as creator copies, without an extra schema column or join.
topic.encrypted_key_pubkey stays for backwards-compat metadata
but the wire truth is the inline prefix.
API (phase 3):
GET /v1/topics/:name/pending-seals list members without keys
POST /v1/topics/:name/seal submit a re-sealed copy
POST /v1/messages now accepts bodyVersion (1|2); v2 skips the
regex mention extraction (server can't read v2 ciphertext).
GET /messages + /stream now return bodyVersion per row.
Broker + web mutations updated to use the inline-sender format
when sealing. ensureGeneralTopic (web) also generates topic keys
per the bugfix that landed earlier today; both producers now
share one wire format.
CLI (claudemesh-cli@1.8.0):
+ apps/cli/src/services/crypto/topic-key.ts — fetch/decrypt/encrypt/seal
+ claudemesh topic post <name> <msg> — encrypted REST send (v2)
* claudemesh topic tail <name> — decrypts v2 on render, runs a
30s background re-seal loop for pending joiners
Web client stays on v1 plaintext until phase 3.5 (browser-side
persistent identity in IndexedDB). Mention fan-out from phase 1
already works for both versions, so /v1/notifications keeps
working through the cutover.
Spec at .artifacts/specs/2026-05-02-topic-key-onboarding.md
updated with the implemented inline-sender format and the
phase 3.5 web plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WSSendMessage gains an optional mentions field; the broker forwards
it into appendTopicMessage so WS-driven topic sends get the same
write-time fan-out path as REST POST /v1/messages. v1 messages
(today's plaintext-base64) still fall back to a body regex when the
field is omitted, so existing CLIs aren't broken; v2 ciphertext
clients in phase 3 will populate it.
Also drops the duplicate meshMember import (kept the meshMember-as-
memberTable alias which the rest of the file uses).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 (infra layer) of v0.3.0. Topics now generate a 32-byte
XSalsa20-Poly1305 key on creation; the broker seals one copy via
crypto_box for the topic creator using an ephemeral x25519
sender keypair (whose public half lives on
topic.encrypted_key_pubkey). Topic key plaintext leaves memory
immediately after the creator's seal — the broker can't read it.
Schema 0026:
+ topic.encrypted_key_pubkey (text, nullable for legacy v0.2.0)
+ topic_message.body_version (integer, 1=plaintext / 2=v2 cipher)
+ topic_member_key (id, topic_id, member_id,
encrypted_key, nonce, rotated_at)
API:
+ GET /v1/topics/:name/key — return the calling member's sealed
copy. 404 if no copy exists yet (joined post-creation, no peer
has re-sealed). 409 if the topic is legacy unencrypted.
Open question parked: how new joiners get their sealed copy
without ceding plaintext to the broker. Spec at
.artifacts/specs/2026-05-02-topic-key-onboarding.md picks
member-driven re-seal (Option B). Pending-seals endpoint, seal
POST, and the actual on-the-wire encryption ship in phase 3.
Mention fan-out from phase 1 (notification table) is decoupled
from ciphertext, so /v1/notifications + MentionsSection keep
working unchanged through both phases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of v0.3.0 — replaces the regex-on-decoded-ciphertext scan
in /v1/notifications and the dashboard MentionsSection with reads
from a new mesh.notification table populated at write time.
Schema 0025: mesh.notification (id, mesh_id, topic_id, message_id,
recipient_member_id, sender_member_id, kind, created_at, read_at)
with a unique (message_id, recipient) so a re-fanned message yields
one row per recipient. Backfills existing v0.2.0 messages by
regex-matching the (still-base64-plaintext) bodies — guarded with
a base64 + length check so binary ciphertext doesn't crash the
migration.
Writers (POST /v1/messages + broker appendTopicMessage) now
extract @-mentions from either an explicit `mentions: string[]`
on the request OR a regex over the base64 plaintext (transitional
fallback). Targets are intersected with the mesh roster + capped
at 32 per message. Web chat panel sends the explicit array now so
it keeps working after phase 2 lands.
Readers switch to JOIN-on-notification:
/v1/notifications — table-backed, supports ?unread=1
POST /v1/notifications/read — new, mark by ids or all-up-to
MentionsSection (RSC) — same JOIN, returns readAt for each row
GET /v1/notifications also gains a read_at field per row so a
future bell UI can show unread vs read.
Once per-topic encryption (phase 2) lands, the regex fallback
becomes a no-op for v2 messages — clients MUST send `mentions`,
which they already do.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claudemesh apikey revoke <id> reported success even when the input
didn't match any row in mesh.api_key. The CLI's `apikey list` shows
truncated 8-char prefixes; users naturally paste those; broker did
exact-id match against meshApiKey.id; UPDATE affected 0 rows; old
revokeApiKey returned void so the CLI couldn't tell. Discovered via
end-to-end CLI smoke test against prod (roadmap validation pass).
Three-part fix:
- broker.revokeApiKey now returns
{ status: "revoked"|"not_found"|"not_unique"; id?, matches? } and
accepts either the full id or a unique prefix (>=6 chars). Prefix
matching is bounded to the caller's mesh and only succeeds if
exactly one row matches; ambiguous prefixes return not_unique so
we never silently revoke the wrong key.
- New WSApiKeyRevokeResponseMessage carries the structured status
back to the CLI. Old apikey_revoke_ok type removed before being
released — never shipped to users. The error path is no longer
used for not_found/not_unique cases; the unified response carries
both outcomes.
- CLI's apiKeyRevoke now resolves with { ok, id } | { ok: false,
code, message }. runApiKeyRevoke surfaces the code/message and
exits non-zero on failure (NOT_FOUND for missing, INVALID_ARGS
for ambiguous prefix).
Net effect: pasting `claudemesh apikey revoke vq0fwjdX` now actually
revokes the key whose id starts with vq0fwjdX (or fails loud if 0
or >1 keys match). Verified against prod via the new branch's CLI
binary before commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for v0.2.0 REST + external WS auth.
Bearer tokens stored as SHA-256 hashes; secrets are 256-bit CSPRNG so
Argon2 would waste cost without security gain.
Adds mesh.api_key table, migration 0023 applied manually to prod, and
helpers: createApiKey, listApiKeys, revokeApiKey, verifyApiKey.
Next slices: CLI apikey verbs and REST endpoints in apps/web router.
Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Broker (all need redeploy):
- sweepOrphanMessages: DELETE undelivered message_queue rows older
than 7 days; hourly sweep. Stops unbounded growth when a sender
typos a name (queued forever, never claimed).
- Per-member send rate limit: TokenBucket(60/min, burst 10) keyed on
memberId so reconnecting can't bypass. Surfaces as queued=false,
error='rate_limit: ...'.
- Pre-flight size cap: reject at handleSend if nonce+ciphertext+
targetSpec exceeds env.MAX_MESSAGE_BYTES with a clear error
instead of silent WSS frame-level kill.
- No-recipient reject: for direct sends, check any matching peer
is connected BEFORE queueing. Kills the self-send silent drop
(sending to your own pubkey when you only have one session
connected) and typo-to-offline-peer silent drops.
- WSAckMessage.error field added for structured failure reasons.
CLI:
- ws-client ack handler reads msg.queued and msg.error; surfaces
rate_limit / too_large / no_recipient to callers instead of
returning ok:true with a dummy messageId.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claudemesh MCP server now declares prompts:{} and resources:{} capabilities.
Mesh skills auto-appear as /claudemesh:skill-name slash commands in Claude Code
via prompts/list+get, and as skill://claudemesh/{name} resources for the
upcoming MCP_SKILLS protocol. share_skill accepts optional metadata (when_to_use,
allowed_tools, model, context, agent) stored in the manifest jsonb column.
Change notifications sent on share/remove so Claude Code refreshes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add the foundation for deploying and managing MCP servers on the VPS
broker, with per-peer credential vaults and visibility scopes.
Architecture:
- One Docker container per mesh with a Node supervisor
- Each MCP server runs as a child process with its own stdio pipe
- claudemesh launch installs native MCP entries in ~/.claude.json
- Mid-session deploys fall back to svc__* dynamic tools + list_changed
New components:
- DB: mesh.service + mesh.vault_entry tables, mesh.skill extensions
- Broker: 19 wire protocol types, 11 message handlers, service catalog
in hello_ack with scope filtering, service-manager.ts (775 lines)
- CLI: 13 tool definitions, 12 WS client methods, tool call handlers,
startServiceProxy() for native MCP proxy mode
- Launch: catalog fetch, native MCP entry install, stale sweep, cleanup,
MCP_TIMEOUT=30s, MAX_MCP_OUTPUT_TOKENS=50k
Security: path sanitization on service names, column whitelist on
upsertService, returning()-based delete checks, vault E2E encryption.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds share_skill, get_skill, list_skills, and remove_skill across the full
stack (Drizzle schema, broker CRUD + WS handlers, CLI client methods, MCP
tools). Skills are mesh-scoped, unique by name, and searchable via ILIKE
on name/description/tags.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extend the WS hello handshake with optional peerType, channel, and model
fields so peers can advertise what kind of client they are. The broker
stores these in-memory on PeerConn and returns them (along with cwd) in
the peers_list response. CLI peers command and MCP list_peers tool now
display the new metadata.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- shareContext: adds optional memberId param; when provided, upserts on
(meshId, memberId) instead of (meshId, presenceId) — prevents stale
context rows accumulating on every reconnect. Falls back to presenceId
for legacy/anonymous connections. Also refreshes presenceId on update
so it stays current.
- schema: adds member_id column + unique index context_mesh_member_idx
on mesh.context table; new migration 0013_context-stable-member-key.sql.
- index.ts call site updated to pass conn.memberId as the stable key.
- createStream: replaces SELECT-then-INSERT TOCTOU race with atomic
INSERT ... ON CONFLICT DO NOTHING RETURNING, followed by SELECT on miss.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
broker: expand member groups to ancestor paths at drain time (pull model)
- @flexicar message reaches peers in @flexicar/core, @flexicar/output, etc.
- Resolved at drainForMember — no DB changes, fully backward-compatible
- Any depth: flexicar/team/backend also matches @flexicar and @flexicar/team
cli: wire --role all the way through to session config + env
- Config.role field added
- launch.ts stores role in sessionConfig, passes CLAUDEMESH_ROLE env var
- mcp/server.ts includes role in identity string
- manager.ts auto-joins groups from config on WS connect (--groups flag now works)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The sender exclusion filter (excludeSenderSessionPubkey) was blocking
delivery of ALL messages from the sender, including direct messages
to other peers. Now only excludes on broadcast (target_spec = '*').
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files: MinIO-backed file sharing built into the broker.
share_file for persistent mesh files, send_message(file:) for
ephemeral attachments. Presigned URLs for download, access
tracking per peer.
Broker infra: MinIO in docker-compose, internal network.
HTTP POST /upload endpoint. WS handlers for get_file,
list_files, file_status, delete_file.
Multi-target: send_message(to:) accepts string or array.
Targets deduplicated before delivery.
Targeted views: MCP instructions teach Claude to send
tailored messages per audience instead of generic broadcasts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase B + C + message delivery status.
State: shared key-value store per mesh. set_state pushes changes
to all peers. get_state/list_state for reads. Peers coordinate
through shared facts instead of messages.
Memory: persistent knowledge with full-text search (tsvector).
remember/recall/forget. New peers recall context from past sessions.
message_status: check delivery status with per-recipient detail
(delivered/held/disconnected).
Multicast fix: broadcast and @group messages now push directly to
all connected peers instead of racing through queue drain.
MCP instructions: dynamic identity injection (name, groups, role),
comprehensive tool reference, group coordination guide.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase A of the claudemesh spec. Peers can now join named groups
with roles, and messages route to @group targets.
Broker:
- @group routing in fan-out (matches peer group membership)
- @all alias for broadcast
- join_group/leave_group WS messages + DB persistence
- list_peers returns group metadata
- drainForMember matches @group targetSpecs in SQL
CLI:
- join_group/leave_group MCP tools
- send_message supports @group targets
- list_peers shows group membership
- PeerInfo includes groups array
- Peer name cache for push notifications
Launch:
- --role flag (optional peer role)
- --groups flag (comma-separated, e.g. "frontend:lead,reviewers")
- Interactive wizard for role + groups when flags omitted
- Groups written to session config for broker hello
Spec: SPEC.md added with full v0.2 vision (groups, state, memory)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
excludeSenderMemberId blocked delivery to ALL peers sharing the
same member_id (all sessions from one join). Replaced with
excludeSenderSessionPubkey which only excludes the sender's own
session — peers with different session pubkeys receive correctly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Store sender's sessionPubkey on message_queue at send time.
drainForMember returns COALESCE(sender_session_pubkey, peer_pubkey)
so the recipient gets the correct sender key for decryption.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each WS connection generates its own ed25519 keypair (sessionPubkey)
sent in the hello handshake. The broker stores it on the presence
row and uses it for message routing + list_peers. This gives every
`claudemesh launch` a unique crypto identity without burning invite
uses — member auth stays permanent, session identity is ephemeral.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove ALL Payload imports, withPayload wrapper, and (payload)
routes. Blog index + changelog are now static data arrays.
Blog post at /blog/peer-messaging-claude-code is static TSX.
Payload CMS stays as a dev dependency for future local admin
but has zero presence in the production build.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Direct messages between peers are now end-to-end encrypted. The
broker only ever sees {nonce, ciphertext} — plaintext lives on the
two endpoints.
apps/cli/src/crypto/envelope.ts:
- encryptDirect(message, recipientPubkeyHex, senderSecretKeyHex)
→ {nonce, ciphertext} via crypto_box_easy, 24-byte fresh nonce
- decryptDirect(envelope, senderPubkeyHex, recipientSecretKeyHex)
→ plaintext or null (null on MAC failure / malformed input)
- ed25519 keys (from Step 17) are converted to X25519 on the fly via
crypto_sign_ed25519_{pk,sk}_to_curve25519 — one signing keypair
covers both signing + encryption roles.
BrokerClient.send():
- if targetSpec is a 64-hex pubkey → encrypt via crypto_box
- else (broadcast "*" or channel "#foo") → base64-wrapped plaintext
(shared-key encryption for channels lands in a later step)
InboundPush now carries:
- plaintext: string | null (decrypted body, null if decryption failed
OR it's a non-direct message)
- kind: "direct" | "broadcast" | "channel" | "unknown"
MCP check_messages formatter reads plaintext directly.
side-fixes pulled in during 18a:
- apps/broker/scripts/seed-test-mesh.ts now generates real ed25519
keypairs (the previous "aaaa…" / "bbbb…" fillers weren't valid
curve points, so crypto_sign_ed25519_pk_to_curve25519 rejected
them). Seed output now includes secretKey for each peer.
- apps/broker/src/broker.ts drainForMember wraps the atomic claim in
a CTE + outer ORDER BY so FIFO ordering is SQL-sourced, not
JS-sorted (Postgres microsecond timestamps collapse to the same
Date.getTime() milliseconds otherwise).
- vitest.config.ts fileParallelism: false — test files share
DB state via cleanupAllTestMeshes afterAll, so running them in
parallel caused one file's cleanup to race another's inserts.
- integration/health.test.ts "returns 200" now uses waitFullyHealthy
(a 200-only waiter) instead of waitHealthyOrAny — prevents a race
with the startup DB ping.
verified live:
- apps/cli/scripts/roundtrip.ts (direct A→B): ciphertext in DB is
opaque bytes (not base64-plaintext), decrypted correctly on arrival
- apps/cli/scripts/join-roundtrip.ts (full join → encrypted send):
PASSED
- 48/48 broker tests green
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
drainForMember previously ran SELECT undelivered rows, THEN UPDATE
delivered_at. Two concurrent callers (e.g. WS fan-out on send +
handleHello's own drain for the target) could both SELECT the same
row before either UPDATEd, pushing the same envelope twice.
now: single atomic UPDATE ... FROM member ... WHERE id IN (
SELECT id ... FOR UPDATE SKIP LOCKED
) RETURNING mq.*, m.peer_pubkey AS sender_pubkey.
FOR UPDATE SKIP LOCKED is the key primitive — concurrent callers
each claim DISJOINT sets, so a message can never be drained twice.
Union of all concurrent drains still covers every eligible row.
re-sorts RETURNING rows by created_at client-side (Postgres makes no
FIFO guarantee on the RETURNING clause's output order), and normalizes
created_at to Date since raw-sql results can come back as ISO strings.
regression: tests/dup-delivery.test.ts (4 tests)
- two concurrent drains produce disjoint result sets
- six concurrent drains partition cleanly (20 messages, each drained once)
- subsequent drain after success returns empty
- FIFO ordering preserved within a single drain
48/48 tests pass. Live round-trip no longer logs the double-push.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Single HTTP POST /join the CLI calls after parsing an invite link +
generating an ed25519 keypair client-side. Broker validates the mesh
exists + is not archived, inserts a mesh.member row (or returns the
existing id for idempotency), returns {ok, memberId, alreadyMember?}.
body: {mesh_id, peer_pubkey, display_name, role}
- peer_pubkey must be 64 hex chars (32 bytes)
- role is "admin" | "member"
v0.1.0 trusts the request — no invite-token validation, no ed25519
signature check. Both land in Step 18 alongside libsodium wrapping.
size cap enforced via MAX_MESSAGE_BYTES (shared with hook endpoint).
structured log line per enrollment with truncated pubkey + whether
it was a new member or re-enrolled existing one.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the minimum ops surface area for a production broker without
over-engineering. All new config knobs are env-var driven with sane
defaults.
New modules:
- logger.ts: structured JSON logs (one line, stderr, ready for
Loki/Datadog ingestion without preprocessing)
- metrics.ts: in-process Prometheus counters + gauges, exposed at
GET /metrics. Tracks connections, messages, queue depth, TTL
sweeps, hook requests, DB health.
- rate-limit.ts: token-bucket rate limiter keyed by (pid, cwd).
Applied to POST /hook/set-status at 30/min default.
- db-health.ts: Postgres ping loop with exponential-backoff retry.
GET /health returns 503 while DB is down.
- build-info.ts: version + gitSha (from GIT_SHA env or `git rev-parse`
fallback) + uptime, surfaced on /health.
Behavior changes:
- Connection caps: MAX_CONNECTIONS_PER_MESH (default 100). Exceed →
close(1008, "capacity") + metric increment.
- Message size: MAX_MESSAGE_BYTES (default 65536). WS applies it via
`ws.maxPayload`. Hook POST bodies cap out with 413.
- Structured logs everywhere replacing the old `log()` helper.
- Env validation stricter: DATABASE_URL required + regex-checked for
postgres:// prefix.
New endpoints:
- GET /health → {status, db, version, gitSha, uptime}. 503 if DB down.
- GET /metrics → Prometheus text format.
Verified: 21/21 tests still pass. Hit /health + /metrics live —
gitSha resolves correctly via `git rev-parse --short HEAD` in dev.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds scripts/{seed-test-mesh,peer-a,peer-b,smoke-test}.ts|.sh that
prove an end-to-end message flow works against a real Postgres:
- seed-test-mesh.ts creates user+mesh+2 members with deterministic
hex pubkeys ("aa..aa", "bb..bb"), writes seed JSON to stdout
- peer-a.ts sends hello then a direct "send" message to peer B's
pubkey with fake ciphertext "hello-from-a"
- peer-b.ts sends hello, waits up to 5s for a push, asserts
senderPubkey matches peer A, exits 0/1
- smoke-test.sh wires the three together
Verified flow: hello registers presence row → send queues into
mesh.message_queue → fanout matches connected peer by pubkey →
drainForMember joins on mesh.member for senderPubkey → push lands
with ciphertext + correct sender attribution.
Also fixes a date-serialization bug that blocked the first run:
applyPendingHookStatus used `sql${col} >= ${jsDate}` which passed
JS Date.toString() to Postgres (failed to parse). Replaced raw
sql`` template with typed gte/desc/isNotNull operators from
drizzle-orm. Same fix applied in sweepPendingStatuses.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The schema/index.ts barrel does `export * from "./mesh"` + `export *
from "./auth"`. Both modules exported a symbol named `member`, which
caused TypeScript to silently exclude the ambiguous re-export and
drizzle-kit's introspection couldn't see mesh.member — its generated
migration was missing that table entirely.
Fix: rename the TypeScript binding only. The DB table name stays
"member" inside pgSchema "mesh" (still mesh.member in SQL):
- `export const member = schema.table("member", ...)` →
`export const meshMember = schema.table("member", ...)`
- Internal references in mesh.ts updated (FK lambdas, relations,
Zod schemas, inferred TS types)
- apps/broker/src/broker.ts import updated to meshMember as memberTable
- migrations/0000_sloppy_stryfe.sql regenerated — now includes all 7
mesh.* tables (audit_log, invite, member, mesh, message_queue,
pending_status, presence)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Single-port refactor:
- Drop the BROKER_PORT+1 HTTP side-port. Use `ws` with noServer:true
and attach to a single node:http server via the 'upgrade' event.
- Clients connect to ws://host:PORT/ws
- Hook POSTs go to http://host:PORT/hook/set-status
- Health probe at http://host:PORT/health
- One port = one Traefik label, one cert, one deploy route. Matches
the Coolify/VPS operational constraints.
senderPubkey on push:
- drainForMember now joins mesh.message_queue → mesh.member to return
the sender's peerPubkey alongside each envelope. No extra round-trip,
no cache invalidation needed (option A from review).
- index.ts populates WSPushMessage.senderPubkey from the join result
instead of the empty-string placeholder.
- Receivers can now identify who sent a message directly from the push.
README updated with a routes table for the single-port layout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- @claudemesh/broker package with bun dev/start scripts
- src/index.ts stub: WS server on BROKER_PORT, SIGTERM cleanup
- src/env.ts: Zod-validated env (BROKER_PORT, DATABASE_URL, STATUS_TTL_SECONDS, HOOK_FRESH_WINDOW_SECONDS)
- src/db.ts: re-exports Drizzle client from @turbostarter/db
- src/broker.ts + src/types.ts: placeholders for step 8 port
- README documents run commands, env vars, deploy targets
- tsconfig extends @turbostarter/tsconfig base
- eslint.config.js extends @turbostarter/eslint-config/base
Dependencies declared but not installed yet (ws, drizzle-orm, zod,
libsodium-wrappers + workspace deps). turbo.json unchanged: the global
dev task already has persistent=true + cache=false which is what the
broker needs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>