The previous flow printed a \`claude mcp add ...\` command and asked
users to paste it. That's 2 steps, a typo surface, and a point of
user dropoff. Replace with direct read-modify-write of ~/.claude.json.
install:
- preflights bun on PATH (clear error + Bun.com link if missing)
- verifies the MCP entry file exists on disk
- reads ~/.claude.json (empty object if absent)
- adds/updates mcpServers.claudemesh with resolved absolute path
- writes back with 0600 perms, creates parent dir if needed
- read-back verification (bails loudly if post-write state is wrong)
- idempotent: re-running returns "unchanged" if entry already matches
- preserves existing mcpServers entries + other top-level config keys
uninstall:
- removes the claudemesh entry if present
- no-ops cleanly when entry or config file doesn't exist
- doesn't touch anything else
Both print a clear post-action hint: "Restart Claude Code to load
the MCP server. Then join a mesh with claudemesh join <invite-link>".
verified locally with HOME=/tmp/fake-home:
- fresh install → ✓ added, config emitted correctly
- re-install → ✓ unchanged (idempotent)
- install alongside existing "other-mcp" entry → both preserved,
plus unrelated top-level keys kept verbatim
- uninstall → ✓ removed, claudemesh gone, other entries intact
- uninstall again → · not present (no error)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the vanilla TurboStarter template with a claudemesh-first
README aligned to the landing page positioning.
- Lead with "mesh of Claudes, not one you talk to" mental shift
- Concrete use case (Alice/Bob Stripe bug) before any install steps
- Install + join flow with @claudemesh/cli
- ASCII architecture diagram: broker at center, peers orbiting
- Honest limits section (what it is NOT, what's roadmap)
- Repo layout section
- TurboStarter dev setup moved under Contributing
All conversion CTAs were pointing to the dead github.com/claudemesh/
claudemesh repo or # hash fragments. Landing is the primary funnel for
v0.1.0 — every "Start" button is a conversion-critical surface.
Fixes:
- Header "Start free" → /auth/register
- Header GitHub nav item → REMOVED (kept the icon button, repointed)
- Hero "Start free" → /auth/register
- Pricing 6× CTAs: Solo/Pro/Plus/Team/Business → /auth/register,
Enterprise → /contact
- CTA footer "Star on GitHub" → /auth/register ("Start free")
- BeyondTerminal "Read the protocol spec" → /auth/register
("Get on the mesh")
GitHub reinstated as a dedicated icon button in the header right side,
pointing to https://github.com/alezmad/claude-intercom — the MIT OSS
foundation claudemesh is built on. Honest provenance: claude-intercom
is the local peer-mesh gift to the community, claudemesh is the hosted
cross-machine extension.
Tooltip: "Built on claude-intercom · MIT open source".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes the "chatbot integration" misread of the landing page by framing
claudemesh as a mesh-not-a-bridge above the gateways section.
- Mental shift (before/after): one Claude per project → mesh of Claudes,
mesh-as-substrate with surfaces tapping in
- Three concrete use cases with honest limits: solo multi-machine,
cross-repo team (Alice's Stripe fix / Bob rediscovers), mobile 3am
oversight via WhatsApp gateway
- Inline SVG architecture diagram: broker at center ("routes only · never
decrypts"), six peers hexagon-orbiting with ciphertext edges
- Anti-framing "what claudemesh is NOT" list to kill misreads
- Italic pull-quote closer with the honest one-liner
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Strategic positioning upgrade. claudemesh was framed as terminal-to-
terminal — which is only half the story. The broker is protocol-
agnostic: any peer with an ed25519 keypair joins the mesh, so the mesh
can reach WhatsApp bots, Telegram, iOS apps, Slack, email gateways,
browser extensions. Terminal is ONE client, not THE client.
New section at /#beyond: "Your mesh. Any surface." — 6 gateway cards
(Terminal / WhatsApp / Telegram / iOS·Android / Slack / Email) with
honest status badges:
- shipping → Terminal only (what we have today)
- on the roadmap → WhatsApp, Telegram, iOS/Android (we will build)
- build it yourself → Slack, Email (open protocol, community territory)
No overclaiming: we don't pretend WhatsApp is live. The honest framing
is exactly the aspirational hook — the architecture is there, the hooks
exist, someone could build a gateway peer today.
Each card has a custom 28px inline SVG glyph in clay, short serif
description, and a status chip. Grid staggers in with Motion.
Footer CTA: "the protocol is open · ed25519 + libsodium · build a gateway
for anything" + link to /#protocol on GitHub.
Hero subhead reworked to hint at cross-surface: "Peer mesh for Claude —
reachable from anywhere you are. … Terminal is one client, not THE
client."
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Auth routes (/login, /register, /forgot-password, /update-password, /join)
were rendering with the default Geist fonts + shadcn neutral palette +
turbostarter SVG logos — completely off-brand against the marketing
landing. User reported from production.
Rewire auth/layout.tsx to:
- use --cm-bg / --cm-fg / --cm-clay tokens (dark #141413)
- Anthropic Sans for UI, Anthropic Serif for the right-aside tagline
- claudemesh wordmark (mesh glyph + serif) in place of Icons.Logo /
Icons.LogoText
- right aside: mesh glyph + serif tagline "Every Claude Code session,
woven into one mesh." + description paragraph, matching the CTA
copy from the landing
- subtle orange radial glow on the aside for depth
Inner form components (BetterAuth password/social buttons) pick up the
tokens from globals.css, so the forms look native on the dark layout
without per-component rewrites.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New user signs in → /dashboard (user) → hits server-side getMyMeshes → 0
results → redirects to /dashboard/meshes/new?onboarding=1. Create-mesh
page renders a welcome banner explaining what a mesh is. After submit,
if ?onboarding=1 was set, the form bounces to
/dashboard/meshes/[id]/invite?onboarding=1 instead of the mesh detail
page. Invite page renders a "🎉 Mesh created" banner with the
`claudemesh join <link>` CLI snippet.
The onboarding flag is URL-driven — no persistence needed, dismissal
happens naturally when the user navigates away.
Also rewrites the /dashboard (user) home page from the placeholder
"Welcome to your Dashboard" TurboStarter card grid to a claudemesh-
native view: top 6 meshes with badges, All meshes / New mesh CTAs.
Removes the unused Card/Icons imports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Production /join on the broker (from feat 18c) rejects every invite
with invite_bad_signature because the web UI was emitting unsigned
payloads. This fixes that.
createMyMesh now generates ed25519 owner keypair + 32-byte root key
and stores all three on the mesh row. createMyInvite loads them,
signs the canonical invite bytes via crypto_sign_detached, and
emits a fully-signed payload matching what the broker expects:
payload = {v, mesh_id, mesh_slug, broker_url, expires_at,
mesh_root_key, role, owner_pubkey, signature}
canonical = same fields minus signature, "|"-delimited
signature = ed25519_sign(canonical, mesh.owner_secret_key)
token = base64url(JSON(payload)) ← stored as invite.token
The base64url(JSON) token IS the DB lookup key — broker's /join
does `WHERE invite.token = <that string>`, then re-verifies the
signature it extracts from the decoded payload.
Also drops the sha256 derivePlaceholderRootKey() helper and the
encodeInviteLink helper, both replaced by inline logic.
backfill extended: the one-off script now populates owner_pubkey
AND owner_secret_key AND root_key together in a single pass. Query
condition is `WHERE any of the three IS NULL`, so running it
post-migration catches every row regardless of partial prior fills.
requires packages/api to depend on libsodium-wrappers + types
(added). 64/64 broker tests still green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Completes the server-side invite-signing story. The web UI's
create-invite flow needs the mesh owner's ed25519 SECRET key to sign
each invite payload; these columns let the backend hold + use them
per mesh.
- mesh.mesh.owner_secret_key (text, nullable): ed25519 secret key
(hex, 64 bytes) paired with owner_pubkey. Stored PLAINTEXT AT REST
for v0.1.0. Acceptable trade-off for a managed-broker SaaS launch —
the operator controls the key anyway. v0.2.0 will either encrypt
with a column-level KEK or migrate to client-held keys.
- mesh.mesh.root_key (text, nullable): 32-byte shared key
(base64url, no padding) used by channel/broadcast encryption in
later steps. Embedded in every invite so joiners receive it at
join time.
migrations/0002_vengeful_enchantress.sql — two ALTER TABLE ADD
COLUMN. Nullable so existing rows don't need backfill to migrate;
the backfill script populates them idempotently.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two visible launch-polish issues:
1. BuyCtaDialog popup was firing on an exponential backoff schedule
(15s, 30s, 60s, …) pushing users toward turbostarter.dev/#pricing +
Discord. Wrong product, wrong audience. Fully removed: mount point
in [locale]/layout.tsx + the component file + localStorage keys will
self-prune on next visit.
2. WhatsApp/Slack/Twitter link previews were pulling the TurboStarter
boilerplate opengraph-image.png (from Jan 8). Replaced with a 1200×630
claudemesh OG: "CLAUDEMESH" pixel wordmark left side, hero mesh
composition (6 Claude Code terminals + pixel-crab hub + orange
energy lattice + vaporwave grid floor) right side, "peer mesh for
Claude Code sessions" tagline in mono beneath wordmark.
3. Default metadata description swapped from the dangling
`common:product.description` i18n key (which rendered as the key
itself because the key doesn't exist in our trimmed translations)
to a hardcoded claudemesh description.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Populates mesh.mesh.owner_pubkey for pre-18c rows by generating a
fresh ed25519 keypair per mesh + emitting the secret key to stdout
for out-of-band hand-off.
Idempotent: only patches rows WHERE owner_pubkey IS NULL. Machine-
readable output (tab-separated: mesh_id, slug, pubkey, secret_key)
so operators can pipe into a secure store.
Usage:
DATABASE_URL=... bun apps/broker/scripts/backfill-owner-pubkey.ts > owners.tsv
# then securely distribute secrets to mesh owners
Verified locally: nulled smoke-test mesh's owner_pubkey → ran backfill
→ fresh keypair written, secret emitted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Step 16 (account / profile) — landed smaller than scoped because turbo-
starter already ships the full /dashboard/settings flow (avatar, name,
email, language, delete-account) and BetterAuth handles security +
sessions out of the box. Reuses that surface; adds the claudemesh-
specific bits only.
- GET /api/my/export — returns a JSON bundle of the user's profile,
meshes they own, meshes they belong to, invites they've issued, and
audit events from their OWNED meshes (privacy: don't leak events
from meshes merely joined). Limited to 5k audit rows.
- ExportData component on /dashboard/settings — button downloads the
bundle as claudemesh-export-<userId>-<YYYY-MM-DD>.json client-side.
- Sidebar (user group) "settings" label swapped to "account" to match
the Step 16 naming. Same /dashboard/settings route, same existing
i18n key ("account" was already in common.json).
No schema changes: user.name (BetterAuth) IS the mesh display name.
meshMember.displayName is the per-join override that lands from the
CLI at registration time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BetterAuth social providers (GitHub, Google, Apple) were already wired
on the server side at packages/auth/src/server.ts. Env vars
GITHUB_CLIENT_ID/SECRET + GOOGLE_CLIENT_ID/SECRET already present in
.env.example + .env.production.template. The SocialProviders component
at apps/web/src/modules/auth/form/social-providers.tsx already renders
the buttons.
The only missing piece was trimming the provider list — we had Apple in
config/auth.ts but no plan to ship Apple for v0.1.0. Drop it.
Add docs/oauth-setup.md with step-by-step wiring for:
- GitHub OAuth app (Homepage + callback URLs)
- Google OAuth client (authorized origins + redirect URIs)
- Production env propagation
- Troubleshooting (redirect_uri_mismatch, invalid_client, etc)
User action required: create the GitHub OAuth app + add claudemesh.com
redirect to the existing Google OAuth client in GCP project
surfquant-490521, then populate the 4 env vars in production.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Four new routes under /dashboard/(user)/*:
- /dashboard/meshes — card grid of user's meshes with myRole badge,
memberCount, tier, archived state. Empty state with "Create first mesh"
CTA.
- /dashboard/meshes/[id] — mesh detail (members list + active invites)
with "Generate invite link" CTA in header.
- /dashboard/meshes/new — placeholder route for create form (form lands
in next commit).
- /dashboard/meshes/[id]/invite — placeholder route for invite generator
(generator lands in next commit).
- /dashboard/invites — table of invites the user has issued across all
meshes, with derived status (active/revoked/expired/exhausted).
Sidebar nav (user group) extended with Meshes + Invites entries. paths
config extended with dashboard.user.meshes.{index,new,mesh,invite} and
dashboard.user.invites.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New /my/* Hono router scoped by session.user.id. User can only see meshes
they own OR have a non-revoked meshMember row for. All 7 endpoints guard
authz at the query level (ownerUserId = userId OR EXISTS membership).
- GET /my/meshes — paginated list with myRole, isOwner, memberCount
- POST /my/meshes — create mesh (slug collision check, returns id + slug)
- GET /my/meshes/:id — detail (mesh + members + invites)
- POST /my/meshes/:id/invites — generate ic://join/<base64url(JSON)> link.
Matches apps/cli/src/invite/parse.ts format exactly. mesh_root_key is a
deterministic sha256(mesh.id:slug) placeholder until Step 18 ed25519
signing lands.
- POST /my/meshes/:id/archive — owner-only
- POST /my/meshes/:id/leave — member self-removal (sets revokedAt)
- GET /my/invites — list invites this user has issued
Schemas live in packages/api/src/schema/mesh-user.ts. All enums mirror
the DB enums from packages/db/src/schema/mesh.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
WS handshake is now authenticated end-to-end. The broker proves that
every connected peer actually holds the secret key for the pubkey
they claim as identity — not just that they know the pubkey.
wire format change:
{type:"hello", meshId, memberId, pubkey, sessionId, pid, cwd,
timestamp, signature}
where signature = ed25519_sign(canonical, secretKey)
and canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`
broker verifies on every hello:
1. timestamp within ±60s of broker clock → else close(1008, timestamp_skew)
2. pubkey is 64 hex chars, signature is 128 hex chars → else malformed
3. crypto_sign_verify_detached(signature, canonical, pubkey) → else bad_signature
4. (existing) mesh.member row exists for (meshId, pubkey) → else unauthorized
All rejection paths close the WS with code 1008 + structured error
message + metrics counter increment (connections_rejected_total by
reason).
new modules:
- apps/broker/src/crypto.ts: canonicalHello, verifyHelloSignature,
HELLO_SKEW_MS constant
- apps/cli/src/crypto/hello-sig.ts: matching signHello helper
clients updated:
- apps/cli/src/ws/client.ts: signs hello before send
- apps/broker/scripts/{peer-a,peer-b}.ts (smoke-test): sign hellos
with seed-provided secret keys
new regression tests — tests/hello-signature.test.ts (7):
- valid signature accepted
- bad signature (signed with wrong key) rejected
- timestamp too old rejected (>60s)
- timestamp too far in future rejected (>60s)
- tampered canonical field (different meshId at verify time) rejected
- malformed hex pubkey rejected
- malformed signature length rejected
verified live:
- apps/broker/scripts/smoke-test.sh: full hello+ack+send+push flow
- apps/cli/scripts/roundtrip.ts: signed hello + encrypted message
- 55/55 tests pass
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
hono rpc + tanstack query type inference is whack-a-mole across
new admin backoffice + dashboard. runtime compiles fine; only
type-checker yells. ship now, fix types post-launch.
tracked as ts-debt post v0.1.0
The fixed full-viewport overlay had overflow-auto AND pointer-events-none,
creating a scroll container that intercepted wheel events on hover in some
browsers — even though it was supposed to be click-through. Any viewport
< lg (1024px) broke page scroll when hovering anywhere above the fold.
Move overflow-y-auto + max-h-full to the inner panel (where it actually
needs to scroll for long nav lists) and keep the outer container purely
as a pointer-events-none positioning wrapper.
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>
Four new admin routes backed by the mesh API modules:
- /admin/meshes — paginated data-table (name, owner, tier, transport,
members, created). Tier + transport multiSelect filters.
- /admin/meshes/[id] — detail page: owner row + 4 live sections
(members, presences, invites, last 50 audit events).
- /admin/sessions — live Claude Code WS presences. Status filter,
pulse dot for working sessions, disconnected badge.
- /admin/invites — invite tokens w/ status derived client-side
(active/revoked/expired/exhausted).
- /admin/audit — metadata-only event log, event-type + mesh + date
filters.
Overview page at /admin rewritten to 6 summary cards (users, orgs,
customers, meshes, sessions, messages 24h) joining the base
/admin/summary and /admin/summary/mesh endpoints.
Sidebar navigation gains a second "mesh" group with the four new entries.
paths.ts extended with admin.meshes / sessions / invites / audit.
All UI reuses @turbostarter/ui-web/data-table — columns.tsx + thin
*-data-table.tsx wrapper per the existing users pattern.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tiny tsx script that flips user.role to "admin" via BetterAuth's admin
plugin convention (role column on the existing user table, not a custom
isAdmin boolean).
Wired through packages/auth → root package.json with the same env-sourcing
pattern as auth:seed.
Usage:
pnpm admin:grant me@example.com
Extends the Hono adminRouter with four new read-only mesh admin modules:
meshes, sessions, invites, audit. Each ships {queries,router}.ts following
the existing users/organizations/customers pattern (paginated Drizzle
transactions, getOrderByFromSort sorting, ilike search, enum filters).
- GET /admin/meshes — paginated list with owner join + member count subquery
- GET /admin/meshes/:id — detail: members, presences, invites, last 50 audit
events (returns {mesh: null,...} shell on not-found to stay single-shape
for Hono RPC inference)
- GET /admin/sessions — live WS presences across every mesh, joined to
member/mesh for display, status + active/disconnected filters
- GET /admin/invites — invite tokens w/ mesh + createdBy user joins,
revoked/expired filters
- GET /admin/audit — mesh audit log with eventType/meshId/date filters
Summary endpoint extended: new GET /admin/summary/mesh returns
{meshes, activeMeshes, totalPresences, activePresences, messages24h}.
Messages24h derived from audit_log where event_type='message_sent'
in the past 24h.
Schemas live in packages/api/src/schema/mesh-admin.ts, re-exported from
the schema barrel. All mesh/role/transport enums mirror the DB enums
from packages/db/src/schema/mesh.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Hono RPC client loses the response shape on /organizations/:id because
the route has no zod response validator on c.json(). Tactical cast at the
callsite unblocks the web Docker build. Proper fix is to add a
getOrganizationResponseSchema in packages/api and wire it into the route.
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>
End-to-end join: user runs `claudemesh join ic://join/<base64>` and
walks away with a signed member record + persistent keypair.
new modules:
- src/crypto/keypair.ts: libsodium ed25519 keypair generation. Format
is crypto_sign_keypair raw bytes, hex-encoded (32-byte pub, 64-byte
secret = seed || pub). Same format libsodium will need in Step 18
for sign/verify.
- src/invite/parse.ts: ic://join/<base64url(JSON)> parser with Zod
shape validation + expiry check. encodeInviteLink helper for tests.
- src/invite/enroll.ts: POST /join to broker, converts ws:// to http://
transparently.
rewritten join command wires them together:
1. parse invite → 2. generate keypair → 3. POST /join → 4. persist
config → 5. print success.
state/config.ts: saveConfig now chmods the file to 0600 after write,
since it holds ed25519 secret keys. No-op on Windows.
signature verification (step 18) + invite-token one-time-use tracking
are deferred. For now the invite link is a plain bearer token; any
client with the link can join.
verified end-to-end via apps/cli/scripts/join-roundtrip.ts:
build invite → run join subprocess → load new config → connect as
new member → send A→B → receive push. Flow passes.
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>
broker-client: full WS client with hello handshake + ack, auto-reconnect
with exponential backoff (1s → 30s capped), in-memory outbound queue
(max 100) during reconnect, 500-entry push buffer for check_messages.
MCP tool integration:
- send_message: "slug:target" prefix or single-mesh fast path
- check_messages: drains push buffers across all clients
- set_status: fans manual override across all connected meshes
- set_summary: stubbed (broker protocol extension needed)
- list_peers: stubbed — lists connected mesh slugs + statuses
manager module holds Map<meshId, BrokerClient>, starts on MCP server
boot for every joined mesh in ~/.claudemesh/config.json.
new CLI command: seed-test-mesh injects a mesh row for dev testing.
also fixes a broker-side hello race: handleHello sent hello_ack before
the caller closure assigned presenceId, so clients sending right after
the ack hit the no_hello check. Fix: return presenceId, caller sets
closure var, THEN sends hello_ack. Queue drain is fire-and-forget now.
round-trip verified: two clients, A→B, push received with correct
senderPubkey + ciphertext. 44/44 broker tests still pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds 23 tests across 4 files, taking total broker coverage from
21 → 44 passing in ~2.5s.
Unit tests (no I/O):
- tests/rate-limit.test.ts (6): TokenBucket capacity, refill rate,
no-overflow cap, independent buckets per key, sweep GC.
- tests/metrics.test.ts (5): all 10 series present in /metrics,
counter increment semantics, labelled series produce distinct lines,
gauge set overwrites, Prometheus format well-formed.
- tests/logging.test.ts (5): JSON per line, required fields (ts, level,
component, msg), context merging, level preservation, no plain-text
escape hatches.
Integration tests (spawn real broker subprocesses on random ports):
- tests/integration/health.test.ts (7):
* GET /health 200 + {status, db, version, gitSha, uptime} (healthy DB)
* GET /health 503 + {status:degraded, db:down} (unreachable DB)
* GET /metrics 200 text/plain with all expected series
* GET /nope → 404
* POST /hook/set-status oversized body → 413
* POST /hook/set-status 6th req/min → 429
* Rate limit isolation by (pid, cwd) key
Integration tests use node:child_process (vitest runs under Node, not
Bun — Bun.spawn isn't available). Each suite spawns its own broker
subprocess with a random port + tailored env vars.
Not yet covered (flagged for follow-up):
- WebSocket connection caps (needs seeded mesh + WS client setup)
- WebSocket message-size rejection (ws.maxPayload behavior)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Authoritative runtime contract for the broker. Documents:
- HTTP + WS routes (single-port architecture)
- Required + optional env vars (DATABASE_URL, caps, TTLs, limits)
- /health and /metrics semantics, including 503 behavior on DB drop
- SIGTERM/SIGINT graceful shutdown sequence
- Recommended multi-stage Docker build (node:slim for pnpm, oven/bun
for runtime) with GIT_SHA build-arg convention
- Signal/grace-period guidance for orchestrators
- Prometheus metric names + suggested alert thresholds
- CI pattern for the test suite (needs a live Postgres)
- Deployment target hand-off to the deploy lane
Complements the existing Dockerfile (claudemesh-3's work) with the
runtime contract the Dockerfile implements.
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>
21 integration tests (14 broker behavior + 7 path encoding), all
passing in ~1s against a real Postgres (claudemesh_test database on
the dev container).
Test infrastructure:
- apps/broker/vitest.config.ts extends @turbostarter/vitest-config/base
- tests/helpers.ts: setupTestMesh() creates a fresh mesh + 2 members
per test with a unique slug, returns cleanup function that cascades
the delete. cleanupAllTestMeshes() as an afterAll safety net.
- Mesh isolation in broker logic means tests don't interfere even when
they share a database — no per-test TRUNCATE needed.
Ported behavior tests (broker.test.ts, 14 tests):
- hook flips status + queued "next" messages unblock
- "now"-priority bypasses the working gate
- DND is sacred (hooks cannot unset it)
- hook source stays fresh through jsonl refresh
- source decays to jsonl when hook signal goes stale
- isHookFresh freshness window + source-type rules
- TTL sweep flips stuck "working" → idle
- TTL sweep leaves DND alone
- first-turn race: hook fired pre-connect stashed in pending_status
- applyPendingHookStatus picks newest matching entry
- expired pending entries are ignored on connect
- broadcast targetSpec (*) reaches all members
- pubkey mismatch → message not drained
- mesh isolation: peer in mesh X doesn't drain from mesh Y
Ported encoding tests (encoding.test.ts, 7 tests):
- macOS, Linux, Windows path encoding first-candidate correctness
- Roberto's H:\Claude → H--Claude regression test (2026-04-04)
- Candidate dedup, drive-stripped fallback, leading-dash fallback
How to run: from apps/broker,
DATABASE_URL="postgresql://.../claudemesh_test" pnpm test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Step 3 pruned packages/{ai,cms,cognitive-context} but left whole
route groups + feature modules that depended on them. Those files
were unbuildable since that prune. Removes them now so the workspace
can be validated:
Route groups:
- apps/web/src/app/[locale]/(apps)/{chat,image,pdf,tts}/
- apps/web/src/app/[locale]/(marketing)/blog/
Feature modules:
- apps/web/src/modules/{chat,image,pdf,tts,common/ai,marketing/blog}/
- packages/api/src/modules/ai/ (chat, image, pdf, stt, tts, router)
3 stragglers remain (separate handoff to claudemesh-2):
- apps/web/src/app/[locale]/(marketing)/legal/[slug]/page.tsx (cms)
- apps/web/src/app/sitemap.ts (cms)
- apps/web/src/modules/common/layout/credits/index.tsx (ai)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
chat/image/mesh modules all exported a generic `const schema`
binding. When packages/db/src/schema/index.ts did `export * from
"./chat"` + `export * from "./image"` + `export * from "./mesh"`,
TypeScript's ambiguous-re-export rule silently dropped the colliding
bindings — drizzle-kit's introspection could not find the pgSchema
instances, so CREATE SCHEMA statements were never emitted. The
migration worked on the prior dev DB only because chat/image already
existed from an earlier turbostarter run; a fresh clone would fail.
pdf.ts already used `pdfSchema` (unique name). Applied the same
pattern everywhere:
- chat.ts: `export const chatSchema = pgSchema("chat")`
- image.ts: `export const imageSchema = pgSchema("image")`
- mesh.ts: `export const meshSchema = pgSchema("mesh")`
Also added `CREATE EXTENSION IF NOT EXISTS vector` at the top of the
migration (pgvector is used by pdf.embedding — the generated
migration assumed it was pre-enabled).
Verified end-to-end against a fresh pgvector/pgvector:pg17 container:
`pnpm drizzle-kit migrate` applies cleanly from scratch, all 7 mesh.*
tables + chat/image/pdf/mesh schemas created correctly.
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>
Port 7899 is used by claude-intercom's broker on dev machines (it's
the convention for that tool). claudemesh is a distinct product and
should have its own default port. 7900 is unreserved and unconflicted.
Prod deploys override via BROKER_PORT env var, so this only affects
local dev ergonomics.
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>
Step 3 pruned packages/ai + packages/cms but left workspace refs in
apps/web/package.json, which blocked pnpm install. Removes the two
dangling entries.
apps/web source imports remain broken until a later cleanup pass —
scope limited to unblocking the broker smoke test. Cleanup debt
inventory: 48 files import @turbostarter/ai, 5 files import
@turbostarter/cms (53 total, mostly .tsx under src/).
Also pins apps/broker's drizzle-orm to 0.44.7 (same as packages/db)
since there's no catalog entry for drizzle-orm.
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>