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

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