Three bugs caught via devtools on live site:
**1. CSP 'font-src 'self' data:' violation × 3 per landing load.**
BaseLayout was loading Geist + Geist_Mono via next/font/google. In
prod builds Next.js self-hosts those under /_next/static, but the
generated CSS still references `--font-sans: "Geist", …` which some
browsers resolve by re-requesting fonts.gstatic.com. Since we ship
Anthropic Sans/Serif/Mono self-hosted already (/fonts/*.woff2 via
@font-face in globals.css), the Geist dependency was pure overhead.
Removed `next/font/google` imports entirely. Added a `.cm-root`
class on <html> that remaps the Tailwind `--font-sans/--font-mono`
tokens to our `--cm-font-sans/--cm-font-mono` vars — so every
Tailwind `font-sans` / `font-mono` utility now resolves to Anthropic
families. No Google Fonts fetch, no CSP violation.
**2. /pricing 401 on public visit.**
`<Plan>` calls `useCustomer()` → `GET /api/billing/customer` which
needs auth. Unauthed visitor on /pricing → 401 in devtools + wasted
round trip. Gated `useCustomer` on `authClient.useSession()` —
query `enabled: !!session?.user`. Public visitors now skip the fetch
entirely; signed-in users still get their customer record.
**3. Residual "Welcome back! 👋" on /auth/login (both locales).**
Emoji sweep (e91fc80) missed the i18n translation files. Removed 👋
from en/auth.json + es/auth.json login header titles.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clickable HTTPS invite URLs replace the raw ic://join/<token> as the
primary share format. Someone receiving a link in Slack now lands on
a friendly page with install instructions, not a dead-end.
Backend:
- createMyInvite returns a new joinUrl field
(https://claudemesh.com/join/<token>) alongside the existing
ic://join/<token> inviteLink and raw token. Schema + Hono route
updated. ic:// scheme stays — CLI parses both.
- New GET /api/public/invite/:token in packages/api/src/modules/public/
(unauthed). Decodes the base64url payload, verifies ed25519
signature against owner_pubkey using the same canonicalInvite()
contract the broker enforces on join, then joins mesh/invite/user
to return the shape needed by the landing page. Does NOT mutate
usedCount — this is a read-only preview.
- Error taxonomy: malformed | bad_signature | expired | revoked |
exhausted | mesh_archived | not_found. Each returned with any
metadata we CAN surface (meshName, inviterName, expiresAt) so the
error page can be specific ("ask Jordan for a new one").
- cache-control: public max-age=30 on valid invites, no-store on
errors (reasons flip as state changes).
Frontend:
- New public route /[locale]/join/[token] (no auth). Server
Component fetches the preview endpoint, branches on valid/invalid,
renders a minimal landing-design-language shell (wordmark header,
clay accents, serif headlines, mono commands).
- Valid-invite view: "You're invited to {meshName}", inviter +
role + member-count lede, install-toggle component.
- Invalid-invite view: per-reason error copy + inviter name when
available + link back to /.
- InstallToggle client component: three-way state
(unknown/yes/no). Asks "first time / already set up?", then shows
either the 3-step install+init+join path with per-step copy
buttons, or the single claudemesh join <token> command for users
who have the CLI. Every code block has copy-to-clipboard.
- Security footer: "ed25519 keypair generated locally, you keep
your keys, broker sees ciphertext only, leave anytime with
claudemesh leave <mesh-slug>".
Invite generator (/dashboard/meshes/[id]/invite):
- QR code now encodes the HTTPS joinUrl instead of ic:// (phone
cameras land on the web page → friendly path).
- Primary CTA copies the HTTPS URL. Secondary "Copy CLI command"
for fast-path users. Footer explanation updated.
CLI coordination note: dispatched to broker/db lane — claudemesh CLI
needs to accept BOTH ic://join/<token> AND
https://claudemesh.com/join/<token> (extract <token> from pathname).
Server side already returns both.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Live social-proof counter on the landing page, tied to the E2E
narrative. Formatted as understated mono footer, not hero brag.
Backend — new GET /api/public/stats (unauthed, 60s in-memory cache):
{
messagesRouted: SELECT COUNT(*) FROM mesh.message_queue,
meshesCreated: SELECT COUNT(*) FROM mesh.mesh WHERE archivedAt IS NULL,
peersActive: SELECT COUNT(*) FROM mesh.presence WHERE disconnectedAt IS NULL,
lastUpdated: ISO timestamp,
}
Aggregate counts only — no ids, no names, no ciphertext, no routing
metadata. Safe for public consumption. cache-control header sets
public/s-maxage=60 for edge caching. `x-cache: HIT|MISS` for debug.
Frontend — new MeshStats Server Component at
modules/marketing/home/mesh-stats.tsx. Reads the endpoint server-side
via the ~/lib/api/server client, renders monospace footer:
ciphertext routed → 4,217 messages · 12 meshes · 8 peers online
broker sees none of it
Graceful zero state: when messagesRouted === 0 shows
"ciphertext → ready to route" instead of embarrassing zeros. Tabular-
nums for the numeric spans so they don't jitter across renders.
Mounted between <CallToAction /> and <LatestNewsToaster />. Page-level
`export const revalidate = 60` so Next.js ISR refreshes the counter
every minute without a DB hit on every request (combined with the
API cache = two-layer 60s TTL, DB sees ~1 query/minute).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use pnpm deploy to flatten each package's runtime subset into /deploy,
then copy ONLY that into the runtime stage. Catalog + workspace:*
specifiers previously forced full-workspace resolution into every
image's node_modules — unnecessary for either runtime.
Results (arm64, same smoke tests pass):
- broker: 3.26GB → 341MB (-90%, drops all devDeps incl. drizzle-kit)
- migrate: 3.27GB → 653MB (-80%, keeps drizzle-kit which IS runtime)
Broker /health confirms GIT_SHA build-arg still propagates (gitSha:
"30bc24f" in smoke test). Migrate still reads drizzle.config.ts and
attempts the connection correctly.
--legacy flag needed because pnpm 10 defaults to inject-workspace-
packages mode which the monorepo doesn't opt into; legacy is safe here.
--ignore-scripts on deploy skips the root postinstall (sherif lint:ws)
which has nothing to do with runtime.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- switch email provider from resend (unused) to postmark (creds available)
- re-enable requireEmailVerification now that email path works
- env vars POSTMARK_API_KEY + EMAIL_FROM must be set in Coolify
RESEND_API_KEY / SMTP credentials not yet configured in production.
Users sign up + land in dashboard immediately, no verification email.
Re-enable requireEmailVerification when email provider is live:
packages/auth/src/server.ts:93
Footer "Built with ✦ TurboStarter" link → "Built on ⎇ claude-intercom · MIT".
Credits the MIT OSS foundation claudemesh sits on and aligns with the
GitHub icon in the header already pointing at alezmad/claude-intercom.
Dropped the 512-byte TurboStarter wordmark SVG + the large brand icon.
Kept a lean GitHub glyph + text so it reads as attribution, not ad.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
One-shot migrate container runs drizzle-kit migrate against DATABASE_URL
and exits 0 before web boots. web service depends_on with condition
service_completed_successfully, so failed migrations block web startup
instead of serving 500s against a stale schema. Broker deliberately does
NOT depend on migrate - it tolerates DB-down gracefully per DEPLOY_SPEC
and should keep serving WS peers even during migration failures.
Also excludes apps/cli from docker build context (CLI ships to npm, not
containers) to sidestep zod spec drift in its package.json vs lockfile.
Known followup: migrate image is 3.27GB due to pnpm catalog: specifiers
forcing full-workspace resolution. pnpm deploy bundle trim is a P2.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wires the Discord-style demo UI to real user data. Users with 1+ meshes
now get situational awareness: who's online, what's in the queue, what
the broker saw recently — polling every 4s, all E2E encrypted.
Extraction pass:
- New `<MeshStream peers messages channelLabel footer>` renderer at
modules/marketing/home/mesh-stream.tsx — pure presentation, no
playback engine, no data fetching. Handles peer filter, hover-for-
ciphertext tooltip, animated message list.
- demo-dashboard.tsx refactored to use it: keeps the playback loop,
traffic-light chrome, and script-driven messages; passes everything
to MeshStream via props. ~120 LOC shorter.
Backend:
- new GET /api/my/meshes/:id/stream in packages/api (same authz gate
as /my/meshes/:id — owner OR non-revoked member). Returns:
- up to 20 live presences (disconnectedAt IS NULL), joined to
meshMember for displayName
- up to 50 most-recent message_queue envelopes with metadata only:
sender + displayName, targetSpec, priority, createdAt, deliveredAt,
byte size, and a 24-char ciphertext preview (this IS what the
broker sees — no plaintext anywhere in the response)
- up to 20 recent audit events
- getMyMeshStreamResponseSchema in schema/mesh-user.ts matches exactly.
Frontend:
- new LiveStreamPanel client component at modules/mesh/live-stream-panel.tsx
— react-query with refetchInterval: 4000ms, refetchIntervalInBackground
false. Maps presences + envelopes to MeshStream's Peer/Message shape,
classifies targetSpec into message type ("tag:*" → ask_mesh, "*" →
broadcast, else direct). Passes through the ciphertextPreview as the
hover content — no fake ciphertext in live view.
- new route /dashboard/meshes/[id]/live with server-side authz preflight
via /my/meshes/:id. Mounts LiveStreamPanel inside a dashboard page
shell with breadcrumb back to mesh detail.
- Mesh detail page gets a new "Live" pill button (clay-pulsing dot)
next to "Generate invite link" in the header.
- paths config gets dashboard.user.meshes.live(id).
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>
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>
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>
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>
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>
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>
- 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>