Files
claudemesh/packages/api/src/modules/mesh/mutations.ts
Alejandro Gutiérrez 77f4316f2d
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
feat(broker+api+cli): per-topic E2E encryption — v0.3.0 phase 3 (CLI)
Wire format:
  topic_member_key.encrypted_key = base64(
    <32-byte sender x25519 pubkey> || crypto_box(topic_key)
  )

Embedding sender pubkey inline lets re-sealed copies (carrying a
different sender than the original creator-seal) decode the same
way as creator copies, without an extra schema column or join.
topic.encrypted_key_pubkey stays for backwards-compat metadata
but the wire truth is the inline prefix.

API (phase 3):
  GET  /v1/topics/:name/pending-seals  list members without keys
  POST /v1/topics/:name/seal           submit a re-sealed copy
  POST /v1/messages now accepts bodyVersion (1|2); v2 skips the
  regex mention extraction (server can't read v2 ciphertext).
  GET  /messages + /stream now return bodyVersion per row.

Broker + web mutations updated to use the inline-sender format
when sealing. ensureGeneralTopic (web) also generates topic keys
per the bugfix that landed earlier today; both producers now
share one wire format.

CLI (claudemesh-cli@1.8.0):
  + apps/cli/src/services/crypto/topic-key.ts — fetch/decrypt/encrypt/seal
  + claudemesh topic post <name> <msg> — encrypted REST send (v2)
  * claudemesh topic tail <name> — decrypts v2 on render, runs a
    30s background re-seal loop for pending joiners

Web client stays on v1 plaintext until phase 3.5 (browser-side
persistent identity in IndexedDB). Mention fan-out from phase 1
already works for both versions, so /v1/notifications keeps
working through the cutover.

Spec at .artifacts/specs/2026-05-02-topic-key-onboarding.md
updated with the implemented inline-sender format and the
phase 3.5 web plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:03:11 +01:00

642 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomBytes } from "node:crypto";
import sodium from "libsodium-wrappers";
import { and, asc, eq, isNull } from "@turbostarter/db";
import {
invite,
mesh,
meshMember,
meshTopic,
meshTopicMember,
meshTopicMemberKey,
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 ?? "wss://ic.claudemesh.com/ws";
const APP_URL = process.env.NEXT_PUBLIC_URL ?? "https://claudemesh.com";
/**
* Canonical invite bytes — MUST match the broker's canonicalInvite()
* in apps/broker/src/crypto.ts exactly. Any delimiter/field change
* between signer and verifier produces `invite_bad_signature`.
*/
const canonicalInvite = (p: {
v: number;
mesh_id: string;
mesh_slug: string;
broker_url: string;
expires_at: number;
mesh_root_key: string;
role: "admin" | "member";
owner_pubkey: 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}`;
/**
* 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) {
await sodium.ready;
sodiumReady = true;
}
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,
}: {
userId: string;
input: CreateMyMeshInput;
}) => {
// 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).
// See mesh.ownerSecretKey comment re: plaintext-at-rest trade-off.
const s = await ensureSodium();
const kp = s.crypto_sign_keypair();
const ownerPubkey = s.to_hex(kp.publicKey);
const ownerSecretKey = s.to_hex(kp.privateKey);
const rootKey = s.to_base64(
s.randombytes_buf(32),
s.base64_variants.URLSAFE_NO_PADDING,
);
const [created] = await db
.insert(mesh)
.values({
name: input.name,
slug,
visibility: input.visibility,
transport: input.transport,
ownerUserId: userId,
ownerPubkey,
ownerSecretKey,
rootKey,
})
.returning({ id: mesh.id, slug: mesh.slug });
if (!created) throw new Error("mesh insert returned no row");
// Create the owner's peer-identity member row. Mirrors what the broker
// does on first WS hello so a web-only user has a valid identity from
// t=0 — without this, the topic chat can't issue a dashboard apikey
// (issuedByMemberId is a FK), and the owner's "oldest member row in
// the mesh" lookup returns null. Fresh ed25519 keypair; secret key is
// discarded because web users don't sign anything in v0.2.0 (no DMs,
// base64 plaintext on topics). If they later install the CLI, the
// broker will mint a separate member row with a CLI-side keypair —
// both work for their respective surfaces.
const peerKp = s.crypto_sign_keypair();
const peerPubkey = s.to_hex(peerKp.publicKey);
const [ownerMember] = await db
.insert(meshMember)
.values({
meshId: created.id,
peerPubkey,
displayName: `${input.name}-owner`,
role: "admin",
userId,
dashboardUserId: userId,
})
.returning({ id: meshMember.id });
if (!ownerMember) throw new Error("owner member insert returned no row");
// Auto-create #general and subscribe the owner as 'lead'.
const generalTopic = await ensureGeneralTopic(created.id);
if (generalTopic) {
await db
.insert(meshTopicMember)
.values({
topicId: generalTopic.id,
memberId: ownerMember.id,
role: "lead",
})
.onConflictDoNothing();
}
return created;
};
/**
* Idempotently create the conventional `#general` topic for a mesh.
*
* `#general` is the default web-readable room: a public topic that every
* mesh has so the dashboard chat surface always has somewhere to land.
* Subscription is not required for read access via the REST surface, so
* subscribing members happens lazily at member-row creation time
* (invite-claim) rather than here.
*
* Safe to call repeatedly — the unique (meshId, name) index keeps it a
* no-op on the second call.
*/
export const ensureGeneralTopic = async (
meshId: string,
): Promise<{ id: string } | null> => {
const [existing] = await db
.select({
id: meshTopic.id,
encryptedKeyPubkey: meshTopic.encryptedKeyPubkey,
})
.from(meshTopic)
.where(and(eq(meshTopic.meshId, meshId), eq(meshTopic.name, "general")))
.limit(1);
if (existing) return { id: existing.id };
// Generate the topic's symmetric key + an ephemeral sender keypair
// for v0.3.0 phase 2 sealing. Mirrors the broker's createTopic path
// so web-created topics aren't stuck as unencrypted v0.2.0 placeholders.
// The plaintext topicKey leaves memory after sealing one copy for
// the mesh owner — the broker never persists it.
await sodium.ready;
const topicKey = sodium.randombytes_buf(32);
const senderKp = sodium.crypto_box_keypair();
const [row] = await db
.insert(meshTopic)
.values({
meshId,
name: "general",
description: "Default mesh-wide channel. Every member can read and post.",
visibility: "public",
encryptedKeyPubkey: sodium.to_hex(senderKp.publicKey),
})
.onConflictDoNothing()
.returning({ id: meshTopic.id });
if (!row) return null;
// Seal a copy for the oldest non-revoked member (the owner, by
// construction — owner-as-member rows are minted at mesh creation
// time, ahead of this call).
const [owner] = await db
.select({
id: meshMember.id,
peerPubkey: meshMember.peerPubkey,
})
.from(meshMember)
.where(and(eq(meshMember.meshId, meshId), isNull(meshMember.revokedAt)))
.orderBy(asc(meshMember.joinedAt))
.limit(1);
if (owner) {
try {
const recipientX25519 = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(owner.peerPubkey),
);
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
const sealed = sodium.crypto_box_easy(
topicKey,
nonce,
recipientX25519,
senderKp.privateKey,
);
// Embed sender x25519 pubkey as the first 32 bytes so future
// re-sealed copies (carrying a different sender) decode the same
// way as creator-sealed copies.
const blob = new Uint8Array(32 + sealed.length);
blob.set(senderKp.publicKey, 0);
blob.set(sealed, 32);
await db.insert(meshTopicMemberKey).values({
topicId: row.id,
memberId: owner.id,
encryptedKey: sodium.to_base64(blob, sodium.base64_variants.ORIGINAL),
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
}).onConflictDoNothing();
} catch {
// Owner pubkey isn't a valid ed25519 key (legacy data?). Topic
// is still created — phase 3 re-seal flow will handle it.
}
}
return row;
};
export const archiveMyMesh = async ({
userId,
meshId,
}: {
userId: string;
meshId: string;
}) => {
const [updated] = await db
.update(mesh)
.set({ archivedAt: new Date() })
.where(and(eq(mesh.id, meshId), eq(mesh.ownerUserId, userId)))
.returning({ id: mesh.id });
if (!updated) {
throw new Error("Mesh not found or you are not the owner.");
}
return updated;
};
/**
* Decline an incoming pending invite addressed to this user's email.
* Marks the pending_invite row as revoked so it no longer surfaces
* in /invites/incoming. The underlying short-code invite is NOT revoked
* (inviter may re-send), only this user's copy is dismissed.
*/
export const declineIncomingInvite = async ({
email,
pendingInviteId,
}: {
email: string;
pendingInviteId: string;
}) => {
const [updated] = await db
.update(pendingInvite)
.set({ revokedAt: new Date() })
.where(
and(
eq(pendingInvite.id, pendingInviteId),
eq(pendingInvite.email, email),
isNull(pendingInvite.acceptedAt),
isNull(pendingInvite.revokedAt),
),
)
.returning({ id: pendingInvite.id });
if (!updated) {
throw new Error("Invitation not found or already resolved.");
}
return updated;
};
export const leaveMyMesh = async ({
userId,
meshId,
}: {
userId: string;
meshId: string;
}) => {
const [updated] = await db
.update(meshMember)
.set({ revokedAt: new Date() })
.where(
and(
eq(meshMember.meshId, meshId),
eq(meshMember.userId, userId),
isNull(meshMember.revokedAt),
),
)
.returning({ id: meshMember.id });
if (!updated) {
throw new Error("You are not a member of this mesh.");
}
return updated;
};
export const createMyInvite = async ({
userId,
meshId,
input,
}: {
userId: string;
meshId: string;
input: CreateMyInviteInput;
}) => {
// Authz: owner or admin member can invite.
const [meshRow] = await db
.select({
id: mesh.id,
slug: mesh.slug,
ownerUserId: mesh.ownerUserId,
ownerPubkey: mesh.ownerPubkey,
ownerSecretKey: mesh.ownerSecretKey,
rootKey: mesh.rootKey,
})
.from(mesh)
.where(eq(mesh.id, meshId))
.limit(1);
if (!meshRow) {
throw new Error("Mesh not found.");
}
if (
!meshRow.ownerPubkey ||
!meshRow.ownerSecretKey ||
!meshRow.rootKey
) {
throw new Error(
"Mesh is missing owner keypair or root key — run backfill script.",
);
}
const isOwner = meshRow.ownerUserId === userId;
if (!isOwner) {
const [membership] = await db
.select({ role: meshMember.role })
.from(meshMember)
.where(
and(
eq(meshMember.meshId, meshId),
eq(meshMember.userId, userId),
isNull(meshMember.revokedAt),
),
)
.limit(1);
if (!membership || membership.role !== "admin") {
throw new Error("Only owners and admins can issue invites.");
}
}
const expiresAt = new Date(
Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000,
);
const expiresAtSec = Math.floor(expiresAt.getTime() / 1000);
// Build the canonical signed payload. Signature covers every field
// except `signature` itself; broker re-verifies identically.
const payloadCore = {
v: 1 as const,
mesh_id: meshRow.id,
mesh_slug: meshRow.slug,
broker_url: BROKER_URL,
expires_at: expiresAtSec,
mesh_root_key: meshRow.rootKey,
role: input.role,
owner_pubkey: meshRow.ownerPubkey,
};
const canonical = canonicalInvite(payloadCore);
const s = await ensureSodium();
const signature = s.to_hex(
s.crypto_sign_detached(
s.from_string(canonical),
s.from_hex(meshRow.ownerSecretKey),
),
);
const fullPayload = { ...payloadCore, signature };
// The base64url(JSON) is BOTH the link payload AND the DB lookup
// token — broker's /join resolves invites by this string.
const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString(
"base64url",
);
// 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,
code: created.code,
expiresAt: created.expiresAt,
inviteLink: `ic://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,
},
);
};