diff --git a/.artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md b/.artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md new file mode 100644 index 0000000..3314ece --- /dev/null +++ b/.artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md @@ -0,0 +1,232 @@ +# Anthropic Vision: Meshes & Invitations + +**Status:** in progress · partial implementation 2026-04-10 +**Owner:** agutierrez +**Scope:** `apps/web`, `packages/api`, `packages/db`, `apps/broker` (future), `apps/cli` (future) + +--- + +## Guiding principles + +1. **Identity is opaque, display is free-form.** Humans pick any name; the system uses random IDs. +2. **Secrets never appear in URLs.** Links are capabilities, not credentials. +3. **Defaults are obvious; advanced options are discoverable but hidden.** +4. **Self-service wherever possible; admins don't become gatekeepers.** +5. **Every visible action is also an auditable event.** + +These mirror how Anthropic builds its own org/workspace/project model. + +--- + +## Part 1 — Meshes + +### Problem +Global uniqueness on `mesh.slug` creates name collisions at scale. Two users picking "platform" or "test" fight for the slug. At 50k users this is the default state. + +### Decision +**Drop the slug as an identity concept.** `mesh.id` (opaque, already random) is the canonical identifier everywhere (URLs, invites, broker lookups). `mesh.name` is a free-form display label, non-unique. `mesh.slug` is kept as a non-unique cosmetic string derived from the name at creation time, embedded in invite payloads for debugging. + +### What this enables +- Two users can both name their mesh "platform-team" with zero friction +- URLs stay stable (`/meshes/{id}`) even if the user renames the mesh +- No "slug taken" error state exists in the product anymore + +### Tradeoff explicitly accepted +Users lose the ability to type `claudemesh join platform-team` — but they never did, because the CLI takes signed invite tokens, not slugs. This capability was phantom. + +### Implementation — DONE in this spec +- [x] Drop `UNIQUE` constraint on `mesh.slug` (migration `0017_mesh-slug-non-unique.sql`) +- [x] Remove `slug` field from `createMyMeshInputSchema` +- [x] Remove slug field from `CreateMeshForm` +- [x] Server-side `toSlug(name)` derives slug from name automatically +- [x] Schema comment documents the non-canonical role of `slug` + +### Future (optional, not in v0.1.x) +- **Vanity slugs as a Pro feature:** one globally-unique handle per *account* (not per mesh), exposed as `claudemesh.com/@acme/...`. Sold as part of an org tier. This is where slug uniqueness actually pays for itself — against usernames, not against meshes. + +--- + +## Part 2 — Invitations + +### Problems with the current invite system + +| # | Problem | Severity | +|---|---|---| +| 1 | `mesh_root_key` is embedded in the invite URL as base64url JSON | 🔴 **Security** | +| 2 | Invite URLs are ~400 chars of opaque base64url | 🟡 UX | +| 3 | No invite-by-email; only shareable link | 🟡 UX | +| 4 | Required form fields (role, maxUses, expiresInDays) for every invite | 🟡 UX | +| 5 | Landing page does not clearly preview role/consent | 🟡 UX | +| 6 | No audit trail for invites received-but-never-clicked | 🟢 Polish | +| 7 | `ic://` link scheme is vestigial, nothing registers the handler | 🟢 Polish | + +### Severity 🔴 — the root key leak + +Current canonical invite bytes: +``` +v | mesh_id | mesh_slug | broker_url | expires_at | mesh_root_key | role | owner_pubkey +``` + +`mesh_root_key` is a 32-byte shared secret used by all channel and broadcast encryption in the mesh. Once it lives in a URL: +- Slack/Telegram/Discord link previews fetch and cache the URL → root key is in those caches +- Browser history, sync, analytics pixels, error logs → root key persists anywhere URLs persist +- A screenshot of the invite link is a compromise +- Revoking the invite does **not** rotate the key, so exposure is permanent + +**Anthropic would never do this.** The fix is a protocol change: the invite grants the *right* to receive the key, it is not the key itself. + +### The v2 invite protocol (spec only in this doc — NOT implemented this session) + +**Design goals** +1. No secret material in any user-visible string (URL, QR, paste buffer) +2. Invite URLs are short (<30 chars): `claudemesh.com/i/abc12345` +3. Existing v1 invites continue to work during a deprecation window +4. Revocation is clean and immediate +5. One recipient = one root-key-delivery capability + +**Flow** +``` +Admin creates invite (v2): + server generates short_code (base62, 8 chars, unique) + server stores in DB: {id, mesh_id, code, role, max_uses, expires_at, signed_capability} + signed_capability = ed25519_sign(canonical_v2_bytes, mesh.owner_secret_key) + canonical_v2_bytes = v=2 | mesh_id | invite_id | expires_at | role | owner_pubkey + NOTE: no root_key, no broker_url + returns: claudemesh.com/i/{code} + +Recipient clicks the link: + web: GET /api/public/invites/code/{code} + returns {mesh_name, inviter_name, role, expires_at, member_count} + no secrets, no signature leaked + web: shows consent landing: "You are joining ACME as a Member" + recipient authenticates (sign up / log in) OR runs CLI + +Recipient claims the invite: + CLI: generates session ed25519 keypair (ephemeral) + CLI: connects to broker ws://ic.claudemesh.com/ws + CLI: sends { type: "claim_invite", code, recipient_pubkey } + broker: looks up invite by code + broker: verifies signed_capability against mesh.owner_pubkey + broker: checks expires_at, max_uses vs used_count, revoked_at + broker: increments used_count, creates mesh.member row + broker: seals mesh.root_key with crypto_box_seal to recipient_pubkey + broker: returns { sealed_root_key, mesh_id, member_id } + CLI: unseals with its secret key → has root_key + CLI: starts normal mesh traffic + +Revocation: + admin sets invite.revoked_at = now() + any future claim fails at broker with invite_revoked + root_key is NOT rotated — past members keep access + (for "kick a member" semantics, use a separate member revocation, which DOES rotate the key) +``` + +**Properties** +- URL contains only `{code}` (8 chars base62) +- `signed_capability` lives server-side; leaks of the URL never expose the root key +- Screenshot of invite URL is harmless +- Link preview bots see nothing sensitive +- Broker DB is the source of truth for revocation + +**Migration strategy (v1 → v2)** +- Add `invite.code`, `invite.v2_capability` columns (nullable for existing rows) +- `createMyInvite` generates BOTH v1 token (legacy) and v2 code +- Web invite UI displays the short URL by default, long URL as "Legacy format" disclosure +- Broker accepts both formats until v0.2.0 +- Announce deprecation window; at v0.2.0 the long-format endpoints 410 Gone + +**Status update 2026-04-10 — v2 is now being implemented in parallel** + +The scope that was deferred at the top of the session is actively landing in a coordinated multi-agent push: +- Broker: new `/api/public/invites/:code/claim` endpoint, `crypto_box_seal` against recipient x25519 pubkey, signed capability verification, single-use accounting. +- DB: `mesh.invite.version` int, `mesh.invite.capability_v2` text nullable, `mesh.invite.claimed_by_pubkey` text nullable. New table `mesh.pending_invite` for email invites. +- CLI / web claim client: generates a fresh x25519 keypair (separate from the ed25519 identity), POSTs the pubkey, unseals the returned `sealed_root_key`, then verifies `canonical_v2` against `owner_pubkey`. +- Email invites (parallel track): Postmark delivery wired on top of `pending_invite`; the email body carries the same `claudemesh.com/i/{code}` short URL. + +v1 invites continue to work throughout v0.1.x. v1 endpoints return `410 Gone` at v0.2.0. + +Docs updated in the same session: `SPEC.md` §14b, `docs/protocol.md` (v2 invites subsection), `docs/roadmap.md` (in progress). + +--- + +### Severity 🟡 — implemented this session + +#### Short invite codes (URL shortening, backward-compatible) + +Additive: invites now get both a long token AND a short opaque code. The web app prefers the short URL. + +**DB:** new nullable `invite.code` column, unique. New migration `0018_invite-short-code.sql`. + +**API:** `createMyInvite` generates `code` (base62, 8 chars, collision-retry). Returns `shortUrl` alongside `inviteLink` / `joinUrl`. + +**Web:** new server route `/i/[code]/page.tsx` that resolves the code server-side and redirects to the canonical `/join/[token]` page. Invite generator UI shows the short URL as the primary "Copy link" target. + +**Backward compat:** existing invites without a `code` keep working via their long token. No broker/CLI changes. + +**This is NOT the v2 protocol.** It only fixes the URL-length problem. The root key is still embedded in the long token that the short code resolves to. The short code is a URL shortener, not a capability boundary. Document this clearly so nobody confuses the two. + +--- + +#### Collapsed advanced fields + +The invite form asks for `role`, `max uses`, `expires in days` upfront. 90% of users only ever create `{ role: member, max_uses: 1, expires_in_days: 7 }`. + +Change: defaults are pre-filled; the three fields are hidden behind an "Advanced" disclosure. + +--- + +### Severity 🟡 — deferred + +#### Invite by email + +- Requires an `invitation_email` table or equivalent pending-invites state +- Requires wire-up to email delivery (already have Postmark via turbostarter) +- Out of scope this session; fits naturally on top of v2 invite protocol + +#### Consent landing redesign + +- The `/join/[token]` page should show: mesh name, inviter, role being granted, member count, expiry, explicit "Join as Member of ACME" button +- Needs a design pass +- Deferred + +--- + +### Severity 🟢 — deferred + +- Remove `ic://` scheme — it's dead, nothing handles it, safe to delete in v0.1.x cleanup +- Received-but-not-clicked audit — falls out of email invites for free + +--- + +## Summary table + +| Change | Status | File(s) | +|---|---|---| +| Drop global slug uniqueness | ✅ done | `packages/db/src/schema/mesh.ts`, migration `0017` | +| Remove slug from create-mesh form | ✅ done | `apps/web/src/modules/mesh/create-mesh-form.tsx` | +| Server-derived slug from name | ✅ done | `packages/api/src/modules/mesh/mutations.ts` | +| Short invite codes (URL shortener) | ✅ done | `packages/db` migration `0018`, api, web `/i/[code]` | +| Collapse invite advanced fields | ✅ done | `apps/web/src/modules/mesh/invite-generator.tsx` | +| v2 invite protocol (root key out of URL) | 🚧 in progress | broker `/api/public/invites/:code/claim`, `mesh.invite.version` + `capability_v2` + `claimed_by_pubkey`, CLI/web claim client | +| Invite by email | 🚧 in progress | `mesh.pending_invite` table, Postmark delivery | +| Consent landing redesign | 📝 spec only | (future PR) | +| Remove `ic://` scheme | 📝 spec only | (cleanup PR) | + +--- + +## Non-goals (for clarity) + +- Not adding per-user mesh namespaces (`alice/platform`) — opaque IDs are enough +- Not adding vanity slugs at v0.1.x — can come as a Pro tier later +- Not changing the broker wire protocol this session +- Not rewriting the CLI join flow this session + +--- + +## Post-implementation checklist + +- [x] Web builds without type errors on changed files +- [x] Migrations run on production DB (`0017` applied; `0018` after review) +- [x] No broker protocol change (backward compat verified) +- [x] Existing long-token invites continue to resolve +- [x] New invites expose `shortUrl` in the API response diff --git a/SPEC.md b/SPEC.md index 8c28e4c..202a37f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -931,6 +931,72 @@ The session keypair generates once on first connect and survives reconnects. Mes --- +## 14b. Invites (v2 protocol) + +### Why v2 + +The v1 invite token embeds `mesh_root_key` (32-byte shared secret) inside a base64url URL. Any path that caches URLs — link previews, browser history, sync, screenshots, analytics pixels, error logs — is a permanent compromise of the mesh key. Revoking the invite does not rotate the key. The URL *is* the secret. + +v2 removes all secret material from the URL. The invite becomes a short opaque code that grants the *right* to receive the key, not the key itself. The server only releases the key after the recipient proves they can receive it, sealed to a public key the recipient controls. + +### Canonical bytes + +The mesh owner ed25519 secret key signs: + +``` +v=2|mesh_id|invite_id|expires_at_unix|role|owner_pubkey_hex +``` + +No `root_key`, no `broker_url`. The signed capability lives in the broker DB. The user-visible URL is `claudemesh.com/i/{code}` — base62, 8 chars. + +### Claim flow + +``` +1. Admin mints invite + broker stores {id, mesh_id, code, role, max_uses, expires_at, + signed_capability, version=2} + returns claudemesh.com/i/{code} + +2. Recipient lands on /i/{code} + web resolves the code, shows consent: mesh name, inviter, role, + expiry, member count. No secrets in the response. + +3. Recipient generates a fresh x25519 keypair + (separate from its ed25519 identity — distinct curve, distinct use) + +4. Recipient POSTs its x25519 public key + POST /api/public/invites/{code}/claim + body: { recipient_x25519_pubkey } + +5. Broker validates and seals + verifies signed_capability against mesh.owner_pubkey + checks expires_at, max_uses vs used_count, revoked_at + creates mesh.member row, increments used_count + sealed_root_key = crypto_box_seal(root_key, recipient_x25519_pubkey) + returns { sealed_root_key, mesh_id, member_id, owner_pubkey, + canonical_v2 } + +6. Recipient unseals with its x25519 secret + root_key = crypto_box_seal_open(sealed_root_key, recipient_x25519_sk) + joins normal mesh traffic +``` + +The server never sees the recipient's private key. `crypto_box_seal` is anonymous — no sender identity, no interaction beyond the single HTTP round trip. + +### v1 deprecation timeline + +- v0.1.x: the broker, CLI, and web accept both v1 (long token with embedded key) and v2 (short code + sealed key delivery). New invites default to v2. +- v0.2.0: v1 endpoints return `410 Gone`. Existing members already in a mesh are unaffected — the key rotation story is orthogonal to invite format. + +### DB additions + +- `mesh.invite.version` int default 1 +- `mesh.invite.capability_v2` text nullable — the canonical signed bytes +- `mesh.invite.claimed_by_pubkey` text nullable — the recipient x25519 pubkey used at claim time (audit trail, single-use enforcement) +- `mesh.pending_invite` new table for email invites: `{id, meshId, email, code, sentAt, acceptedAt, revokedAt, createdBy, createdAt}`. Email delivery goes through Postmark (already wired via turbostarter). + +--- + ## 14. Production hardening (implemented) | Feature | Description | diff --git a/apps/broker/src/crypto.ts b/apps/broker/src/crypto.ts index 52d2300..cb36205 100644 --- a/apps/broker/src/crypto.ts +++ b/apps/broker/src/crypto.ts @@ -7,7 +7,10 @@ * current member of the claimed mesh. */ +import { and, eq, isNull, lt, sql } from "drizzle-orm"; import sodium from "libsodium-wrappers"; +import { db } from "./db"; +import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh"; let ready = false; async function ensureSodium(): Promise { @@ -69,6 +72,70 @@ export async function verifyEd25519( } } +/** + * Canonical v2 invite bytes — signed by the mesh owner's ed25519 secret key. + * NOTE: deliberately does NOT include the root_key or broker_url; the v2 + * protocol moves the root_key out of the URL entirely. Format is locked: + * `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey` (no trailing newline). + */ +export function canonicalInviteV2(p: { + mesh_id: string; + invite_id: string; + expires_at: number; // unix seconds + role: "admin" | "member"; + owner_pubkey: string; // hex +}): string { + return `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`; +} + +/** + * Verify an ed25519 signature over the v2 canonical invite bytes against + * the mesh owner's public key. Returns true on valid signature. + */ +export async function verifyInviteV2(params: { + canonical: string; + signatureHex: string; + ownerPubkeyHex: string; +}): Promise { + return verifyEd25519( + params.canonical, + params.signatureHex, + params.ownerPubkeyHex, + ); +} + +/** + * Seal the mesh root_key to a recipient-provided x25519 public key using + * libsodium's sealed box (crypto_box_seal). Only the holder of the matching + * x25519 secret key can unseal. + * + * rootKeyBase64url is the mesh.root_key column value (base64url of 32 bytes). + * recipientX25519PubkeyBase64url is the 32-byte x25519 pubkey the recipient + * provided in its claim request. We do NOT convert an ed25519 pubkey here — + * the recipient generates a dedicated x25519 keypair and sends us the pubkey. + * + * Returns base64url of the sealed ciphertext. + */ +export async function sealRootKeyToRecipient(params: { + rootKeyBase64url: string; + recipientX25519PubkeyBase64url: string; +}): Promise { + const s = await ensureSodium(); + const rootKeyBytes = s.from_base64( + params.rootKeyBase64url, + s.base64_variants.URLSAFE_NO_PADDING, + ); + const recipientPk = s.from_base64( + params.recipientX25519PubkeyBase64url, + s.base64_variants.URLSAFE_NO_PADDING, + ); + if (recipientPk.length !== 32) { + throw new Error("recipient_x25519_pubkey must decode to 32 bytes"); + } + const sealed = s.crypto_box_seal(rootKeyBytes, recipientPk); + return s.to_base64(sealed, s.base64_variants.URLSAFE_NO_PADDING); +} + export const HELLO_SKEW_MS = 60_000; /** @@ -118,3 +185,185 @@ export async function verifyHelloSignature(args: { return { ok: false, reason: "malformed" }; } } + +// ---------------------------------------------------------------------------- +// v2 invite claim core — exported for the HTTP handler in index.ts AND for +// tests that need to exercise the logic without spinning up the broker server. +// ---------------------------------------------------------------------------- +// +// capabilityV2 column is stored as JSON: +// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey", +// "signature": "" } +// The broker recomputes the canonical bytes from the invite row and verifies +// the signature against mesh.ownerPubkey. v1 rows (version === 1 OR +// capabilityV2 === null) skip verification — the legacy path still works +// during the deprecation window. + +export type InviteClaimV2Result = + | { + ok: true; + status: 200; + body: { + sealed_root_key: string; + mesh_id: string; + member_id: string; + owner_pubkey: string; + canonical_v2: string; + }; + } + | { ok: false; status: 400 | 404 | 410; body: { error: string } }; + +export async function claimInviteV2Core(params: { + code: string; + recipientX25519PubkeyBase64url: string; + displayName?: string; + now?: number; +}): Promise { + const now = params.now ?? Date.now(); + const recipientPk = params.recipientX25519PubkeyBase64url; + + if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + + // 1. Look up the invite by opaque code. + const [inv] = await db + .select() + .from(inviteTable) + .where(eq(inviteTable.code, params.code)) + .limit(1); + if (!inv) return { ok: false, status: 404, body: { error: "not_found" } }; + + // 2. Lifecycle checks: revoked → expired → exhausted. + if (inv.revokedAt) { + return { ok: false, status: 410, body: { error: "revoked" } }; + } + if (inv.expiresAt.getTime() < now) { + return { ok: false, status: 410, body: { error: "expired" } }; + } + if (inv.usedCount >= inv.maxUses) { + return { ok: false, status: 410, body: { error: "exhausted" } }; + } + + // 3. Load the mesh for owner_pubkey + root_key. + const [m] = await db + .select({ + id: mesh.id, + ownerPubkey: mesh.ownerPubkey, + rootKey: mesh.rootKey, + }) + .from(mesh) + .where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt))) + .limit(1); + if (!m) return { ok: false, status: 404, body: { error: "not_found" } }; + if (!m.ownerPubkey || !m.rootKey) { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + + // 4. Compute canonical_v2 from the row (used in the response either way). + const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000); + const canonical = canonicalInviteV2({ + mesh_id: inv.meshId, + invite_id: inv.id, + expires_at: expiresAtUnix, + role: inv.role as "admin" | "member", + owner_pubkey: m.ownerPubkey, + }); + + if (inv.version === 2 && inv.capabilityV2) { + let storedCanonical: string | undefined; + let signatureHex: string | undefined; + try { + const parsed = JSON.parse(inv.capabilityV2) as { + canonical?: string; + signature?: string; + }; + storedCanonical = parsed.canonical; + signatureHex = parsed.signature; + } catch { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + if (!storedCanonical || !signatureHex) { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + // Broker-recomputed canonical must match the signed bytes exactly. + if (storedCanonical !== canonical) { + return { ok: false, status: 400, body: { error: "bad_signature" } }; + } + const sigOk = await verifyInviteV2({ + canonical: storedCanonical, + signatureHex, + ownerPubkeyHex: m.ownerPubkey, + }); + if (!sigOk) { + return { ok: false, status: 400, body: { error: "bad_signature" } }; + } + } + // v1 rows: skip signature verification (legacy path during migration). + + // 5. Atomic consume: increment used_count iff still under max_uses. + const [claimed] = await db + .update(inviteTable) + .set({ + usedCount: sql`${inviteTable.usedCount} + 1`, + claimedByPubkey: recipientPk, + }) + .where( + and( + eq(inviteTable.id, inv.id), + lt(inviteTable.usedCount, inv.maxUses), + ), + ) + .returning({ id: inviteTable.id }); + if (!claimed) { + return { ok: false, status: 410, body: { error: "exhausted" } }; + } + + // 6. Create a member row for the claimant. + const preset = (inv.preset as { + displayName?: string; + roleTag?: string; + groups?: Array<{ name: string; role?: string }>; + messageMode?: string; + } | null) ?? {}; + const displayName = + preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`; + const [row] = await db + .insert(meshMember) + .values({ + meshId: inv.meshId, + peerPubkey: recipientPk, + displayName, + role: inv.role, + roleTag: preset.roleTag ?? null, + defaultGroups: preset.groups ?? [], + messageMode: preset.messageMode ?? "push", + }) + .returning({ id: meshMember.id }); + if (!row) { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + + // 7. Seal the mesh root_key to the recipient's x25519 pubkey. + let sealed: string; + try { + sealed = await sealRootKeyToRecipient({ + rootKeyBase64url: m.rootKey, + recipientX25519PubkeyBase64url: recipientPk, + }); + } catch { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + + return { + ok: true, + status: 200, + body: { + sealed_root_key: sealed, + mesh_id: inv.meshId, + member_id: row.id, + owner_pubkey: m.ownerPubkey, + canonical_v2: canonical, + }, + }; +} diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 9862798..34a5f33 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -15,10 +15,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { Duplex } from "node:stream"; import { WebSocketServer, type WebSocket } from "ws"; -import { and, eq, isNull, sql } from "drizzle-orm"; +import { and, eq, isNull, lt, sql } from "drizzle-orm"; import { env } from "./env"; import { db } from "./db"; -import { mesh, meshMember, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh"; +import { invite as inviteTable, mesh, meshMember, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh"; import { user } from "@turbostarter/db/schema/auth"; import { handleCliSync, type CliSyncRequest } from "./cli-sync"; import { updateMemberProfile, listMeshMembers, updateMeshSettings } from "./member-api"; @@ -102,7 +102,7 @@ import { metrics, metricsToText } from "./metrics"; import { TokenBucket } from "./rate-limit"; import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health"; import { buildInfo } from "./build-info"; -import { verifyHelloSignature } from "./crypto"; +import { canonicalInviteV2, sealRootKeyToRecipient, verifyHelloSignature, verifyInviteV2 } from "./crypto"; import { handleWebhook } from "./webhooks"; import { audit, loadLastHashes, ensureAuditLogTable, verifyChain, queryAuditLog } from "./audit"; @@ -590,6 +590,16 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void { return; } + // v2 invite claim: POST /invites/:code/claim + // Body: { recipient_x25519_pubkey: "" } + // On success, returns a sealed copy of the mesh root_key the recipient + // alone can unseal. See .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md + const claimMatch = req.method === "POST" && req.url?.match(/^\/invites\/([^/]+)\/claim$/); + if (claimMatch) { + handleInviteClaimV2Post(req, res, claimMatch[1]!, started); + return; + } + if (req.method === "POST" && req.url === "/upload") { handleUploadPost(req, res, started); return; @@ -864,6 +874,270 @@ function handleJoinPost( }); } +// ---------------------------------------------------------------------------- +// v2 invite claim — POST /invites/:code/claim +// ---------------------------------------------------------------------------- +// The v2 protocol moves the mesh root_key out of the invite URL. Invite +// URLs are short opaque codes; on claim the broker verifies the signed +// capability (stored server-side) and seals the root_key to a recipient- +// provided x25519 pubkey so only that recipient can unseal it. +// +// capabilityV2 is stored as JSON on the invite row: +// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey", +// "signature": "" } +// The broker recomputes the canonical bytes from the invite row and +// verifies the signature against mesh.ownerPubkey. +// +// v1 rows (version === 1 OR capabilityV2 === null) are still accepted: +// the broker computes the v2 canonical on the fly from the row, but +// skips signature verification since there is no v2 signature on file. +// This lets v2 clients claim legacy invites during the deprecation window. + +export type InviteClaimV2Result = + | { + ok: true; + status: 200; + body: { + sealed_root_key: string; + mesh_id: string; + member_id: string; + owner_pubkey: string; + canonical_v2: string; + }; + } + | { ok: false; status: 400 | 404 | 410; body: { error: string } }; + +/** + * Core claim logic, extracted from the HTTP handler so tests can call it + * directly without spinning up the full broker server. + */ +export async function claimInviteV2Core(params: { + code: string; + recipientX25519PubkeyBase64url: string; + displayName?: string; + now?: number; +}): Promise { + const now = params.now ?? Date.now(); + const recipientPk = params.recipientX25519PubkeyBase64url; + + // Cheap shape check on the recipient pubkey — full length check happens + // inside sealRootKeyToRecipient, but reject obvious garbage early so + // we return 400 malformed before touching the DB. + if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + + // 1. Look up the invite by opaque code. + const [inv] = await db + .select() + .from(inviteTable) + .where(eq(inviteTable.code, params.code)) + .limit(1); + if (!inv) return { ok: false, status: 404, body: { error: "not_found" } }; + + // 2. Lifecycle checks: revoked → expired → exhausted. + if (inv.revokedAt) { + return { ok: false, status: 410, body: { error: "revoked" } }; + } + if (inv.expiresAt.getTime() < now) { + return { ok: false, status: 410, body: { error: "expired" } }; + } + if (inv.usedCount >= inv.maxUses) { + return { ok: false, status: 410, body: { error: "exhausted" } }; + } + + // 3. Load the mesh for owner_pubkey + root_key. + const [m] = await db + .select({ + id: mesh.id, + ownerPubkey: mesh.ownerPubkey, + rootKey: mesh.rootKey, + }) + .from(mesh) + .where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt))) + .limit(1); + if (!m) return { ok: false, status: 404, body: { error: "not_found" } }; + if (!m.ownerPubkey || !m.rootKey) { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + + // 4. v2 signature verification when applicable. + // Always compute the canonical on the fly so the response can echo it. + const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000); + const canonical = canonicalInviteV2({ + mesh_id: inv.meshId, + invite_id: inv.id, + expires_at: expiresAtUnix, + role: inv.role as "admin" | "member", + owner_pubkey: m.ownerPubkey, + }); + + if (inv.version === 2 && inv.capabilityV2) { + // Parse capability + verify. + let storedCanonical: string | undefined; + let signatureHex: string | undefined; + try { + const parsed = JSON.parse(inv.capabilityV2) as { + canonical?: string; + signature?: string; + }; + storedCanonical = parsed.canonical; + signatureHex = parsed.signature; + } catch { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + if (!storedCanonical || !signatureHex) { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + // Broker-recomputed canonical must match the signed bytes exactly. + if (storedCanonical !== canonical) { + return { ok: false, status: 400, body: { error: "bad_signature" } }; + } + const sigOk = await verifyInviteV2({ + canonical: storedCanonical, + signatureHex, + ownerPubkeyHex: m.ownerPubkey, + }); + if (!sigOk) { + return { ok: false, status: 400, body: { error: "bad_signature" } }; + } + } + // v1 rows: skip signature verification (legacy path during migration). + + // 5. Atomic consume: increment used_count iff still under max_uses. + // Mirrors the invariant enforced for v1 joins in broker.joinMesh(). + const [claimed] = await db + .update(inviteTable) + .set({ + usedCount: sql`${inviteTable.usedCount} + 1`, + claimedByPubkey: recipientPk, + }) + .where( + and( + eq(inviteTable.id, inv.id), + lt(inviteTable.usedCount, inv.maxUses), + ), + ) + .returning({ id: inviteTable.id }); + if (!claimed) { + return { ok: false, status: 410, body: { error: "exhausted" } }; + } + + // 6. Create a member row for the claimant. The peerPubkey column holds + // the claimant's signing identity; for v2 the recipient hasn't + // necessarily connected over WS yet, so we use the x25519 pubkey as + // a placeholder for the pre-claim phase. This matches the spec's + // "one recipient = one root-key-delivery capability" invariant. + const preset = (inv.preset as { + displayName?: string; + roleTag?: string; + groups?: Array<{ name: string; role?: string }>; + messageMode?: string; + } | null) ?? {}; + const displayName = + preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`; + const [row] = await db + .insert(meshMember) + .values({ + meshId: inv.meshId, + peerPubkey: recipientPk, + displayName, + role: inv.role, + roleTag: preset.roleTag ?? null, + defaultGroups: preset.groups ?? [], + messageMode: preset.messageMode ?? "push", + }) + .returning({ id: meshMember.id }); + if (!row) { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + + // 7. Seal the mesh root_key to the recipient's x25519 pubkey. + let sealed: string; + try { + sealed = await sealRootKeyToRecipient({ + rootKeyBase64url: m.rootKey, + recipientX25519PubkeyBase64url: recipientPk, + }); + } catch { + return { ok: false, status: 400, body: { error: "malformed" } }; + } + + return { + ok: true, + status: 200, + body: { + sealed_root_key: sealed, + mesh_id: inv.meshId, + member_id: row.id, + owner_pubkey: m.ownerPubkey, + canonical_v2: canonical, + }, + }; +} + +function handleInviteClaimV2Post( + req: IncomingMessage, + res: ServerResponse, + code: string, + started: number, +): void { + const chunks: Buffer[] = []; + let total = 0; + let aborted = false; + req.on("data", (chunk: Buffer) => { + if (aborted) return; + total += chunk.length; + if (total > env.MAX_MESSAGE_BYTES) { + aborted = true; + writeJson(res, 413, { error: "payload too large" }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", async () => { + if (aborted) return; + try { + const raw = Buffer.concat(chunks).toString(); + let payload: { recipient_x25519_pubkey?: string; display_name?: string }; + try { + payload = JSON.parse(raw); + } catch { + writeJson(res, 400, { error: "malformed" }); + return; + } + if ( + !payload.recipient_x25519_pubkey || + typeof payload.recipient_x25519_pubkey !== "string" + ) { + writeJson(res, 400, { error: "malformed" }); + return; + } + const result = await claimInviteV2Core({ + code, + recipientX25519PubkeyBase64url: payload.recipient_x25519_pubkey, + displayName: payload.display_name, + }); + writeJson(res, result.status, result.body); + log.info("invite claim v2", { + route: "POST /invites/:code/claim", + code, + status: result.status, + ok: result.ok, + latency_ms: Date.now() - started, + }); + } catch (e) { + writeJson(res, 500, { + error: e instanceof Error ? e.message : String(e), + }); + log.error("invite claim v2 handler error", { + error: e instanceof Error ? e.message : String(e), + }); + } + }); +} + function handleUploadPost( req: IncomingMessage, res: ServerResponse, @@ -4282,4 +4556,8 @@ function main(): void { }); } -main(); +// Skip starting the HTTP/WS server when running under vitest — tests import +// claimInviteV2Core() directly and must not bind ports on module load. +if (!process.env.VITEST) { + main(); +} diff --git a/apps/broker/tests/invite-v2.test.ts b/apps/broker/tests/invite-v2.test.ts new file mode 100644 index 0000000..25dc1dd --- /dev/null +++ b/apps/broker/tests/invite-v2.test.ts @@ -0,0 +1,268 @@ +/** + * v2 invite protocol — broker claim endpoint. + * + * Covers the sealed-root-key delivery flow added in + * .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md : + * + * - happy path: signed v2 invite claim returns a sealed root_key the + * recipient can unseal back to the mesh.rootKey column value + * - tampered signature → 400 bad_signature + * - expired invite → 410 expired + * - revoked invite → 410 revoked + * - exhausted invite (usedCount === maxUses) → 410 exhausted + * - round-trip: recipient-side crypto_box_seal_open recovers the real key + * + * Tests talk directly to claimInviteV2Core() to avoid spinning up the + * full broker HTTP server. The handler delegates to this function with + * zero extra logic, so coverage is equivalent. + */ + +import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest"; +import { eq } from "drizzle-orm"; +import sodium from "libsodium-wrappers"; +import { db } from "../src/db"; +import { invite, mesh } from "@turbostarter/db/schema/mesh"; +import { canonicalInviteV2 } from "../src/crypto"; +import { claimInviteV2Core } from "../src/index"; +import { + cleanupAllTestMeshes, + setupTestMesh, + type TestMesh, +} from "./helpers"; + +afterAll(async () => { + await cleanupAllTestMeshes(); +}); + +beforeAll(async () => { + await sodium.ready; +}); + +/** + * Set a random base64url root_key on an existing test mesh. The helpers + * don't set one by default, so v2 tests prime it per-mesh here. + */ +async function primeRootKey(meshId: string): Promise { + const key = sodium.randombytes_buf(32); + const b64 = sodium.to_base64(key, sodium.base64_variants.URLSAFE_NO_PADDING); + await db.update(mesh).set({ rootKey: b64 }).where(eq(mesh.id, meshId)); + return key; +} + +/** + * Insert a signed v2 invite row. Returns the opaque short code + the + * recipient x25519 keypair the test will use to unseal. + */ +async function insertV2Invite( + m: TestMesh, + opts: { + code: string; + expiresInSec?: number; + maxUses?: number; + role?: "admin" | "member"; + tamper?: boolean; // corrupt the signature + revoked?: boolean; + used?: number; + }, +): Promise<{ inviteId: string; canonical: string }> { + const expiresInSec = opts.expiresInSec ?? 3600; + const expiresAt = new Date(Date.now() + expiresInSec * 1000); + const maxUses = opts.maxUses ?? 1; + const role = opts.role ?? "member"; + + // Insert first with a placeholder capability so we have the invite id. + const [row] = await db + .insert(invite) + .values({ + meshId: m.meshId, + token: `v2-test-token-${opts.code}`, + code: opts.code, + maxUses, + usedCount: opts.used ?? 0, + role, + expiresAt, + createdBy: "test-user-integration", + version: 2, + revokedAt: opts.revoked ? new Date() : null, + }) + .returning({ id: invite.id }); + if (!row) throw new Error("v2 invite insert failed"); + + // Now compute canonical_v2 using the real invite id and sign with the + // mesh owner's ed25519 secret key. + const expiresAtUnix = Math.floor(expiresAt.getTime() / 1000); + const canonical = canonicalInviteV2({ + mesh_id: m.meshId, + invite_id: row.id, + expires_at: expiresAtUnix, + role, + owner_pubkey: m.ownerPubkey, + }); + let signatureHex = sodium.to_hex( + sodium.crypto_sign_detached( + sodium.from_string(canonical), + sodium.from_hex(m.ownerSecretKey), + ), + ); + if (opts.tamper) { + // Flip a single hex nibble — keeps length valid, invalidates signature. + const first = signatureHex[0] === "0" ? "1" : "0"; + signatureHex = first + signatureHex.slice(1); + } + + const capability = JSON.stringify({ + canonical, + signature: signatureHex, + }); + await db + .update(invite) + .set({ capabilityV2: capability }) + .where(eq(invite.id, row.id)); + return { inviteId: row.id, canonical }; +} + +function genRecipientX25519(): { pk: string; sk: Uint8Array } { + const kp = sodium.crypto_box_keypair(); + return { + pk: sodium.to_base64(kp.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING), + sk: kp.privateKey, + }; +} + +describe("claimInviteV2Core — v2 invite claim", () => { + let m: TestMesh; + afterEach(async () => m && (await m.cleanup())); + + test("happy path: signed v2 invite returns sealed root_key and member row", async () => { + m = await setupTestMesh("v2-ok"); + const rootKeyBytes = await primeRootKey(m.meshId); + const code = `c${Math.random().toString(36).slice(2, 10)}`; + const { inviteId, canonical } = await insertV2Invite(m, { code }); + const recipient = genRecipientX25519(); + + const result = await claimInviteV2Core({ + code, + recipientX25519PubkeyBase64url: recipient.pk, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.status).toBe(200); + expect(result.body.mesh_id).toBe(m.meshId); + expect(result.body.owner_pubkey).toBe(m.ownerPubkey); + expect(result.body.canonical_v2).toBe(canonical); + expect(result.body.member_id).toBeTruthy(); + + // Recipient unseals the sealed_root_key using its x25519 secret key. + const sealed = sodium.from_base64( + result.body.sealed_root_key, + sodium.base64_variants.URLSAFE_NO_PADDING, + ); + const recipientPkBytes = sodium.from_base64( + recipient.pk, + sodium.base64_variants.URLSAFE_NO_PADDING, + ); + const opened = sodium.crypto_box_seal_open( + sealed, + recipientPkBytes, + recipient.sk, + ); + expect(opened).toBeInstanceOf(Uint8Array); + expect(opened.length).toBe(32); + expect(Array.from(opened)).toEqual(Array.from(rootKeyBytes)); + + // usedCount incremented and claimedByPubkey recorded. + const [updated] = await db + .select({ + usedCount: invite.usedCount, + claimedByPubkey: invite.claimedByPubkey, + }) + .from(invite) + .where(eq(invite.id, inviteId)); + expect(updated?.usedCount).toBe(1); + expect(updated?.claimedByPubkey).toBe(recipient.pk); + }); + + test("tampered signature → 400 bad_signature", async () => { + m = await setupTestMesh("v2-tampered"); + await primeRootKey(m.meshId); + const code = `c${Math.random().toString(36).slice(2, 10)}`; + await insertV2Invite(m, { code, tamper: true }); + const recipient = genRecipientX25519(); + + const result = await claimInviteV2Core({ + code, + recipientX25519PubkeyBase64url: recipient.pk, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(400); + expect(result.body.error).toBe("bad_signature"); + }); + + test("expired invite → 410 expired", async () => { + m = await setupTestMesh("v2-expired"); + await primeRootKey(m.meshId); + const code = `c${Math.random().toString(36).slice(2, 10)}`; + await insertV2Invite(m, { code, expiresInSec: -60 }); + const recipient = genRecipientX25519(); + + const result = await claimInviteV2Core({ + code, + recipientX25519PubkeyBase64url: recipient.pk, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(410); + expect(result.body.error).toBe("expired"); + }); + + test("revoked invite → 410 revoked", async () => { + m = await setupTestMesh("v2-revoked"); + await primeRootKey(m.meshId); + const code = `c${Math.random().toString(36).slice(2, 10)}`; + await insertV2Invite(m, { code, revoked: true }); + const recipient = genRecipientX25519(); + + const result = await claimInviteV2Core({ + code, + recipientX25519PubkeyBase64url: recipient.pk, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(410); + expect(result.body.error).toBe("revoked"); + }); + + test("exhausted invite (usedCount >= maxUses) → 410 exhausted", async () => { + m = await setupTestMesh("v2-exhausted"); + await primeRootKey(m.meshId); + const code = `c${Math.random().toString(36).slice(2, 10)}`; + await insertV2Invite(m, { code, maxUses: 1, used: 1 }); + const recipient = genRecipientX25519(); + + const result = await claimInviteV2Core({ + code, + recipientX25519PubkeyBase64url: recipient.pk, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(410); + expect(result.body.error).toBe("exhausted"); + }); + + test("unknown code → 404 not_found", async () => { + m = await setupTestMesh("v2-404"); + await primeRootKey(m.meshId); + const recipient = genRecipientX25519(); + + const result = await claimInviteV2Core({ + code: "nonexistent", + recipientX25519PubkeyBase64url: recipient.pk, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(404); + expect(result.body.error).toBe("not_found"); + }); +}); diff --git a/apps/web/src/app/[locale]/i/[code]/page.tsx b/apps/web/src/app/[locale]/i/[code]/page.tsx new file mode 100644 index 0000000..40942f7 --- /dev/null +++ b/apps/web/src/app/[locale]/i/[code]/page.tsx @@ -0,0 +1,48 @@ +import { notFound, redirect } from "next/navigation"; + +import { api } from "~/lib/api/server"; +import { getMetadata } from "~/lib/metadata"; + +export const generateMetadata = getMetadata({ + title: "Join a mesh", + description: "You've been invited to a claudemesh mesh.", +}); + +/** + * Short invite URL: /i/{code} + * + * Resolves the short code to the canonical long token server-side and + * redirects to `/join/[token]`. Keeps the rest of the join UX in a single + * place and leaves the broker protocol untouched. + * + * This is a URL shortener, NOT a security boundary — the long token still + * carries the mesh root_key. See the v2 invite protocol spec: + * .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md + */ +export default async function ShortInvitePage({ + params, +}: { + params: Promise<{ locale: string; code: string }>; +}) { + const { locale, code } = await params; + + // Hit the public resolver. Returns {found, token} or 404. + const res = await api.public["invite-code"][":code"] + .$get({ param: { code } }) + .catch(() => null); + + if (!res || !res.ok) { + notFound(); + } + + const body = (await res.json()) as + | { found: true; token: string } + | { found: false }; + + if (!body.found) { + notFound(); + } + + // next/navigation `redirect` throws — no need to return anything after. + redirect(`/${locale}/join/${body.token}`); +} diff --git a/apps/web/src/app/[locale]/join/[token]/page.tsx b/apps/web/src/app/[locale]/join/[token]/page.tsx index c0bf996..46bbfee 100644 --- a/apps/web/src/app/[locale]/join/[token]/page.tsx +++ b/apps/web/src/app/[locale]/join/[token]/page.tsx @@ -9,6 +9,7 @@ import { handle } from "@turbostarter/api/utils"; import { api } from "~/lib/api/server"; import { getMetadata } from "~/lib/metadata"; import { InstallToggle } from "~/modules/join/install-toggle"; +import { InviteCard } from "~/modules/join/invite-card"; export const generateMetadata = getMetadata({ title: "Join a mesh", @@ -112,42 +113,29 @@ export default async function JoinPage({ -
+
{invite.valid ? ( <> -
- — invitation -
-

- You're invited to{" "} - - {invite.meshName} - -

-

- {invite.inviterName - ? `${invite.inviterName} added you as a ${invite.role}.` - : `You've been added as a ${invite.role}.`}{" "} - {invite.memberCount} other{" "} - {invite.memberCount === 1 ? "peer is" : "peers are"} already on - the mesh. -

+ -
+
+
+ — to accept, run this in your terminal +
By joining, you'll be known as a peer with an ed25519 @@ -163,24 +151,27 @@ export default async function JoinPage({

- expires {new Date(invite.expiresAt).toLocaleDateString()} ·{" "} {invite.maxUses - invite.usedCount} of {invite.maxUses} uses remaining

) : ( - <> +
— invitation unavailable

{ERROR_COPY[invite.reason].title} @@ -210,7 +201,7 @@ export default async function JoinPage({ ← claudemesh.com

- + )}
diff --git a/apps/web/src/modules/join/consent-summary.tsx b/apps/web/src/modules/join/consent-summary.tsx new file mode 100644 index 0000000..9670d59 --- /dev/null +++ b/apps/web/src/modules/join/consent-summary.tsx @@ -0,0 +1,45 @@ +const BULLETS = [ + "Send and receive end-to-end encrypted messages with every peer on the mesh", + "Read the shared audit log of mesh events", + "Generate a local ed25519 keypair — your secret key never leaves your machine", +] as const; + +export function ConsentSummary() { + return ( +
+
+ Joining this mesh will let you +
+
    + {BULLETS.map((text) => ( +
  • + + {text} +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/modules/join/invite-card.tsx b/apps/web/src/modules/join/invite-card.tsx new file mode 100644 index 0000000..593755e --- /dev/null +++ b/apps/web/src/modules/join/invite-card.tsx @@ -0,0 +1,119 @@ +import { ConsentSummary } from "./consent-summary"; +import { InviterLine } from "./inviter-line"; +import { RoleBadge, roleLabel } from "./role-badge"; + +interface InviteCardProps { + meshName: string; + inviterName: string | null; + role: "admin" | "member"; + memberCount: number; + expiresAt: Date; +} + +export function InviteCard({ + meshName, + inviterName, + role, + memberCount, + expiresAt, +}: InviteCardProps) { + const peerWord = memberCount === 1 ? "peer" : "peers"; + + return ( +
+ {/* Eyebrow */} +
+ — invitation +
+ + {/* Hero */} +

+ You've been invited to join{" "} + {meshName} +

+ + {/* Inviter + stats row */} +
+ +
+
+
+ + {/* Role badge */} +
+ +
+ + {/* Consent bullets */} +
+ +
+ + {/* Primary action block */} +
+ + Join {meshName} as {roleLabel(role)} + + +

+ + valid until{" "} + {expiresAt.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + + Not you? Sign out + +

+
+
+ ); +} diff --git a/apps/web/src/modules/join/inviter-line.tsx b/apps/web/src/modules/join/inviter-line.tsx new file mode 100644 index 0000000..a05ae45 --- /dev/null +++ b/apps/web/src/modules/join/inviter-line.tsx @@ -0,0 +1,29 @@ +interface InviterLineProps { + inviterName: string | null; +} + +export function InviterLine({ inviterName }: InviterLineProps) { + const initial = (inviterName ?? "?").trim().charAt(0).toUpperCase() || "?"; + return ( +
+ +
+ + Invited by + + + {inviterName ?? "the mesh owner"} + +
+
+ ); +} diff --git a/apps/web/src/modules/join/role-badge.tsx b/apps/web/src/modules/join/role-badge.tsx new file mode 100644 index 0000000..cbdf8b3 --- /dev/null +++ b/apps/web/src/modules/join/role-badge.tsx @@ -0,0 +1,110 @@ +type Role = "admin" | "member"; + +const ROLE_CONFIG: Record< + Role, + { + label: string; + description: string; + icon: React.ReactNode; + accent: string; + dot: string; + } +> = { + admin: { + label: "Admin", + description: + "Full control: invite and remove peers, manage settings, send and receive messages.", + // subtle warning treatment — fig (pinkish) accent, not alarming + accent: "#c46686", + dot: "#c46686", + icon: ( + + ), + }, + member: { + label: "Member", + description: + "Send and receive messages, read the shared audit log, participate in mesh traffic.", + accent: "var(--cm-clay)", + dot: "var(--cm-clay)", + icon: ( + + ), + }, +}; + +interface RoleBadgeProps { + role: Role; +} + +export function RoleBadge({ role }: RoleBadgeProps) { + const cfg = ROLE_CONFIG[role]; + return ( +
+
+ {cfg.icon} +
+
+
+ + You'll join as {cfg.label} + +
+

+ {cfg.description} +

+
+
+ ); +} + +export function roleLabel(role: Role) { + return ROLE_CONFIG[role].label; +} diff --git a/apps/web/src/modules/mesh/create-mesh-form.tsx b/apps/web/src/modules/mesh/create-mesh-form.tsx index 2e1628e..54dcaea 100644 --- a/apps/web/src/modules/mesh/create-mesh-form.tsx +++ b/apps/web/src/modules/mesh/create-mesh-form.tsx @@ -1,7 +1,6 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -32,14 +31,6 @@ import { import { pathsConfig } from "~/config/paths"; import { api } from "~/lib/api/client"; -const slugify = (s: string) => - s - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 40); - export const CreateMeshForm = ({ onboarding = false, }: { onboarding?: boolean } = {}) => { @@ -48,30 +39,16 @@ export const CreateMeshForm = ({ resolver: zodResolver(createMyMeshInputSchema), defaultValues: { name: "", - slug: "", visibility: "private", transport: "managed", }, }); - const nameValue = form.watch("name"); - const slugDirty = form.formState.dirtyFields.slug; - - useEffect(() => { - if (!slugDirty && nameValue) { - form.setValue("slug", slugify(nameValue)); - } - }, [nameValue, slugDirty, form]); - const onSubmit = async (values: CreateMyMeshInput) => { try { const res = (await handle(api.my.meshes.$post)({ json: values, - })) as { id: string; slug: string } | { error: string }; - if ("error" in res) { - form.setError("slug", { message: res.error }); - return; - } + })) as { id: string; slug: string }; router.push( onboarding ? `${pathsConfig.dashboard.user.meshes.invite(res.id)}?onboarding=1` @@ -97,23 +74,7 @@ export const CreateMeshForm = ({ - Display name — what teammates see. - - - - )} - /> - ( - - Slug - - - - - URL-safe identifier: lowercase letters, digits, hyphens. + Display name — what teammates see. Pick anything. diff --git a/apps/web/src/modules/mesh/invite-generator.tsx b/apps/web/src/modules/mesh/invite-generator.tsx index 9dae42b..5340f43 100644 --- a/apps/web/src/modules/mesh/invite-generator.tsx +++ b/apps/web/src/modules/mesh/invite-generator.tsx @@ -36,6 +36,8 @@ interface GeneratedInvite { token: string; inviteLink: string; joinUrl: string; + /** Short human-friendly URL, preferred for sharing. Null if the backend didn't mint one. */ + shortUrl: string | null; expiresAt: Date; qrDataUrl: string; } @@ -43,6 +45,7 @@ interface GeneratedInvite { export const InviteGenerator = ({ meshId }: { meshId: string }) => { const [result, setResult] = useState(null); const [copied, setCopied] = useState<"url" | "cli" | null>(null); + const [showAdvanced, setShowAdvanced] = useState(false); const form = useForm({ resolver: zodResolver(createMyInviteInputSchema), @@ -54,24 +57,20 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { const res = (await handle(api.my.meshes[":id"].invites.$post)({ param: { id: meshId }, json: values, - })) as - | { - id: string; - token: string; - inviteLink: string; - joinUrl: string; - expiresAt: string; - } - | { error: string }; + })) as { + id: string; + token: string; + inviteLink: string; + joinUrl: string; + shortUrl: string | null; + expiresAt: string; + }; - if ("error" in res) { - form.setError("root", { message: res.error }); - return; - } - - // QR encodes the HTTPS join URL now — anyone with a camera can - // scan and land on the friendly /join/[token] page. - const qrDataUrl = await QRCode.toDataURL(res.joinUrl, { + // QR encodes the SHORT URL when available — scannable at camera distance + // and short enough for the QR to stay low-density. Falls back to the + // long token URL for legacy invites minted before the shortener shipped. + const qrTarget = res.shortUrl ?? res.joinUrl; + const qrDataUrl = await QRCode.toDataURL(qrTarget, { width: 256, margin: 1, color: { dark: "#141413", light: "#ffffff" }, @@ -82,6 +81,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { token: res.token, inviteLink: res.inviteLink, joinUrl: res.joinUrl, + shortUrl: res.shortUrl, expiresAt: new Date(res.expiresAt), qrDataUrl, }); @@ -99,6 +99,10 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { }; if (result) { + // Prefer the short URL everywhere it exists. CLI command still uses the + // long token because the broker resolves by token — swapping CLI to short + // codes is part of the v2 protocol, not this URL-shortener change. + const primaryUrl = result.shortUrl ?? result.joinUrl; const cliCmd = `claudemesh join ${result.token}`; return (
@@ -117,7 +121,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { Share this link
- {result.joinUrl} + {primaryUrl}
@@ -126,7 +130,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
- + {showAdvanced && ( +
+ ( + + Role + + + + )} + /> + ( + + Max uses + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + ( + + Expires in (days) + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> +
)} - /> - ( - - Max uses - - field.onChange(Number(e.target.value))} - /> - - - - )} - /> - ( - - Expires in (days) - - field.onChange(Number(e.target.value))} - /> - - - - )} - /> +
+ {form.formState.errors.root && (

{form.formState.errors.root.message} diff --git a/docs/protocol.md b/docs/protocol.md index 423a715..9492077 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -366,6 +366,74 @@ the new peer and rebroadcasts presence. Invite-link issuance: [`apps/cli/src/invite/`](../apps/cli/src/invite/). +### v2 invites (in progress) + +v1 embeds the mesh root key inside the URL. v2 removes it: the URL is a +short opaque code, and the root key is sealed to a recipient-controlled +x25519 public key on claim. Both formats are accepted through v0.1.x; +v1 is removed at v0.2.0. + +Canonical bytes signed by the mesh owner ed25519 secret: + +``` +v=2|mesh_id|invite_id|expires_at_unix|role|owner_pubkey_hex +``` + +User-visible URL: `https://claudemesh.com/i/{code}` (base62, 8 chars). + +#### Claim endpoint + +``` +POST /api/public/invites/:code/claim +Content-Type: application/json + +{ + "recipient_x25519_pubkey": "" +} +``` + +The recipient generates a fresh x25519 keypair (distinct from its +ed25519 identity) and sends the public half. The server never sees the +secret. + +Success response: + +```jsonc +{ + "sealed_root_key": "", // crypto_box_seal(root_key, recipient_pubkey) + "mesh_id": "", + "member_id": "", + "owner_pubkey": "", // mesh owner ed25519 pubkey + "canonical_v2": "v=2|..." // the signed bytes, for local verification +} +``` + +The recipient unseals with `crypto_box_seal_open` using its x25519 +secret key, then verifies `canonical_v2` against `owner_pubkey`. + +#### Error codes + +| Status | Body `code` | Meaning | +|--------|-------------|---------| +| 400 | `malformed` | Body missing or `recipient_x25519_pubkey` not a valid 32-byte key | +| 400 | `bad_signature` | Stored `capability_v2` fails ed25519 verification against the mesh owner pubkey | +| 404 | `not_found` | No invite row matches `code` | +| 410 | `expired` | `expires_at` is in the past | +| 410 | `revoked` | `revoked_at` is set | +| 410 | `exhausted` | `used_count >= max_uses` | + +The broker increments `used_count` and stores +`claimed_by_pubkey = recipient_x25519_pubkey` atomically with the +member row insert. A second claim against a single-use invite fails +with `410 exhausted`. + +#### Email invites + +A `pending_invite` row is created when an admin invites by email. The +email contains `https://claudemesh.com/i/{code}` — the same short URL +surface as link invites. On successful claim the broker sets +`pending_invite.accepted_at`. + --- ## Self-hosting diff --git a/docs/roadmap.md b/docs/roadmap.md index adce70d..46538dc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -17,6 +17,21 @@ broker, ready for real teams. --- +## In progress — *v0.1.x* + +Security and onboarding work landing inside the v0.1 line, before +v0.2.0 cuts. + +- **v2 invite protocol** — short opaque codes (`claudemesh.com/i/{code}`) + replace base64url URLs that embedded the mesh root key. The key is + now sealed to a recipient-controlled x25519 pubkey on claim, never in + a URL. v1 invites keep working through v0.1.x; removed at v0.2.0. +- **Email invites** — admins invite by email. A new `pending_invite` + table tracks `{email, code, sentAt, acceptedAt, revokedAt}`; + delivery goes through Postmark. + +--- + ## v0.2.0 — *next* The surface layer. The protocol is ready; these are gateways + routing diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts index c7f84fd..a80e50b 100644 --- a/packages/api/src/modules/mesh/mutations.ts +++ b/packages/api/src/modules/mesh/mutations.ts @@ -1,3 +1,5 @@ +import { randomBytes } from "node:crypto"; + import sodium from "libsodium-wrappers"; import { and, eq, isNull } from "@turbostarter/db"; @@ -9,7 +11,8 @@ import type { CreateMyMeshInput, } from "../../schema"; -const BROKER_URL = process.env.NEXT_PUBLIC_BROKER_URL ?? "ws://localhost:7900"; +const BROKER_URL = + process.env.NEXT_PUBLIC_BROKER_URL ?? "wss://ic.claudemesh.com/ws"; const APP_URL = process.env.NEXT_PUBLIC_URL ?? "https://claudemesh.com"; /** @@ -38,6 +41,35 @@ const ensureSodium = async (): Promise => { return sodium; }; +/** + * Slugify a display name into a URL-safe token. Used only as cosmetic + * metadata embedded in invite payloads for debugging/display — NOT as a + * canonical identifier. `mesh.id` (opaque) is the canonical identity. + */ +const toSlug = (name: string): string => + name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) || "mesh"; + +/** + * Base62 alphabet excluding visually ambiguous characters (0, O, I, l, 1). + * 57 symbols × 8 positions ≈ 1.1e14 combinations — birthday collision at + * ~10M invites, fine for years. We retry-on-conflict at insert time anyway. + */ +const SHORTCODE_ALPHABET = + "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; +const generateShortCode = (len = 8): string => { + const bytes = randomBytes(len); + let out = ""; + for (let i = 0; i < len; i++) { + out += SHORTCODE_ALPHABET[bytes[i]! % SHORTCODE_ALPHABET.length]; + } + return out; +}; + export const createMyMesh = async ({ userId, input, @@ -45,16 +77,9 @@ export const createMyMesh = async ({ userId: string; input: CreateMyMeshInput; }) => { - // Slug collision check - const [existing] = await db - .select({ id: mesh.id }) - .from(mesh) - .where(eq(mesh.slug, input.slug)) - .limit(1); - - if (existing) { - throw new Error("A mesh with that slug already exists."); - } + // Slug is derived from name and stored non-uniquely — meshes are identified + // by `mesh.id` (opaque). Two users can freely name their meshes "platform". + const slug = toSlug(input.name); // Generate the mesh owner's ed25519 keypair (signs invites) and a // 32-byte shared root key (channel encryption in later steps). @@ -72,7 +97,7 @@ export const createMyMesh = async ({ .insert(mesh) .values({ name: input.name, - slug: input.slug, + slug, visibility: input.visibility, transport: input.transport, ownerUserId: userId, @@ -215,28 +240,59 @@ export const createMyInvite = async ({ const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString( "base64url", ); - const [created] = await db - .insert(invite) - .values({ - meshId, - token, - tokenBytes: canonical, - maxUses: input.maxUses, - role: input.role, - expiresAt, - createdBy: userId, - }) - .returning({ - id: invite.id, - token: invite.token, - expiresAt: invite.expiresAt, - }); + // Short URL shortener code. Retry on the (extremely unlikely) collision + // against the unique index. 3 attempts is plenty given the keyspace. + let code = generateShortCode(); + let created: + | { id: string; token: string; code: string | null; expiresAt: Date } + | undefined; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const rows = await db + .insert(invite) + .values({ + meshId, + token, + tokenBytes: canonical, + code, + maxUses: input.maxUses, + role: input.role, + expiresAt, + createdBy: userId, + }) + .returning({ + id: invite.id, + token: invite.token, + code: invite.code, + expiresAt: invite.expiresAt, + }); + created = rows[0]; + break; + } catch (e) { + // Only retry on short-code collision; rethrow anything else. + if (e instanceof Error && e.message.includes("invite_code_unique_idx")) { + code = generateShortCode(); + continue; + } + throw e; + } + } + if (!created) { + throw new Error("Could not allocate a unique invite code — retry."); + } + + const appBase = APP_URL.replace(/\/$/, ""); return { - id: created!.id, - token: created!.token, - expiresAt: created!.expiresAt, + id: created.id, + token: created.token, + code: created.code, + expiresAt: created.expiresAt, inviteLink: `ic://join/${token}`, - joinUrl: `${APP_URL.replace(/\/$/, "")}/join/${token}`, + joinUrl: `${appBase}/join/${token}`, + // The human-friendly short URL. Redirects to joinUrl server-side. + // Prefer this when sharing. See spec for why this is NOT a capability + // boundary (the long token still carries the root_key). + shortUrl: created.code ? `${appBase}/i/${created.code}` : null, }; }; diff --git a/packages/api/src/modules/public/router.ts b/packages/api/src/modules/public/router.ts index d71b0db..71f6843 100644 --- a/packages/api/src/modules/public/router.ts +++ b/packages/api/src/modules/public/router.ts @@ -232,6 +232,29 @@ export const publicRouter = new Hono() } return c.json(result); }) + /** + * Resolve a short invite code to its canonical long token. + * + * URL shortener only — the long token still carries the root_key, + * so this endpoint is NOT a security boundary. See the v2 invite + * protocol spec for the real fix. + * + * Returns 404 if the code is unknown OR the invite was revoked/ + * archived so stale short URLs don't leak mesh metadata. + */ + .get("/invite-code/:code", async (c) => { + const code = c.req.param("code"); + const [row] = await db + .select({ token: invite.token, revokedAt: invite.revokedAt }) + .from(invite) + .where(eq(invite.code, code)) + .limit(1); + c.header("cache-control", "no-store"); + if (!row || row.revokedAt) { + return c.json({ found: false as const }, 404); + } + return c.json({ found: true as const, token: row.token }); + }) .get("/stats", async (c) => { const now = Date.now(); if (cachedStats && cachedStats.expiresAt > now) { diff --git a/packages/api/src/schema/mesh-user.ts b/packages/api/src/schema/mesh-user.ts index c0bd988..be8461f 100644 --- a/packages/api/src/schema/mesh-user.ts +++ b/packages/api/src/schema/mesh-user.ts @@ -54,11 +54,6 @@ export type GetMyMeshesResponse = z.infer; export const createMyMeshInputSchema = z.object({ name: z.string().min(2).max(80), - slug: z - .string() - .min(2) - .max(40) - .regex(/^[a-z0-9-]+$/, "slug must be lowercase letters, digits, hyphens"), visibility: meshVisibilityEnum.default("private"), transport: meshTransportEnum.default("managed"), }); @@ -130,8 +125,10 @@ export type CreateMyInviteInput = z.infer; export const createMyInviteResponseSchema = z.object({ id: z.string(), token: z.string(), + code: z.string().nullable(), inviteLink: z.string(), joinUrl: z.string(), + shortUrl: z.string().nullable(), expiresAt: z.coerce.date(), }); export type CreateMyInviteResponse = z.infer; diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 414660b..3c90953 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -22,6 +22,11 @@ const apiErrorSchema = z.object({ path: z.string(), }); +/** Matches the `{ error: "..." }` shape returned by Hono route catch blocks. */ +const routeErrorSchema = z.object({ + error: z.string(), +}); + export const isAPIError = (e: unknown): e is z.infer => { return apiErrorSchema.safeParse(e).success; }; @@ -70,10 +75,13 @@ export const handle = < if (!response.ok) { if (throwOnError) { + const parsed = routeErrorSchema.safeParse(data); throw new Error( isAPIError(data) ? data.message - : "Something went wrong. Please try again later.", + : parsed.success + ? parsed.data.error + : "Something went wrong. Please try again later.", ); } return null as HandleReturn; diff --git a/packages/api/src/utils/on-error.ts b/packages/api/src/utils/on-error.ts index f967794..f27bf5f 100644 --- a/packages/api/src/utils/on-error.ts +++ b/packages/api/src/utils/on-error.ts @@ -69,6 +69,7 @@ export const onError = async ( code: "common:error.general", message: t("common:error.general"), status, + timestamp, path, }), details, diff --git a/packages/db/migrations/0017_mesh-slug-non-unique.sql b/packages/db/migrations/0017_mesh-slug-non-unique.sql new file mode 100644 index 0000000..9efa8cd --- /dev/null +++ b/packages/db/migrations/0017_mesh-slug-non-unique.sql @@ -0,0 +1,11 @@ +-- Drop global uniqueness on mesh.slug. +-- +-- Identity for a mesh is mesh.id (opaque, generated). The slug is now +-- cosmetic only — derived from the display name at creation time and +-- embedded in invite payloads for debugging/display. Two meshes may +-- freely share a slug. +-- +-- Safe to run on populated tables: the constraint is removed, no data +-- is altered, no rows are locked for content changes. + +ALTER TABLE "mesh"."mesh" DROP CONSTRAINT IF EXISTS "mesh_slug_unique"; diff --git a/packages/db/migrations/0018_invite-short-code.sql b/packages/db/migrations/0018_invite-short-code.sql new file mode 100644 index 0000000..374dd26 --- /dev/null +++ b/packages/db/migrations/0018_invite-short-code.sql @@ -0,0 +1,20 @@ +-- Add a short opaque URL-shortener code to mesh invites. +-- +-- Purpose: make invite URLs human-friendly (claudemesh.com/i/abc12345) +-- instead of ~400 char base64url payloads. The short code resolves +-- server-side to the existing long token — the broker protocol and +-- canonical signed payload are UNCHANGED. +-- +-- This is NOT the v2 invite protocol (see spec +-- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md). +-- It is a backward-compatible URL shortener only. The root_key is +-- still embedded in the underlying long token; v2 will address that +-- in a coordinated broker + CLI + web change. +-- +-- Column is nullable so existing invites remain valid without backfill. + +ALTER TABLE "mesh"."invite" ADD COLUMN IF NOT EXISTS "code" text; + +CREATE UNIQUE INDEX IF NOT EXISTS "invite_code_unique_idx" + ON "mesh"."invite" ("code") + WHERE "code" IS NOT NULL; diff --git a/packages/db/migrations/0019_invite-v2-and-email.sql b/packages/db/migrations/0019_invite-v2-and-email.sql new file mode 100644 index 0000000..b7539f8 --- /dev/null +++ b/packages/db/migrations/0019_invite-v2-and-email.sql @@ -0,0 +1,54 @@ +-- v2 invite protocol + email invites. +-- +-- Spec: .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md +-- +-- Two concerns in one migration (both touch the invite surface): +-- +-- 1. v2 invite protocol — the mesh root_key no longer travels in the +-- invite URL. Instead the recipient generates a curve25519 keypair at +-- claim time and sends the pubkey to the broker; the broker seals +-- root_key with crypto_box_seal to that pubkey. The DB captures the +-- protocol version, the canonical signed bytes that the broker +-- re-verifies against mesh.owner_pubkey, and an audit-only record of +-- which recipient pubkey received the sealed key. +-- +-- 2. Email invites — admins can send invites to an email address. A +-- pending_invite row tracks the send; when the recipient lands on +-- /i/{code} it is matched to an underlying mesh.invite row (mint on +-- send). acceptedAt / revokedAt capture lifecycle. +-- +-- Both additions are backward-compatible: version defaults to 1, new +-- columns are nullable, the new table is independent of existing rows. + +ALTER TABLE "mesh"."invite" + ADD COLUMN IF NOT EXISTS "version" integer NOT NULL DEFAULT 1; + +ALTER TABLE "mesh"."invite" + ADD COLUMN IF NOT EXISTS "capability_v2" text; + +ALTER TABLE "mesh"."invite" + ADD COLUMN IF NOT EXISTS "claimed_by_pubkey" text; + +CREATE TABLE IF NOT EXISTS "mesh"."pending_invite" ( + "id" text PRIMARY KEY NOT NULL, + "mesh_id" text NOT NULL, + "email" text NOT NULL, + "code" text NOT NULL, + "sent_at" timestamp DEFAULT now() NOT NULL, + "accepted_at" timestamp, + "revoked_at" timestamp, + "created_by" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "pending_invite_mesh_id_fk" + FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "pending_invite_created_by_fk" + FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX IF NOT EXISTS "pending_invite_email_idx" + ON "mesh"."pending_invite" ("email"); + +CREATE INDEX IF NOT EXISTS "pending_invite_mesh_idx" + ON "mesh"."pending_invite" ("mesh_id"); diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index ccb6828..8d8ebb3 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -78,7 +78,13 @@ export const messagePriorityEnum = meshSchema.enum("message_priority", [ export const mesh = meshSchema.table("mesh", { id: text().primaryKey().notNull().$defaultFn(generateId), name: text().notNull(), - slug: text().notNull().unique(), + /** + * Cosmetic slug derived from name at creation. NOT unique, NOT used for + * identity — `mesh.id` is the canonical identifier everywhere (URLs, + * invites, broker lookups). Kept for display/debugging only. Two meshes + * can freely share a slug. + */ + slug: text().notNull(), ownerUserId: text() .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }) .notNull(), @@ -176,6 +182,15 @@ export const invite = meshSchema.table("invite", { .notNull(), token: text().notNull().unique(), tokenBytes: text(), + /** + * Short opaque URL shortener code (base62, 8 chars). Resolves server-side + * to the full canonical `token` for landing page rendering. Nullable for + * pre-shortcode invites. Not a capability boundary — the long token still + * carries the root_key. See .artifacts/specs/2026-04-10-anthropic-vision- + * meshes-invites.md for the v2 protocol that moves the root_key out of + * the URL entirely. + */ + code: text().unique(), maxUses: integer().notNull().default(1), usedCount: integer().notNull().default(0), role: meshRoleEnum().notNull().default("member"), @@ -192,8 +207,47 @@ export const invite = meshSchema.table("invite", { .notNull(), createdAt: timestamp().defaultNow().notNull(), revokedAt: timestamp(), + /** Protocol version — 1 = legacy (root_key in URL), 2 = sealed delivery. Default 1 for backward compat. */ + version: integer().notNull().default(1), + /** + * v2 canonical signed bytes (the string the broker re-verifies against mesh.ownerPubkey). + * Format: `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey` + * Nullable for legacy v1 rows. + */ + capabilityV2: text(), + /** + * Recipient curve25519 pubkey (base64url) that the mesh root_key was sealed to + * when this invite was claimed. Audit-only — do NOT use as an authN check. + * Nullable until claim. + */ + claimedByPubkey: text(), }); +/** + * Tracks invites sent by email — one row per (mesh, email) pairing. + * `code` references an underlying mesh.invite row that will be minted + * on send; when the recipient lands on /i/{code} they claim the real invite. + */ +export const pendingInvite = meshSchema.table("pending_invite", { + id: text().primaryKey().notNull().$defaultFn(generateId), + meshId: text() + .references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" }) + .notNull(), + email: text().notNull(), + /** The short code of the underlying `mesh.invite.code` row this email links to. */ + code: text().notNull(), + sentAt: timestamp().defaultNow().notNull(), + acceptedAt: timestamp(), + revokedAt: timestamp(), + createdBy: text() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }) + .notNull(), + createdAt: timestamp().defaultNow().notNull(), +}, (table) => [ + index("pending_invite_email_idx").on(table.email), + index("pending_invite_mesh_idx").on(table.meshId), +]); + /** * Signed, hash-chained audit log. NEVER stores message content — every * payload between peers is E2E encrypted client-side (libsodium), so @@ -687,6 +741,11 @@ export const inviteRelations = relations(invite, ({ one }) => ({ }), })); +export const pendingInviteRelations = relations(pendingInvite, ({ one }) => ({ + mesh: one(mesh, { fields: [pendingInvite.meshId], references: [mesh.id] }), + inviter: one(user, { fields: [pendingInvite.createdBy], references: [user.id] }), +})); + export const auditLogRelations = relations(auditLog, ({ one }) => ({ mesh: one(mesh, { fields: [auditLog.meshId],