feat(api): /v1 REST surface for external clients (v0.2.0)
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

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_<secret>. 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) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 02:19:12 +01:00
parent 13d691980a
commit 56d7cc1c48
3 changed files with 374 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ import { adminRouter } from "./modules/admin/router";
import { authRouter } from "./modules/auth/router"; import { authRouter } from "./modules/auth/router";
import { billingRouter } from "./modules/billing/router"; import { billingRouter } from "./modules/billing/router";
import { myRouter } from "./modules/mesh/router"; import { myRouter } from "./modules/mesh/router";
import { v1Router } from "./modules/mesh/v1-router";
import { organizationRouter } from "./modules/organization/router"; import { organizationRouter } from "./modules/organization/router";
import { publicRouter } from "./modules/public/router"; import { publicRouter } from "./modules/public/router";
import { storageRouter } from "./modules/storage/router"; import { storageRouter } from "./modules/storage/router";
@@ -51,6 +52,7 @@ const appRouter = new Hono()
.route("/auth", authRouter) .route("/auth", authRouter)
.route("/billing", billingRouter) .route("/billing", billingRouter)
.route("/my", myRouter) .route("/my", myRouter)
.route("/", v1Router)
.route("/organizations", organizationRouter) .route("/organizations", organizationRouter)
.route("/public", publicRouter) .route("/public", publicRouter)
.route("/storage", storageRouter) .route("/storage", storageRouter)

View File

@@ -0,0 +1,111 @@
/**
* API key bearer-token auth for /v1/* REST endpoints (v0.2.0).
*
* Authorization: Bearer cm_<base64url>
* 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<AuthedApiKey | null> {
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",
});
}

View File

@@ -0,0 +1,261 @@
/**
* /api/v1/* — REST surface for external clients (humans, scripts, bots).
*
* Auth: Bearer cm_<secret>. 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<Env>()
.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=<id>
.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 });
});