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:
232
.artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
Normal file
232
.artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
Normal 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
|
||||||
66
SPEC.md
66
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)
|
## 14. Production hardening (implemented)
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
* current member of the claimed mesh.
|
* current member of the claimed mesh.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||||
import sodium from "libsodium-wrappers";
|
import sodium from "libsodium-wrappers";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||||
|
|
||||||
let ready = false;
|
let ready = false;
|
||||||
async function ensureSodium(): Promise<typeof sodium> {
|
async function ensureSodium(): Promise<typeof sodium> {
|
||||||
@@ -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<boolean> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
export const HELLO_SKEW_MS = 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,3 +185,185 @@ export async function verifyHelloSignature(args: {
|
|||||||
return { ok: false, reason: "malformed" };
|
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": "<hex ed25519 detached 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<InviteClaimV2Result> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
import { WebSocketServer, type WebSocket } from "ws";
|
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 { env } from "./env";
|
||||||
import { db } from "./db";
|
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 { user } from "@turbostarter/db/schema/auth";
|
||||||
import { handleCliSync, type CliSyncRequest } from "./cli-sync";
|
import { handleCliSync, type CliSyncRequest } from "./cli-sync";
|
||||||
import { updateMemberProfile, listMeshMembers, updateMeshSettings } from "./member-api";
|
import { updateMemberProfile, listMeshMembers, updateMeshSettings } from "./member-api";
|
||||||
@@ -102,7 +102,7 @@ import { metrics, metricsToText } from "./metrics";
|
|||||||
import { TokenBucket } from "./rate-limit";
|
import { TokenBucket } from "./rate-limit";
|
||||||
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
|
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
|
||||||
import { buildInfo } from "./build-info";
|
import { buildInfo } from "./build-info";
|
||||||
import { verifyHelloSignature } from "./crypto";
|
import { canonicalInviteV2, sealRootKeyToRecipient, verifyHelloSignature, verifyInviteV2 } from "./crypto";
|
||||||
import { handleWebhook } from "./webhooks";
|
import { handleWebhook } from "./webhooks";
|
||||||
import { audit, loadLastHashes, ensureAuditLogTable, verifyChain, queryAuditLog } from "./audit";
|
import { audit, loadLastHashes, ensureAuditLogTable, verifyChain, queryAuditLog } from "./audit";
|
||||||
|
|
||||||
@@ -590,6 +590,16 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2 invite claim: POST /invites/:code/claim
|
||||||
|
// Body: { recipient_x25519_pubkey: "<base64url, 32 bytes>" }
|
||||||
|
// 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") {
|
if (req.method === "POST" && req.url === "/upload") {
|
||||||
handleUploadPost(req, res, started);
|
handleUploadPost(req, res, started);
|
||||||
return;
|
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": "<hex ed25519 detached 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<InviteClaimV2Result> {
|
||||||
|
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(
|
function handleUploadPost(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
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();
|
||||||
|
}
|
||||||
|
|||||||
268
apps/broker/tests/invite-v2.test.ts
Normal file
268
apps/broker/tests/invite-v2.test.ts
Normal file
@@ -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<Uint8Array> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
48
apps/web/src/app/[locale]/i/[code]/page.tsx
Normal file
48
apps/web/src/app/[locale]/i/[code]/page.tsx
Normal file
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { handle } from "@turbostarter/api/utils";
|
|||||||
import { api } from "~/lib/api/server";
|
import { api } from "~/lib/api/server";
|
||||||
import { getMetadata } from "~/lib/metadata";
|
import { getMetadata } from "~/lib/metadata";
|
||||||
import { InstallToggle } from "~/modules/join/install-toggle";
|
import { InstallToggle } from "~/modules/join/install-toggle";
|
||||||
|
import { InviteCard } from "~/modules/join/invite-card";
|
||||||
|
|
||||||
export const generateMetadata = getMetadata({
|
export const generateMetadata = getMetadata({
|
||||||
title: "Join a mesh",
|
title: "Join a mesh",
|
||||||
@@ -112,42 +113,29 @@ export default async function JoinPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
|
<div className="mx-auto w-full max-w-2xl px-6 py-12 md:px-12 md:py-20">
|
||||||
{invite.valid ? (
|
{invite.valid ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<InviteCard
|
||||||
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
meshName={invite.meshName}
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
inviterName={invite.inviterName}
|
||||||
>
|
role={invite.role}
|
||||||
— invitation
|
memberCount={invite.memberCount}
|
||||||
</div>
|
expiresAt={new Date(invite.expiresAt)}
|
||||||
<h1
|
/>
|
||||||
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
||||||
>
|
|
||||||
You're invited to{" "}
|
|
||||||
<span className="italic text-[var(--cm-clay)]">
|
|
||||||
{invite.meshName}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
className="mt-4 text-lg leading-[1.6] text-[var(--cm-fg-secondary)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
||||||
>
|
|
||||||
{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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-12">
|
<div id="install" className="mt-14 scroll-mt-24">
|
||||||
|
<div
|
||||||
|
className="mb-4 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
— to accept, run this in your terminal
|
||||||
|
</div>
|
||||||
<InstallToggle token={invite.token} />
|
<InstallToggle token={invite.token} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="mt-14 rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-5 text-[13px] leading-[1.65] text-[var(--cm-fg-tertiary)]"
|
className="mt-12 rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-5 text-[13px] leading-[1.65] text-[var(--cm-fg-tertiary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
By joining, you'll be known as a peer with an ed25519
|
By joining, you'll be known as a peer with an ed25519
|
||||||
@@ -163,24 +151,27 @@ export default async function JoinPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
className="mt-8 text-xs text-[var(--cm-fg-tertiary)]"
|
className="mt-6 text-xs text-[var(--cm-fg-tertiary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
>
|
>
|
||||||
expires {new Date(invite.expiresAt).toLocaleDateString()} ·{" "}
|
|
||||||
{invite.maxUses - invite.usedCount} of {invite.maxUses} uses
|
{invite.maxUses - invite.usedCount} of {invite.maxUses} uses
|
||||||
remaining
|
remaining
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<section
|
||||||
|
aria-labelledby="invite-error-heading"
|
||||||
|
className="rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 p-7 md:p-9"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[#c46686]"
|
className="text-[11px] uppercase tracking-[0.22em] text-[#c46686]"
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
>
|
>
|
||||||
— invitation unavailable
|
— invitation unavailable
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-[clamp(1.75rem,3.5vw,2.25rem)] font-medium leading-[1.15] text-[var(--cm-fg)]"
|
id="invite-error-heading"
|
||||||
|
className="mt-4 text-[clamp(1.75rem,3.5vw,2.25rem)] font-medium leading-[1.15] text-[var(--cm-fg)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
{ERROR_COPY[invite.reason].title}
|
{ERROR_COPY[invite.reason].title}
|
||||||
@@ -210,7 +201,7 @@ export default async function JoinPage({
|
|||||||
← claudemesh.com
|
← claudemesh.com
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
45
apps/web/src/modules/join/consent-summary.tsx
Normal file
45
apps/web/src/modules/join/consent-summary.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
|
||||||
|
Joining this mesh will let you
|
||||||
|
</div>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{BULLETS.map((text) => (
|
||||||
|
<li
|
||||||
|
key={text}
|
||||||
|
className="flex items-start gap-2.5 text-[13.5px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-[3px] shrink-0 text-[var(--cm-clay)]"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5 12l4 4 10-10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
apps/web/src/modules/join/invite-card.tsx
Normal file
119
apps/web/src/modules/join/invite-card.tsx
Normal file
@@ -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 (
|
||||||
|
<section
|
||||||
|
aria-labelledby="invite-heading"
|
||||||
|
className="relative overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 p-7 md:p-9"
|
||||||
|
>
|
||||||
|
{/* Eyebrow */}
|
||||||
|
<div
|
||||||
|
className="text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
— invitation
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<h1
|
||||||
|
id="invite-heading"
|
||||||
|
className="mt-4 text-[clamp(1.9rem,3.6vw,2.65rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
You've been invited to join{" "}
|
||||||
|
<span className="italic text-[var(--cm-clay)]">{meshName}</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Inviter + stats row */}
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<InviterLine inviterName={inviterName} />
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 text-[12.5px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-cactus)]"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{memberCount} {peerWord} · private mesh
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role badge */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<RoleBadge role={role} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consent bullets */}
|
||||||
|
<div className="mt-5">
|
||||||
|
<ConsentSummary />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary action block */}
|
||||||
|
<div className="mt-8 flex flex-col gap-3">
|
||||||
|
<a
|
||||||
|
href="#install"
|
||||||
|
className="inline-flex w-full items-center justify-center gap-2 rounded-[var(--cm-radius-md)] bg-[var(--cm-clay)] px-6 py-4 text-[15px] font-medium text-[var(--cm-gray-050)] transition-colors hover:bg-[var(--cm-clay-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cm-clay)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--cm-bg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
aria-label={`Join ${meshName} as ${roleLabel(role)}`}
|
||||||
|
>
|
||||||
|
Join {meshName} as {roleLabel(role)}
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5 12h14M13 5l7 7-7 7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<p
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 text-[11.5px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
valid until{" "}
|
||||||
|
{expiresAt.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="/auth/logout"
|
||||||
|
className="underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Not you? Sign out
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/web/src/modules/join/inviter-line.tsx
Normal file
29
apps/web/src/modules/join/inviter-line.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
interface InviterLineProps {
|
||||||
|
inviterName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviterLine({ inviterName }: InviterLineProps) {
|
||||||
|
const initial = (inviterName ?? "?").trim().charAt(0).toUpperCase() || "?";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] text-[13px] font-medium text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.16em] text-[var(--cm-fg-tertiary)]">
|
||||||
|
Invited by
|
||||||
|
</span>
|
||||||
|
<span className="text-[14.5px] font-medium text-[var(--cm-fg)]">
|
||||||
|
{inviterName ?? "the mesh owner"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
apps/web/src/modules/join/role-badge.tsx
Normal file
110
apps/web/src/modules/join/role-badge.tsx
Normal file
@@ -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: (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 2l3 6 6 1-4.5 4.5L18 20l-6-3-6 3 1.5-6.5L3 9l6-1 3-6z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
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: (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path
|
||||||
|
d="M4 20c0-4 4-6 8-6s8 2 8 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RoleBadgeProps {
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleBadge({ role }: RoleBadgeProps) {
|
||||||
|
const cfg = ROLE_CONFIG[role];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-3 rounded-[var(--cm-radius-md)] border p-4"
|
||||||
|
style={{
|
||||||
|
borderColor: cfg.accent,
|
||||||
|
backgroundColor:
|
||||||
|
"color-mix(in srgb, var(--cm-bg-elevated) 70%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||||
|
style={{
|
||||||
|
color: cfg.accent,
|
||||||
|
backgroundColor: "color-mix(in srgb, var(--cm-bg) 60%, transparent)",
|
||||||
|
border: `1px solid ${cfg.accent}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cfg.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 text-[13px] font-medium"
|
||||||
|
style={{ color: cfg.accent, fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
<span className="uppercase tracking-[0.14em]">
|
||||||
|
You'll join as {cfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="mt-1 text-[13.5px] leading-[1.55] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{cfg.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleLabel(role: Role) {
|
||||||
|
return ROLE_CONFIG[role].label;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
@@ -32,14 +31,6 @@ import {
|
|||||||
import { pathsConfig } from "~/config/paths";
|
import { pathsConfig } from "~/config/paths";
|
||||||
import { api } from "~/lib/api/client";
|
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 = ({
|
export const CreateMeshForm = ({
|
||||||
onboarding = false,
|
onboarding = false,
|
||||||
}: { onboarding?: boolean } = {}) => {
|
}: { onboarding?: boolean } = {}) => {
|
||||||
@@ -48,30 +39,16 @@ export const CreateMeshForm = ({
|
|||||||
resolver: zodResolver(createMyMeshInputSchema),
|
resolver: zodResolver(createMyMeshInputSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
slug: "",
|
|
||||||
visibility: "private",
|
visibility: "private",
|
||||||
transport: "managed",
|
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) => {
|
const onSubmit = async (values: CreateMyMeshInput) => {
|
||||||
try {
|
try {
|
||||||
const res = (await handle(api.my.meshes.$post)({
|
const res = (await handle(api.my.meshes.$post)({
|
||||||
json: values,
|
json: values,
|
||||||
})) as { id: string; slug: string } | { error: string };
|
})) as { id: string; slug: string };
|
||||||
if ("error" in res) {
|
|
||||||
form.setError("slug", { message: res.error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(
|
router.push(
|
||||||
onboarding
|
onboarding
|
||||||
? `${pathsConfig.dashboard.user.meshes.invite(res.id)}?onboarding=1`
|
? `${pathsConfig.dashboard.user.meshes.invite(res.id)}?onboarding=1`
|
||||||
@@ -97,23 +74,7 @@ export const CreateMeshForm = ({
|
|||||||
<Input placeholder="Platform team" {...field} />
|
<Input placeholder="Platform team" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Display name — what teammates see.
|
Display name — what teammates see. Pick anything.
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="slug"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Slug</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="platform-team" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
URL-safe identifier: lowercase letters, digits, hyphens.
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ interface GeneratedInvite {
|
|||||||
token: string;
|
token: string;
|
||||||
inviteLink: string;
|
inviteLink: string;
|
||||||
joinUrl: string;
|
joinUrl: string;
|
||||||
|
/** Short human-friendly URL, preferred for sharing. Null if the backend didn't mint one. */
|
||||||
|
shortUrl: string | null;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
qrDataUrl: string;
|
qrDataUrl: string;
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@ interface GeneratedInvite {
|
|||||||
export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||||
const [result, setResult] = useState<GeneratedInvite | null>(null);
|
const [result, setResult] = useState<GeneratedInvite | null>(null);
|
||||||
const [copied, setCopied] = useState<"url" | "cli" | null>(null);
|
const [copied, setCopied] = useState<"url" | "cli" | null>(null);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
const form = useForm<CreateMyInviteInput>({
|
const form = useForm<CreateMyInviteInput>({
|
||||||
resolver: zodResolver(createMyInviteInputSchema),
|
resolver: zodResolver(createMyInviteInputSchema),
|
||||||
@@ -54,24 +57,20 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
|||||||
const res = (await handle(api.my.meshes[":id"].invites.$post)({
|
const res = (await handle(api.my.meshes[":id"].invites.$post)({
|
||||||
param: { id: meshId },
|
param: { id: meshId },
|
||||||
json: values,
|
json: values,
|
||||||
})) as
|
})) as {
|
||||||
| {
|
id: string;
|
||||||
id: string;
|
token: string;
|
||||||
token: string;
|
inviteLink: string;
|
||||||
inviteLink: string;
|
joinUrl: string;
|
||||||
joinUrl: string;
|
shortUrl: string | null;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
};
|
||||||
| { error: string };
|
|
||||||
|
|
||||||
if ("error" in res) {
|
// QR encodes the SHORT URL when available — scannable at camera distance
|
||||||
form.setError("root", { message: res.error });
|
// and short enough for the QR to stay low-density. Falls back to the
|
||||||
return;
|
// long token URL for legacy invites minted before the shortener shipped.
|
||||||
}
|
const qrTarget = res.shortUrl ?? res.joinUrl;
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(qrTarget, {
|
||||||
// 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, {
|
|
||||||
width: 256,
|
width: 256,
|
||||||
margin: 1,
|
margin: 1,
|
||||||
color: { dark: "#141413", light: "#ffffff" },
|
color: { dark: "#141413", light: "#ffffff" },
|
||||||
@@ -82,6 +81,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
|||||||
token: res.token,
|
token: res.token,
|
||||||
inviteLink: res.inviteLink,
|
inviteLink: res.inviteLink,
|
||||||
joinUrl: res.joinUrl,
|
joinUrl: res.joinUrl,
|
||||||
|
shortUrl: res.shortUrl,
|
||||||
expiresAt: new Date(res.expiresAt),
|
expiresAt: new Date(res.expiresAt),
|
||||||
qrDataUrl,
|
qrDataUrl,
|
||||||
});
|
});
|
||||||
@@ -99,6 +99,10 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (result) {
|
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}`;
|
const cliCmd = `claudemesh join ${result.token}`;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -117,7 +121,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
|||||||
Share this link
|
Share this link
|
||||||
</div>
|
</div>
|
||||||
<code className="bg-muted block break-all rounded p-3 font-mono text-xs">
|
<code className="bg-muted block break-all rounded p-3 font-mono text-xs">
|
||||||
{result.joinUrl}
|
{primaryUrl}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||||
@@ -126,7 +130,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={() => copy(result.joinUrl, "url")} size="sm">
|
<Button onClick={() => copy(primaryUrl, "url")} size="sm">
|
||||||
{copied === "url" ? "Copied ✓" : "Copy link"}
|
{copied === "url" ? "Copied ✓" : "Copy link"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -168,65 +172,89 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-md space-y-5">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-md space-y-5">
|
||||||
<FormField
|
<p className="text-muted-foreground text-sm">
|
||||||
control={form.control}
|
One-time invite for a new member. Valid for 7 days.
|
||||||
name="role"
|
</p>
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
{/* Advanced options — hidden by default. Defaults ship 90% of users. */}
|
||||||
<FormLabel>Role</FormLabel>
|
<div className="rounded-md border border-dashed">
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<button
|
||||||
<FormControl>
|
type="button"
|
||||||
<SelectTrigger>
|
onClick={() => setShowAdvanced((s) => !s)}
|
||||||
<SelectValue />
|
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between px-3 py-2 text-xs uppercase tracking-wider"
|
||||||
</SelectTrigger>
|
aria-expanded={showAdvanced}
|
||||||
</FormControl>
|
>
|
||||||
<SelectContent>
|
<span>Advanced</span>
|
||||||
<SelectItem value="member">Member</SelectItem>
|
<span aria-hidden="true">{showAdvanced ? "−" : "+"}</span>
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
</button>
|
||||||
</SelectContent>
|
{showAdvanced && (
|
||||||
</Select>
|
<div className="space-y-4 border-t px-3 py-4">
|
||||||
<FormMessage />
|
<FormField
|
||||||
</FormItem>
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Role</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxUses"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max uses</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="expiresInDays"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Expires in (days)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="maxUses"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Max uses</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={1000}
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="expiresInDays"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Expires in (days)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={365}
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{form.formState.errors.root && (
|
{form.formState.errors.root && (
|
||||||
<p className="text-destructive text-sm">
|
<p className="text-destructive text-sm">
|
||||||
{form.formState.errors.root.message}
|
{form.formState.errors.root.message}
|
||||||
|
|||||||
@@ -366,6 +366,74 @@ the new peer and rebroadcasts presence.
|
|||||||
|
|
||||||
Invite-link issuance: [`apps/cli/src/invite/`](../apps/cli/src/invite/).
|
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": "<base64url>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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": "<base64url>", // crypto_box_seal(root_key, recipient_pubkey)
|
||||||
|
"mesh_id": "<text>",
|
||||||
|
"member_id": "<text>",
|
||||||
|
"owner_pubkey": "<hex>", // 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
|
## Self-hosting
|
||||||
|
|||||||
@@ -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*
|
## v0.2.0 — *next*
|
||||||
|
|
||||||
The surface layer. The protocol is ready; these are gateways + routing
|
The surface layer. The protocol is ready; these are gateways + routing
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
import sodium from "libsodium-wrappers";
|
import sodium from "libsodium-wrappers";
|
||||||
|
|
||||||
import { and, eq, isNull } from "@turbostarter/db";
|
import { and, eq, isNull } from "@turbostarter/db";
|
||||||
@@ -9,7 +11,8 @@ import type {
|
|||||||
CreateMyMeshInput,
|
CreateMyMeshInput,
|
||||||
} from "../../schema";
|
} 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";
|
const APP_URL = process.env.NEXT_PUBLIC_URL ?? "https://claudemesh.com";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +41,35 @@ const ensureSodium = async (): Promise<typeof sodium> => {
|
|||||||
return sodium;
|
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 ({
|
export const createMyMesh = async ({
|
||||||
userId,
|
userId,
|
||||||
input,
|
input,
|
||||||
@@ -45,16 +77,9 @@ export const createMyMesh = async ({
|
|||||||
userId: string;
|
userId: string;
|
||||||
input: CreateMyMeshInput;
|
input: CreateMyMeshInput;
|
||||||
}) => {
|
}) => {
|
||||||
// Slug collision check
|
// Slug is derived from name and stored non-uniquely — meshes are identified
|
||||||
const [existing] = await db
|
// by `mesh.id` (opaque). Two users can freely name their meshes "platform".
|
||||||
.select({ id: mesh.id })
|
const slug = toSlug(input.name);
|
||||||
.from(mesh)
|
|
||||||
.where(eq(mesh.slug, input.slug))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
throw new Error("A mesh with that slug already exists.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the mesh owner's ed25519 keypair (signs invites) and a
|
// Generate the mesh owner's ed25519 keypair (signs invites) and a
|
||||||
// 32-byte shared root key (channel encryption in later steps).
|
// 32-byte shared root key (channel encryption in later steps).
|
||||||
@@ -72,7 +97,7 @@ export const createMyMesh = async ({
|
|||||||
.insert(mesh)
|
.insert(mesh)
|
||||||
.values({
|
.values({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
slug: input.slug,
|
slug,
|
||||||
visibility: input.visibility,
|
visibility: input.visibility,
|
||||||
transport: input.transport,
|
transport: input.transport,
|
||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
@@ -215,28 +240,59 @@ export const createMyInvite = async ({
|
|||||||
const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString(
|
const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString(
|
||||||
"base64url",
|
"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 {
|
return {
|
||||||
id: created!.id,
|
id: created.id,
|
||||||
token: created!.token,
|
token: created.token,
|
||||||
expiresAt: created!.expiresAt,
|
code: created.code,
|
||||||
|
expiresAt: created.expiresAt,
|
||||||
inviteLink: `ic://join/${token}`,
|
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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -232,6 +232,29 @@ export const publicRouter = new Hono()
|
|||||||
}
|
}
|
||||||
return c.json(result);
|
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) => {
|
.get("/stats", async (c) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (cachedStats && cachedStats.expiresAt > now) {
|
if (cachedStats && cachedStats.expiresAt > now) {
|
||||||
|
|||||||
@@ -54,11 +54,6 @@ export type GetMyMeshesResponse = z.infer<typeof getMyMeshesResponseSchema>;
|
|||||||
|
|
||||||
export const createMyMeshInputSchema = z.object({
|
export const createMyMeshInputSchema = z.object({
|
||||||
name: z.string().min(2).max(80),
|
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"),
|
visibility: meshVisibilityEnum.default("private"),
|
||||||
transport: meshTransportEnum.default("managed"),
|
transport: meshTransportEnum.default("managed"),
|
||||||
});
|
});
|
||||||
@@ -130,8 +125,10 @@ export type CreateMyInviteInput = z.infer<typeof createMyInviteInputSchema>;
|
|||||||
export const createMyInviteResponseSchema = z.object({
|
export const createMyInviteResponseSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
|
code: z.string().nullable(),
|
||||||
inviteLink: z.string(),
|
inviteLink: z.string(),
|
||||||
joinUrl: z.string(),
|
joinUrl: z.string(),
|
||||||
|
shortUrl: z.string().nullable(),
|
||||||
expiresAt: z.coerce.date(),
|
expiresAt: z.coerce.date(),
|
||||||
});
|
});
|
||||||
export type CreateMyInviteResponse = z.infer<typeof createMyInviteResponseSchema>;
|
export type CreateMyInviteResponse = z.infer<typeof createMyInviteResponseSchema>;
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ const apiErrorSchema = z.object({
|
|||||||
path: z.string(),
|
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<typeof apiErrorSchema> => {
|
export const isAPIError = (e: unknown): e is z.infer<typeof apiErrorSchema> => {
|
||||||
return apiErrorSchema.safeParse(e).success;
|
return apiErrorSchema.safeParse(e).success;
|
||||||
};
|
};
|
||||||
@@ -70,10 +75,13 @@ export const handle = <
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (throwOnError) {
|
if (throwOnError) {
|
||||||
|
const parsed = routeErrorSchema.safeParse(data);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
isAPIError(data)
|
isAPIError(data)
|
||||||
? data.message
|
? 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<TResponse, E, S>;
|
return null as HandleReturn<TResponse, E, S>;
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const onError = async (
|
|||||||
code: "common:error.general",
|
code: "common:error.general",
|
||||||
message: t("common:error.general"),
|
message: t("common:error.general"),
|
||||||
status,
|
status,
|
||||||
|
timestamp,
|
||||||
path,
|
path,
|
||||||
}),
|
}),
|
||||||
details,
|
details,
|
||||||
|
|||||||
11
packages/db/migrations/0017_mesh-slug-non-unique.sql
Normal file
11
packages/db/migrations/0017_mesh-slug-non-unique.sql
Normal file
@@ -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";
|
||||||
20
packages/db/migrations/0018_invite-short-code.sql
Normal file
20
packages/db/migrations/0018_invite-short-code.sql
Normal file
@@ -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;
|
||||||
54
packages/db/migrations/0019_invite-v2-and-email.sql
Normal file
54
packages/db/migrations/0019_invite-v2-and-email.sql
Normal file
@@ -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");
|
||||||
@@ -78,7 +78,13 @@ export const messagePriorityEnum = meshSchema.enum("message_priority", [
|
|||||||
export const mesh = meshSchema.table("mesh", {
|
export const mesh = meshSchema.table("mesh", {
|
||||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||||
name: text().notNull(),
|
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()
|
ownerUserId: text()
|
||||||
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
|
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||||
.notNull(),
|
.notNull(),
|
||||||
@@ -176,6 +182,15 @@ export const invite = meshSchema.table("invite", {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
token: text().notNull().unique(),
|
token: text().notNull().unique(),
|
||||||
tokenBytes: text(),
|
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),
|
maxUses: integer().notNull().default(1),
|
||||||
usedCount: integer().notNull().default(0),
|
usedCount: integer().notNull().default(0),
|
||||||
role: meshRoleEnum().notNull().default("member"),
|
role: meshRoleEnum().notNull().default("member"),
|
||||||
@@ -192,8 +207,47 @@ export const invite = meshSchema.table("invite", {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
createdAt: timestamp().defaultNow().notNull(),
|
createdAt: timestamp().defaultNow().notNull(),
|
||||||
revokedAt: timestamp(),
|
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
|
* Signed, hash-chained audit log. NEVER stores message content — every
|
||||||
* payload between peers is E2E encrypted client-side (libsodium), so
|
* 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 }) => ({
|
export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||||
mesh: one(mesh, {
|
mesh: one(mesh, {
|
||||||
fields: [auditLog.meshId],
|
fields: [auditLog.meshId],
|
||||||
|
|||||||
Reference in New Issue
Block a user