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:
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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)
|
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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user