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 { 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)
|
||||||
|
|||||||
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