Commit Graph

320 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
8931296e82 feat(cli): scaffold @claudemesh/cli MCP client package (stubs)
The user-facing tool. Two invocation modes:
  - `claudemesh mcp`           → MCP server (stdio), consumed by Claude Code
  - `claudemesh <subcommand>`  → human CLI

Layout:
  apps/cli/
  ├── package.json       bin: { claudemesh: ./src/index.ts }
  ├── README.md          install + usage
  └── src/
      ├── index.ts       dispatcher (mcp | install | join | list | leave | --help)
      ├── env.ts         CLAUDEMESH_BROKER_URL, CONFIG_DIR, DEBUG
      ├── mcp/
      │   ├── server.ts  MCP stdio server with 5 tools
      │   ├── tools.ts   tool schemas (send_message, list_peers,
      │   │              check_messages, set_summary, set_status)
      │   └── types.ts
      ├── ws/client.ts   broker connection (stub for 15b)
      ├── state/config.ts ~/.claudemesh/config.json (joined meshes + keys)
      └── commands/
          ├── install.ts print `claude mcp add ...` instruction
          ├── join.ts    parse ic://join/... (stub, Step 17)
          ├── list.ts    show joined meshes
          └── leave.ts   remove mesh from local config

Tool stubs return "not connected, run `claudemesh join <invite-link>`"
errors until 15b wires the WS client.

Verified:
- `bun src/index.ts --help` → prints usage
- `bun src/index.ts install` → prints MCP add command with resolved path
- `bun src/index.ts list` → "No meshes joined yet"
- `bun src/index.ts mcp` (via stdin) → returns tools/list with all 5 tools

Deps: @modelcontextprotocol/sdk, ws, libsodium-wrappers, zod.
Lockfile regenerated in the same commit per claudemesh-3's flag —
avoids breaking Coolify's --frozen-lockfile deploys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:23:12 +01:00
Alejandro Gutiérrez
c6674e971a chore(deploy): production Dockerfiles for broker + web + env template
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
- apps/broker/Dockerfile: oven/bun 1.2-slim runtime, multi-stage, pnpm deps,
  non-root bun user, GIT_SHA build-arg, /health-based HEALTHCHECK, port 7900
- apps/web/Dockerfile: Next.js 15 standalone, multi-stage, non-root nextjs
  user, NEXT_PUBLIC_* baked as build args, port 3000
- .env.production.template: DATABASE_URL, BetterAuth, OAuth, broker caps;
  no secrets
- Build context: repo root (pnpm workspace needs root pnpm-lock.yaml +
  pnpm-workspace.yaml); build with -f apps/{broker,web}/Dockerfile .

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:22:05 +01:00
Alejandro Gutiérrez
3458860c1f test(broker): coverage for hardening modules — caps, limits, metrics, health, logs
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>
2026-04-04 22:19:14 +01:00
Alejandro Gutiérrez
5f8567614a docs(broker): production deployment spec
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>
2026-04-04 22:15:24 +01:00
Alejandro Gutiérrez
5bf815b304 feat(broker): production hardening — caps, limits, metrics, logging
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>
2026-04-04 22:14:31 +01:00
Alejandro Gutiérrez
84e14ff410 feat(web): marketing landing page with Anthropic design system
Landing page at / matching claude.com/product/claude-code structure:
hero, surfaces, pricing, laptop-to-laptop, features, meets-you, faq, cta,
+ floating "Latest news" toaster. Motion-based scroll reveals.

Design system extracted from claude.com via playwriter reverse-engineering:
- Self-hosted Anthropic Sans/Serif/Mono fonts (6 woff2 files)
- --cm-* tokens in globals.css (clay #d97757, gray-050..900, fluid clamps)
- Serif display, Sans UI, Mono terminals & section markers
- Italic clay phrases for emphasis

Header rewritten for design consistency: claudemesh wordmark (mesh glyph +
serif), dark bg, nav (Docs · Pricing · Changelog · GitHub), "Start free" CTA.

Free-first messaging: hero subhead "Free and open-source. Forever.", primary
CTA "Start free", pricing defaults to Solo=Free.

Fixes:
- packages/api: comment out aiRouter (module removed in 1f094c4)
- packages/db/schema/mesh.ts: rename memberRelations → meshMemberRelations
  (missed in beeaa3b rename pass, caught via web build — ack'd by BotMou)
- credits/{api,server,index}: stub out @turbostarter/ai/credits/utils
- remove (marketing)/legal/[slug] route and common/mdx.tsx (cms-backed)
- sitemap: drop blog/legal enumeration (cms removed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:09:38 +01:00
Alejandro Gutiérrez
e25115f1b0 test(broker): port test suite from claude-intercom to drizzle/postgres
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>
2026-04-04 22:09:06 +01:00
Alejandro Gutiérrez
1f094c4c53 chore: remove files importing pruned packages (ai, cms, cognitive-context)
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>
2026-04-04 22:02:26 +01:00
Alejandro Gutiérrez
8ce8b04e75 fix(db): rename pgSchema exports to prevent barrel collision
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>
2026-04-04 22:02:09 +01:00
Alejandro Gutiérrez
3ab3fbcdf6 chore(web): rebrand TurboStarter → claudemesh (i18n, env, icons)
- Replaced brand references in env.config.ts default (NEXT_PUBLIC_PRODUCT_NAME)
- Updated all 6 i18n locale files (en/es × marketing/auth/organization)
- Generated favicon.ico + icon.png + apple-icon.png from pixelated mascot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:01:29 +01:00
Alejandro Gutiérrez
76760c9b8c test(broker): smoke test for hello + direct message flow
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
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>
2026-04-04 21:53:33 +01:00
Alejandro Gutiérrez
56b70ac54c fix(broker): default port 7899 → 7900 to avoid collision with claude-intercom dev
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>
2026-04-04 21:48:57 +01:00
Alejandro Gutiérrez
beeaa3b3c6 fix(db): rename mesh.member export to meshMember to avoid collision with auth.member
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>
2026-04-04 21:47:02 +01:00
Alejandro Gutiérrez
cde08ea3c3 fix(broker,api): pin real ws version, drop @turbostarter/ai from packages/api
- apps/broker: ws 8.19.1 (didn't exist) → 8.20.0 (latest)
- packages/api: drop dangling @turbostarter/ai workspace ref (same
  prune debt as apps/web)
- pnpm-lock.yaml regenerated from 27 workspaces, 2476 resolved packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:38:49 +01:00
Alejandro Gutiérrez
8438e498b6 fix(web): drop dangling @turbostarter/ai and @turbostarter/cms deps after prune
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>
2026-04-04 21:37:22 +01:00
Alejandro Gutiérrez
0a97a0c369 refactor(broker): merge HTTP+WS to single port, populate senderPubkey on push
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>
2026-04-04 21:35:05 +01:00
Alejandro Gutiérrez
3c0154ae70 feat(broker): port routing + status model from claude-intercom to postgres
Ports the proven claude-intercom broker logic into apps/broker with
SQLite → Drizzle/Postgres translation. Core state engine kept verbatim:
source-priority writes (hook > manual > jsonl), fresh-gating, TTL
sweeper for stuck-working, pending-status race handler, priority
delivery gates (now/next/low), Windows path encoding (5-candidate
fallback incl. Roberto's H:\Claude → H--Claude rule).

New modules:
- broker.ts (492 lines): writeStatus, handleHookSetStatus, sweepers,
  presence lifecycle, message queueing + drainForMember, sourceRank +
  isHookFresh / isSourceFresh logic, findMemberByPubkey (WS auth hook).
- paths.ts (141): cwdToProjectKeyCandidates + findActiveJsonl +
  inferStatusFromJsonl — JSONL fallback inference for peers without
  hooks installed or with stale hook signals.
- types.ts (111): WS protocol envelopes (hello/send/push/ack/error/
  set_status), HookSetStatusRequest/Response, ConnectedPeer view.
- index.ts (323): HTTP on BROKER_PORT+1 for /hook/set-status + /health;
  WebSocket on BROKER_PORT for authenticated peer connections with
  hello/send/set_status handlers; connections registry; heartbeat
  ping/pong every 30s; graceful SIGTERM/SIGINT that marks all active
  presences disconnected.

Mesh scoping: every query/mutation includes meshId. Peer identity is
split between mesh.member (stable) and mesh.presence (ephemeral). WS
hello authenticates by pubkey against mesh.member (signature verify is
stubbed — libsodium wiring lands in client-side package later).

Broker never sees plaintext: nonce + ciphertext are opaque text fields
passed through. Routing happens on targetSpec (pubkey | "#channel" |
"tag:xyz" | "*"), resolved against currently-connected peers.

Dependencies not installed; no tests run. Verified via static review
of imports against @turbostarter/db exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:32:14 +01:00
Alejandro Gutiérrez
e758205eb8 feat(db): runtime presence + message queue + pending_status tables for broker
- mesh.presence: live WS connection tracking (memberId, sessionId, pid,
  cwd, status, statusSource, statusUpdatedAt, connectedAt, lastPingAt,
  disconnectedAt). Persisted so broker can resume after restart.
- mesh.message_queue: E2E-encrypted envelopes awaiting delivery (meshId,
  senderMemberId, targetSpec, priority, nonce, ciphertext, expiresAt).
  Broker routes ciphertext only — crypto happens client-side.
- mesh.pending_status: first-turn race catcher keyed by (pid, cwd). No
  FK to member (member doesn't exist yet when hook fires pre-register).
- Enums: presence_status, presence_status_source, message_priority.
- Relations: mesh→messageQueue, member→presences+sentMessages,
  presence→member, messageQueue→mesh+sender.
- Cascade chain: mesh → member → presence + messageQueue (no orphans).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:28:24 +01:00
Alejandro Gutiérrez
d5d0e6fdbb feat(broker): scaffold apps/broker workspace (bun WS runtime, no port yet)
- @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>
2026-04-04 21:24:17 +01:00
Alejandro Gutiérrez
d3163a5bff feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain
- Enums: visibility, transport, tier, role
- audit_log is metadata-only (E2E encryption enforced at broker/client)
- Cascade on mesh delete, soft-delete via archivedAt/revokedAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:19:32 +01:00