feat: v2 invite API + CLI claim flow + CLI friction reducer (wave 2)

Wires the v2 invite protocol end-to-end from a CLI user's perspective.
Broker foundation landed in c1fa3bc; this commit is the glue between
it and the human.

API (packages/api)
- createMyInvite now mints BOTH v1 token (legacy) AND v2 capability.
  Two-phase insert: row first (to get invite.id), then UPDATE with
  signed canonical bytes stored as JSON {canonical, signature} in the
  capabilityV2 column. Broker's claim handler parses the same shape.
- canonicalInviteV2 locked to `v=2|mesh_id|invite_id|expires_at|role|
  owner_pubkey_hex` — byte-identical to apps/broker/src/crypto.ts.
- brokerHttpBase() helper rewrites wss://host/ws → https://host for
  server-to-server calls.
- POST /api/public/invites/:code/claim — thin proxy to broker;
  passes status + body through, 502 broker_unreachable on fetch fail,
  cache-control: no-store.
- POST /api/my/meshes/:id/invites/email — mints a normal v2 invite
  via createMyInvite, records a pending_invite row, calls stubbed
  sendEmailInvite (logs TODO for Postmark wiring in a later PR).
- New schemas: claimInviteInput/ResponseSchema,
  createEmailInviteInput/ResponseSchema, v2 fields on
  createMyInviteResponseSchema.
- v1 paths untouched — legacy /join/[token] and /api/public/invite/:token
  continue to work throughout v0.1.x.

CLI (apps/cli)
- New `claudemesh join <code-or-url>` subcommand.
- Accepts bare code (abc12345), short URL (claudemesh.com/i/abc12345),
  or legacy ic://join/<token>. Detects v2 vs v1 and dispatches.
- v2 path: generates fresh ephemeral x25519 keypair (separate from
  the ed25519 identity) → POST /api/public/invites/:code/claim →
  unseals sealed_root_key via crypto_box_seal_open → persists mesh
  with inviteVersion: 2 and base64url rootKey to local config.
- Signature verification skipped with TODO — v0.1.x trusts broker;
  seal-open is already authenticated.
- apps/cli/src/lib/invite-v2.ts: generateX25519Keypair, claimInviteV2,
  parseV2InviteInput.
- state/config.ts: additive rootKey?/inviteVersion? fields.

CLI friction reducer
- apps/cli/src/index.ts: flag-first invocations
  (`claudemesh --resume xxx`, `claudemesh -c`, `claudemesh -- --model
  opus`) now route through `launch` automatically. Bare `claudemesh`
  still shows welcome; known subcommands dispatch normally.
- Removes one word of cognitive load: users never type `launch`.

No schema changes. No new deps. v1 fully backward compatible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-10 19:35:21 +01:00
parent c1fa3bcb5c
commit fb7a84aed6
8 changed files with 651 additions and 14 deletions

View File

@@ -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 * Accepts either:
* 2. Generate a fresh ed25519 keypair (libsodium) * - v2 short invite: `claudemesh.com/i/<code>` or bare `<code>`
* 3. POST /join to the broker → get member_id * → POSTs to /api/public/invites/:code/claim, unseals root_key,
* 4. Persist the mesh + keypair to ~/.claudemesh/config.json (0600) * persists mesh + fresh ed25519 identity.
* 5. Print success * - 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 { parseInviteLink } from "../invite/parse";
import { enrollWithBroker } from "../invite/enroll"; import { enrollWithBroker } from "../invite/enroll";
import { generateKeypair } from "../crypto/keypair"; import { generateKeypair } from "../crypto/keypair";
import { loadConfig, saveConfig, getConfigPath } from "../state/config"; 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 { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { homedir, hostname } from "node:os"; import { homedir, hostname } from "node:os";
import { env } from "../env"; 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> { export async function runJoin(args: string[]): Promise<void> {
const link = args[0]; const link = args[0];
if (!link) { if (!link) {
console.error("Usage: claudemesh join <invite-url-or-token>"); console.error("Usage: claudemesh join <invite-url-or-code>");
console.error(""); console.error("");
console.error( console.error("Examples:");
"Example: claudemesh join https://claudemesh.com/join/eyJ2IjoxLC4uLn0", 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); 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. // 1. Parse + verify signature client-side.
let invite; let invite;
try { try {

View File

@@ -331,9 +331,27 @@ const main = defineCommand({
}, },
}), }),
}, },
run() { async run() {
runWelcome(); 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); runMain(main);

View 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;
}

View File

@@ -26,6 +26,15 @@ export interface JoinedMesh {
secretKey: string; // ed25519 hex (64 bytes = 128 chars) secretKey: string; // ed25519 hex (64 bytes = 128 chars)
brokerUrl: string; brokerUrl: string;
joinedAt: 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 { export interface GroupEntry {

View File

@@ -3,10 +3,11 @@ import { randomBytes } from "node:crypto";
import sodium from "libsodium-wrappers"; import sodium from "libsodium-wrappers";
import { and, eq, isNull } from "@turbostarter/db"; import { and, eq, isNull } from "@turbostarter/db";
import { invite, mesh, meshMember } from "@turbostarter/db/schema"; import { invite, mesh, meshMember, pendingInvite } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server"; import { db } from "@turbostarter/db/server";
import type { import type {
CreateEmailInviteInput,
CreateMyInviteInput, CreateMyInviteInput,
CreateMyMeshInput, CreateMyMeshInput,
} from "../../schema"; } from "../../schema";
@@ -32,6 +33,40 @@ const canonicalInvite = (p: {
}): string => }): string =>
`${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`; `${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; let sodiumReady = false;
const ensureSodium = async (): Promise<typeof sodium> => { const ensureSodium = async (): Promise<typeof sodium> => {
if (!sodiumReady) { if (!sodiumReady) {
@@ -260,6 +295,10 @@ export const createMyInvite = async ({
role: input.role, role: input.role,
expiresAt, expiresAt,
createdBy: userId, 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({ .returning({
id: invite.id, id: invite.id,
@@ -282,6 +321,34 @@ export const createMyInvite = async ({
throw new Error("Could not allocate a unique invite code — retry."); 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(/\/$/, ""); const appBase = APP_URL.replace(/\/$/, "");
return { return {
id: created.id, id: created.id,
@@ -294,5 +361,111 @@ export const createMyInvite = async ({
// Prefer this when sharing. See spec for why this is NOT a capability // Prefer this when sharing. See spec for why this is NOT a capability
// boundary (the long token still carries the root_key). // boundary (the long token still carries the root_key).
shortUrl: created.code ? `${appBase}/i/${created.code}` : null, 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,
},
);
};

View File

@@ -4,6 +4,7 @@ import type { User } from "@turbostarter/auth";
import { enforceAuth, validate } from "../../middleware"; import { enforceAuth, validate } from "../../middleware";
import { import {
createEmailInviteInputSchema,
createMyInviteInputSchema, createMyInviteInputSchema,
createMyMeshInputSchema, createMyMeshInputSchema,
getMyMeshesInputSchema, getMyMeshesInputSchema,
@@ -11,6 +12,7 @@ import {
import { import {
archiveMyMesh, archiveMyMesh,
createEmailInvite,
createMyInvite, createMyInvite,
createMyMesh, createMyMesh,
leaveMyMesh, 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) => { .post("/meshes/:id/archive", async (c) => {
const user = c.var.user; const user = c.var.user;
try { try {

View File

@@ -12,6 +12,10 @@ import {
} from "@turbostarter/db/schema"; } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server"; 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. * Unauthed public stats for the landing page counter.
* *
@@ -255,6 +259,60 @@ export const publicRouter = new Hono()
} }
return c.json({ found: true as const, token: row.token }); 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) => { .get("/stats", async (c) => {
const now = Date.now(); const now = Date.now();
if (cachedStats && cachedStats.expiresAt > now) { if (cachedStats && cachedStats.expiresAt > now) {

View File

@@ -130,9 +130,56 @@ export const createMyInviteResponseSchema = z.object({
joinUrl: z.string(), joinUrl: z.string(),
shortUrl: z.string().nullable(), shortUrl: z.string().nullable(),
expiresAt: z.coerce.date(), 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>; 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) // List my invites (pending + sent)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------