Files
claudemesh/.artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
Alejandro Gutiérrez c1fa3bcb5c feat: anthropic-style mesh + invite redesign (wave 1 checkpoint)
Ships the user-visible friction fixes and the foundation for the v2
invite protocol. API wiring + CLI client + email UI ship in wave 2.

Meshes — shipped
- Drop global UNIQUE on mesh.slug; mesh.id is canonical everywhere
- Server derives slug from name; create form has no slug field
- Two users can freely name their mesh "platform"; no collision errors
- Migration 0017

Invites v1 — shipped (URL shortener, backward compatible)
- New invite.code column (base62, 8 chars, nullable unique index)
- createMyInvite mints both token + short code; returns shortUrl
- GET /api/public/invite-code/:code resolves short code to token
- New route /i/[code] server-redirects to /join/[token]
- Invite generator UI shows short URL; QR encodes short URL
- Advanced fields (role/maxUses/expiresInDays) collapsed under disclosure
- Migration 0018

Invites v2 — foundation (broker + DB only; API+CLI+Web wiring in wave 2)
- Broker: canonicalInviteV2, verifyInviteV2, sealRootKeyToRecipient
- Broker: POST /invites/:code/claim endpoint (atomic single-use accounting)
- Broker tests: invite-v2.test.ts (signature, expiry, revocation, exhaustion)
- DB: mesh.invite gains version/capabilityV2/claimedByPubkey columns
- DB: new mesh.pending_invite table for email invites
- Migration 0019
- Contract locked in docs/protocol.md §v2 + SPEC.md §14b

Consent landing — shipped
- /join/[token] redesigned: explicit role, inviter, mesh stats, consent
- New server components: invite-card, role-badge, inviter-line, consent-summary
- "Join [mesh] as [Role]" primary action (not just "Join")

Error surfacing — shipped
- handle() now parses {error} responses from hono route catch blocks
- onError fallback includes timestamp so handle() can match apiErrorSchema
- Real error messages reach the UI instead of "Something went wrong"

Docs
- SPEC.md §14b: v2 invite protocol
- docs/protocol.md: v2 claim wire format
- docs/roadmap.md: status
- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md

Deferred to wave 2/3
- API claim route wiring (packages/api)
- createMyInvite v2 capability generation
- Email invite mutation + Postmark delivery
- CLI v2 join flow (x25519 keypair + unseal)
- Web invite-generator email field + v2 display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:41:11 +01:00

11 KiB

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

  • Drop UNIQUE constraint on mesh.slug (migration 0017_mesh-slug-non-unique.sql)
  • Remove slug field from createMyMeshInputSchema
  • Remove slug field from CreateMeshForm
  • Server-side toSlug(name) derives slug from name automatically
  • 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
  • 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

  • Web builds without type errors on changed files
  • Migrations run on production DB (0017 applied; 0018 after review)
  • No broker protocol change (backward compat verified)
  • Existing long-token invites continue to resolve
  • New invites expose shortUrl in the API response