Compare commits
4 Commits
dbea96960f
...
5e97d48cd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e97d48cd5 | ||
|
|
c8ae6462e3 | ||
|
|
fb7a84aed6 | ||
|
|
c1fa3bcb5c |
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)
|
||||
|
||||
| Feature | Description |
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
* current member of the claimed mesh.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { db } from "./db";
|
||||
import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||
|
||||
let ready = false;
|
||||
async function ensureSodium(): Promise<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;
|
||||
|
||||
/**
|
||||
@@ -118,3 +185,185 @@ export async function verifyHelloSignature(args: {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// v2 invite claim core — exported for the HTTP handler in index.ts AND for
|
||||
// tests that need to exercise the logic without spinning up the broker server.
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// capabilityV2 column is stored as JSON:
|
||||
// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
|
||||
// "signature": "<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 type { Duplex } from "node:stream";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import { env } from "./env";
|
||||
import { db } from "./db";
|
||||
import { mesh, meshMember, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh";
|
||||
import { invite as inviteTable, mesh, meshMember, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh";
|
||||
import { user } from "@turbostarter/db/schema/auth";
|
||||
import { handleCliSync, type CliSyncRequest } from "./cli-sync";
|
||||
import { updateMemberProfile, listMeshMembers, updateMeshSettings } from "./member-api";
|
||||
@@ -102,7 +102,7 @@ import { metrics, metricsToText } from "./metrics";
|
||||
import { TokenBucket } from "./rate-limit";
|
||||
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
|
||||
import { buildInfo } from "./build-info";
|
||||
import { verifyHelloSignature } from "./crypto";
|
||||
import { canonicalInviteV2, sealRootKeyToRecipient, verifyHelloSignature, verifyInviteV2 } from "./crypto";
|
||||
import { handleWebhook } from "./webhooks";
|
||||
import { audit, loadLastHashes, ensureAuditLogTable, verifyChain, queryAuditLog } from "./audit";
|
||||
|
||||
@@ -590,6 +590,16 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// v2 invite claim: POST /invites/:code/claim
|
||||
// Body: { recipient_x25519_pubkey: "<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") {
|
||||
handleUploadPost(req, res, started);
|
||||
return;
|
||||
@@ -864,6 +874,270 @@ function handleJoinPost(
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// v2 invite claim — POST /invites/:code/claim
|
||||
// ----------------------------------------------------------------------------
|
||||
// The v2 protocol moves the mesh root_key out of the invite URL. Invite
|
||||
// URLs are short opaque codes; on claim the broker verifies the signed
|
||||
// capability (stored server-side) and seals the root_key to a recipient-
|
||||
// provided x25519 pubkey so only that recipient can unseal it.
|
||||
//
|
||||
// capabilityV2 is stored as JSON on the invite row:
|
||||
// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
|
||||
// "signature": "<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(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -4282,4 +4556,8 @@ function main(): void {
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
// Skip starting the HTTP/WS server when running under vitest — tests import
|
||||
// claimInviteV2Core() directly and must not bind ports on module load.
|
||||
if (!process.env.VITEST) {
|
||||
main();
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,123 @@
|
||||
/**
|
||||
* `claudemesh join <invite-link>` — full join flow.
|
||||
* `claudemesh join <invite-link-or-code>` — full join flow.
|
||||
*
|
||||
* 1. Parse + validate the ic://join/... link
|
||||
* 2. Generate a fresh ed25519 keypair (libsodium)
|
||||
* 3. POST /join to the broker → get member_id
|
||||
* 4. Persist the mesh + keypair to ~/.claudemesh/config.json (0600)
|
||||
* 5. Print success
|
||||
* Accepts either:
|
||||
* - v2 short invite: `claudemesh.com/i/<code>` or bare `<code>`
|
||||
* → POSTs to /api/public/invites/:code/claim, unseals root_key,
|
||||
* persists mesh + fresh ed25519 identity.
|
||||
* - v1 legacy invite: `ic://join/<token>` or `https://.../join/<token>`
|
||||
* → parses signed payload, calls broker /join, persists.
|
||||
*
|
||||
* Signature verification + invite-token one-time-use land in Step 18.
|
||||
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
|
||||
*/
|
||||
|
||||
import { parseInviteLink } from "../invite/parse";
|
||||
import { enrollWithBroker } from "../invite/enroll";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
||||
import { claimInviteV2, parseV2InviteInput } from "../lib/invite-v2";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir, hostname } from "node:os";
|
||||
import { env } from "../env";
|
||||
|
||||
/** Derive the web app base URL from the broker URL, unless explicitly overridden. */
|
||||
function deriveAppBaseUrl(): string {
|
||||
const override = process.env.CLAUDEMESH_APP_URL;
|
||||
if (override) return override.replace(/\/$/, "");
|
||||
// Broker is `wss://ic.claudemesh.com/ws` → app is `https://claudemesh.com`.
|
||||
// For self-hosted: honour the broker host's parent domain as best-effort.
|
||||
try {
|
||||
const u = new URL(env.CLAUDEMESH_BROKER_URL);
|
||||
const host = u.host.replace(/^ic\./, "");
|
||||
const scheme = u.protocol === "wss:" ? "https:" : "http:";
|
||||
return `${scheme}//${host}`;
|
||||
} catch {
|
||||
return "https://claudemesh.com";
|
||||
}
|
||||
}
|
||||
|
||||
async function runJoinV2(code: string): Promise<void> {
|
||||
const appBaseUrl = deriveAppBaseUrl();
|
||||
console.log(`Claiming invite ${code} via ${appBaseUrl}…`);
|
||||
|
||||
let claim;
|
||||
try {
|
||||
claim = await claimInviteV2({ appBaseUrl, code });
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate a fresh ed25519 identity for this peer. The v2 claim
|
||||
// endpoint creates the member row keyed on the x25519 pubkey we sent;
|
||||
// the ed25519 keypair is what the `hello` handshake and future
|
||||
// envelope signing will use. Stored locally only.
|
||||
const keypair = await generateKeypair();
|
||||
const displayName = `${hostname()}-${process.pid}`;
|
||||
|
||||
// Encode the unsealed 32-byte root key as URL-safe base64url (no pad)
|
||||
// to match the format used everywhere else (broker stores it the
|
||||
// same way in mesh.rootKey).
|
||||
await sodium.ready;
|
||||
const rootKeyB64 = sodium.to_base64(
|
||||
claim.rootKey,
|
||||
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
|
||||
// Persist. We don't have a mesh_slug in the v2 response — the server
|
||||
// derives slug from name and slug is no longer globally unique. Use a
|
||||
// stable short derivative of the mesh id so `list` / `launch --mesh`
|
||||
// still have something to match on.
|
||||
const fallbackSlug = `mesh-${claim.meshId.slice(0, 8)}`;
|
||||
const config = loadConfig();
|
||||
config.meshes = config.meshes.filter((m) => m.meshId !== claim.meshId);
|
||||
config.meshes.push({
|
||||
meshId: claim.meshId,
|
||||
memberId: claim.memberId,
|
||||
slug: fallbackSlug,
|
||||
name: fallbackSlug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: env.CLAUDEMESH_BROKER_URL,
|
||||
joinedAt: new Date().toISOString(),
|
||||
rootKey: rootKeyB64,
|
||||
inviteVersion: 2,
|
||||
});
|
||||
saveConfig(config);
|
||||
|
||||
console.log("");
|
||||
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
|
||||
console.log(` member id: ${claim.memberId}`);
|
||||
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}…`);
|
||||
console.log(` broker: ${env.CLAUDEMESH_BROKER_URL}`);
|
||||
console.log(` config: ${getConfigPath()}`);
|
||||
console.log("");
|
||||
console.log("Restart Claude Code to pick up the new mesh.");
|
||||
}
|
||||
|
||||
export async function runJoin(args: string[]): Promise<void> {
|
||||
const link = args[0];
|
||||
if (!link) {
|
||||
console.error("Usage: claudemesh join <invite-url-or-token>");
|
||||
console.error("Usage: claudemesh join <invite-url-or-code>");
|
||||
console.error("");
|
||||
console.error(
|
||||
"Example: claudemesh join https://claudemesh.com/join/eyJ2IjoxLC4uLn0",
|
||||
);
|
||||
console.error("Examples:");
|
||||
console.error(" claudemesh join https://claudemesh.com/i/abc12345");
|
||||
console.error(" claudemesh join abc12345");
|
||||
console.error(" claudemesh join ic://join/eyJ2IjoxLC4uLn0 (v1 legacy)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Try v2 first — short code / `/i/<code>` URL.
|
||||
const v2Code = parseV2InviteInput(link);
|
||||
if (v2Code) {
|
||||
await runJoinV2(v2Code);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Parse + verify signature client-side.
|
||||
let invite;
|
||||
try {
|
||||
|
||||
@@ -331,9 +331,27 @@ const main = defineCommand({
|
||||
},
|
||||
}),
|
||||
},
|
||||
run() {
|
||||
runWelcome();
|
||||
async run() {
|
||||
await runWelcome();
|
||||
},
|
||||
});
|
||||
|
||||
// Friction reducer: if the user types `claudemesh --resume xxx` or any other
|
||||
// flag-first invocation, route it through `launch`. This keeps `claudemesh`
|
||||
// bare (welcome screen), `claudemesh <known-sub>` (dispatch normally), and
|
||||
// every flag-only form as implicit `launch`.
|
||||
const KNOWN_SUBCOMMANDS = new Set(Object.keys(main.subCommands ?? {}));
|
||||
// Flags citty handles on the root command — must not be rewritten to `launch`.
|
||||
const ROOT_PASSTHROUGH_FLAGS = new Set(["--help", "-h", "--version", "-v"]);
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const first = argv[0];
|
||||
if (first && !ROOT_PASSTHROUGH_FLAGS.has(first) && !KNOWN_SUBCOMMANDS.has(first)) {
|
||||
// Starts with a flag, or an unknown bareword → treat as launch args.
|
||||
// (Unknown barewords that look like typos would otherwise hit citty's
|
||||
// "unknown command" path; forwarding to launch lets claude surface the
|
||||
// error if it's a real claude flag, and launch's own parser rejects junk.)
|
||||
process.argv.splice(2, 0, "launch");
|
||||
}
|
||||
|
||||
runMain(main);
|
||||
|
||||
217
apps/cli/src/lib/invite-v2.ts
Normal file
217
apps/cli/src/lib/invite-v2.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* v2 invite claim client.
|
||||
*
|
||||
* The v2 invite URL is a short opaque code (e.g. `claudemesh.com/i/abc12345`).
|
||||
* The mesh root key is NOT embedded. Instead:
|
||||
*
|
||||
* 1. Client generates a fresh x25519 keypair (separate from the peer's
|
||||
* ed25519 identity) just for this claim.
|
||||
* 2. Client POSTs `recipient_x25519_pubkey` to
|
||||
* `${appBaseUrl}/api/public/invites/:code/claim`.
|
||||
* 3. Server responds with `sealed_root_key` (crypto_box_seal of the real
|
||||
* mesh root key to the recipient pubkey) + mesh metadata +
|
||||
* `canonical_v2` (the signed capability bytes).
|
||||
* 4. Client unseals the root key with its x25519 secret key.
|
||||
*
|
||||
* Wire contract is LOCKED — see `docs/protocol.md` §v2 invites and
|
||||
* `apps/broker/tests/invite-v2.test.ts`.
|
||||
*/
|
||||
|
||||
import sodium from "libsodium-wrappers";
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh x25519 (Curve25519) keypair suitable for
|
||||
* `crypto_box_seal`. This is intentionally distinct from the peer's
|
||||
* long-lived ed25519 identity — we do NOT want the mesh root key sealed
|
||||
* against a key that's reused for signing.
|
||||
*
|
||||
* Returns the public key as URL-safe base64url (no padding) to match
|
||||
* the format used by the broker's `sealed_root_key` response.
|
||||
*/
|
||||
export async function generateX25519Keypair(): Promise<{
|
||||
publicKeyB64: string;
|
||||
secretKey: Uint8Array;
|
||||
}> {
|
||||
const s = await ensureSodium();
|
||||
const kp = s.crypto_box_keypair();
|
||||
const publicKeyB64 = s.to_base64(
|
||||
kp.publicKey,
|
||||
s.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
return { publicKeyB64, secretKey: kp.privateKey };
|
||||
}
|
||||
|
||||
export interface ClaimV2Result {
|
||||
meshId: string;
|
||||
memberId: string;
|
||||
ownerPubkey: string;
|
||||
canonicalV2: string;
|
||||
/** Unsealed mesh root key, 32 raw bytes. */
|
||||
rootKey: Uint8Array;
|
||||
}
|
||||
|
||||
interface ClaimResponseBody {
|
||||
sealed_root_key?: string;
|
||||
mesh_id?: string;
|
||||
member_id?: string;
|
||||
owner_pubkey?: string;
|
||||
canonical_v2?: string;
|
||||
}
|
||||
|
||||
interface ClaimErrorBody {
|
||||
error?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim a v2 invite by its short code. Performs the x25519 keypair
|
||||
* generation, POST, and local unseal of the returned `sealed_root_key`.
|
||||
*
|
||||
* Throws with a descriptive message on 4xx/5xx or on seal-open failure.
|
||||
*/
|
||||
export async function claimInviteV2(opts: {
|
||||
appBaseUrl: string; // e.g. "https://claudemesh.com"
|
||||
code: string;
|
||||
}): Promise<ClaimV2Result> {
|
||||
const s = await ensureSodium();
|
||||
const { publicKeyB64, secretKey } = await generateX25519Keypair();
|
||||
const publicKeyBytes = s.from_base64(
|
||||
publicKeyB64,
|
||||
s.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
|
||||
const base = opts.appBaseUrl.replace(/\/$/, "");
|
||||
const code = encodeURIComponent(opts.code);
|
||||
const url = `${base}/api/public/invites/${code}/claim`;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ recipient_x25519_pubkey: publicKeyB64 }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`claim request failed (network): ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse body first — server returns JSON for both success and error.
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch {
|
||||
// fall through with parsed=null
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = (parsed ?? {}) as ClaimErrorBody;
|
||||
const reason =
|
||||
err.error ?? err.code ?? err.message ?? `HTTP ${res.status}`;
|
||||
switch (res.status) {
|
||||
case 400:
|
||||
throw new Error(`invite claim rejected: ${reason}`);
|
||||
case 404:
|
||||
throw new Error(`invite not found: ${reason}`);
|
||||
case 410:
|
||||
throw new Error(`invite no longer usable: ${reason}`);
|
||||
default:
|
||||
throw new Error(`invite claim failed (${res.status}): ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
const body = (parsed ?? {}) as ClaimResponseBody;
|
||||
if (
|
||||
!body.sealed_root_key ||
|
||||
!body.mesh_id ||
|
||||
!body.member_id ||
|
||||
!body.owner_pubkey ||
|
||||
!body.canonical_v2
|
||||
) {
|
||||
throw new Error(
|
||||
`invite claim response malformed: missing required field(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unseal the root key with our x25519 secret.
|
||||
let rootKey: Uint8Array;
|
||||
try {
|
||||
const sealed = s.from_base64(
|
||||
body.sealed_root_key,
|
||||
s.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
const opened = s.crypto_box_seal_open(sealed, publicKeyBytes, secretKey);
|
||||
if (!opened) throw new Error("crypto_box_seal_open returned empty");
|
||||
rootKey = opened;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`failed to unseal root key (server sealed to wrong pubkey?): ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
if (rootKey.length !== 32) {
|
||||
throw new Error(
|
||||
`unsealed root key has wrong length: ${rootKey.length} (expected 32)`,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(v0.1.5): when the claim response grows a `signature` field,
|
||||
// re-verify canonical_v2 against owner_pubkey locally as a
|
||||
// belt-and-suspenders check against a compromised broker.
|
||||
// For v0.1.x the broker is trusted: it verified capability_v2 before
|
||||
// sealing, and a malicious broker could already lie about mesh_id.
|
||||
|
||||
return {
|
||||
meshId: body.mesh_id,
|
||||
memberId: body.member_id,
|
||||
ownerPubkey: body.owner_pubkey,
|
||||
canonicalV2: body.canonical_v2,
|
||||
rootKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a v2 invite input (bare code or full URL) into a short code.
|
||||
*
|
||||
* Accepted forms:
|
||||
* - `abc12345`
|
||||
* - `claudemesh.com/i/abc12345`
|
||||
* - `https://claudemesh.com/i/abc12345`
|
||||
* - `https://claudemesh.com/es/i/abc12345` (locale prefix)
|
||||
*
|
||||
* Returns `null` if the input doesn't look like a v2 code/URL — callers
|
||||
* should fall back to the v1 `ic://join/...` parser in that case.
|
||||
*/
|
||||
export function parseV2InviteInput(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Full URL with /i/<code>
|
||||
const urlMatch = trimmed.match(
|
||||
/^https?:\/\/[^/]+(?:\/[a-z]{2})?\/i\/([A-Za-z0-9]+)\/?$/,
|
||||
);
|
||||
if (urlMatch) return urlMatch[1]!;
|
||||
|
||||
// Schemeless "claudemesh.com/i/<code>"
|
||||
const schemelessMatch = trimmed.match(
|
||||
/^[^/]+(?:\/[a-z]{2})?\/i\/([A-Za-z0-9]+)\/?$/,
|
||||
);
|
||||
if (schemelessMatch) return schemelessMatch[1]!;
|
||||
|
||||
// Bare short code — base62, typically 8 chars. Be a little lenient
|
||||
// (6-16) to accommodate future tweaks but stay tight enough not to
|
||||
// collide with a v1 base64url token (which contains `-` / `_` and is
|
||||
// much longer).
|
||||
if (/^[A-Za-z0-9]{6,16}$/.test(trimmed)) return trimmed;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -26,6 +26,15 @@ export interface JoinedMesh {
|
||||
secretKey: string; // ed25519 hex (64 bytes = 128 chars)
|
||||
brokerUrl: string;
|
||||
joinedAt: string;
|
||||
/**
|
||||
* Mesh root key (32 bytes) as URL-safe base64url, no padding.
|
||||
* Present for v2 invite joins (sealed then unsealed client-side).
|
||||
* Absent for v1 joins, where the root key lives inside the saved
|
||||
* invite token on disk instead. Used by channel/group `crypto_secretbox`.
|
||||
*/
|
||||
rootKey?: string;
|
||||
/** Invite protocol version used to join. `2` for v2, omitted/`1` for legacy. */
|
||||
inviteVersion?: 1 | 2;
|
||||
}
|
||||
|
||||
export interface GroupEntry {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hero } from "~/modules/marketing/home/hero";
|
||||
import { HeroWithMesh } from "~/modules/marketing/home/hero-with-mesh";
|
||||
import { Features } from "~/modules/marketing/home/features";
|
||||
import { WhereMeshFits } from "~/modules/marketing/home/where-mesh-fits";
|
||||
import { WhatIsClaudemesh } from "~/modules/marketing/home/what-is-claudemesh";
|
||||
import { Timeline } from "~/modules/marketing/home/timeline";
|
||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||
@@ -16,8 +17,9 @@ const HomePage = () => {
|
||||
className="bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<Hero />
|
||||
<HeroWithMesh />
|
||||
<Features />
|
||||
<WhereMeshFits />
|
||||
<WhatIsClaudemesh />
|
||||
<Timeline />
|
||||
<Pricing />
|
||||
|
||||
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 { getMetadata } from "~/lib/metadata";
|
||||
import { InstallToggle } from "~/modules/join/install-toggle";
|
||||
import { InviteCard } from "~/modules/join/invite-card";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Join a mesh",
|
||||
@@ -112,42 +113,29 @@ export default async function JoinPage({
|
||||
</Link>
|
||||
</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 ? (
|
||||
<>
|
||||
<div
|
||||
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— invitation
|
||||
</div>
|
||||
<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>
|
||||
<InviteCard
|
||||
meshName={invite.meshName}
|
||||
inviterName={invite.inviterName}
|
||||
role={invite.role}
|
||||
memberCount={invite.memberCount}
|
||||
expiresAt={new Date(invite.expiresAt)}
|
||||
/>
|
||||
|
||||
<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} />
|
||||
</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)" }}
|
||||
>
|
||||
By joining, you'll be known as a peer with an ed25519
|
||||
@@ -163,24 +151,27 @@ export default async function JoinPage({
|
||||
</div>
|
||||
|
||||
<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)" }}
|
||||
>
|
||||
expires {new Date(invite.expiresAt).toLocaleDateString()} ·{" "}
|
||||
{invite.maxUses - invite.usedCount} of {invite.maxUses} uses
|
||||
remaining
|
||||
</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
|
||||
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)" }}
|
||||
>
|
||||
— invitation unavailable
|
||||
</div>
|
||||
<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)" }}
|
||||
>
|
||||
{ERROR_COPY[invite.reason].title}
|
||||
@@ -210,7 +201,7 @@ export default async function JoinPage({
|
||||
← claudemesh.com
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fccTheme } from "./theme";
|
||||
|
||||
export type ClawdPose = "default" | "arms-up" | "look-left" | "look-right";
|
||||
|
||||
const APPLE_EYES: Record<ClawdPose, string> = {
|
||||
default: " \u2597 \u2596 ",
|
||||
"look-left": " \u2598 \u2598 ",
|
||||
"look-right": " \u259d \u259d ",
|
||||
"arms-up": " \u2597 \u2596 ",
|
||||
};
|
||||
|
||||
export function Clawd({ pose = "default" }: { pose?: ClawdPose }) {
|
||||
const monoStyle: React.CSSProperties = {
|
||||
fontFamily: fccTheme.fontMono,
|
||||
color: fccTheme.clawdBody,
|
||||
lineHeight: 1,
|
||||
letterSpacing: 0,
|
||||
fontVariantLigatures: "none",
|
||||
fontFeatureSettings: '"liga" 0, "calt" 0',
|
||||
whiteSpace: "pre",
|
||||
};
|
||||
|
||||
const eyesStyle: React.CSSProperties = {
|
||||
backgroundColor: fccTheme.clawdBody,
|
||||
color: fccTheme.clawdBackground,
|
||||
};
|
||||
|
||||
const bodyRowStyle: React.CSSProperties = {
|
||||
backgroundColor: fccTheme.clawdBody,
|
||||
color: fccTheme.clawdBody,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
...monoStyle,
|
||||
}}
|
||||
aria-label="Claude Code mascot"
|
||||
>
|
||||
<div>
|
||||
<span>{"\u2597"}</span>
|
||||
<span style={eyesStyle}>{APPLE_EYES[pose]}</span>
|
||||
<span>{"\u2596"}</span>
|
||||
</div>
|
||||
<div style={bodyRowStyle}>{" "}</div>
|
||||
<div>{"\u2598\u2598 \u259d\u259d"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Session, type SessionEvent, type SessionStep } from "./session";
|
||||
import { fccTheme } from "./theme";
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
type SessionConfig = {
|
||||
id: string;
|
||||
/** Display name used by mesh-send `to` fields to route particles */
|
||||
displayName: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
script: SessionStep[];
|
||||
startDelayMs?: number;
|
||||
position: {
|
||||
xPct: number;
|
||||
yPct: number;
|
||||
scale?: number;
|
||||
rotate?: number;
|
||||
opacity?: number;
|
||||
zIndex?: number;
|
||||
/** 0..1 — 1 is full color, 0 is grayscale */
|
||||
saturate?: number;
|
||||
/** pixels — adds depth-of-field bokeh blur to background peers */
|
||||
blurPx?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ArcConfig = {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
triggerStepKind: "mesh-send";
|
||||
};
|
||||
|
||||
type FlyingParticle = {
|
||||
id: number;
|
||||
fromId: string;
|
||||
toId: string;
|
||||
bornAt: number;
|
||||
};
|
||||
|
||||
type MeshHeroProps = {
|
||||
sessions: SessionConfig[];
|
||||
arcs?: ArcConfig[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
const PARTICLE_LIFE_MS = 1100;
|
||||
const TRAIL_SEGMENTS = 18;
|
||||
const TRAIL_SPAN = 0.34;
|
||||
const ICON_W = 38;
|
||||
const ICON_H = 26;
|
||||
|
||||
export function MeshHero({
|
||||
sessions,
|
||||
arcs = [],
|
||||
width = 1440,
|
||||
height = 720,
|
||||
}: MeshHeroProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const anchorsRef = useRef<Record<string, Point>>({});
|
||||
const [particles, setParticles] = useState<FlyingParticle[]>([]);
|
||||
const particleIdRef = useRef(0);
|
||||
const [, forceTick] = useState(0);
|
||||
const [reactions, setReactions] = useState<
|
||||
Record<string, { nonce: number; kind: "receive" | "send" | "arrive" }>
|
||||
>({});
|
||||
const reactionTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>(
|
||||
{},
|
||||
);
|
||||
const arrivedParticlesRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const bumpReaction = (
|
||||
sessionId: string,
|
||||
kind: "receive" | "send" | "arrive",
|
||||
) => {
|
||||
setReactions((prev) => ({
|
||||
...prev,
|
||||
[sessionId]: { nonce: (prev[sessionId]?.nonce ?? 0) + 1, kind },
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const loop = () => {
|
||||
forceTick((n) => (n + 1) % 1_000_000);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (particles.length === 0) return;
|
||||
const now = performance.now();
|
||||
const next = particles.filter((p) => now - p.bornAt < PARTICLE_LIFE_MS);
|
||||
if (next.length !== particles.length) setParticles(next);
|
||||
});
|
||||
|
||||
const handleEvent = (e: SessionEvent) => {
|
||||
if (e.kind !== "mesh-send") return;
|
||||
// Resolve destination by matching the mesh-send `to` field against
|
||||
// session displayNames. Fall back to the configured arcs if provided.
|
||||
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
const target = normalize(e.to);
|
||||
const toSession = sessions.find(
|
||||
(s) => normalize(s.displayName) === target,
|
||||
);
|
||||
let fromId = e.sessionId;
|
||||
let toId = toSession?.id;
|
||||
if (!toId) {
|
||||
const arc = arcs.find((a) => a.fromId === e.sessionId);
|
||||
if (!arc) return;
|
||||
toId = arc.toId;
|
||||
}
|
||||
if (fromId === toId) return;
|
||||
bumpReaction(fromId, "send");
|
||||
const id = particleIdRef.current++;
|
||||
setParticles((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
fromId,
|
||||
toId,
|
||||
bornAt: performance.now(),
|
||||
},
|
||||
]);
|
||||
const timer = setTimeout(
|
||||
() => bumpReaction(toId!, "arrive"),
|
||||
PARTICLE_LIFE_MS - 60,
|
||||
);
|
||||
reactionTimersRef.current[`${id}`] = timer;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(reactionTimersRef.current).forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setAnchor = (id: string) => (el: HTMLDivElement | null) => {
|
||||
if (!el || !containerRef.current) return;
|
||||
const container = containerRef.current.getBoundingClientRect();
|
||||
const rect = el.getBoundingClientRect();
|
||||
anchorsRef.current[id] = {
|
||||
x: rect.left - container.left + rect.width / 2,
|
||||
y: rect.top - container.top + rect.height / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const arcForParticle = (fromId: string, toId: string) => {
|
||||
const from = anchorsRef.current[fromId];
|
||||
const to = anchorsRef.current[toId];
|
||||
if (!from || !to) return null;
|
||||
const midX = (from.x + to.x) / 2;
|
||||
const midY = (from.y + to.y) / 2 - Math.abs(to.x - from.x) * 0.08 - 30;
|
||||
return { from, to, midX, midY };
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
height,
|
||||
background:
|
||||
"radial-gradient(ellipse at 50% 40%, rgba(215,119,87,0.07) 0%, rgba(0,0,0,0) 55%), #0a0a0a",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{sessions.map((s) => {
|
||||
const left = (s.position.xPct / 100) * width;
|
||||
const top = (s.position.yPct / 100) * height;
|
||||
const scale = s.position.scale ?? 1;
|
||||
const rotate = s.position.rotate ?? 0;
|
||||
const opacity = s.position.opacity ?? 1;
|
||||
const zIndex = s.position.zIndex ?? 1;
|
||||
const saturate = s.position.saturate ?? 1;
|
||||
const blurPx = s.position.blurPx ?? 0;
|
||||
const filters = [
|
||||
"drop-shadow(0 30px 50px rgba(0,0,0,0.6))",
|
||||
saturate !== 1 ? `saturate(${saturate})` : "",
|
||||
blurPx > 0 ? `blur(${blurPx}px)` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
ref={setAnchor(s.id)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left,
|
||||
top,
|
||||
transform: `translate(-50%, -50%) scale(${scale}) rotate(${rotate}deg)`,
|
||||
transformOrigin: "center center",
|
||||
filter: filters,
|
||||
opacity,
|
||||
zIndex,
|
||||
}}
|
||||
>
|
||||
<Session
|
||||
sessionId={s.id}
|
||||
script={s.script}
|
||||
title={s.title}
|
||||
cwd={s.cwd}
|
||||
width={720}
|
||||
height={480}
|
||||
startDelayMs={s.startDelayMs}
|
||||
onEvent={handleEvent}
|
||||
reactionNonce={reactions[s.id]?.nonce ?? 0}
|
||||
reactionKind={reactions[s.id]?.kind ?? "receive"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
mixBlendMode: "screen",
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<filter id="meshGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<symbol id="meshMsgIcon" viewBox="0 0 38 26">
|
||||
<rect
|
||||
x="1.5"
|
||||
y="1.5"
|
||||
width="35"
|
||||
height="23"
|
||||
rx="3"
|
||||
ry="3"
|
||||
fill={fccTheme.clawdBody}
|
||||
stroke={fccTheme.claudeShimmer}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<path
|
||||
d="M 4 5 L 19 15 L 34 5"
|
||||
stroke={fccTheme.clawdBackground}
|
||||
strokeWidth="2.2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</symbol>
|
||||
</defs>
|
||||
{particles.map((p) => {
|
||||
const arc = arcForParticle(p.fromId, p.toId);
|
||||
if (!arc) return null;
|
||||
const age = (performance.now() - p.bornAt) / PARTICLE_LIFE_MS;
|
||||
if (age > 1) return null;
|
||||
const head = Math.min(1, Math.max(0, age));
|
||||
|
||||
const pointAt = (t: number) => {
|
||||
const tt = Math.max(0, Math.min(1, t));
|
||||
const inv = 1 - tt;
|
||||
return {
|
||||
x:
|
||||
inv * inv * arc.from.x +
|
||||
2 * inv * tt * arc.midX +
|
||||
tt * tt * arc.to.x,
|
||||
y:
|
||||
inv * inv * arc.from.y +
|
||||
2 * inv * tt * arc.midY +
|
||||
tt * tt * arc.to.y,
|
||||
};
|
||||
};
|
||||
|
||||
const trailNodes = Array.from({ length: TRAIL_SEGMENTS }, (_, i) => {
|
||||
const frac = i / TRAIL_SEGMENTS;
|
||||
const t = head - frac * TRAIL_SPAN;
|
||||
if (t < 0) return null;
|
||||
const pt = pointAt(t);
|
||||
const falloff = Math.pow(1 - frac, 2.2);
|
||||
return {
|
||||
x: pt.x,
|
||||
y: pt.y,
|
||||
r: 2 + falloff * 5,
|
||||
opacity: 0.75 * falloff,
|
||||
};
|
||||
}).filter((n): n is NonNullable<typeof n> => n !== null);
|
||||
|
||||
const headPt = pointAt(head);
|
||||
const iconOpacity = Math.min(1, Math.sin(head * Math.PI) * 1.2 + 0.15);
|
||||
|
||||
return (
|
||||
<g key={p.id} filter="url(#meshGlow)">
|
||||
{trailNodes.map((n, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={n.r}
|
||||
fill={fccTheme.clawdBody}
|
||||
opacity={n.opacity}
|
||||
/>
|
||||
))}
|
||||
<use
|
||||
href="#meshMsgIcon"
|
||||
x={headPt.x - ICON_W / 2}
|
||||
y={headPt.y - ICON_H / 2}
|
||||
width={ICON_W}
|
||||
height={ICON_H}
|
||||
opacity={iconOpacity}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { fccTheme } from "./theme";
|
||||
|
||||
type BaseProps = { children: ReactNode };
|
||||
|
||||
export function UserPromptRow({ children }: BaseProps) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "1ch", marginTop: 6 }}>
|
||||
<span style={{ color: fccTheme.dim }}>{"\u003e"}</span>
|
||||
<span style={{ color: fccTheme.text }}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BashRunRow({
|
||||
command,
|
||||
lines,
|
||||
}: {
|
||||
command: string;
|
||||
lines?: string[];
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginTop: 10, marginBottom: 6 }}>
|
||||
<div style={{ display: "flex", gap: "0.7ch", alignItems: "baseline" }}>
|
||||
<span style={{ color: fccTheme.success }}>{"\u25cf"}</span>
|
||||
<span style={{ fontWeight: 700 }}>Bash</span>
|
||||
<span style={{ color: fccTheme.dim }}>({command})</span>
|
||||
</div>
|
||||
{lines?.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
paddingLeft: "2.2ch",
|
||||
color: fccTheme.dim,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: fccTheme.subtle, marginRight: "0.7ch" }}>
|
||||
{"\u2514"}
|
||||
</span>
|
||||
{l}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BulletRow({
|
||||
color = "success",
|
||||
children,
|
||||
}: {
|
||||
color?: "success" | "error" | "dim";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const c =
|
||||
color === "error"
|
||||
? fccTheme.error
|
||||
: color === "dim"
|
||||
? fccTheme.dim
|
||||
: fccTheme.success;
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "0.7ch", marginTop: 8 }}>
|
||||
<span style={{ color: c }}>{"\u25cf"}</span>
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolUseRow({
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
}: {
|
||||
name: string;
|
||||
args?: string;
|
||||
result?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ display: "flex", gap: "0.7ch" }}>
|
||||
<span style={{ color: fccTheme.clawdBody }}>{"\u25cf"}</span>
|
||||
<span style={{ fontWeight: 700 }}>{name}</span>
|
||||
{args && <span style={{ color: fccTheme.dim }}>({args})</span>}
|
||||
</div>
|
||||
{result && (
|
||||
<div style={{ paddingLeft: "2.2ch", color: fccTheme.dim }}>
|
||||
<span style={{ color: fccTheme.subtle, marginRight: "0.7ch" }}>
|
||||
{"\u2514"}
|
||||
</span>
|
||||
{result}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssistantTextRow({ children }: BaseProps) {
|
||||
return (
|
||||
<div style={{ marginTop: 8, color: fccTheme.text }}>
|
||||
<span style={{ color: fccTheme.clawdBody, marginRight: "0.7ch" }}>
|
||||
{"\u25cf"}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MeshMessageRow({
|
||||
direction,
|
||||
peer,
|
||||
message,
|
||||
}: {
|
||||
direction: "out" | "in";
|
||||
peer: string;
|
||||
message: string;
|
||||
}) {
|
||||
const arrow = direction === "out" ? "\u2192" : "\u2190";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: "6px 10px",
|
||||
border: `1px solid ${fccTheme.clawdBody}`,
|
||||
borderRadius: 4,
|
||||
color: fccTheme.text,
|
||||
display: "flex",
|
||||
gap: "0.7ch",
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: fccTheme.clawdBody }}>mesh</span>
|
||||
<span style={{ color: fccTheme.dim }}>{arrow}</span>
|
||||
<span style={{ color: fccTheme.clawdBody, fontWeight: 700 }}>
|
||||
{peer}
|
||||
</span>
|
||||
<span style={{ color: fccTheme.dim }}>:</span>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { fccTheme } from "./theme";
|
||||
|
||||
type PromptInputProps = {
|
||||
value?: string;
|
||||
caret?: boolean;
|
||||
};
|
||||
|
||||
export function PromptInput({ value = "", caret = true }: PromptInputProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
border: `1px solid ${fccTheme.promptBorder}`,
|
||||
borderRadius: 4,
|
||||
padding: "6px 10px",
|
||||
color: fccTheme.text,
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: "0.7ch",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: fccTheme.dim }}>{"\u003e"}</span>
|
||||
<span>{value}</span>
|
||||
{caret && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "0.6ch",
|
||||
height: "1em",
|
||||
backgroundColor: fccTheme.text,
|
||||
animation: "fccCaret 1s steps(1) infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<style>{`@keyframes fccCaret { 50% { opacity: 0; } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
398
apps/web/src/modules/marketing/home/fake-claude-code/session.tsx
Normal file
398
apps/web/src/modules/marketing/home/fake-claude-code/session.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
// useRef is still used for onEventRef below
|
||||
import { fccTheme } from "./theme";
|
||||
import { TerminalWindow } from "./terminal-window";
|
||||
import { Welcome } from "./welcome";
|
||||
import {
|
||||
AssistantTextRow,
|
||||
BulletRow,
|
||||
MeshMessageRow,
|
||||
ToolUseRow,
|
||||
UserPromptRow,
|
||||
} from "./message-row";
|
||||
import { PromptInput } from "./prompt-input";
|
||||
import { StatusBar } from "./status-bar";
|
||||
import { ThinkingSpinner } from "./thinking-spinner";
|
||||
|
||||
export type SessionStep =
|
||||
| { type: "user-input"; text: string; typeMs?: number }
|
||||
| { type: "thinking"; durationMs: number; label?: string }
|
||||
| { type: "assistant-text"; text: string; streamMs?: number }
|
||||
| { type: "tool-use"; name: string; args?: string; result?: string }
|
||||
| { type: "bullet"; text: string; color?: "success" | "error" | "dim" }
|
||||
| {
|
||||
type: "mesh-send";
|
||||
to: string;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: "mesh-receive";
|
||||
from: string;
|
||||
message: string;
|
||||
}
|
||||
| { type: "pause"; durationMs: number };
|
||||
|
||||
export type SessionEvent =
|
||||
| { kind: "mesh-send"; sessionId: string; to: string; message: string; stepIndex: number }
|
||||
| { kind: "mesh-receive"; sessionId: string; from: string; message: string; stepIndex: number }
|
||||
| { kind: "step-start"; sessionId: string; stepIndex: number }
|
||||
| { kind: "script-complete"; sessionId: string };
|
||||
|
||||
export type SessionReaction = "receive" | "send" | "arrive";
|
||||
|
||||
export type SessionProps = {
|
||||
sessionId: string;
|
||||
script: SessionStep[];
|
||||
title?: string;
|
||||
cwd?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
contextPct?: number;
|
||||
loop?: boolean;
|
||||
startDelayMs?: number;
|
||||
onEvent?: (event: SessionEvent) => void;
|
||||
/**
|
||||
* Bumps to trigger a reaction animation. Parent increments this to fire the
|
||||
* matching effect — e.g. an "arrive" pulse when a mesh particle lands.
|
||||
*/
|
||||
reactionNonce?: number;
|
||||
reactionKind?: SessionReaction;
|
||||
};
|
||||
|
||||
type RenderedStep =
|
||||
| { kind: "user-input"; text: string; done: boolean }
|
||||
| { kind: "thinking"; label: string }
|
||||
| { kind: "assistant-text"; text: string; done: boolean }
|
||||
| { kind: "tool-use"; name: string; args?: string; result?: string }
|
||||
| { kind: "bullet"; text: string; color: "success" | "error" | "dim" }
|
||||
| { kind: "mesh-send"; to: string; message: string }
|
||||
| { kind: "mesh-receive"; from: string; message: string };
|
||||
|
||||
export function Session({
|
||||
sessionId,
|
||||
script,
|
||||
title,
|
||||
cwd = "/Users/agutierrez",
|
||||
width = 760,
|
||||
height = 540,
|
||||
contextPct = 6,
|
||||
loop = true,
|
||||
startDelayMs = 0,
|
||||
onEvent,
|
||||
reactionNonce = 0,
|
||||
reactionKind = "receive",
|
||||
}: SessionProps) {
|
||||
const [rendered, setRendered] = useState<RenderedStep[]>([]);
|
||||
const [liveInput, setLiveInput] = useState("");
|
||||
const [cycle, setCycle] = useState(0);
|
||||
const onEventRef = useRef(onEvent);
|
||||
onEventRef.current = onEvent;
|
||||
|
||||
const scriptKey = useMemo(
|
||||
() => script.map((s) => s.type).join("|") + "::" + sessionId,
|
||||
[script, sessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setRendered([]);
|
||||
setLiveInput("");
|
||||
|
||||
const wait = (ms: number) =>
|
||||
new Promise<void>((res) => {
|
||||
const id = setTimeout(() => {
|
||||
if (!cancelled) res();
|
||||
}, ms);
|
||||
void id;
|
||||
});
|
||||
|
||||
const emit = (e: SessionEvent) => {
|
||||
if (!cancelled) onEventRef.current?.(e);
|
||||
};
|
||||
|
||||
const appendStep = (step: RenderedStep) => {
|
||||
if (cancelled) return;
|
||||
setRendered((prev) => [...prev, step]);
|
||||
};
|
||||
|
||||
const updateLast = (mut: (s: RenderedStep) => RenderedStep) => {
|
||||
if (cancelled) return;
|
||||
setRendered((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const next = prev.slice();
|
||||
next[next.length - 1] = mut(next[next.length - 1]);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const popLast = () => {
|
||||
if (cancelled) return;
|
||||
setRendered((prev) => prev.slice(0, -1));
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (startDelayMs > 0) await wait(startDelayMs);
|
||||
for (let i = 0; i < script.length; i++) {
|
||||
if (cancelled) return;
|
||||
const step = script[i];
|
||||
emit({ kind: "step-start", sessionId, stepIndex: i });
|
||||
|
||||
switch (step.type) {
|
||||
case "pause":
|
||||
await wait(step.durationMs);
|
||||
break;
|
||||
|
||||
case "user-input": {
|
||||
const ms = step.typeMs ?? 35;
|
||||
for (let c = 1; c <= step.text.length; c++) {
|
||||
if (cancelled) return;
|
||||
const slice = step.text.slice(0, c);
|
||||
if (!cancelled) setLiveInput(slice);
|
||||
await wait(ms);
|
||||
}
|
||||
// Submit: clear the input box and push the prompt into scrollback
|
||||
await wait(260);
|
||||
if (cancelled) return;
|
||||
setLiveInput("");
|
||||
appendStep({ kind: "user-input", text: step.text, done: true });
|
||||
await wait(140);
|
||||
break;
|
||||
}
|
||||
|
||||
case "thinking": {
|
||||
appendStep({ kind: "thinking", label: step.label ?? "Thinking" });
|
||||
await wait(step.durationMs);
|
||||
popLast();
|
||||
break;
|
||||
}
|
||||
|
||||
case "assistant-text": {
|
||||
appendStep({ kind: "assistant-text", text: "", done: false });
|
||||
const ms = step.streamMs ?? 18;
|
||||
for (let c = 1; c <= step.text.length; c++) {
|
||||
if (cancelled) return;
|
||||
const slice = step.text.slice(0, c);
|
||||
updateLast((s) =>
|
||||
s.kind === "assistant-text" ? { ...s, text: slice } : s,
|
||||
);
|
||||
await wait(ms);
|
||||
}
|
||||
updateLast((s) =>
|
||||
s.kind === "assistant-text" ? { ...s, done: true } : s,
|
||||
);
|
||||
await wait(250);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-use": {
|
||||
appendStep({
|
||||
kind: "tool-use",
|
||||
name: step.name,
|
||||
args: step.args,
|
||||
result: step.result,
|
||||
});
|
||||
await wait(400);
|
||||
break;
|
||||
}
|
||||
|
||||
case "bullet": {
|
||||
appendStep({
|
||||
kind: "bullet",
|
||||
text: step.text,
|
||||
color: step.color ?? "success",
|
||||
});
|
||||
await wait(200);
|
||||
break;
|
||||
}
|
||||
|
||||
case "mesh-send": {
|
||||
appendStep({
|
||||
kind: "mesh-send",
|
||||
to: step.to,
|
||||
message: step.message,
|
||||
});
|
||||
emit({
|
||||
kind: "mesh-send",
|
||||
sessionId,
|
||||
to: step.to,
|
||||
message: step.message,
|
||||
stepIndex: i,
|
||||
});
|
||||
await wait(350);
|
||||
break;
|
||||
}
|
||||
|
||||
case "mesh-receive": {
|
||||
appendStep({
|
||||
kind: "mesh-receive",
|
||||
from: step.from,
|
||||
message: step.message,
|
||||
});
|
||||
emit({
|
||||
kind: "mesh-receive",
|
||||
sessionId,
|
||||
from: step.from,
|
||||
message: step.message,
|
||||
stepIndex: i,
|
||||
});
|
||||
await wait(350);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
emit({ kind: "script-complete", sessionId });
|
||||
|
||||
if (loop) {
|
||||
await wait(2000);
|
||||
if (cancelled) return;
|
||||
setCycle((c) => c + 1);
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [scriptKey, cycle, loop, script, sessionId, startDelayMs]);
|
||||
|
||||
const reactionClass = `fcc-react-${reactionKind}`;
|
||||
const reactionKey = `${reactionKind}-${reactionNonce}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={reactionKey}
|
||||
className={reactionNonce > 0 ? reactionClass : undefined}
|
||||
style={{ willChange: "transform, filter" }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes fccPulseReceive {
|
||||
0% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(215,119,87,0)); }
|
||||
30% { transform: scale(1.02); filter: drop-shadow(0 0 22px rgba(215,119,87,0.55)); }
|
||||
100% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(215,119,87,0)); }
|
||||
}
|
||||
@keyframes fccPulseArrive {
|
||||
0% { transform: scale(1); filter: brightness(1) drop-shadow(0 0 0 rgba(215,119,87,0)); }
|
||||
25% { transform: scale(1.015); filter: brightness(1.25) drop-shadow(0 0 30px rgba(215,119,87,0.7)); }
|
||||
100% { transform: scale(1); filter: brightness(1) drop-shadow(0 0 0 rgba(215,119,87,0)); }
|
||||
}
|
||||
@keyframes fccPulseSend {
|
||||
0% { transform: scale(1); }
|
||||
35% { transform: scale(0.99); filter: drop-shadow(0 0 12px rgba(215,119,87,0.35)); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
.fcc-react-receive { animation: fccPulseReceive 380ms cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
.fcc-react-arrive { animation: fccPulseArrive 520ms cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
.fcc-react-send { animation: fccPulseSend 260ms cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
`}</style>
|
||||
<TerminalWindow width={width} height={height} title={title}>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<span style={{ color: fccTheme.dim }}>[(base) </span>
|
||||
<span style={{ color: fccTheme.text }}>agutierrez@Mac</span>
|
||||
<span style={{ color: fccTheme.dim }}> ~ % </span>
|
||||
<span style={{ color: fccTheme.text }}>claude</span>
|
||||
</div>
|
||||
<Welcome cwd={cwd} />
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
marginTop: 8,
|
||||
marginBottom: 4,
|
||||
height: 1,
|
||||
background: `repeating-linear-gradient(90deg, ${fccTheme.subtle} 0 6px, transparent 6px 10px)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
minHeight: 180,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{rendered.map((s, i) => {
|
||||
switch (s.kind) {
|
||||
case "user-input":
|
||||
return (
|
||||
<UserPromptRow key={i}>
|
||||
{s.text}
|
||||
{!s.done && <BlinkCursor />}
|
||||
</UserPromptRow>
|
||||
);
|
||||
case "thinking":
|
||||
return (
|
||||
<div key={i} style={{ marginTop: 8 }}>
|
||||
<ThinkingSpinner label={s.label} />
|
||||
</div>
|
||||
);
|
||||
case "assistant-text":
|
||||
return (
|
||||
<AssistantTextRow key={i}>
|
||||
{s.text}
|
||||
{!s.done && <BlinkCursor />}
|
||||
</AssistantTextRow>
|
||||
);
|
||||
case "tool-use":
|
||||
return (
|
||||
<ToolUseRow
|
||||
key={i}
|
||||
name={s.name}
|
||||
args={s.args}
|
||||
result={s.result}
|
||||
/>
|
||||
);
|
||||
case "bullet":
|
||||
return (
|
||||
<BulletRow key={i} color={s.color}>
|
||||
{s.text}
|
||||
</BulletRow>
|
||||
);
|
||||
case "mesh-send":
|
||||
return (
|
||||
<MeshMessageRow
|
||||
key={i}
|
||||
direction="out"
|
||||
peer={s.to}
|
||||
message={s.message}
|
||||
/>
|
||||
);
|
||||
case "mesh-receive":
|
||||
return (
|
||||
<MeshMessageRow
|
||||
key={i}
|
||||
direction="in"
|
||||
peer={s.from}
|
||||
message={s.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
<PromptInput value={liveInput} />
|
||||
<StatusBar cwd="~" contextPct={contextPct} />
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlinkCursor() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "0.55ch",
|
||||
height: "1em",
|
||||
marginLeft: "0.15ch",
|
||||
verticalAlign: "text-bottom",
|
||||
backgroundColor: fccTheme.text,
|
||||
animation: "fccCaret 1s steps(1) infinite",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { fccTheme } from "./theme";
|
||||
|
||||
type StatusBarProps = {
|
||||
user?: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
contextPct?: number;
|
||||
errorNote?: string;
|
||||
errorAction?: string;
|
||||
};
|
||||
|
||||
export function StatusBar({
|
||||
user = "agutierrez@Mac",
|
||||
cwd = "~",
|
||||
model = "Opus 4.6 (1M context)",
|
||||
contextPct = 6,
|
||||
errorNote,
|
||||
errorAction,
|
||||
}: StatusBarProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
paddingTop: 8,
|
||||
borderTop: `1px solid ${fccTheme.subtle}`,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: fccTheme.dim,
|
||||
fontSize: "inherit",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span>{user}</span>
|
||||
<span style={{ margin: "0 0.7ch" }}>{"\u007c"}</span>
|
||||
<span>{cwd}</span>
|
||||
<span style={{ margin: "0 0.7ch" }}>{"\u007c"}</span>
|
||||
<span>{model}</span>
|
||||
<span style={{ marginLeft: "0.7ch" }}>{`[ctx:${contextPct}%]`}</span>
|
||||
</div>
|
||||
{errorNote && (
|
||||
<div>
|
||||
<span style={{ color: fccTheme.error }}>{errorNote}</span>
|
||||
{errorAction && (
|
||||
<>
|
||||
<span style={{ color: fccTheme.dim, margin: "0 0.7ch" }}>
|
||||
{"\u00b7"}
|
||||
</span>
|
||||
<span style={{ color: fccTheme.clawdBody }}>{errorAction}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { fccTheme } from "./theme";
|
||||
|
||||
type TerminalWindowProps = {
|
||||
title?: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function TerminalWindow({
|
||||
title = "agutierrez — \u2728 Initialize new coding project — node · claude — 80\u00d724",
|
||||
width = 760,
|
||||
height = 520,
|
||||
children,
|
||||
}: TerminalWindowProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
boxShadow:
|
||||
"0 30px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.05)",
|
||||
backgroundColor: fccTheme.terminalBg,
|
||||
fontFamily: fccTheme.fontMono,
|
||||
}}
|
||||
>
|
||||
<TitleBar title={title} />
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "14px 18px 16px 18px",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.45,
|
||||
color: fccTheme.text,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleBar({ title }: { title: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: 28,
|
||||
paddingInline: 12,
|
||||
backgroundColor: fccTheme.terminalChrome,
|
||||
borderBottom: "1px solid rgba(0,0,0,0.4)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<TrafficLight color="#ff5f57" />
|
||||
<TrafficLight color="#febc2e" />
|
||||
<TrafficLight color="#28c840" />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
fontWeight: 600,
|
||||
pointerEvents: "none",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<FolderIcon />
|
||||
<span
|
||||
style={{
|
||||
maxWidth: "70%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrafficLight({ color }: { color: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
boxShadow: "inset 0 0 0 0.5px rgba(0,0,0,0.25)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M1.5 4.5a1 1 0 0 1 1-1h3.3l1.4 1.4h6.3a1 1 0 0 1 1 1v5.6a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4.5Z"
|
||||
stroke="rgba(255,255,255,0.75)"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const fccTheme = {
|
||||
clawdBody: "rgb(215,119,87)",
|
||||
clawdBackground: "rgb(0,0,0)",
|
||||
text: "rgb(255,255,255)",
|
||||
dim: "rgb(153,153,153)",
|
||||
subtle: "rgb(80,80,80)",
|
||||
success: "rgb(78,186,101)",
|
||||
error: "rgb(255,107,128)",
|
||||
claudeShimmer: "rgb(235,159,127)",
|
||||
promptBorder: "rgb(136,136,136)",
|
||||
bashBorder: "rgb(253,93,177)",
|
||||
terminalBg: "rgb(0,0,0)",
|
||||
terminalChrome: "rgb(55,55,57)",
|
||||
fontMono:
|
||||
"'Menlo', 'Monaco', 'SF Mono', 'JetBrains Mono', 'Consolas', ui-monospace, monospace",
|
||||
} as const;
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fccTheme } from "./theme";
|
||||
|
||||
const FRAMES = ["\u2847", "\u284f", "\u285f", "\u287f", "\u28ff", "\u28f7", "\u28e7", "\u28c7"];
|
||||
|
||||
type ThinkingSpinnerProps = {
|
||||
label?: string;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
export function ThinkingSpinner({
|
||||
label = "Thinking",
|
||||
intervalMs = 80,
|
||||
}: ThinkingSpinnerProps) {
|
||||
const [i, setI] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setI((n) => (n + 1) % FRAMES.length), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "0.6ch", color: fccTheme.claudeShimmer }}>
|
||||
<span style={{ color: fccTheme.clawdBody }}>{FRAMES[i]}</span>
|
||||
<span style={{ color: fccTheme.dim, fontStyle: "italic" }}>{label}…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Clawd, type ClawdPose } from "./clawd";
|
||||
import { fccTheme } from "./theme";
|
||||
|
||||
type WelcomeProps = {
|
||||
pose?: ClawdPose;
|
||||
version?: string;
|
||||
model?: string;
|
||||
billing?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export function Welcome({
|
||||
pose = "default",
|
||||
version = "2.1.101",
|
||||
model = "Opus 4.6 (1M context)",
|
||||
billing = "Claude Max",
|
||||
cwd = "/Users/agutierrez",
|
||||
}: WelcomeProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: "1ch",
|
||||
alignItems: "flex-start",
|
||||
fontFamily: fccTheme.fontMono,
|
||||
color: fccTheme.text,
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, paddingTop: "0.1em" }}>
|
||||
<Clawd pose={pose} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.05em",
|
||||
paddingTop: "0.1em",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ fontWeight: 700 }}>Claude Code</span>{" "}
|
||||
<span style={{ color: fccTheme.dim }}>v{version}</span>
|
||||
</div>
|
||||
<div style={{ color: fccTheme.dim }}>
|
||||
{model} · {billing}
|
||||
</div>
|
||||
<div style={{ color: fccTheme.dim }}>{cwd}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,33 @@ import { useState } from "react";
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
key: "skills",
|
||||
tab: "Skills",
|
||||
title: "Publish a skill once, every peer invokes it",
|
||||
body: "Write a skill in ~/.claude/skills/review-pr, share it to the mesh, and every teammate's Claude Code has /review-pr. Update the skill on your end → every peer auto-refreshes. No manual CLAUDE.md edits, no git pulls, no copy-paste.",
|
||||
code: `share_skill(name: "review-pr", dir: "./.claude/skills/review-pr")
|
||||
mesh_skill_deploy("review-pr")
|
||||
list_skills() → all skills live on the mesh`,
|
||||
},
|
||||
{
|
||||
key: "mcps",
|
||||
tab: "MCPs",
|
||||
title: "Share an MCP server once, every peer sees its tools",
|
||||
body: "Register an MCP on your machine — Postgres, Stripe, internal API, whatever — then mesh_mcp_deploy it. Every peer's Claude Code auto-discovers the tools, with per-mesh scope and audit logs. Credentials never leave your machine.",
|
||||
code: `mesh_mcp_register("postgres-prod", command: "npx mcp-postgres")
|
||||
mesh_mcp_deploy("postgres-prod")
|
||||
mesh_mcp_catalog() → every MCP live on the mesh`,
|
||||
},
|
||||
{
|
||||
key: "commands",
|
||||
tab: "Commands",
|
||||
title: "Slash commands that travel with the mesh",
|
||||
body: "Any slash command you've defined — /deploy, /audit, /review-pr — can be published to the mesh. Teammates invoke it from their own Claude Code. The command runs with your logic and rules, their context. Shared muscle memory, no copying files between repos.",
|
||||
code: `share_skill(name: "deploy", kind: "command")
|
||||
// Peer B types /deploy in their session
|
||||
// → runs your publisher-side playbook in their repo`,
|
||||
},
|
||||
{
|
||||
key: "groups",
|
||||
tab: "Groups",
|
||||
@@ -90,7 +117,7 @@ export const Features = () => {
|
||||
className="mx-auto mt-4 max-w-xl text-center text-sm text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
43 MCP tools. Groups, state, memory, files, databases, vectors, streams — all shipped.
|
||||
Skills, MCPs, slash commands, groups, state, memory, files, databases, vectors, streams — every primitive meshed, end-to-end encrypted.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={3}>
|
||||
|
||||
354
apps/web/src/modules/marketing/home/hero-mesh-animation.tsx
Normal file
354
apps/web/src/modules/marketing/home/hero-mesh-animation.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MeshHero } from "./fake-claude-code/mesh-hero";
|
||||
import type { SessionStep } from "./fake-claude-code/session";
|
||||
|
||||
const NATURAL_W = 1600;
|
||||
const NATURAL_H = 860;
|
||||
|
||||
const SCRIPT_A: SessionStep[] = [
|
||||
{ type: "pause", durationMs: 400 },
|
||||
{ type: "user-input", text: "share_skill /review-pr" },
|
||||
{ type: "mesh-send", to: "Lug Nut", message: "share_skill /review-pr" },
|
||||
{ type: "pause", durationMs: 1200 },
|
||||
{ type: "mesh-receive", from: "Mou", message: "postgres-prod MCP live" },
|
||||
{ type: "pause", durationMs: 800 },
|
||||
{
|
||||
type: "tool-use",
|
||||
name: "mesh_tool_call",
|
||||
args: "postgres-prod.query",
|
||||
result: "142 rows",
|
||||
},
|
||||
{ type: "pause", durationMs: 1100 },
|
||||
{ type: "mesh-send", to: "Mou", message: "thanks — skill in use" },
|
||||
{ type: "pause", durationMs: 2200 },
|
||||
];
|
||||
|
||||
const SCRIPT_B: SessionStep[] = [
|
||||
{ type: "pause", durationMs: 700 },
|
||||
{ type: "mesh-receive", from: "Alexis", message: "/review-pr shared" },
|
||||
{ type: "pause", durationMs: 800 },
|
||||
{ type: "user-input", text: "/review-pr PR #142" },
|
||||
{ type: "thinking", durationMs: 700 },
|
||||
{
|
||||
type: "tool-use",
|
||||
name: "Read",
|
||||
args: "auth/middleware.ts",
|
||||
result: "142 lines",
|
||||
},
|
||||
{ type: "pause", durationMs: 800 },
|
||||
{ type: "mesh-send", to: "Mou", message: "found 2 issues in auth flow" },
|
||||
{ type: "pause", durationMs: 1500 },
|
||||
{ type: "mesh-receive", from: "Alexis", message: "thanks — skill in use" },
|
||||
{ type: "pause", durationMs: 1600 },
|
||||
];
|
||||
|
||||
const SCRIPT_C: SessionStep[] = [
|
||||
{ type: "pause", durationMs: 300 },
|
||||
{ type: "user-input", text: "expose postgres to mesh" },
|
||||
{
|
||||
type: "tool-use",
|
||||
name: "mesh_mcp_deploy",
|
||||
args: "postgres-prod",
|
||||
result: "exposed to 6 peers",
|
||||
},
|
||||
{ type: "mesh-send", to: "Alexis", message: "postgres-prod MCP live" },
|
||||
{ type: "pause", durationMs: 1400 },
|
||||
{
|
||||
type: "mesh-receive",
|
||||
from: "Lug Nut",
|
||||
message: "found 2 issues in auth flow",
|
||||
},
|
||||
{ type: "pause", durationMs: 700 },
|
||||
{ type: "assistant-text", text: "Patching issues via mesh." },
|
||||
{ type: "pause", durationMs: 900 },
|
||||
{
|
||||
type: "mesh-send",
|
||||
to: "Lug Nut",
|
||||
message: "fix pushed — rerun /review-pr",
|
||||
},
|
||||
{ type: "pause", durationMs: 1800 },
|
||||
];
|
||||
|
||||
const SCRIPT_PIP: SessionStep[] = [
|
||||
{ type: "pause", durationMs: 1200 },
|
||||
{ type: "mesh-receive", from: "Alexis", message: "share_skill /review-pr" },
|
||||
{ type: "pause", durationMs: 1600 },
|
||||
{ type: "mesh-send", to: "Alexis", message: "cache warm" },
|
||||
{ type: "pause", durationMs: 3200 },
|
||||
];
|
||||
|
||||
const SCRIPT_RIPPLE: SessionStep[] = [
|
||||
{ type: "pause", durationMs: 2100 },
|
||||
{ type: "mesh-receive", from: "Mou", message: "postgres-prod MCP live" },
|
||||
{ type: "pause", durationMs: 1800 },
|
||||
{ type: "mesh-send", to: "Mou", message: "mirror ready" },
|
||||
{ type: "pause", durationMs: 3000 },
|
||||
];
|
||||
|
||||
const SCRIPT_NEBULA: SessionStep[] = [
|
||||
{ type: "pause", durationMs: 2800 },
|
||||
{ type: "mesh-receive", from: "Lug Nut", message: "need security review" },
|
||||
{ type: "pause", durationMs: 1500 },
|
||||
{ type: "mesh-send", to: "Lug Nut", message: "reviewed — LGTM" },
|
||||
{ type: "pause", durationMs: 3000 },
|
||||
];
|
||||
|
||||
const SCRIPT_JET: SessionStep[] = [
|
||||
{ type: "pause", durationMs: 1800 },
|
||||
{ type: "mesh-receive", from: "Alexis", message: "thanks — skill in use" },
|
||||
{ type: "pause", durationMs: 1800 },
|
||||
{ type: "mesh-send", to: "Alexis", message: "heartbeat ok" },
|
||||
{ type: "pause", durationMs: 3200 },
|
||||
];
|
||||
|
||||
const SCRIPT_VELA: SessionStep[] = [
|
||||
{ type: "pause", durationMs: 900 },
|
||||
{ type: "mesh-send", to: "Lug Nut", message: "broker uptime 99.98" },
|
||||
{ type: "pause", durationMs: 2400 },
|
||||
{ type: "mesh-receive", from: "Mou", message: "postgres-prod MCP live" },
|
||||
{ type: "pause", durationMs: 3400 },
|
||||
];
|
||||
|
||||
const SCRIPT_OREL: SessionStep[] = [
|
||||
{ type: "pause", durationMs: 2400 },
|
||||
{ type: "mesh-receive", from: "Alexis", message: "share_skill /review-pr" },
|
||||
{ type: "pause", durationMs: 1600 },
|
||||
{ type: "mesh-send", to: "Alexis", message: "mirrored downstream" },
|
||||
{ type: "pause", durationMs: 3000 },
|
||||
];
|
||||
|
||||
type HeroMeshAnimationProps = {
|
||||
/**
|
||||
* `cover` — fill both width and height of the parent, overflow clipped (for
|
||||
* use as a hero background). `contain` — fit within width, height scales
|
||||
* proportionally (standalone use).
|
||||
*/
|
||||
fit?: "cover" | "contain";
|
||||
};
|
||||
|
||||
export function HeroMeshAnimation({ fit = "contain" }: HeroMeshAnimationProps) {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const [fitScale, setFitScale] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const el = outerRef.current;
|
||||
if (!el) return;
|
||||
const compute = (w: number, h: number) => {
|
||||
if (fit === "cover") {
|
||||
// Pick the larger ratio so the composition fills both dimensions.
|
||||
// Never scale below 1 in cover mode — we want overflow if the parent
|
||||
// is smaller than the natural size.
|
||||
const s = Math.max(w / NATURAL_W, h / NATURAL_H);
|
||||
setFitScale(Math.max(s, 0.001));
|
||||
} else {
|
||||
setFitScale(Math.min(1, w / NATURAL_W));
|
||||
}
|
||||
};
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const rect = entries[0]?.contentRect;
|
||||
if (!rect) return;
|
||||
compute(rect.width, rect.height);
|
||||
});
|
||||
ro.observe(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
compute(rect.width, rect.height);
|
||||
return () => ro.disconnect();
|
||||
}, [fit]);
|
||||
|
||||
const isCover = fit === "cover";
|
||||
const scaledW = NATURAL_W * fitScale;
|
||||
const scaledH = NATURAL_H * fitScale;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerRef}
|
||||
className={isCover ? "h-full w-full" : "w-full"}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
...(isCover ? {} : { height: scaledH }),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: scaledW,
|
||||
height: scaledH,
|
||||
...(isCover
|
||||
? {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}
|
||||
: { margin: "0 auto" }),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: NATURAL_W,
|
||||
height: NATURAL_H,
|
||||
transform: `scale(${fitScale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<MeshHero
|
||||
width={NATURAL_W}
|
||||
height={NATURAL_H}
|
||||
sessions={[
|
||||
{
|
||||
id: "P_VELA",
|
||||
displayName: "Vela",
|
||||
title: "vela · claude — 80\u00d724",
|
||||
cwd: "~/broker",
|
||||
script: SCRIPT_VELA,
|
||||
position: {
|
||||
xPct: 50,
|
||||
yPct: 10,
|
||||
scale: 0.38,
|
||||
opacity: 0.55,
|
||||
saturate: 0.35,
|
||||
blurPx: 0.6,
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "P_OREL",
|
||||
displayName: "Orel",
|
||||
title: "orel · claude — 80\u00d724",
|
||||
cwd: "~/registry",
|
||||
script: SCRIPT_OREL,
|
||||
position: {
|
||||
xPct: 50,
|
||||
yPct: 88,
|
||||
scale: 0.38,
|
||||
opacity: 0.55,
|
||||
saturate: 0.35,
|
||||
blurPx: 0.6,
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "P1",
|
||||
displayName: "Pip",
|
||||
title: "pip · claude — 80\u00d724",
|
||||
cwd: "~/tools",
|
||||
script: SCRIPT_PIP,
|
||||
position: {
|
||||
xPct: 8,
|
||||
yPct: 20,
|
||||
scale: 0.42,
|
||||
rotate: -4,
|
||||
opacity: 0.6,
|
||||
saturate: 0.4,
|
||||
blurPx: 0.5,
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "P2",
|
||||
displayName: "Ripple",
|
||||
title: "ripple · claude — 80\u00d724",
|
||||
cwd: "~/infra",
|
||||
script: SCRIPT_RIPPLE,
|
||||
position: {
|
||||
xPct: 92,
|
||||
yPct: 20,
|
||||
scale: 0.42,
|
||||
rotate: 4,
|
||||
opacity: 0.6,
|
||||
saturate: 0.4,
|
||||
blurPx: 0.5,
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "P3",
|
||||
displayName: "Nebula",
|
||||
title: "nebula · claude — 80\u00d724",
|
||||
cwd: "~/ops",
|
||||
script: SCRIPT_NEBULA,
|
||||
position: {
|
||||
xPct: 10,
|
||||
yPct: 82,
|
||||
scale: 0.4,
|
||||
rotate: 3,
|
||||
opacity: 0.58,
|
||||
saturate: 0.38,
|
||||
blurPx: 0.5,
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "P4",
|
||||
displayName: "Jet",
|
||||
title: "jet · claude — 80\u00d724",
|
||||
cwd: "~/monorepo",
|
||||
script: SCRIPT_JET,
|
||||
position: {
|
||||
xPct: 90,
|
||||
yPct: 82,
|
||||
scale: 0.4,
|
||||
rotate: -3,
|
||||
opacity: 0.58,
|
||||
saturate: 0.38,
|
||||
blurPx: 0.5,
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "A",
|
||||
displayName: "Alexis",
|
||||
title: "agutierrez — alexis · claude — 80\u00d724",
|
||||
cwd: "~/claudemesh",
|
||||
script: SCRIPT_A,
|
||||
position: {
|
||||
xPct: 20,
|
||||
yPct: 58,
|
||||
scale: 0.65,
|
||||
rotate: -3,
|
||||
saturate: 1,
|
||||
opacity: 1,
|
||||
zIndex: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "B",
|
||||
displayName: "Lug Nut",
|
||||
title: "agutierrez — lug-nut · claude — 80\u00d724",
|
||||
cwd: "~/whyrating",
|
||||
script: SCRIPT_B,
|
||||
position: {
|
||||
xPct: 50,
|
||||
yPct: 40,
|
||||
scale: 0.65,
|
||||
rotate: 0,
|
||||
saturate: 1,
|
||||
opacity: 1,
|
||||
zIndex: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C",
|
||||
displayName: "Mou",
|
||||
title: "agutierrez — mou · claude — 80\u00d724",
|
||||
cwd: "~/mineryreport",
|
||||
script: SCRIPT_C,
|
||||
position: {
|
||||
xPct: 80,
|
||||
yPct: 58,
|
||||
scale: 0.65,
|
||||
rotate: 3,
|
||||
saturate: 1,
|
||||
opacity: 1,
|
||||
zIndex: 2,
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
apps/web/src/modules/marketing/home/hero-with-mesh.tsx
Normal file
118
apps/web/src/modules/marketing/home/hero-with-mesh.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { HeroMeshAnimation } from "./hero-mesh-animation";
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
export const HeroWithMesh = () => {
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Full-bleed mesh animation as hero background */}
|
||||
<div className="pointer-events-none absolute inset-0 z-0">
|
||||
<div className="absolute inset-0">
|
||||
<HeroMeshAnimation fit="cover" />
|
||||
</div>
|
||||
{/* Radial vignette: dark where text sits, transparent at the edges
|
||||
so the corner peers keep pulsing visibly */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 900px 540px at 50% 38%, rgba(5,5,5,0.92) 0%, rgba(5,5,5,0.75) 38%, rgba(5,5,5,0.3) 68%, rgba(5,5,5,0) 100%)",
|
||||
}}
|
||||
/>
|
||||
{/* Top/bottom fades so the animation bleeds into surrounding sections */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-32"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, rgba(5,5,5,0.85) 0%, rgba(5,5,5,0) 100%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-32"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, rgba(5,5,5,0.95) 0%, rgba(5,5,5,0) 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto flex max-w-[var(--cm-max-w)] flex-col items-center px-6 py-24 md:px-12 md:py-32">
|
||||
<Reveal className="mb-8">
|
||||
<SectionIcon glyph="mesh" />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={1}>
|
||||
<h1
|
||||
className="max-w-4xl text-center text-[clamp(2.75rem,7vw,5.25rem)] font-medium leading-[1.08] tracking-tight text-[var(--cm-fg)]"
|
||||
style={{
|
||||
fontFamily: "var(--cm-font-serif)",
|
||||
textShadow: "0 2px 30px rgba(0,0,0,0.85)",
|
||||
}}
|
||||
>
|
||||
Your Claude Code sessions{" "}
|
||||
<span className="text-[var(--cm-clay)]">work alone.</span>
|
||||
<br />
|
||||
<span className="text-[var(--cm-fg-secondary)]">
|
||||
claudemesh connects them.
|
||||
</span>
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={2}>
|
||||
<p
|
||||
className="mx-auto mt-8 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)] md:text-xl"
|
||||
style={{
|
||||
fontFamily: "var(--cm-font-serif)",
|
||||
textShadow: "0 2px 20px rgba(0,0,0,0.8)",
|
||||
}}
|
||||
>
|
||||
Share context, files, skills, and MCPs across every Claude Code
|
||||
session — encrypted, with zero setup. The broker routes ciphertext.
|
||||
It never reads your messages.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={3}>
|
||||
<div className="mt-10 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="group inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-5 py-3 text-[15px] font-medium text-[var(--cm-fg)] shadow-[0_10px_40px_rgba(215,119,87,0.35)] transition-colors duration-300 hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Start free
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/85 px-4 py-3 text-[13px] text-[var(--cm-fg-secondary)] backdrop-blur-md"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="text-[var(--cm-clay)]">$</span>
|
||||
<span>curl -fsSL claudemesh.com/install | bash</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={4}>
|
||||
<p
|
||||
className="mt-14 text-center text-sm text-[var(--cm-fg-tertiary)]"
|
||||
style={{
|
||||
fontFamily: "var(--cm-font-sans)",
|
||||
textShadow: "0 2px 16px rgba(0,0,0,0.8)",
|
||||
}}
|
||||
>
|
||||
Open-source CLI · Free during public beta ·{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
|
||||
>
|
||||
View source
|
||||
</Link>
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
128
apps/web/src/modules/marketing/home/where-mesh-fits.tsx
Normal file
128
apps/web/src/modules/marketing/home/where-mesh-fits.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
type Card = {
|
||||
label: string;
|
||||
title: string;
|
||||
theyDo: string;
|
||||
weDo: string;
|
||||
tone: "compare" | "claim";
|
||||
};
|
||||
|
||||
const CARDS: Card[] = [
|
||||
{
|
||||
label: "vs. MCP",
|
||||
title: "One Claude to its tools",
|
||||
theyDo:
|
||||
"MCP wires one Claude session to external services — GitHub, Postgres, a browser. The tool never knows who called it, never talks back, never sees other sessions.",
|
||||
weDo: "claudemesh ships as an MCP server itself. We extend the model: publish an MCP once, every peer's Claude Code sees its tools. Credentials stay on the publisher's machine.",
|
||||
tone: "compare",
|
||||
},
|
||||
{
|
||||
label: "vs. Subagents",
|
||||
title: "Helpers inside one session",
|
||||
theyDo:
|
||||
"Subagents spawn helper agents within a single Claude Code session. They share one context, one terminal, one machine. When the session closes, they're gone.",
|
||||
weDo: "claudemesh connects full, independent Claude Code sessions across machines, across developers, across continents. Each peer keeps its own repo, its own perspective, its own scrollback.",
|
||||
tone: "compare",
|
||||
},
|
||||
{
|
||||
label: "vs. OpenClaw",
|
||||
title: "Autonomous agents that run while you sleep",
|
||||
theyDo:
|
||||
"OpenClaw runs unattended. One agent brain, many subagents, 200+ LLMs on tap. It triages issues overnight, opens PRs, pokes CI, reacts to webhooks — all without a human in the loop. Different job, and a good one.",
|
||||
weDo: "claudemesh is about the sessions you're actively running. When your Claude Code is open and you're shipping, the mesh wires your session to your teammates'. OpenClaw automates overnight; claudemesh meshes your work hours. They compose — put an OpenClaw instance on the mesh and it joins as just another peer.",
|
||||
tone: "compare",
|
||||
},
|
||||
{
|
||||
label: "What claudemesh is",
|
||||
title: "The wire between Claude Code sessions",
|
||||
theyDo:
|
||||
"Every Claude Code session today is an island. Context dies with the terminal. Skills and MCPs are per-developer. Teammates relay insights through Slack.",
|
||||
weDo: "claudemesh is one thing: a peer network for Claude Code. Share context, files, skills, MCPs, and slash commands across sessions — end-to-end encrypted. The broker routes ciphertext. It never reads your messages.",
|
||||
tone: "claim",
|
||||
},
|
||||
];
|
||||
|
||||
export const WhereMeshFits = () => {
|
||||
return (
|
||||
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-24 md:px-12 md:py-32">
|
||||
<div className="mx-auto max-w-[var(--cm-max-w)]">
|
||||
<Reveal className="mb-6 flex justify-center">
|
||||
<SectionIcon glyph="arrow" />
|
||||
</Reveal>
|
||||
<Reveal delay={1}>
|
||||
<h2
|
||||
className="mx-auto max-w-4xl text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Where claudemesh fits
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal delay={2}>
|
||||
<p
|
||||
className="mx-auto mt-4 max-w-2xl text-center text-sm leading-[1.6] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
A quick tour of what claudemesh is — and what it isn't. We
|
||||
compose with the rest of the Claude Code ecosystem. We don't
|
||||
replace any of it.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-16 grid gap-5 md:grid-cols-2">
|
||||
{CARDS.map((c) => {
|
||||
const isClaim = c.tone === "claim";
|
||||
return (
|
||||
<Reveal key={c.label} delay={3}>
|
||||
<div
|
||||
className={
|
||||
"flex h-full flex-col rounded-[var(--cm-radius-md)] border p-7 md:p-8 " +
|
||||
(isClaim
|
||||
? "border-[var(--cm-clay)]/60 bg-[var(--cm-clay)]/[0.06]"
|
||||
: "border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]")
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"mb-3 text-[11px] uppercase tracking-[0.18em] " +
|
||||
(isClaim
|
||||
? "text-[var(--cm-clay)]"
|
||||
: "text-[var(--cm-fg-tertiary)]")
|
||||
}
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{c.label}
|
||||
</div>
|
||||
<h3
|
||||
className="mb-4 text-[22px] font-medium leading-snug text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{c.title}
|
||||
</h3>
|
||||
<p
|
||||
className="text-[14px] leading-[1.65] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{c.theyDo}
|
||||
</p>
|
||||
<div className="my-4 h-px bg-[var(--cm-border)]" />
|
||||
<p
|
||||
className={
|
||||
"text-[14px] leading-[1.65] " +
|
||||
(isClaim
|
||||
? "text-[var(--cm-fg)]"
|
||||
: "text-[var(--cm-fg-secondary)]")
|
||||
}
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{c.weDo}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
@@ -32,14 +31,6 @@ import {
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
|
||||
export const CreateMeshForm = ({
|
||||
onboarding = false,
|
||||
}: { onboarding?: boolean } = {}) => {
|
||||
@@ -48,30 +39,16 @@ export const CreateMeshForm = ({
|
||||
resolver: zodResolver(createMyMeshInputSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
visibility: "private",
|
||||
transport: "managed",
|
||||
},
|
||||
});
|
||||
|
||||
const nameValue = form.watch("name");
|
||||
const slugDirty = form.formState.dirtyFields.slug;
|
||||
|
||||
useEffect(() => {
|
||||
if (!slugDirty && nameValue) {
|
||||
form.setValue("slug", slugify(nameValue));
|
||||
}
|
||||
}, [nameValue, slugDirty, form]);
|
||||
|
||||
const onSubmit = async (values: CreateMyMeshInput) => {
|
||||
try {
|
||||
const res = (await handle(api.my.meshes.$post)({
|
||||
json: values,
|
||||
})) as { id: string; slug: string } | { error: string };
|
||||
if ("error" in res) {
|
||||
form.setError("slug", { message: res.error });
|
||||
return;
|
||||
}
|
||||
})) as { id: string; slug: string };
|
||||
router.push(
|
||||
onboarding
|
||||
? `${pathsConfig.dashboard.user.meshes.invite(res.id)}?onboarding=1`
|
||||
@@ -97,23 +74,7 @@ export const CreateMeshForm = ({
|
||||
<Input placeholder="Platform team" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Display name — what teammates see.
|
||||
</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.
|
||||
Display name — what teammates see. Pick anything.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -6,7 +6,9 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import {
|
||||
createEmailInviteInputSchema,
|
||||
createMyInviteInputSchema,
|
||||
type CreateEmailInviteInput,
|
||||
type CreateMyInviteInput,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
@@ -33,75 +35,168 @@ import { api } from "~/lib/api/client";
|
||||
|
||||
interface GeneratedInvite {
|
||||
id: string;
|
||||
/** Raw token (only set for link-mode results — empty string when email mode). */
|
||||
token: string;
|
||||
inviteLink: string;
|
||||
/** Short code for the CLI command. Falls back to shortUrl display if null. */
|
||||
code: string | null;
|
||||
joinUrl: string;
|
||||
/** Short human-friendly URL, preferred for sharing. Null if the backend didn't mint one. */
|
||||
shortUrl: string | null;
|
||||
expiresAt: Date;
|
||||
qrDataUrl: string;
|
||||
/** When set, the invite was dispatched via email and a confirmation banner is shown. */
|
||||
sentToEmail?: string;
|
||||
}
|
||||
|
||||
type Mode = "link" | "email";
|
||||
|
||||
const qrOptions = {
|
||||
width: 256,
|
||||
margin: 1,
|
||||
color: { dark: "#141413", light: "#ffffff" },
|
||||
} as const;
|
||||
|
||||
export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
const [mode, setMode] = useState<Mode>("link");
|
||||
const [result, setResult] = useState<GeneratedInvite | null>(null);
|
||||
const [copied, setCopied] = useState<"url" | "cli" | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const form = useForm<CreateMyInviteInput>({
|
||||
// Two separate forms — simpler than conditional validation, clearer state
|
||||
// boundaries, and each form owns its own submit + error surface.
|
||||
const linkForm = useForm<CreateMyInviteInput>({
|
||||
resolver: zodResolver(createMyInviteInputSchema),
|
||||
defaultValues: { role: "member", maxUses: 1, expiresInDays: 7 },
|
||||
});
|
||||
|
||||
const onSubmit = async (values: CreateMyInviteInput) => {
|
||||
const emailForm = useForm<CreateEmailInviteInput>({
|
||||
resolver: zodResolver(createEmailInviteInputSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
role: "member",
|
||||
maxUses: 1,
|
||||
expiresInDays: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const activeForm = mode === "link" ? linkForm : emailForm;
|
||||
|
||||
const onSubmitLink = async (values: CreateMyInviteInput) => {
|
||||
try {
|
||||
const res = (await handle(api.my.meshes[":id"].invites.$post)({
|
||||
param: { id: meshId },
|
||||
json: values,
|
||||
})) as
|
||||
| {
|
||||
id: string;
|
||||
token: string;
|
||||
inviteLink: string;
|
||||
joinUrl: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
| { error: string };
|
||||
})) as {
|
||||
id: string;
|
||||
token: string;
|
||||
code: string | null;
|
||||
inviteLink: string;
|
||||
joinUrl: string;
|
||||
shortUrl: string | null;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
if ("error" in res) {
|
||||
form.setError("root", { message: res.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// QR encodes the HTTPS join URL now — anyone with a camera can
|
||||
// scan and land on the friendly /join/[token] page.
|
||||
const qrDataUrl = await QRCode.toDataURL(res.joinUrl, {
|
||||
width: 256,
|
||||
margin: 1,
|
||||
color: { dark: "#141413", light: "#ffffff" },
|
||||
});
|
||||
// QR encodes the SHORT URL when available — scannable at camera distance
|
||||
// and short enough for the QR to stay low-density. Falls back to the
|
||||
// long token URL for legacy invites minted before the shortener shipped.
|
||||
const qrTarget = res.shortUrl ?? res.joinUrl;
|
||||
const qrDataUrl = await QRCode.toDataURL(qrTarget, qrOptions);
|
||||
|
||||
setResult({
|
||||
id: res.id,
|
||||
token: res.token,
|
||||
inviteLink: res.inviteLink,
|
||||
code: res.code,
|
||||
joinUrl: res.joinUrl,
|
||||
shortUrl: res.shortUrl,
|
||||
expiresAt: new Date(res.expiresAt),
|
||||
qrDataUrl,
|
||||
});
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
linkForm.setError("root", {
|
||||
message: e instanceof Error ? e.message : "Failed to generate invite.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitEmail = async (values: CreateEmailInviteInput) => {
|
||||
try {
|
||||
// TODO(types): remove `as any` after RPC type regen picks up the new
|
||||
// `.email` subroute registered in packages/api/src/modules/mesh/router.ts.
|
||||
const res = (await handle(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(api.my.meshes[":id"].invites as any).email.$post,
|
||||
)({
|
||||
param: { id: meshId },
|
||||
json: values,
|
||||
})) as {
|
||||
pendingInviteId: string;
|
||||
code: string;
|
||||
email: string;
|
||||
shortUrl: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
const qrDataUrl = await QRCode.toDataURL(res.shortUrl, qrOptions);
|
||||
|
||||
setResult({
|
||||
id: res.pendingInviteId,
|
||||
token: "",
|
||||
code: res.code,
|
||||
joinUrl: res.shortUrl,
|
||||
shortUrl: res.shortUrl,
|
||||
expiresAt: new Date(res.expiresAt),
|
||||
qrDataUrl,
|
||||
sentToEmail: res.email,
|
||||
});
|
||||
} catch (e) {
|
||||
emailForm.setError("root", {
|
||||
message: e instanceof Error ? e.message : "Failed to send invite.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (text: string, key: "url" | "cli") => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
setResult(null);
|
||||
linkForm.reset();
|
||||
emailForm.reset();
|
||||
};
|
||||
|
||||
if (result) {
|
||||
const cliCmd = `claudemesh join ${result.token}`;
|
||||
// Prefer the short URL everywhere it exists. CLI command uses the code
|
||||
// when available (short, easy to paste); otherwise falls back to the
|
||||
// shortUrl, which the CLI also accepts as an argument.
|
||||
const primaryUrl = result.shortUrl ?? result.joinUrl;
|
||||
const cliArg = result.code ?? result.shortUrl ?? "";
|
||||
const cliCmd = `claudemesh join ${cliArg}`;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{result.sentToEmail && (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
role="status"
|
||||
className="border-primary/30 bg-primary/5 text-foreground flex items-start gap-3 rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<span aria-hidden="true" className="text-primary mt-0.5">
|
||||
✓
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
Invite sent to {result.sentToEmail}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
Email delivery is stubbed in v0.1.x — the invite is valid.
|
||||
Share the link directly if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border p-6">
|
||||
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
|
||||
<div className="flex items-start justify-center">
|
||||
@@ -117,7 +212,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
Share this link
|
||||
</div>
|
||||
<code className="bg-muted block break-all rounded p-3 font-mono text-xs">
|
||||
{result.joinUrl}
|
||||
{primaryUrl}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
@@ -126,7 +221,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
</Badge>
|
||||
</div>
|
||||
<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"}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -136,14 +231,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
>
|
||||
{copied === "cli" ? "Copied ✓" : "Copy CLI command"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={resetAll}>
|
||||
Generate another
|
||||
</Button>
|
||||
</div>
|
||||
@@ -165,77 +253,196 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-md space-y-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
const ModeToggle = () => (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Invite delivery mode"
|
||||
className="bg-muted inline-flex rounded-md p-1 text-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "link"}
|
||||
onClick={() => setMode("link")}
|
||||
className={`focus-visible:ring-ring rounded px-3 py-1.5 font-medium transition focus-visible:outline-none focus-visible:ring-2 ${
|
||||
mode === "link"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "email"}
|
||||
onClick={() => setMode("email")}
|
||||
className={`focus-visible:ring-ring rounded px-3 py-1.5 font-medium transition focus-visible:outline-none focus-visible:ring-2 ${
|
||||
mode === "email"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Email
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Advanced block is rendered against whichever form is active. Because the
|
||||
// two schemas share identical role/maxUses/expiresInDays shapes, the field
|
||||
// components are structurally the same — we just bind to the active form.
|
||||
const AdvancedBlock = () => (
|
||||
<div className="rounded-md border border-dashed">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((s) => !s)}
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between px-3 py-2 text-xs uppercase tracking-wider"
|
||||
aria-expanded={showAdvanced}
|
||||
>
|
||||
<span>Advanced</span>
|
||||
<span aria-hidden="true">{showAdvanced ? "−" : "+"}</span>
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 border-t px-3 py-4">
|
||||
<FormField
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
control={activeForm.control as any}
|
||||
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
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
control={activeForm.control as any}
|
||||
name="maxUses"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max uses</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.root && (
|
||||
<p className="text-destructive text-sm">
|
||||
{form.formState.errors.root.message}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Generating…" : "Generate invite"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
control={activeForm.control as any}
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-md space-y-5">
|
||||
<ModeToggle />
|
||||
|
||||
{mode === "link" ? (
|
||||
<Form {...linkForm}>
|
||||
<form
|
||||
onSubmit={linkForm.handleSubmit(onSubmitLink)}
|
||||
className="space-y-5"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
One-time invite for a new member. Valid for 7 days.
|
||||
</p>
|
||||
|
||||
<AdvancedBlock />
|
||||
|
||||
{linkForm.formState.errors.root && (
|
||||
<p className="text-destructive text-sm">
|
||||
{linkForm.formState.errors.root.message}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={linkForm.formState.isSubmitting}>
|
||||
{linkForm.formState.isSubmitting
|
||||
? "Generating…"
|
||||
: "Generate invite"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...emailForm}>
|
||||
<form
|
||||
onSubmit={emailForm.handleSubmit(onSubmitEmail)}
|
||||
className="space-y-5"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Send a one-time invite directly to an email address. Valid for 7
|
||||
days.
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
control={emailForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="teammate@company.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AdvancedBlock />
|
||||
|
||||
{emailForm.formState.errors.root && (
|
||||
<p className="text-destructive text-sm">
|
||||
{emailForm.formState.errors.root.message}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={emailForm.formState.isSubmitting}>
|
||||
{emailForm.formState.isSubmitting ? "Sending…" : "Send invite"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -366,6 +366,74 @@ the new peer and rebroadcasts presence.
|
||||
|
||||
Invite-link issuance: [`apps/cli/src/invite/`](../apps/cli/src/invite/).
|
||||
|
||||
### v2 invites (in progress)
|
||||
|
||||
v1 embeds the mesh root key inside the URL. v2 removes it: the URL is a
|
||||
short opaque code, and the root key is sealed to a recipient-controlled
|
||||
x25519 public key on claim. Both formats are accepted through v0.1.x;
|
||||
v1 is removed at v0.2.0.
|
||||
|
||||
Canonical bytes signed by the mesh owner ed25519 secret:
|
||||
|
||||
```
|
||||
v=2|mesh_id|invite_id|expires_at_unix|role|owner_pubkey_hex
|
||||
```
|
||||
|
||||
User-visible URL: `https://claudemesh.com/i/{code}` (base62, 8 chars).
|
||||
|
||||
#### Claim endpoint
|
||||
|
||||
```
|
||||
POST /api/public/invites/:code/claim
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"recipient_x25519_pubkey": "<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
|
||||
|
||||
@@ -17,6 +17,21 @@ broker, ready for real teams.
|
||||
|
||||
---
|
||||
|
||||
## In progress — *v0.1.x*
|
||||
|
||||
Security and onboarding work landing inside the v0.1 line, before
|
||||
v0.2.0 cuts.
|
||||
|
||||
- **v2 invite protocol** — short opaque codes (`claudemesh.com/i/{code}`)
|
||||
replace base64url URLs that embedded the mesh root key. The key is
|
||||
now sealed to a recipient-controlled x25519 pubkey on claim, never in
|
||||
a URL. v1 invites keep working through v0.1.x; removed at v0.2.0.
|
||||
- **Email invites** — admins invite by email. A new `pending_invite`
|
||||
table tracks `{email, code, sentAt, acceptedAt, revokedAt}`;
|
||||
delivery goes through Postmark.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0 — *next*
|
||||
|
||||
The surface layer. The protocol is ready; these are gateways + routing
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import sodium from "libsodium-wrappers";
|
||||
|
||||
import { and, eq, isNull } from "@turbostarter/db";
|
||||
import { invite, mesh, meshMember } from "@turbostarter/db/schema";
|
||||
import { invite, mesh, meshMember, pendingInvite } from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
|
||||
import type {
|
||||
CreateEmailInviteInput,
|
||||
CreateMyInviteInput,
|
||||
CreateMyMeshInput,
|
||||
} from "../../schema";
|
||||
|
||||
const BROKER_URL = process.env.NEXT_PUBLIC_BROKER_URL ?? "ws://localhost:7900";
|
||||
const BROKER_URL =
|
||||
process.env.NEXT_PUBLIC_BROKER_URL ?? "wss://ic.claudemesh.com/ws";
|
||||
const APP_URL = process.env.NEXT_PUBLIC_URL ?? "https://claudemesh.com";
|
||||
|
||||
/**
|
||||
@@ -29,6 +33,40 @@ const canonicalInvite = (p: {
|
||||
}): string =>
|
||||
`${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`;
|
||||
|
||||
/**
|
||||
* v2 canonical invite bytes — format is LOCKED and MUST match
|
||||
* `canonicalInviteV2` in apps/broker/src/crypto.ts exactly. The broker
|
||||
* recomputes this on every claim and compares byte-for-byte against the
|
||||
* signed `capabilityV2.canonical` stored on the invite row. Any drift
|
||||
* between this string and the broker's version produces `bad_signature`.
|
||||
*
|
||||
* No root_key and no broker_url: the v2 protocol moves the root_key out
|
||||
* of the URL and the broker is the authority for where the key lives.
|
||||
*/
|
||||
const canonicalInviteV2 = (p: {
|
||||
mesh_id: string;
|
||||
invite_id: string;
|
||||
expires_at: number; // unix seconds
|
||||
role: "admin" | "member";
|
||||
owner_pubkey: string; // hex
|
||||
}): string =>
|
||||
`v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`;
|
||||
|
||||
/**
|
||||
* Derive the broker's HTTP base URL from the configured WebSocket URL.
|
||||
* `wss://host/ws` → `https://host`, `ws://host/ws` → `http://host`.
|
||||
* The claim endpoint lives at `${base}/invites/:code/claim`.
|
||||
*/
|
||||
export const brokerHttpBase = (): string => {
|
||||
const wsUrl = BROKER_URL;
|
||||
const httpUrl = wsUrl
|
||||
.replace(/^wss:\/\//, "https://")
|
||||
.replace(/^ws:\/\//, "http://")
|
||||
.replace(/\/ws\/?$/, "")
|
||||
.replace(/\/$/, "");
|
||||
return httpUrl;
|
||||
};
|
||||
|
||||
let sodiumReady = false;
|
||||
const ensureSodium = async (): Promise<typeof sodium> => {
|
||||
if (!sodiumReady) {
|
||||
@@ -38,6 +76,35 @@ const ensureSodium = async (): Promise<typeof 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 ({
|
||||
userId,
|
||||
input,
|
||||
@@ -45,16 +112,9 @@ export const createMyMesh = async ({
|
||||
userId: string;
|
||||
input: CreateMyMeshInput;
|
||||
}) => {
|
||||
// Slug collision check
|
||||
const [existing] = await db
|
||||
.select({ id: mesh.id })
|
||||
.from(mesh)
|
||||
.where(eq(mesh.slug, input.slug))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new Error("A mesh with that slug already exists.");
|
||||
}
|
||||
// Slug is derived from name and stored non-uniquely — meshes are identified
|
||||
// by `mesh.id` (opaque). Two users can freely name their meshes "platform".
|
||||
const slug = toSlug(input.name);
|
||||
|
||||
// Generate the mesh owner's ed25519 keypair (signs invites) and a
|
||||
// 32-byte shared root key (channel encryption in later steps).
|
||||
@@ -72,7 +132,7 @@ export const createMyMesh = async ({
|
||||
.insert(mesh)
|
||||
.values({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
slug,
|
||||
visibility: input.visibility,
|
||||
transport: input.transport,
|
||||
ownerUserId: userId,
|
||||
@@ -215,28 +275,197 @@ export const createMyInvite = async ({
|
||||
const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString(
|
||||
"base64url",
|
||||
);
|
||||
const [created] = await db
|
||||
.insert(invite)
|
||||
.values({
|
||||
meshId,
|
||||
token,
|
||||
tokenBytes: canonical,
|
||||
maxUses: input.maxUses,
|
||||
role: input.role,
|
||||
expiresAt,
|
||||
createdBy: userId,
|
||||
})
|
||||
.returning({
|
||||
id: invite.id,
|
||||
token: invite.token,
|
||||
expiresAt: invite.expiresAt,
|
||||
});
|
||||
|
||||
// Short URL shortener code. Retry on the (extremely unlikely) collision
|
||||
// against the unique index. 3 attempts is plenty given the keyspace.
|
||||
let code = generateShortCode();
|
||||
let created:
|
||||
| { id: string; token: string; code: string | null; expiresAt: Date }
|
||||
| undefined;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const rows = await db
|
||||
.insert(invite)
|
||||
.values({
|
||||
meshId,
|
||||
token,
|
||||
tokenBytes: canonical,
|
||||
code,
|
||||
maxUses: input.maxUses,
|
||||
role: input.role,
|
||||
expiresAt,
|
||||
createdBy: userId,
|
||||
// v2 starts here — capabilityV2 is backfilled below in a second
|
||||
// UPDATE because the canonical bytes depend on invite.id which
|
||||
// we only know post-insert.
|
||||
version: 2,
|
||||
})
|
||||
.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.");
|
||||
}
|
||||
|
||||
// --- v2 capability: sign canonical bytes that include the invite id ---
|
||||
// The broker recomputes these exact bytes on claim and verifies the
|
||||
// signature against mesh.ownerPubkey. Stored shape is the JSON literal
|
||||
// the broker expects in `invite.capabilityV2`:
|
||||
// { "canonical": "v=2|...", "signature": "<hex>" }
|
||||
// We reuse the existing `capabilityV2` text column — no schema change.
|
||||
const canonicalV2 = canonicalInviteV2({
|
||||
mesh_id: meshRow.id,
|
||||
invite_id: created.id,
|
||||
expires_at: expiresAtSec,
|
||||
role: input.role,
|
||||
owner_pubkey: meshRow.ownerPubkey,
|
||||
});
|
||||
const signatureV2 = s.to_hex(
|
||||
s.crypto_sign_detached(
|
||||
s.from_string(canonicalV2),
|
||||
s.from_hex(meshRow.ownerSecretKey),
|
||||
),
|
||||
);
|
||||
const capabilityV2Json = JSON.stringify({
|
||||
canonical: canonicalV2,
|
||||
signature: signatureV2,
|
||||
});
|
||||
await db
|
||||
.update(invite)
|
||||
.set({ capabilityV2: capabilityV2Json })
|
||||
.where(eq(invite.id, created.id));
|
||||
|
||||
const appBase = APP_URL.replace(/\/$/, "");
|
||||
return {
|
||||
id: created!.id,
|
||||
token: created!.token,
|
||||
expiresAt: created!.expiresAt,
|
||||
id: created.id,
|
||||
token: created.token,
|
||||
code: created.code,
|
||||
expiresAt: created.expiresAt,
|
||||
inviteLink: `ic://join/${token}`,
|
||||
joinUrl: `${APP_URL.replace(/\/$/, "")}/join/${token}`,
|
||||
joinUrl: `${appBase}/join/${token}`,
|
||||
// The human-friendly short URL. Redirects to joinUrl server-side.
|
||||
// Prefer this when sharing. See spec for why this is NOT a capability
|
||||
// boundary (the long token still carries the root_key).
|
||||
shortUrl: created.code ? `${appBase}/i/${created.code}` : null,
|
||||
// v2 surface: safe to share (no root_key, no secrets).
|
||||
version: 2 as const,
|
||||
canonicalV2,
|
||||
ownerPubkey: meshRow.ownerPubkey,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Email invites (v2 only)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a mesh invite by email. Mints a normal v2 invite (same short code
|
||||
* path as `createMyInvite`), then records a `pending_invite` row tying
|
||||
* `(mesh, email)` to the underlying invite code. Delivery goes through
|
||||
* the email provider if one is wired; otherwise we log a TODO and
|
||||
* return success so the rest of the flow is testable end-to-end.
|
||||
*
|
||||
* The email body contains `${APP_URL}/i/${code}` — the exact same short
|
||||
* URL that link-shares use. No new user-visible surface.
|
||||
*/
|
||||
export const createEmailInvite = async ({
|
||||
userId,
|
||||
meshId,
|
||||
input,
|
||||
}: {
|
||||
userId: string;
|
||||
meshId: string;
|
||||
input: CreateEmailInviteInput;
|
||||
}) => {
|
||||
// Reuse createMyInvite — all authz, signing, and short-code collision
|
||||
// logic lives there. We only add the pending_invite row + email send.
|
||||
const minted = await createMyInvite({
|
||||
userId,
|
||||
meshId,
|
||||
input: {
|
||||
role: input.role,
|
||||
maxUses: input.maxUses,
|
||||
expiresInDays: input.expiresInDays,
|
||||
},
|
||||
});
|
||||
|
||||
if (!minted.code) {
|
||||
// Should never happen — createMyInvite always allocates a code now.
|
||||
throw new Error("Could not mint an email invite (no short code).");
|
||||
}
|
||||
|
||||
const [pending] = await db
|
||||
.insert(pendingInvite)
|
||||
.values({
|
||||
meshId,
|
||||
email: input.email,
|
||||
code: minted.code,
|
||||
createdBy: userId,
|
||||
})
|
||||
.returning({ id: pendingInvite.id });
|
||||
|
||||
if (!pending) {
|
||||
throw new Error("Could not record pending invite row.");
|
||||
}
|
||||
|
||||
const appBase = APP_URL.replace(/\/$/, "");
|
||||
const shortUrl = `${appBase}/i/${minted.code}`;
|
||||
|
||||
// Fire-and-forget-ish send. Failures are logged but do NOT roll back
|
||||
// the invite — the admin can copy the short URL from the dashboard.
|
||||
await sendEmailInvite({
|
||||
to: input.email,
|
||||
shortUrl,
|
||||
inviterUserId: userId,
|
||||
meshId,
|
||||
});
|
||||
|
||||
return {
|
||||
pendingInviteId: pending.id,
|
||||
code: minted.code,
|
||||
email: input.email,
|
||||
shortUrl,
|
||||
expiresAt: minted.expiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Deliver the email that carries a `claudemesh.com/i/{code}` short URL.
|
||||
*
|
||||
* TODO: wire this to the turbostarter Postmark provider. The email
|
||||
* package exposes `sendEmail` via a template system; adding a new
|
||||
* template file lives in `packages/email/**` which is out of scope for
|
||||
* this wave. For now we log the intended send so the upstream mutation
|
||||
* resolves cleanly and the rest of the flow is integration-testable.
|
||||
*/
|
||||
const sendEmailInvite = async (params: {
|
||||
to: string;
|
||||
shortUrl: string;
|
||||
inviterUserId: string;
|
||||
meshId: string;
|
||||
}): Promise<void> => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[claudemesh] TODO: wire email invite to Postmark provider",
|
||||
{
|
||||
to: params.to,
|
||||
shortUrl: params.shortUrl,
|
||||
inviterUserId: params.inviterUserId,
|
||||
meshId: params.meshId,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { User } from "@turbostarter/auth";
|
||||
|
||||
import { enforceAuth, validate } from "../../middleware";
|
||||
import {
|
||||
createEmailInviteInputSchema,
|
||||
createMyInviteInputSchema,
|
||||
createMyMeshInputSchema,
|
||||
getMyMeshesInputSchema,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
|
||||
import {
|
||||
archiveMyMesh,
|
||||
createEmailInvite,
|
||||
createMyInvite,
|
||||
createMyMesh,
|
||||
leaveMyMesh,
|
||||
@@ -89,6 +91,31 @@ export const myRouter = new Hono<Env>()
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/meshes/:id/invites/email",
|
||||
validate("json", createEmailInviteInputSchema),
|
||||
async (c) => {
|
||||
const user = c.var.user;
|
||||
try {
|
||||
const result = await createEmailInvite({
|
||||
userId: user.id,
|
||||
meshId: c.req.param("id"),
|
||||
input: c.req.valid("json"),
|
||||
});
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Failed to send email invite.",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.post("/meshes/:id/archive", async (c) => {
|
||||
const user = c.var.user;
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
} from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
|
||||
import { validate } from "../../middleware";
|
||||
import { claimInviteInputSchema } from "../../schema";
|
||||
import { brokerHttpBase } from "../mesh/mutations";
|
||||
|
||||
/**
|
||||
* Unauthed public stats for the landing page counter.
|
||||
*
|
||||
@@ -232,6 +236,83 @@ export const publicRouter = new Hono()
|
||||
}
|
||||
return c.json(result);
|
||||
})
|
||||
/**
|
||||
* Resolve a short invite code to its canonical long token.
|
||||
*
|
||||
* URL shortener only — the long token still carries the root_key,
|
||||
* so this endpoint is NOT a security boundary. See the v2 invite
|
||||
* protocol spec for the real fix.
|
||||
*
|
||||
* Returns 404 if the code is unknown OR the invite was revoked/
|
||||
* archived so stale short URLs don't leak mesh metadata.
|
||||
*/
|
||||
.get("/invite-code/:code", async (c) => {
|
||||
const code = c.req.param("code");
|
||||
const [row] = await db
|
||||
.select({ token: invite.token, revokedAt: invite.revokedAt })
|
||||
.from(invite)
|
||||
.where(eq(invite.code, code))
|
||||
.limit(1);
|
||||
c.header("cache-control", "no-store");
|
||||
if (!row || row.revokedAt) {
|
||||
return c.json({ found: false as const }, 404);
|
||||
}
|
||||
return c.json({ found: true as const, token: row.token });
|
||||
})
|
||||
/**
|
||||
* v2 invite claim — proxies straight to the broker.
|
||||
*
|
||||
* The broker owns all claim logic (signature verification, atomic
|
||||
* used_count increment, crypto_box_seal of the root key to the
|
||||
* recipient pubkey). The API layer only forwards the request and
|
||||
* mirrors the broker's status + body so CLI/web clients can speak
|
||||
* a single contract regardless of which host serves the claim.
|
||||
*
|
||||
* Error codes are the broker's: 400 malformed|bad_signature,
|
||||
* 404 not_found, 410 expired|revoked|exhausted.
|
||||
*/
|
||||
.post(
|
||||
"/invites/:code/claim",
|
||||
validate("json", claimInviteInputSchema),
|
||||
async (c) => {
|
||||
c.header("cache-control", "no-store");
|
||||
const code = c.req.param("code");
|
||||
const body = c.req.valid("json");
|
||||
const url = `${brokerHttpBase()}/invites/${encodeURIComponent(code)}/claim`;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
recipient_x25519_pubkey: body.recipient_x25519_pubkey,
|
||||
}),
|
||||
});
|
||||
// Pass through status and body verbatim; broker already shapes
|
||||
// the error envelope the way the spec documents.
|
||||
const text = await resp.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
parsed = { error: "upstream_malformed" };
|
||||
}
|
||||
// Hono's c.json only accepts a subset of status codes; cast
|
||||
// through ContentfulStatusCode for the passthrough.
|
||||
return c.json(
|
||||
parsed as Record<string, unknown>,
|
||||
resp.status as 200 | 400 | 404 | 410 | 500,
|
||||
);
|
||||
} catch (e) {
|
||||
return c.json(
|
||||
{
|
||||
error: "broker_unreachable",
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
502,
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.get("/stats", async (c) => {
|
||||
const now = Date.now();
|
||||
if (cachedStats && cachedStats.expiresAt > now) {
|
||||
|
||||
@@ -54,11 +54,6 @@ export type GetMyMeshesResponse = z.infer<typeof getMyMeshesResponseSchema>;
|
||||
|
||||
export const createMyMeshInputSchema = z.object({
|
||||
name: z.string().min(2).max(80),
|
||||
slug: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(40)
|
||||
.regex(/^[a-z0-9-]+$/, "slug must be lowercase letters, digits, hyphens"),
|
||||
visibility: meshVisibilityEnum.default("private"),
|
||||
transport: meshTransportEnum.default("managed"),
|
||||
});
|
||||
@@ -130,12 +125,61 @@ export type CreateMyInviteInput = z.infer<typeof createMyInviteInputSchema>;
|
||||
export const createMyInviteResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
token: z.string(),
|
||||
code: z.string().nullable(),
|
||||
inviteLink: z.string(),
|
||||
joinUrl: z.string(),
|
||||
shortUrl: z.string().nullable(),
|
||||
expiresAt: z.coerce.date(),
|
||||
// v2 fields — present on every new invite. v1-only rows will return
|
||||
// these as undefined on the legacy list endpoint; new rows always set
|
||||
// them because createMyInvite now mints v2 capabilities by default.
|
||||
version: z.literal(2).optional(),
|
||||
canonicalV2: z.string().optional(),
|
||||
ownerPubkey: z.string().optional(),
|
||||
});
|
||||
export type CreateMyInviteResponse = z.infer<typeof createMyInviteResponseSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Email invites
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export const createEmailInviteInputSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: meshRoleEnum.default("member"),
|
||||
maxUses: z.number().int().min(1).max(1000).default(1),
|
||||
expiresInDays: z.number().int().min(1).max(365).default(7),
|
||||
});
|
||||
export type CreateEmailInviteInput = z.infer<typeof createEmailInviteInputSchema>;
|
||||
|
||||
export const createEmailInviteResponseSchema = z.object({
|
||||
pendingInviteId: z.string(),
|
||||
code: z.string(),
|
||||
email: z.string(),
|
||||
shortUrl: z.string(),
|
||||
expiresAt: z.coerce.date(),
|
||||
});
|
||||
export type CreateEmailInviteResponse = z.infer<
|
||||
typeof createEmailInviteResponseSchema
|
||||
>;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// v2 invite claim (public, proxies to broker)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export const claimInviteInputSchema = z.object({
|
||||
recipient_x25519_pubkey: z.string().min(32),
|
||||
});
|
||||
export type ClaimInviteInput = z.infer<typeof claimInviteInputSchema>;
|
||||
|
||||
export const claimInviteResponseSchema = z.object({
|
||||
sealed_root_key: z.string(),
|
||||
mesh_id: z.string(),
|
||||
member_id: z.string(),
|
||||
owner_pubkey: z.string(),
|
||||
canonical_v2: z.string(),
|
||||
});
|
||||
export type ClaimInviteResponse = z.infer<typeof claimInviteResponseSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// List my invites (pending + sent)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -22,6 +22,11 @@ const apiErrorSchema = z.object({
|
||||
path: z.string(),
|
||||
});
|
||||
|
||||
/** Matches the `{ error: "..." }` shape returned by Hono route catch blocks. */
|
||||
const routeErrorSchema = z.object({
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export const isAPIError = (e: unknown): e is z.infer<typeof apiErrorSchema> => {
|
||||
return apiErrorSchema.safeParse(e).success;
|
||||
};
|
||||
@@ -70,10 +75,13 @@ export const handle = <
|
||||
|
||||
if (!response.ok) {
|
||||
if (throwOnError) {
|
||||
const parsed = routeErrorSchema.safeParse(data);
|
||||
throw new Error(
|
||||
isAPIError(data)
|
||||
? data.message
|
||||
: "Something went wrong. Please try again later.",
|
||||
: parsed.success
|
||||
? parsed.data.error
|
||||
: "Something went wrong. Please try again later.",
|
||||
);
|
||||
}
|
||||
return null as HandleReturn<TResponse, E, S>;
|
||||
|
||||
@@ -69,6 +69,7 @@ export const onError = async (
|
||||
code: "common:error.general",
|
||||
message: t("common:error.general"),
|
||||
status,
|
||||
timestamp,
|
||||
path,
|
||||
}),
|
||||
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", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
name: text().notNull(),
|
||||
slug: text().notNull().unique(),
|
||||
/**
|
||||
* Cosmetic slug derived from name at creation. NOT unique, NOT used for
|
||||
* identity — `mesh.id` is the canonical identifier everywhere (URLs,
|
||||
* invites, broker lookups). Kept for display/debugging only. Two meshes
|
||||
* can freely share a slug.
|
||||
*/
|
||||
slug: text().notNull(),
|
||||
ownerUserId: text()
|
||||
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
@@ -176,6 +182,15 @@ export const invite = meshSchema.table("invite", {
|
||||
.notNull(),
|
||||
token: text().notNull().unique(),
|
||||
tokenBytes: text(),
|
||||
/**
|
||||
* Short opaque URL shortener code (base62, 8 chars). Resolves server-side
|
||||
* to the full canonical `token` for landing page rendering. Nullable for
|
||||
* pre-shortcode invites. Not a capability boundary — the long token still
|
||||
* carries the root_key. See .artifacts/specs/2026-04-10-anthropic-vision-
|
||||
* meshes-invites.md for the v2 protocol that moves the root_key out of
|
||||
* the URL entirely.
|
||||
*/
|
||||
code: text().unique(),
|
||||
maxUses: integer().notNull().default(1),
|
||||
usedCount: integer().notNull().default(0),
|
||||
role: meshRoleEnum().notNull().default("member"),
|
||||
@@ -192,8 +207,47 @@ export const invite = meshSchema.table("invite", {
|
||||
.notNull(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
revokedAt: timestamp(),
|
||||
/** Protocol version — 1 = legacy (root_key in URL), 2 = sealed delivery. Default 1 for backward compat. */
|
||||
version: integer().notNull().default(1),
|
||||
/**
|
||||
* v2 canonical signed bytes (the string the broker re-verifies against mesh.ownerPubkey).
|
||||
* Format: `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey`
|
||||
* Nullable for legacy v1 rows.
|
||||
*/
|
||||
capabilityV2: text(),
|
||||
/**
|
||||
* Recipient curve25519 pubkey (base64url) that the mesh root_key was sealed to
|
||||
* when this invite was claimed. Audit-only — do NOT use as an authN check.
|
||||
* Nullable until claim.
|
||||
*/
|
||||
claimedByPubkey: text(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Tracks invites sent by email — one row per (mesh, email) pairing.
|
||||
* `code` references an underlying mesh.invite row that will be minted
|
||||
* on send; when the recipient lands on /i/{code} they claim the real invite.
|
||||
*/
|
||||
export const pendingInvite = meshSchema.table("pending_invite", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
email: text().notNull(),
|
||||
/** The short code of the underlying `mesh.invite.code` row this email links to. */
|
||||
code: text().notNull(),
|
||||
sentAt: timestamp().defaultNow().notNull(),
|
||||
acceptedAt: timestamp(),
|
||||
revokedAt: timestamp(),
|
||||
createdBy: text()
|
||||
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index("pending_invite_email_idx").on(table.email),
|
||||
index("pending_invite_mesh_idx").on(table.meshId),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Signed, hash-chained audit log. NEVER stores message content — every
|
||||
* payload between peers is E2E encrypted client-side (libsodium), so
|
||||
@@ -687,6 +741,11 @@ export const inviteRelations = relations(invite, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const pendingInviteRelations = relations(pendingInvite, ({ one }) => ({
|
||||
mesh: one(mesh, { fields: [pendingInvite.meshId], references: [mesh.id] }),
|
||||
inviter: one(user, { fields: [pendingInvite.createdBy], references: [user.id] }),
|
||||
}));
|
||||
|
||||
export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||
mesh: one(mesh, {
|
||||
fields: [auditLog.meshId],
|
||||
|
||||
Reference in New Issue
Block a user