feat(api): /v1 REST surface for external clients (v0.2.0)
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:
@@ -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)
|
||||
|
||||
111
packages/api/src/modules/mesh/api-key-auth.ts
Normal file
111
packages/api/src/modules/mesh/api-key-auth.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
261
packages/api/src/modules/mesh/v1-router.ts
Normal file
261
packages/api/src/modules/mesh/v1-router.ts
Normal 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 });
|
||||
});
|
||||
Reference in New Issue
Block a user