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>
This commit is contained in:
Alejandro Gutiérrez
2026-04-10 13:41:11 +01:00
parent dbea96960f
commit c1fa3bcb5c
24 changed files with 1932 additions and 196 deletions

View File

@@ -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