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>
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
- Identity is opaque, display is free-form. Humans pick any name; the system uses random IDs.
- Secrets never appear in URLs. Links are capabilities, not credentials.
- Defaults are obvious; advanced options are discoverable but hidden.
- Self-service wherever possible; admins don't become gatekeepers.
- 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
UNIQUEconstraint onmesh.slug(migration0017_mesh-slug-non-unique.sql) - Remove
slugfield fromcreateMyMeshInputSchema - 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
- No secret material in any user-visible string (URL, QR, paste buffer)
- Invite URLs are short (<30 chars):
claudemesh.com/i/abc12345 - Existing v1 invites continue to work during a deprecation window
- Revocation is clean and immediate
- 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_capabilitylives 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_capabilitycolumns (nullable for existing rows) createMyInvitegenerates 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/claimendpoint,crypto_box_sealagainst recipient x25519 pubkey, signed capability verification, single-use accounting. - DB:
mesh.invite.versionint,mesh.invite.capability_v2text nullable,mesh.invite.claimed_by_pubkeytext nullable. New tablemesh.pending_invitefor 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 verifiescanonical_v2againstowner_pubkey. - Email invites (parallel track): Postmark delivery wired on top of
pending_invite; the email body carries the sameclaudemesh.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_emailtable 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
- Web builds without type errors on changed files
- Migrations run on production DB (
0017applied;0018after review) - No broker protocol change (backward compat verified)
- Existing long-token invites continue to resolve
- New invites expose
shortUrlin the API response