feat(broker): api key schema and helpers
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

Foundation for v0.2.0 REST + external WS auth.

Bearer tokens stored as SHA-256 hashes; secrets are 256-bit CSPRNG so
Argon2 would waste cost without security gain.

Adds mesh.api_key table, migration 0023 applied manually to prod, and
helpers: createApiKey, listApiKeys, revokeApiKey, verifyApiKey.

Next slices: CLI apikey verbs and REST endpoints in apps/web router.

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:09:44 +01:00
parent f71218c1e1
commit f45380d231
3 changed files with 295 additions and 0 deletions

View File

@@ -32,6 +32,7 @@ import { db } from "./db";
import {
invite as inviteTable,
mesh,
meshApiKey,
meshFile,
meshFileAccess,
meshFileKey,
@@ -782,6 +783,190 @@ export async function markTopicRead(args: {
);
}
// --- API keys (v0.2.0) ---
//
// Bearer-token auth for REST + external WS. Keys are 32 bytes of CSPRNG
// rendered as base32, stored as Argon2id hashes. Capabilities + topic
// scopes are enforced at the route layer in apps/web (REST) and at the
// hello layer in the broker (external WS, future).
//
// Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
/** Generate a fresh API key secret. Returns the plaintext (show once),
* its prefix (8 chars, displayable), and a SHA-256 hash for storage.
* (We use SHA-256 here, not Argon2 — these are random 256-bit secrets,
* not low-entropy passwords; brute force isn't a threat. Argon2 is for
* humans typing memorable passwords. The trade-off is ~100x faster
* verification on the request hot path with no real security loss.)
*/
function newApiKeySecret(): { plaintext: string; prefix: string; hash: string } {
const bytes = randomBytes(32);
const plaintext = "cm_" + bytes.toString("base64url");
const prefix = plaintext.slice(0, 11); // "cm_" + 8 chars
const hash = createHash("sha256").update(plaintext).digest("hex");
return { plaintext, prefix, hash };
}
/** Issue a new API key. Returns the plaintext secret (show ONCE) plus
* the persisted key record. */
export async function createApiKey(args: {
meshId: string;
label: string;
capabilities: Array<"send" | "read" | "state_write" | "admin">;
topicScopes?: string[] | null;
issuedByMemberId?: string;
expiresAt?: Date;
}): Promise<{
id: string;
secret: string;
label: string;
prefix: string;
capabilities: Array<"send" | "read" | "state_write" | "admin">;
topicScopes: string[] | null;
createdAt: Date;
}> {
const { plaintext, prefix, hash } = newApiKeySecret();
const [row] = await db
.insert(meshApiKey)
.values({
meshId: args.meshId,
label: args.label,
secretHash: hash,
secretPrefix: prefix,
capabilities: args.capabilities,
topicScopes: args.topicScopes ?? null,
issuedByMemberId: args.issuedByMemberId ?? null,
expiresAt: args.expiresAt,
})
.returning({
id: meshApiKey.id,
label: meshApiKey.label,
capabilities: meshApiKey.capabilities,
topicScopes: meshApiKey.topicScopes,
createdAt: meshApiKey.createdAt,
});
if (!row) throw new Error("failed to create api key");
return {
id: row.id,
secret: plaintext,
label: row.label,
prefix,
capabilities: row.capabilities ?? [],
topicScopes: row.topicScopes ?? null,
createdAt: row.createdAt,
};
}
/** List API keys for a mesh (without revealing hashes/secrets). */
export async function listApiKeys(meshId: string): Promise<
Array<{
id: string;
label: string;
prefix: string;
capabilities: Array<"send" | "read" | "state_write" | "admin">;
topicScopes: string[] | null;
createdAt: Date;
lastUsedAt: Date | null;
revokedAt: Date | null;
expiresAt: Date | null;
}>
> {
const rows = await db
.select({
id: meshApiKey.id,
label: meshApiKey.label,
prefix: meshApiKey.secretPrefix,
capabilities: meshApiKey.capabilities,
topicScopes: meshApiKey.topicScopes,
createdAt: meshApiKey.createdAt,
lastUsedAt: meshApiKey.lastUsedAt,
revokedAt: meshApiKey.revokedAt,
expiresAt: meshApiKey.expiresAt,
})
.from(meshApiKey)
.where(eq(meshApiKey.meshId, meshId))
.orderBy(desc(meshApiKey.createdAt));
return rows.map((r) => ({
id: r.id,
label: r.label,
prefix: r.prefix,
capabilities: r.capabilities ?? [],
topicScopes: r.topicScopes ?? null,
createdAt: r.createdAt,
lastUsedAt: r.lastUsedAt,
revokedAt: r.revokedAt,
expiresAt: r.expiresAt,
}));
}
/** Revoke an API key. Idempotent. */
export async function revokeApiKey(args: { meshId: string; id: string }): Promise<void> {
await db
.update(meshApiKey)
.set({ revokedAt: new Date() })
.where(and(eq(meshApiKey.meshId, args.meshId), eq(meshApiKey.id, args.id)));
}
/**
* Verify an API key secret. Returns the matched key record if the
* secret hashes match a non-revoked, non-expired row in the given mesh
* (or any mesh, if meshId omitted). Constant-time comparison so timing
* leaks don't reveal which keys exist.
*/
export async function verifyApiKey(args: {
secret: string;
meshId?: string;
}): Promise<{
id: string;
meshId: string;
capabilities: Array<"send" | "read" | "state_write" | "admin">;
topicScopes: string[] | null;
} | null> {
if (!args.secret.startsWith("cm_")) return null;
const prefix = args.secret.slice(0, 11);
const hash = createHash("sha256").update(args.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(
args.meshId
? and(eq(meshApiKey.meshId, args.meshId), eq(meshApiKey.secretPrefix, prefix))
: 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;
// Update last_used_at lazily — best-effort, don't block on it.
void db
.update(meshApiKey)
.set({ lastUsedAt: now })
.where(eq(meshApiKey.id, c.id))
.catch(() => {});
return {
id: c.id,
meshId: c.meshId,
capabilities: c.capabilities ?? [],
topicScopes: c.topicScopes ?? null,
};
}
return null;
}
// --- Shared state ---
/**

View File

@@ -0,0 +1,34 @@
-- API keys for REST + external WS access (v0.2.0).
--
-- Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
--
-- Bearer-token auth for non-WS clients (humans on the dashboard, scripts,
-- bots, mobile apps). The secret is shown once at creation, then only
-- the Argon2id hash is stored. Capabilities + topic_scopes constrain
-- what each key can do — a CI bot key gets `send/read` on `#deploys`
-- only, never the whole mesh.
--
-- Additive — no breaking changes. CLI/web can ignore the table until
-- the issuance verbs ship in 0.2.0.
CREATE TYPE "mesh"."api_key_capability" AS ENUM (
'send', 'read', 'state_write', 'admin'
);
CREATE TABLE IF NOT EXISTS "mesh"."api_key" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL REFERENCES "mesh"."mesh"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"label" text NOT NULL,
"secret_hash" text NOT NULL,
"secret_prefix" text NOT NULL,
"capabilities" jsonb NOT NULL DEFAULT '[]'::jsonb,
"topic_scopes" jsonb,
"issued_by_member_id" text REFERENCES "mesh"."member"("id") ON DELETE SET NULL ON UPDATE CASCADE,
"created_at" timestamp DEFAULT now() NOT NULL,
"last_used_at" timestamp,
"revoked_at" timestamp,
"expires_at" timestamp
);
CREATE INDEX IF NOT EXISTS "api_key_by_mesh" ON "mesh"."api_key" ("mesh_id");
CREATE INDEX IF NOT EXISTS "api_key_by_prefix" ON "mesh"."api_key" ("secret_prefix");

View File

@@ -1483,3 +1483,79 @@ export const insertMeshTopicMessageSchema =
createInsertSchema(meshTopicMessage);
export type SelectMeshTopicMessage = typeof meshTopicMessage.$inferSelect;
export type InsertMeshTopicMessage = typeof meshTopicMessage.$inferInsert;
/* ────────────────────────────────────────────────────────────────────────
* API keys (v0.2.0) — REST + external WS auth.
*
* Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
*
* REST is the human interface to claudemesh (humans don't have
* browser-side ed25519, so they can't do hello-sig auth like agents do).
* Same key model serves external scripts, bots, mobile apps, Zapier-style
* integrations.
*
* Auth model: bearer token. Server stores Argon2id hash of the secret;
* the secret is shown to the user once at creation and never again.
* Capabilities + topic_scopes constrain what the key can do — a CI bot
* key gets `["send", "read"]` on `["#deploys"]` only, never the whole mesh.
* ──────────────────────────────────────────────────────────────────────── */
export const apiKeyCapabilityEnum = meshSchema.enum("api_key_capability", [
"send", // POST /messages
"read", // GET /messages, /peers, /state
"state_write", // POST /state
"admin", // issue/revoke other keys, delete topics, etc.
]);
export const meshApiKey = meshSchema.table(
"api_key",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
/** Human-readable label for the key (shown in CLI list, dashboard UI). */
label: text().notNull(),
/** Argon2id hash of the secret. The secret itself is never stored. */
secretHash: text().notNull(),
/** First 8 chars of the secret, plaintext — for display in lists. */
secretPrefix: text().notNull(),
/** Granted capabilities. Empty = no permissions; key is a stub. */
capabilities: jsonb()
.$type<Array<"send" | "read" | "state_write" | "admin">>()
.notNull()
.default([]),
/**
* Topic-scope whitelist (#topic ids or names). Null = all topics in
* the mesh; explicit array = only these. Empty array = nothing —
* functionally a disabled key.
*/
topicScopes: jsonb().$type<string[]>(),
/** Issuer's member id — for audit trail. Null if issued by a service. */
issuedByMemberId: text().references(() => meshMember.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
createdAt: timestamp().defaultNow().notNull(),
lastUsedAt: timestamp(),
revokedAt: timestamp(),
expiresAt: timestamp(),
},
(t) => [
index("api_key_by_mesh").on(t.meshId),
index("api_key_by_prefix").on(t.secretPrefix),
],
);
export const meshApiKeyRelations = relations(meshApiKey, ({ one }) => ({
mesh: one(mesh, { fields: [meshApiKey.meshId], references: [mesh.id] }),
issuedBy: one(meshMember, {
fields: [meshApiKey.issuedByMemberId],
references: [meshMember.id],
}),
}));
export const selectMeshApiKeySchema = createSelectSchema(meshApiKey);
export const insertMeshApiKeySchema = createInsertSchema(meshApiKey);
export type SelectMeshApiKey = typeof meshApiKey.$inferSelect;
export type InsertMeshApiKey = typeof meshApiKey.$inferInsert;