From 56d7cc1c48560dc48cc81609890714edad7935b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 2 May 2026 02:19:12 +0100 Subject: [PATCH] feat(api): /v1 REST surface for external clients (v0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bearer-auth REST endpoints for humans, scripts, bots — anyone without browser-side ed25519. Same key model as broker WS, scoped by capability and optional topic whitelist. Endpoints (v0.2.0 minimum): - POST /v1/messages - GET /v1/topics - GET /v1/topics/:name/messages (limit, before cursor) - GET /v1/peers Auth: Authorization: Bearer cm_. Middleware verifies prefix + SHA-256 hash with constant-time compare; capability + topic-scope asserted per route. Cross-mesh isolation: every endpoint scopes to apiKey.meshId. Live delivery: writes to messageQueue + topic_message; broker's existing pendingTimer drains and pushes to live peers. Real-time push from REST writes is a follow-up. Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/index.ts | 2 + packages/api/src/modules/mesh/api-key-auth.ts | 111 ++++++++ packages/api/src/modules/mesh/v1-router.ts | 261 ++++++++++++++++++ 3 files changed, 374 insertions(+) create mode 100644 packages/api/src/modules/mesh/api-key-auth.ts create mode 100644 packages/api/src/modules/mesh/v1-router.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 99478e3..5c2c9f5 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -14,6 +14,7 @@ import { adminRouter } from "./modules/admin/router"; import { authRouter } from "./modules/auth/router"; import { billingRouter } from "./modules/billing/router"; import { myRouter } from "./modules/mesh/router"; +import { v1Router } from "./modules/mesh/v1-router"; import { organizationRouter } from "./modules/organization/router"; import { publicRouter } from "./modules/public/router"; import { storageRouter } from "./modules/storage/router"; @@ -51,6 +52,7 @@ const appRouter = new Hono() .route("/auth", authRouter) .route("/billing", billingRouter) .route("/my", myRouter) + .route("/", v1Router) .route("/organizations", organizationRouter) .route("/public", publicRouter) .route("/storage", storageRouter) diff --git a/packages/api/src/modules/mesh/api-key-auth.ts b/packages/api/src/modules/mesh/api-key-auth.ts new file mode 100644 index 0000000..788310f --- /dev/null +++ b/packages/api/src/modules/mesh/api-key-auth.ts @@ -0,0 +1,111 @@ +/** + * API key bearer-token auth for /v1/* REST endpoints (v0.2.0). + * + * Authorization: Bearer cm_ + * secret prefix → narrow candidate set by `secret_prefix` index + * timing-safe SHA-256 compare → identify the key + * capability + topic-scope checks happen at the route layer + * + * Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md + */ + +import { createMiddleware } from "hono/factory"; +import { HttpException } from "@turbostarter/shared/utils"; +import { HttpStatusCode } from "@turbostarter/shared/constants"; +import { db } from "@turbostarter/db/server"; +import { meshApiKey } from "@turbostarter/db/schema/mesh"; +import { and, eq } from "drizzle-orm"; +import { createHash, timingSafeEqual } from "node:crypto"; + +export type ApiKeyCapability = "send" | "read" | "state_write" | "admin"; + +export interface AuthedApiKey { + id: string; + meshId: string; + capabilities: ApiKeyCapability[]; + topicScopes: string[] | null; +} + +async function verifyBearer(secret: string): Promise { + if (!secret.startsWith("cm_")) return null; + const prefix = secret.slice(0, 11); + const hash = createHash("sha256").update(secret).digest("hex"); + const candidates = await db + .select({ + id: meshApiKey.id, + meshId: meshApiKey.meshId, + secretHash: meshApiKey.secretHash, + capabilities: meshApiKey.capabilities, + topicScopes: meshApiKey.topicScopes, + revokedAt: meshApiKey.revokedAt, + expiresAt: meshApiKey.expiresAt, + }) + .from(meshApiKey) + .where(eq(meshApiKey.secretPrefix, prefix)); + const now = new Date(); + for (const c of candidates) { + if (c.revokedAt) continue; + if (c.expiresAt && c.expiresAt < now) continue; + const a = Buffer.from(c.secretHash, "hex"); + const b = Buffer.from(hash, "hex"); + if (a.length !== b.length) continue; + if (!timingSafeEqual(a, b)) continue; + void db + .update(meshApiKey) + .set({ lastUsedAt: now }) + .where(eq(meshApiKey.id, c.id)) + .catch(() => {}); + return { + id: c.id, + meshId: c.meshId, + capabilities: (c.capabilities ?? []) as ApiKeyCapability[], + topicScopes: c.topicScopes ?? null, + }; + } + return null; +} + +/** Middleware: verifies the bearer token and stashes the AuthedApiKey on the + * context as `apiKey`. Throws 401 on missing/invalid creds. Capability + * + topic-scope enforcement is per-route. */ +export const enforceApiKey = createMiddleware<{ + Variables: { apiKey: AuthedApiKey }; +}>(async (c, next) => { + const auth = c.req.header("Authorization") ?? ""; + const m = auth.match(/^Bearer\s+(.+)$/); + if (!m) { + throw new HttpException(HttpStatusCode.UNAUTHORIZED, { + code: "error.api_key_missing", + }); + } + const key = await verifyBearer(m[1]!.trim()); + if (!key) { + throw new HttpException(HttpStatusCode.UNAUTHORIZED, { + code: "error.api_key_invalid", + }); + } + c.set("apiKey", key); + await next(); +}); + +/** Inline helper: assert the authed key has a capability. */ +export function requireCapability( + key: AuthedApiKey, + cap: ApiKeyCapability, +): void { + if (!key.capabilities.includes(cap) && !key.capabilities.includes("admin")) { + throw new HttpException(HttpStatusCode.FORBIDDEN, { + code: "error.api_key_missing_capability", + }); + } +} + +/** Inline helper: assert the authed key may operate on this topic name. + * Pass topic name as it appears to users (without # prefix). */ +export function requireTopicScope(key: AuthedApiKey, topicName: string): void { + if (!key.topicScopes) return; // null = unscoped, allowed everywhere + if (key.topicScopes.includes(topicName)) return; + throw new HttpException(HttpStatusCode.FORBIDDEN, { + code: "error.api_key_topic_out_of_scope", + }); +} diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts new file mode 100644 index 0000000..87ba374 --- /dev/null +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -0,0 +1,261 @@ +/** + * /api/v1/* — REST surface for external clients (humans, scripts, bots). + * + * Auth: Bearer cm_. Capability + topic-scope checks per route. + * Cross-mesh isolation: every endpoint scopes to apiKey.meshId — a key + * for mesh A cannot read or write mesh B. + * + * Endpoints (v0.2.0 minimum): + * POST /v1/messages — send to a topic + * GET /v1/topics — list topics in the key's mesh + * GET /v1/topics/:name/messages — fetch topic history (paginated) + * GET /v1/peers — list peers in the mesh + * + * Live delivery: writes to mesh.message_queue + mesh.topic_message. The + * broker's existing pendingTimer drains the queue and pushes to live + * peers. Latency = polling interval (~2s today). Real-time push from + * REST writes is a follow-up. + * + * Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md + */ + +import { Hono } from "hono"; +import { z } from "zod"; + +import { db } from "@turbostarter/db/server"; +import { + mesh, + meshMember, + meshTopic, + meshTopicMember, + meshTopicMessage, + messageQueue, + presence, +} from "@turbostarter/db/schema/mesh"; +import { and, asc, desc, eq, isNull, lt } from "drizzle-orm"; + +import { validate } from "../../middleware"; +import { + enforceApiKey, + requireCapability, + requireTopicScope, + type AuthedApiKey, +} from "./api-key-auth"; + +type Env = { Variables: { apiKey: AuthedApiKey } }; + +const sendMessageSchema = z.object({ + topic: z.string().min(1), + /** base64-encoded ciphertext; client encrypts before sending. */ + ciphertext: z.string().min(1), + /** base64 nonce. */ + nonce: z.string().min(1), + priority: z.enum(["now", "next", "low"]).optional().default("next"), +}); + +const historyQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional().default(50), + before: z.string().optional(), +}); + +export const v1Router = new Hono() + .basePath("/v1") + .use(enforceApiKey) + + // POST /v1/messages — send to a topic + .post("/messages", validate("json", sendMessageSchema), async (c) => { + const key = c.var.apiKey; + requireCapability(key, "send"); + + const body = c.req.valid("json"); + requireTopicScope(key, body.topic); + + // Resolve topic by name within the key's mesh. + const [topic] = await db + .select({ id: meshTopic.id }) + .from(meshTopic) + .where( + and( + eq(meshTopic.meshId, key.meshId), + eq(meshTopic.name, body.topic), + isNull(meshTopic.archivedAt), + ), + ); + if (!topic) { + return c.json({ error: "topic_not_found", topic: body.topic }, 404); + } + + // External keys aren't tied to a specific member. Use the mesh owner + // as the sender placeholder so the FK on senderMemberId resolves. + // (Future: introduce a synthetic "external" member per key.) + const [meshRow] = await db + .select({ ownerUserId: mesh.ownerUserId }) + .from(mesh) + .where(eq(mesh.id, key.meshId)); + if (!meshRow) return c.json({ error: "mesh_not_found" }, 404); + + const [ownerMember] = await db + .select({ id: meshMember.id }) + .from(meshMember) + .where( + and(eq(meshMember.meshId, key.meshId), isNull(meshMember.revokedAt)), + ) + .orderBy(asc(meshMember.joinedAt)) + .limit(1); + if (!ownerMember) return c.json({ error: "no_mesh_member" }, 500); + + // Persist to history (topic_message) + ephemeral queue (message_queue). + // Broker's drain loop picks up the queue entry and pushes to live peers. + const [historyRow] = await db + .insert(meshTopicMessage) + .values({ + topicId: topic.id, + senderMemberId: ownerMember.id, + nonce: body.nonce, + ciphertext: body.ciphertext, + }) + .returning({ id: meshTopicMessage.id }); + + const [queueRow] = await db + .insert(messageQueue) + .values({ + meshId: key.meshId, + senderMemberId: ownerMember.id, + targetSpec: "#" + topic.id, + priority: body.priority, + nonce: body.nonce, + ciphertext: body.ciphertext, + }) + .returning({ id: messageQueue.id }); + + return c.json({ + messageId: queueRow?.id ?? null, + historyId: historyRow?.id ?? null, + topic: body.topic, + topicId: topic.id, + }); + }) + + // GET /v1/topics — list topics in the key's mesh + .get("/topics", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + const rows = await db + .select({ + id: meshTopic.id, + name: meshTopic.name, + description: meshTopic.description, + visibility: meshTopic.visibility, + createdAt: meshTopic.createdAt, + }) + .from(meshTopic) + .where(and(eq(meshTopic.meshId, key.meshId), isNull(meshTopic.archivedAt))) + .orderBy(asc(meshTopic.name)); + const filtered = key.topicScopes + ? rows.filter((r) => key.topicScopes!.includes(r.name)) + : rows; + return c.json({ + topics: filtered.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + visibility: t.visibility, + createdAt: t.createdAt.toISOString(), + })), + }); + }) + + // GET /v1/topics/:name/messages?limit=50&before= + .get( + "/topics/:name/messages", + validate("query", historyQuerySchema), + async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + const name = c.req.param("name"); + requireTopicScope(key, name); + + const [topic] = await db + .select({ id: meshTopic.id }) + .from(meshTopic) + .where( + and( + eq(meshTopic.meshId, key.meshId), + eq(meshTopic.name, name), + isNull(meshTopic.archivedAt), + ), + ); + if (!topic) { + return c.json({ error: "topic_not_found", topic: name }, 404); + } + + const { limit, before } = c.req.valid("query"); + let beforeAt: Date | null = null; + if (before) { + const [b] = await db + .select({ createdAt: meshTopicMessage.createdAt }) + .from(meshTopicMessage) + .where(eq(meshTopicMessage.id, before)); + beforeAt = b?.createdAt ?? null; + } + + const rows = await db + .select({ + id: meshTopicMessage.id, + senderMemberId: meshTopicMessage.senderMemberId, + senderPubkey: meshMember.peerPubkey, + senderName: meshMember.displayName, + nonce: meshTopicMessage.nonce, + ciphertext: meshTopicMessage.ciphertext, + createdAt: meshTopicMessage.createdAt, + }) + .from(meshTopicMessage) + .innerJoin( + meshMember, + eq(meshTopicMessage.senderMemberId, meshMember.id), + ) + .where( + beforeAt + ? and( + eq(meshTopicMessage.topicId, topic.id), + lt(meshTopicMessage.createdAt, beforeAt), + ) + : eq(meshTopicMessage.topicId, topic.id), + ) + .orderBy(desc(meshTopicMessage.createdAt)) + .limit(limit); + + return c.json({ + topic: name, + topicId: topic.id, + messages: rows.map((r) => ({ + id: r.id, + senderPubkey: r.senderPubkey, + senderName: r.senderName, + nonce: r.nonce, + ciphertext: r.ciphertext, + createdAt: r.createdAt.toISOString(), + })), + }); + }, + ) + + // GET /v1/peers — connected peers in the key's mesh + .get("/peers", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + const rows = await db + .select({ + pubkey: meshMember.peerPubkey, + displayName: meshMember.displayName, + status: presence.status, + summary: presence.summary, + groups: presence.groups, + }) + .from(presence) + .innerJoin(meshMember, eq(presence.memberId, meshMember.id)) + .where( + and(eq(meshMember.meshId, key.meshId), isNull(presence.disconnectedAt)), + ); + return c.json({ peers: rows }); + });