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

@@ -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;