fix(security): resolve all 17 codex findings — auth, grants, crypto, ops
Critical: broker HTTP auth via cli_session bearer token on all /cli/*; file download requires auth+membership; v2 claim gated; duplicate claimInviteV2Core removed; grant enforcement tries member then session pubkey; audit hash uses canonical sorted-keys JSON. High: rate limit args fixed (burst 10, 60/min) + both buckets swept; BROKER_ENCRYPTION_KEY fail-fast in prod; migrate uses pg_try + lock_ timeout; hello validates sessionPubkey hex; blocked DMs rejected pre- queue; watch timers cleaned on disconnect. Medium: inbound pushes serialized; reconnect jitter + timer guard; hardcoded URLs through env; v2 claim path configurable. Low: WSHelloMessage optional protocolVersion+capabilities. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
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, lt, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNull, lt, sql } from "drizzle-orm";
|
||||
import { env } from "./env";
|
||||
import { db } from "./db";
|
||||
import { invite as inviteTable, mesh, meshMember, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh";
|
||||
@@ -103,7 +103,10 @@ import { metrics, metricsToText } from "./metrics";
|
||||
import { TokenBucket } from "./rate-limit";
|
||||
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
|
||||
import { buildInfo } from "./build-info";
|
||||
import { canonicalInvite, canonicalInviteV2, sealRootKeyToRecipient, verifyHelloSignature, verifyInviteV2 } from "./crypto";
|
||||
import { canonicalInvite, canonicalInviteV2, claimInviteV2Core as _claimInviteV2Core, sealRootKeyToRecipient, verifyHelloSignature, verifyInviteV2 } from "./crypto";
|
||||
// Alias for in-module callers; the public re-export below surfaces the
|
||||
// same symbol without colliding with tests that import from index.ts.
|
||||
const claimInviteV2Core = _claimInviteV2Core;
|
||||
import { handleWebhook } from "./webhooks";
|
||||
import { audit, loadLastHashes, ensureAuditLogTable, verifyChain, queryAuditLog } from "./audit";
|
||||
|
||||
@@ -485,11 +488,14 @@ const hookRateLimit = new TokenBucket(
|
||||
|
||||
/**
|
||||
* Per-member send rate limit. Protects the mesh from a runaway peer
|
||||
* dumping messages. Bucket is 60 msgs/min with a burst of 10 — generous
|
||||
* for conversational use, tight enough that a loop bug surfaces in seconds.
|
||||
* Configurable via env in a later pass.
|
||||
* dumping messages. Burst of 10, refill 60/min — generous for
|
||||
* conversational use, tight enough that a loop bug surfaces in seconds.
|
||||
*
|
||||
* NOTE: TokenBucket signature is `(capacity, refillPerMinute)`, so the
|
||||
* args ARE (burst, per-minute). Swept periodically below so old keys
|
||||
* don't leak.
|
||||
*/
|
||||
const sendRateLimit = new TokenBucket(60, 10);
|
||||
const sendRateLimit = new TokenBucket(10, 60);
|
||||
|
||||
function sendToPeer(presenceId: string, msg: WSServerMessage): void {
|
||||
const conn = connections.get(presenceId);
|
||||
@@ -616,35 +622,59 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
|
||||
// File download proxy: streams from MinIO so clients don't need internal URLs.
|
||||
// GET /download/{fileId}?mesh={meshId}
|
||||
// Auth: Bearer token + mesh membership. Previously wide open — anyone
|
||||
// who knew a fileId could exfiltrate.
|
||||
if (req.method === "GET" && req.url?.startsWith("/download/")) {
|
||||
const parts = req.url.split("?");
|
||||
const fileId = parts[0]!.replace("/download/", "");
|
||||
const params = new URLSearchParams(parts[1] ?? "");
|
||||
const meshId = params.get("mesh");
|
||||
if (!fileId || !meshId) {
|
||||
writeJson(res, 400, { error: "fileId and ?mesh= required" });
|
||||
log.info("download", { route: "GET /download", status: 400, latency_ms: Date.now() - started });
|
||||
return;
|
||||
}
|
||||
getFile(meshId, fileId).then(async (file) => {
|
||||
if (!file) {
|
||||
writeJson(res, 404, { error: "file not found" });
|
||||
log.info("download", { route: "GET /download", status: 404, file_id: fileId, latency_ms: Date.now() - started });
|
||||
(async () => {
|
||||
const auth = await requireCliAuth(req, res);
|
||||
if (!auth) return;
|
||||
const parts = req.url!.split("?");
|
||||
const fileId = parts[0]!.replace("/download/", "");
|
||||
const params = new URLSearchParams(parts[1] ?? "");
|
||||
const meshId = params.get("mesh");
|
||||
if (!fileId || !meshId) {
|
||||
writeJson(res, 400, { error: "fileId and ?mesh= required" });
|
||||
log.info("download", { route: "GET /download", status: 400, latency_ms: Date.now() - started });
|
||||
return;
|
||||
}
|
||||
const bucket = meshBucketName(meshId);
|
||||
const stream = await minioClient.getObject(bucket, file.minioKey);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": file.mimeType ?? "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${file.name}"`,
|
||||
"Cache-Control": "private, max-age=60",
|
||||
});
|
||||
stream.pipe(res);
|
||||
log.info("download", { route: "GET /download", file_id: fileId, name: file.name, latency_ms: Date.now() - started });
|
||||
}).catch((e) => {
|
||||
writeJson(res, 500, { error: "download failed" });
|
||||
log.error("download error", { file_id: fileId, error: e instanceof Error ? e.message : String(e) });
|
||||
});
|
||||
// Membership check: the authenticated user must have a live member
|
||||
// row in the requested mesh.
|
||||
try {
|
||||
const [m] = await db
|
||||
.select({ id: meshMember.id })
|
||||
.from(meshMember)
|
||||
.where(and(eq(meshMember.meshId, meshId), eq(meshMember.userId, auth.userId), isNull(meshMember.revokedAt)))
|
||||
.limit(1);
|
||||
if (!m) {
|
||||
writeJson(res, 403, { error: "not a member of this mesh" });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
writeJson(res, 500, { error: "membership check failed" });
|
||||
log.error("download-auth", { err: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const file = await getFile(meshId, fileId);
|
||||
if (!file) {
|
||||
writeJson(res, 404, { error: "file not found" });
|
||||
log.info("download", { route: "GET /download", status: 404, file_id: fileId, latency_ms: Date.now() - started });
|
||||
return;
|
||||
}
|
||||
const bucket = meshBucketName(meshId);
|
||||
const stream = await minioClient.getObject(bucket, file.minioKey);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": file.mimeType ?? "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${file.name}"`,
|
||||
"Cache-Control": "private, max-age=60",
|
||||
});
|
||||
stream.pipe(res);
|
||||
log.info("download", { route: "GET /download", file_id: fileId, name: file.name, latency_ms: Date.now() - started });
|
||||
} catch (e) {
|
||||
writeJson(res, 500, { error: "download failed" });
|
||||
log.error("download error", { file_id: fileId, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -976,188 +1006,13 @@ function handleJoinPost(
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
// NOTE: canonical `claimInviteV2Core` + `InviteClaimV2Result` live in
|
||||
// `./crypto.ts`. Re-exported here for backward-compat imports and
|
||||
// tests that pulled from index.ts. The previous duplicate in this
|
||||
// file had diverged from the crypto.ts copy and was deleted on
|
||||
// 2026-04-15 (Codex review finding).
|
||||
export { type InviteClaimV2Result } from "./crypto";
|
||||
export { claimInviteV2Core };
|
||||
|
||||
function handleInviteClaimV2Post(
|
||||
req: IncomingMessage,
|
||||
@@ -1197,6 +1052,19 @@ function handleInviteClaimV2Post(
|
||||
writeJson(res, 400, { error: "malformed" });
|
||||
return;
|
||||
}
|
||||
// Feature-flag: the v2 claim flow inserts a member row with
|
||||
// peerPubkey=<x25519 base64url>, but hello requires ed25519 hex.
|
||||
// Result: claimed invites can never complete the WS handshake.
|
||||
// Keep the endpoint behind an env flag until the two-step binding
|
||||
// (send x25519 for seal, bind ed25519 on first hello) lands. Spec:
|
||||
// .artifacts/specs/2026-04-15-invite-v2-cli-migration.md.
|
||||
if (process.env.BROKER_INVITE_V2_ENABLED !== "1") {
|
||||
writeJson(res, 501, {
|
||||
error: "invite_v2_disabled",
|
||||
detail: "v2 claim flow is behind BROKER_INVITE_V2_ENABLED=1 until the ed25519 binding step ships",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await claimInviteV2Core({
|
||||
code,
|
||||
recipientX25519PubkeyBase64url: payload.recipient_x25519_pubkey,
|
||||
@@ -1221,11 +1089,14 @@ function handleInviteClaimV2Post(
|
||||
});
|
||||
}
|
||||
|
||||
function handleUploadPost(
|
||||
async function handleUploadPost(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
started: number,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const auth = await requireCliAuth(req, res);
|
||||
if (!auth) return;
|
||||
|
||||
const meshId = req.headers["x-mesh-id"] as string | undefined;
|
||||
const memberId = req.headers["x-member-id"] as string | undefined;
|
||||
const fileName = req.headers["x-file-name"] as string | undefined;
|
||||
@@ -1244,6 +1115,25 @@ function handleUploadPost(
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the caller is actually a member of the mesh they claim, AND
|
||||
// that the X-Member-Id they sent belongs to them. Previously we trusted
|
||||
// both headers blindly — anyone could upload as anyone.
|
||||
try {
|
||||
const [m] = await db
|
||||
.select({ id: meshMember.id, userId: meshMember.userId, revokedAt: meshMember.revokedAt })
|
||||
.from(meshMember)
|
||||
.where(eq(meshMember.id, memberId))
|
||||
.limit(1);
|
||||
if (!m || m.revokedAt || m.userId !== auth.userId) {
|
||||
writeJson(res, 403, { ok: false, error: "member does not belong to authenticated user" });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
writeJson(res, 500, { ok: false, error: "auth check failed" });
|
||||
log.error("upload-auth", { err: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
}
|
||||
|
||||
const persistent = persistentRaw !== "false";
|
||||
let tags: string[] = [];
|
||||
if (tagsRaw) {
|
||||
@@ -1675,7 +1565,30 @@ async function restorePeerState(
|
||||
async function handleHello(
|
||||
ws: WebSocket,
|
||||
hello: Extract<WSClientMessage, { type: "hello" }>,
|
||||
): Promise<{ presenceId: string; memberDisplayName: string } | null> {
|
||||
): Promise<{
|
||||
presenceId: string;
|
||||
memberDisplayName: string;
|
||||
memberProfile?: unknown;
|
||||
meshPolicy?: Record<string, unknown>;
|
||||
restored?: boolean;
|
||||
lastSummary?: string;
|
||||
lastSeenAt?: string;
|
||||
restoredGroups?: Array<{ name: string; role?: string }>;
|
||||
restoredStats?: unknown;
|
||||
} | null> {
|
||||
// Validate sessionPubkey shape — it becomes a routable identity in
|
||||
// listPeers/drainForMember, so arbitrary strings let a client claim
|
||||
// nonsense pubkeys. Required-if-present: empty is allowed (falls back
|
||||
// to memberPubkey), but if present must be 64 lower-case hex.
|
||||
if (hello.sessionPubkey != null && hello.sessionPubkey !== "") {
|
||||
if (typeof hello.sessionPubkey !== "string" || !/^[0-9a-f]{64}$/.test(hello.sessionPubkey)) {
|
||||
metrics.connectionsRejected.inc({ reason: "bad_session_pubkey" });
|
||||
sendError(ws, "bad_session_pubkey", "sessionPubkey must be 64 lowercase hex chars");
|
||||
ws.close(1008, "bad_session_pubkey");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Capacity check BEFORE touching DB.
|
||||
const existing = connectionsPerMesh.get(hello.meshId) ?? 0;
|
||||
if (existing >= env.MAX_CONNECTIONS_PER_MESH) {
|
||||
@@ -1845,17 +1758,20 @@ async function handleSend(
|
||||
(isGroupTargetEarly && msg.targetSpec === "@all");
|
||||
const isDirectEarly = !isGroupTargetEarly && !isBroadcastEarly && msg.targetSpec !== "*";
|
||||
if (isDirectEarly) {
|
||||
let hasRecipient = false;
|
||||
for (const [pid, peer] of connections) {
|
||||
// Identify candidate recipient connections — anyone in the mesh whose
|
||||
// member or session pubkey matches the target. Then check grants to
|
||||
// see if at least one of them has granted the sender `dm`. Without
|
||||
// this check, blocked DMs get queued and sit in the DB forever
|
||||
// (multicast marks delivered on queue; direct relies on drain-or-push).
|
||||
const candidateMemberIds: string[] = [];
|
||||
for (const [, peer] of connections) {
|
||||
if (peer.meshId !== conn.meshId) continue;
|
||||
if (peer.ws === conn.ws) continue;
|
||||
if (peer.memberPubkey === msg.targetSpec || peer.sessionPubkey === msg.targetSpec) {
|
||||
hasRecipient = true;
|
||||
break;
|
||||
candidateMemberIds.push(peer.memberId);
|
||||
}
|
||||
void pid;
|
||||
}
|
||||
if (!hasRecipient) {
|
||||
if (candidateMemberIds.length === 0) {
|
||||
metrics.messagesRejectedTotal.inc({ reason: "no_recipient" });
|
||||
const errAck: WSServerMessage = {
|
||||
type: "ack",
|
||||
@@ -1867,6 +1783,34 @@ async function handleSend(
|
||||
conn.ws.send(JSON.stringify(errAck));
|
||||
return;
|
||||
}
|
||||
|
||||
// Load grants for the candidate recipient members and pick the first
|
||||
// that allows `dm` from the sender's stable memberPubkey. If none
|
||||
// allow it, reject pre-queue so the DB stays clean.
|
||||
const DEFAULT_CAPS_DM = ["read", "dm", "broadcast", "state-read"] as const;
|
||||
const grantRows = await db
|
||||
.select({ id: meshMember.id, peerGrants: meshMember.peerGrants })
|
||||
.from(meshMember)
|
||||
.where(and(eq(meshMember.meshId, conn.meshId), inArray(meshMember.id, candidateMemberIds)));
|
||||
const senderKey = conn.memberPubkey;
|
||||
const anyAllows = grantRows.some((row) => {
|
||||
const grants = (row.peerGrants as Record<string, string[]>) ?? {};
|
||||
const entry = grants[senderKey];
|
||||
if (entry === undefined) return (DEFAULT_CAPS_DM as readonly string[]).includes("dm");
|
||||
return entry.includes("dm");
|
||||
});
|
||||
if (!anyAllows) {
|
||||
metrics.messagesDroppedByGrantTotal?.inc?.({ cap: "dm" });
|
||||
const errAck: WSServerMessage = {
|
||||
type: "ack",
|
||||
id: msg.id ?? "",
|
||||
messageId: "",
|
||||
queued: false,
|
||||
error: "blocked by recipient grants (sender lacks dm capability)",
|
||||
};
|
||||
conn.ws.send(JSON.stringify(errAck));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const messageId = await queueMessage({
|
||||
@@ -1921,11 +1865,16 @@ async function handleSend(
|
||||
|
||||
// Per-peer grant enforcement — load recipient grant maps once per send.
|
||||
// See .artifacts/specs/2026-04-15-per-peer-capabilities.md.
|
||||
//
|
||||
// We look up grants by BOTH the sender's stable member pubkey AND their
|
||||
// ephemeral session pubkey, because CLI clients historically wrote grant
|
||||
// entries keyed on session pubkey (from listPeers which preferred
|
||||
// session key). Member key is preferred; session is a fall-through for
|
||||
// compatibility with older clients until they migrate.
|
||||
const DEFAULT_CAPS = ["read", "dm", "broadcast", "state-read"] as const;
|
||||
const capNeeded: "dm" | "broadcast" = isMulticast ? "broadcast" : "dm";
|
||||
const senderPubkey = conn.memberPubkey; // stable member key (survives session rotation)
|
||||
// Fetch grant maps for all connected peers in this mesh in one query.
|
||||
// Small (bounded by concurrent connections per mesh); acceptable per send.
|
||||
const senderMemberKey = conn.memberPubkey;
|
||||
const senderSessionKey = conn.sessionPubkey ?? null;
|
||||
const grantRows = await db
|
||||
.select({ id: meshMember.id, peerGrants: meshMember.peerGrants })
|
||||
.from(meshMember)
|
||||
@@ -1935,10 +1884,14 @@ async function handleSend(
|
||||
);
|
||||
function allowed(recipientMemberId: string): boolean {
|
||||
const grants = grantsByMemberId.get(recipientMemberId);
|
||||
if (!grants) return DEFAULT_CAPS.includes(capNeeded);
|
||||
const entry = grants[senderPubkey];
|
||||
if (entry === undefined) return DEFAULT_CAPS.includes(capNeeded);
|
||||
return entry.includes(capNeeded);
|
||||
if (!grants) return (DEFAULT_CAPS as readonly string[]).includes(capNeeded);
|
||||
const memberEntry = grants[senderMemberKey];
|
||||
if (memberEntry !== undefined) return memberEntry.includes(capNeeded);
|
||||
if (senderSessionKey) {
|
||||
const sessionEntry = grants[senderSessionKey];
|
||||
if (sessionEntry !== undefined) return sessionEntry.includes(capNeeded);
|
||||
}
|
||||
return (DEFAULT_CAPS as readonly string[]).includes(capNeeded);
|
||||
}
|
||||
|
||||
for (const [pid, peer] of connections) {
|
||||
@@ -2154,6 +2107,7 @@ function handleConnection(ws: WebSocket): void {
|
||||
const pc = connByPubkey.get(p.pubkey);
|
||||
return {
|
||||
pubkey: p.pubkey,
|
||||
memberPubkey: p.memberPubkey,
|
||||
displayName: p.displayName,
|
||||
status: p.status as "idle" | "working" | "dnd",
|
||||
summary: p.summary,
|
||||
@@ -3907,7 +3861,8 @@ function handleConnection(ws: WebSocket): void {
|
||||
}
|
||||
throw dupErr;
|
||||
}
|
||||
const webhookUrl = `https://ic.claudemesh.com/hook/${conn.meshId}/${webhookSecret}`;
|
||||
const brokerPublicUrl = process.env.BROKER_PUBLIC_URL ?? "https://ic.claudemesh.com";
|
||||
const webhookUrl = `${brokerPublicUrl.replace(/\/$/, "")}/hook/${conn.meshId}/${webhookSecret}`;
|
||||
sendToPeer(presenceId, {
|
||||
type: "webhook_ack",
|
||||
name: cw.name,
|
||||
@@ -3928,11 +3883,12 @@ function handleConnection(ws: WebSocket): void {
|
||||
})
|
||||
.from(meshWebhook)
|
||||
.where(and(eq(meshWebhook.meshId, conn.meshId), eq(meshWebhook.active, true)));
|
||||
const brokerPublicUrlList = process.env.BROKER_PUBLIC_URL ?? "https://ic.claudemesh.com";
|
||||
sendToPeer(presenceId, {
|
||||
type: "webhook_list",
|
||||
webhooks: whRows.map((r) => ({
|
||||
name: r.name,
|
||||
url: `https://ic.claudemesh.com/hook/${conn.meshId}/${r.secret}`,
|
||||
url: `${brokerPublicUrlList.replace(/\/$/, "")}/hook/${conn.meshId}/${r.secret}`,
|
||||
active: r.active,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
@@ -4253,6 +4209,14 @@ function handleConnection(ws: WebSocket): void {
|
||||
if (conn) {
|
||||
void audit(conn.meshId, "peer_left", conn.memberId, conn.displayName, {});
|
||||
}
|
||||
// Clean up URL watches owned by this peer — the interval was
|
||||
// happily fetching forever after the peer disconnected.
|
||||
for (const [watchId, watch] of urlWatches) {
|
||||
if (watch.presenceId === presenceId) {
|
||||
clearInterval(watch.timer);
|
||||
urlWatches.delete(watchId);
|
||||
}
|
||||
}
|
||||
// Clean up stream subscriptions for this peer
|
||||
for (const [key, subs] of streamSubscriptions) {
|
||||
subs.delete(presenceId);
|
||||
@@ -4483,7 +4447,10 @@ async function main(): Promise<void> {
|
||||
pingInterval.unref();
|
||||
|
||||
// GC rate-limit buckets periodically.
|
||||
const rlSweep = setInterval(() => hookRateLimit.sweep(), 5 * 60_000);
|
||||
const rlSweep = setInterval(() => {
|
||||
hookRateLimit.sweep();
|
||||
sendRateLimit.sweep();
|
||||
}, 5 * 60_000);
|
||||
rlSweep.unref();
|
||||
|
||||
// Queue depth gauge refresh (fires the metric; cheap COUNT query).
|
||||
@@ -4624,7 +4591,7 @@ async function main(): Promise<void> {
|
||||
.where(and(eq(telegramBridge.chatId, BigInt(chatId) as any), eq(telegramBridge.meshId, meshId)));
|
||||
},
|
||||
tgBotToken,
|
||||
"wss://ic.claudemesh.com/ws",
|
||||
process.env.BROKER_WS_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
// lookupMeshesByEmail: find user's meshes, create a bridge-specific member with fresh keypair
|
||||
async (email) => {
|
||||
const users = await db.select({ id: user.id, name: user.name }).from(user).where(eq(user.email, email)).limit(1);
|
||||
@@ -4786,6 +4753,52 @@ async function hashToken(token: string): Promise<string> {
|
||||
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the caller holds a valid CLI session token and return the
|
||||
* authenticated user_id. Used by every authenticated /cli/... route
|
||||
* to replace the former pattern of trusting body.user_id blindly.
|
||||
*
|
||||
* Returns null (and writes 401) on missing/invalid/revoked tokens.
|
||||
* Callers must `return` immediately after a null response.
|
||||
*/
|
||||
async function requireCliAuth(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<{ userId: string; sessionId: string } | null> {
|
||||
const header = req.headers["authorization"];
|
||||
if (!header || typeof header !== "string" || !header.startsWith("Bearer ")) {
|
||||
writeJson(res, 401, { error: "missing_bearer_token" });
|
||||
return null;
|
||||
}
|
||||
const token = header.slice("Bearer ".length).trim();
|
||||
if (!token) {
|
||||
writeJson(res, 401, { error: "empty_bearer_token" });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const hash = await hashToken(token);
|
||||
const [session] = await db
|
||||
.select({ id: cliSessionTable.id, userId: cliSessionTable.userId, revokedAt: cliSessionTable.revokedAt })
|
||||
.from(cliSessionTable)
|
||||
.where(eq(cliSessionTable.tokenHash, hash))
|
||||
.limit(1);
|
||||
if (!session || session.revokedAt) {
|
||||
writeJson(res, 401, { error: "invalid_or_revoked_token" });
|
||||
return null;
|
||||
}
|
||||
// Touch last-seen so operators can see stale sessions.
|
||||
db.update(cliSessionTable)
|
||||
.set({ lastSeenAt: new Date() })
|
||||
.where(eq(cliSessionTable.id, session.id))
|
||||
.catch(() => { /* non-fatal */ });
|
||||
return { userId: session.userId, sessionId: session.id };
|
||||
} catch (e) {
|
||||
log.error("auth", { err: e instanceof Error ? e.message : String(e) });
|
||||
writeJson(res, 500, { error: "auth_check_failed" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /cli/device-code — create a new device code. */
|
||||
async function handleDeviceCodeNew(req: IncomingMessage, res: ServerResponse, started: number): Promise<void> {
|
||||
let body: { hostname?: string; platform?: string; arch?: string } = {};
|
||||
@@ -4959,13 +4972,9 @@ async function handleDeviceCodeApprove(req: IncomingMessage, code: string, res:
|
||||
/** GET /cli/sessions?user_id=... — list CLI sessions for a user. */
|
||||
/** GET /cli/meshes?user_id=... — list all meshes for a user with member counts. */
|
||||
async function handleCliMeshesList(req: IncomingMessage, res: ServerResponse, started: number): Promise<void> {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
const userId = url.searchParams.get("user_id");
|
||||
|
||||
if (!userId) {
|
||||
writeJson(res, 400, { error: "user_id required" });
|
||||
return;
|
||||
}
|
||||
const auth = await requireCliAuth(req, res);
|
||||
if (!auth) return;
|
||||
const userId = auth.userId;
|
||||
|
||||
try {
|
||||
// Find meshes via two paths:
|
||||
@@ -5168,7 +5177,10 @@ import { meshPermission } from "@turbostarter/db/schema/mesh";
|
||||
* for a specific peer = blocked. Explicit null = reset to defaults.
|
||||
*/
|
||||
async function handleCliMeshGrants(req: IncomingMessage, slug: string, res: ServerResponse, started: number): Promise<void> {
|
||||
let body: { user_id: string; grants: Record<string, string[] | null> };
|
||||
const auth = await requireCliAuth(req, res);
|
||||
if (!auth) return;
|
||||
|
||||
let body: { grants: Record<string, string[] | null> };
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||
@@ -5177,8 +5189,8 @@ async function handleCliMeshGrants(req: IncomingMessage, slug: string, res: Serv
|
||||
writeJson(res, 400, { error: "Invalid body" });
|
||||
return;
|
||||
}
|
||||
if (!body.user_id || !body.grants) {
|
||||
writeJson(res, 400, { error: "user_id and grants required" });
|
||||
if (!body.grants) {
|
||||
writeJson(res, 400, { error: "grants required" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -5186,7 +5198,7 @@ async function handleCliMeshGrants(req: IncomingMessage, slug: string, res: Serv
|
||||
if (!m) { writeJson(res, 404, { error: "Mesh not found" }); return; }
|
||||
// Find the caller's member row.
|
||||
const [member] = await db.select().from(meshMember)
|
||||
.where(and(eq(meshMember.meshId, m.id), eq(meshMember.userId, body.user_id), isNull(meshMember.revokedAt)))
|
||||
.where(and(eq(meshMember.meshId, m.id), eq(meshMember.userId, auth.userId), isNull(meshMember.revokedAt)))
|
||||
.limit(1);
|
||||
if (!member) {
|
||||
writeJson(res, 403, { error: "Not a member of this mesh" });
|
||||
@@ -5209,7 +5221,10 @@ async function handleCliMeshGrants(req: IncomingMessage, slug: string, res: Serv
|
||||
|
||||
/** POST /cli/mesh/:slug/invite — generate an invite for a mesh. */
|
||||
async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: ServerResponse, started: number): Promise<void> {
|
||||
let body: { user_id: string; email?: string; expires_in?: string; role?: string };
|
||||
const auth = await requireCliAuth(req, res);
|
||||
if (!auth) return;
|
||||
|
||||
let body: { email?: string; expires_in?: string; role?: string };
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||
@@ -5219,15 +5234,10 @@ async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: Serv
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body.user_id) {
|
||||
writeJson(res, 400, { error: "user_id required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [m] = await db.select().from(mesh).where(eq(mesh.slug, slug)).limit(1);
|
||||
if (!m) { writeJson(res, 404, { error: "Mesh not found" }); return; }
|
||||
if (m.ownerUserId !== body.user_id) {
|
||||
if (m.ownerUserId !== auth.userId) {
|
||||
writeJson(res, 403, { error: "Only the owner can invite (for now)" });
|
||||
return;
|
||||
}
|
||||
@@ -5280,7 +5290,7 @@ async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: Serv
|
||||
maxUses: 1,
|
||||
role,
|
||||
expiresAt,
|
||||
createdBy: body.user_id,
|
||||
createdBy: auth.userId,
|
||||
version: 2,
|
||||
}).returning({ id: inviteTable.id });
|
||||
inviteId = rows[0]!.id;
|
||||
@@ -5349,7 +5359,10 @@ async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: Serv
|
||||
}
|
||||
|
||||
async function handleCliMeshCreate(req: IncomingMessage, res: ServerResponse, started: number): Promise<void> {
|
||||
let body: { user_id: string; name: string; pubkey?: string; slug?: string; template?: string; description?: string };
|
||||
const auth = await requireCliAuth(req, res);
|
||||
if (!auth) return;
|
||||
|
||||
let body: { name: string; pubkey?: string; slug?: string; template?: string; description?: string };
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||
@@ -5359,8 +5372,8 @@ async function handleCliMeshCreate(req: IncomingMessage, res: ServerResponse, st
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body.user_id || !body.name) {
|
||||
writeJson(res, 400, { error: "user_id and name required" });
|
||||
if (!body.name) {
|
||||
writeJson(res, 400, { error: "name required" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5395,7 +5408,7 @@ async function handleCliMeshCreate(req: IncomingMessage, res: ServerResponse, st
|
||||
// Create mesh — use raw SQL to avoid Drizzle default-column issues
|
||||
await db.execute(sql`
|
||||
INSERT INTO mesh.mesh (id, name, slug, owner_user_id, owner_pubkey, owner_secret_key, root_key)
|
||||
VALUES (${meshId}, ${body.name}, ${slug}, ${body.user_id}, ${ownerPubkey}, ${ownerSecretKey}, ${rootKey})
|
||||
VALUES (${meshId}, ${body.name}, ${slug}, ${auth.userId}, ${ownerPubkey}, ${ownerSecretKey}, ${rootKey})
|
||||
`);
|
||||
|
||||
// Create owner member.
|
||||
@@ -5410,11 +5423,11 @@ async function handleCliMeshCreate(req: IncomingMessage, res: ServerResponse, st
|
||||
const memberId = generateId();
|
||||
await db.execute(sql`
|
||||
INSERT INTO mesh.member (id, mesh_id, user_id, peer_pubkey, display_name, role)
|
||||
VALUES (${memberId}, ${meshId}, ${body.user_id}, ${body.pubkey}, ${body.name + "-owner"}, ${"admin"})
|
||||
VALUES (${memberId}, ${meshId}, ${auth.userId}, ${body.pubkey}, ${body.name + "-owner"}, ${"admin"})
|
||||
`);
|
||||
|
||||
writeJson(res, 200, { id: meshId, slug, name: body.name, member_id: memberId });
|
||||
log.info("mesh-create", { route: "POST /cli/mesh/create", slug, user_id: body.user_id, latency_ms: Date.now() - started });
|
||||
log.info("mesh-create", { route: "POST /cli/mesh/create", slug, user_id: auth.userId, latency_ms: Date.now() - started });
|
||||
} catch (e) {
|
||||
log.error("mesh-create", { error: e instanceof Error ? e.message : String(e) });
|
||||
writeJson(res, 500, { error: "Failed to create mesh" });
|
||||
@@ -5423,37 +5436,22 @@ async function handleCliMeshCreate(req: IncomingMessage, res: ServerResponse, st
|
||||
|
||||
/** DELETE /cli/mesh/:slug — delete a mesh (owner only). */
|
||||
async function handleMeshDelete(req: IncomingMessage, slug: string, res: ServerResponse, started: number): Promise<void> {
|
||||
let body: { user_id: string };
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||
body = JSON.parse(Buffer.concat(chunks).toString()) as typeof body;
|
||||
} catch {
|
||||
writeJson(res, 400, { error: "Invalid body" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body.user_id) {
|
||||
writeJson(res, 400, { error: "user_id required" });
|
||||
return;
|
||||
}
|
||||
const auth = await requireCliAuth(req, res);
|
||||
if (!auth) return;
|
||||
|
||||
try {
|
||||
// Find mesh by slug
|
||||
const [m] = await db.select().from(mesh).where(eq(mesh.slug, slug)).limit(1);
|
||||
if (!m) { writeJson(res, 404, { error: "Mesh not found" }); return; }
|
||||
|
||||
// Only owner can delete
|
||||
if (m.ownerUserId !== body.user_id) {
|
||||
if (m.ownerUserId !== auth.userId) {
|
||||
writeJson(res, 403, { error: "Only the mesh owner can delete it" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Soft delete (archive)
|
||||
await db.update(mesh).set({ archivedAt: new Date() }).where(eq(mesh.id, m.id));
|
||||
|
||||
writeJson(res, 200, { ok: true, deleted: slug });
|
||||
log.info("mesh-delete", { route: "DELETE /cli/mesh/:slug", slug, user_id: body.user_id, latency_ms: Date.now() - started });
|
||||
log.info("mesh-delete", { route: "DELETE /cli/mesh/:slug", slug, user_id: auth.userId, latency_ms: Date.now() - started });
|
||||
} catch (e) {
|
||||
log.error("mesh-delete", { error: e instanceof Error ? e.message : String(e) });
|
||||
writeJson(res, 500, { error: "Failed to delete mesh" });
|
||||
|
||||
Reference in New Issue
Block a user