diff --git a/apps/api/package.json b/apps/api/package.json index c79a6a25fe..46b4adbc0e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -51,6 +51,7 @@ "pino": "^10.3.1", "pino-http": "^11.0.0", "prom-client": "^15.1.3", + "swagger-ui-express": "^5.0.1", "undici": "^6.27.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^4.4.3" @@ -65,6 +66,7 @@ "@types/node": "^22.14.1", "@types/pino-http": "^5.8.4", "@types/supertest": "^7.2.0", + "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^8.59.4", "@typescript-eslint/parser": "^8.59.4", "@vitest/coverage-v8": "^4.1.7", diff --git a/apps/api/schema.sql b/apps/api/schema.sql index 99e1733239..e91e8f6a77 100644 --- a/apps/api/schema.sql +++ b/apps/api/schema.sql @@ -246,6 +246,77 @@ create index if not exists idx_user_api_keys_user alter table public.user_api_keys enable row level security; +-- ── Developer platform: programmatic API keys ─────────────────────────────── +-- Long-lived credentials a developer mints to call the API from scripts/CI. +-- We store only a SHA-256 hash of the secret (never the secret itself) plus a +-- short non-secret prefix for display. See apps/api/src/lib/apiKeys.ts. +create table if not exists public.api_keys ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + name text not null, + key_prefix text not null, + key_hash text not null unique, + scopes text[] not null default array['read','write'], + last_used_at timestamptz, + created_at timestamptz not null default now(), + revoked_at timestamptz +); + +-- Lookup is by the non-secret prefix; partial index covers only live keys. +create index if not exists idx_api_keys_prefix_active + on public.api_keys(key_prefix) + where revoked_at is null; +create index if not exists idx_api_keys_user + on public.api_keys(user_id); + +alter table public.api_keys enable row level security; + +-- ── Developer platform: webhook endpoints ─────────────────────────────────── +-- Where to POST events, which events to send, and the per-endpoint HMAC secret. +create table if not exists public.webhook_endpoints ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + url text not null, + encrypted_secret text not null, + secret_iv text not null, + secret_tag text not null, + enabled boolean not null default true, + event_types text[] not null default array[]::text[], + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_webhook_endpoints_user + on public.webhook_endpoints(user_id); + +alter table public.webhook_endpoints enable row level security; + +-- ── Developer platform: webhook deliveries ────────────────────────────────── +-- One row per attempt-set: the event, payload, status and recorded response. +create table if not exists public.webhook_deliveries ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + endpoint_id uuid not null references public.webhook_endpoints(id) on delete cascade, + event_type text not null, + payload jsonb not null default '{}'::jsonb, + status text not null default 'pending' + check (status in ('pending', 'succeeded', 'failed')), + attempts integer not null default 0, + response_status integer, + response_body text, + last_error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + delivered_at timestamptz +); + +create index if not exists idx_webhook_deliveries_endpoint + on public.webhook_deliveries(endpoint_id, created_at desc); +create index if not exists idx_webhook_deliveries_user + on public.webhook_deliveries(user_id, created_at desc); + +alter table public.webhook_deliveries enable row level security; + create table if not exists public.user_mcp_connectors ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, @@ -1357,6 +1428,9 @@ revoke all on public.tabular_cells from anon, authenticated; revoke all on public.tabular_review_chats from anon, authenticated; revoke all on public.tabular_review_chat_messages from anon, authenticated; revoke all on public.user_api_keys from anon, authenticated; +revoke all on public.api_keys from anon, authenticated; +revoke all on public.webhook_endpoints from anon, authenticated; +revoke all on public.webhook_deliveries from anon, authenticated; revoke all on public.user_mcp_connectors from anon, authenticated; revoke all on public.user_mcp_oauth_tokens from anon, authenticated; revoke all on public.user_mcp_oauth_states from anon, authenticated; diff --git a/apps/api/src/__tests__/integration/chat.routes.test.ts b/apps/api/src/__tests__/integration/chat.routes.test.ts index 0e7743b72c..088dcbf983 100644 --- a/apps/api/src/__tests__/integration/chat.routes.test.ts +++ b/apps/api/src/__tests__/integration/chat.routes.test.ts @@ -72,6 +72,8 @@ vi.mock("../../lib/supabase", () => ({ // JWT path. requireMfaIfEnrolled must be exported too — userRouter (mounted by // the app factory) imports it at module load. vi.mock("../../middleware/auth", () => ({ + requireUserSession: (_req: unknown, _res: unknown, next: () => void) => + next(), requireAuth: ( _req: unknown, res: { locals: Record }, diff --git a/apps/api/src/__tests__/integration/dmsConnectors.routes.test.ts b/apps/api/src/__tests__/integration/dmsConnectors.routes.test.ts index c731bfaf87..feee8dfce3 100644 --- a/apps/api/src/__tests__/integration/dmsConnectors.routes.test.ts +++ b/apps/api/src/__tests__/integration/dmsConnectors.routes.test.ts @@ -16,6 +16,8 @@ vi.mock("../../lib/supabase", () => ({ // Authenticated as u1 for every request (mirrors the other route tests). vi.mock("../../middleware/auth", () => ({ + requireUserSession: (_req: unknown, _res: unknown, next: () => void) => + next(), requireAuth: ( _req: unknown, res: { locals: Record }, diff --git a/apps/api/src/__tests__/integration/documentsUpload.routes.test.ts b/apps/api/src/__tests__/integration/documentsUpload.routes.test.ts index 96b8b80245..1143e252f6 100644 --- a/apps/api/src/__tests__/integration/documentsUpload.routes.test.ts +++ b/apps/api/src/__tests__/integration/documentsUpload.routes.test.ts @@ -48,6 +48,8 @@ vi.mock("../../lib/supabase", () => ({ })); vi.mock("../../middleware/auth", () => ({ + requireUserSession: (_req: unknown, _res: unknown, next: () => void) => + next(), requireAuth: ( _req: unknown, res: { locals: Record }, diff --git a/apps/api/src/__tests__/integration/orgs.routes.test.ts b/apps/api/src/__tests__/integration/orgs.routes.test.ts index b98757ca33..07f42cf71f 100644 --- a/apps/api/src/__tests__/integration/orgs.routes.test.ts +++ b/apps/api/src/__tests__/integration/orgs.routes.test.ts @@ -65,6 +65,8 @@ vi.mock("../../lib/supabase", () => ({ // requireAuth double: enforces a bearer token (so 401 is testable) and, when // present, seeds the standard res.locals identity. vi.mock("../../middleware/auth", () => ({ + requireUserSession: (_req: unknown, _res: unknown, next: () => void) => + next(), requireAuth: ( req: { headers: Record }, res: { diff --git a/apps/api/src/__tests__/integration/projectChat.routes.test.ts b/apps/api/src/__tests__/integration/projectChat.routes.test.ts index a2ba81fde9..996558fe09 100644 --- a/apps/api/src/__tests__/integration/projectChat.routes.test.ts +++ b/apps/api/src/__tests__/integration/projectChat.routes.test.ts @@ -59,6 +59,8 @@ vi.mock("../../lib/supabase", () => ({ })); vi.mock("../../middleware/auth", () => ({ + requireUserSession: (_req: unknown, _res: unknown, next: () => void) => + next(), requireAuth: ( _req: unknown, res: { locals: Record }, diff --git a/apps/api/src/__tests__/integration/projects.routes.test.ts b/apps/api/src/__tests__/integration/projects.routes.test.ts index 5137e29ea7..dd93b3e04f 100644 --- a/apps/api/src/__tests__/integration/projects.routes.test.ts +++ b/apps/api/src/__tests__/integration/projects.routes.test.ts @@ -97,6 +97,8 @@ vi.mock("../../lib/supabase", () => ({ })); vi.mock("../../middleware/auth", () => ({ + requireUserSession: (_req: unknown, _res: unknown, next: () => void) => + next(), requireAuth: ( _req: unknown, res: { locals: Record }, diff --git a/apps/api/src/__tests__/integration/tabular.routes.test.ts b/apps/api/src/__tests__/integration/tabular.routes.test.ts index 11eec55d1a..a01c239cb2 100644 --- a/apps/api/src/__tests__/integration/tabular.routes.test.ts +++ b/apps/api/src/__tests__/integration/tabular.routes.test.ts @@ -107,6 +107,8 @@ vi.mock("../../lib/supabase", () => ({ })); vi.mock("../../middleware/auth", () => ({ + requireUserSession: (_req: unknown, _res: unknown, next: () => void) => + next(), requireAuth: ( _req: unknown, res: { locals: Record }, diff --git a/apps/api/src/__tests__/integration/user.routes.test.ts b/apps/api/src/__tests__/integration/user.routes.test.ts index e93454f793..d471c0d900 100644 --- a/apps/api/src/__tests__/integration/user.routes.test.ts +++ b/apps/api/src/__tests__/integration/user.routes.test.ts @@ -130,6 +130,8 @@ vi.mock("../../lib/supabase", () => ({ // guard so we can drive both the satisfied (next()) and rejected // (403 mfa_verification_required) paths. vi.mock("../../middleware/auth", () => ({ + requireUserSession: (_req: unknown, _res: unknown, next: () => void) => + next(), requireAuth: ( _req: unknown, res: { locals: Record }, diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index bef1ab8d7a..05eb181490 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -17,6 +17,10 @@ import { userRouter } from "./modules/user/user.routes"; import { downloadsRouter } from "./modules/downloads/downloads.routes"; import { caseLawRouter } from "./modules/case-law/caseLaw.routes"; import { guestRouter } from "./modules/auth/auth.routes"; +import { apiKeysRouter } from "./routes/apiKeys"; +import { webhooksRouter } from "./routes/webhooks"; +import swaggerUi from "swagger-ui-express"; +import { openApiDocument } from "./lib/openapi"; import { getAdminClient } from "./lib/supabase"; import { checkStorageReady } from "./lib/storage"; import { env } from "./lib/env"; @@ -202,6 +206,42 @@ app.use("/download", downloadsRouter); app.use("/case-law", caseLawRouter); app.use("/auth", guestRouter); +// ── Developer platform ────────────────────────────────────────────────────── +app.use("/v1/api-keys", apiKeysRouter); +app.use("/v1/webhooks", webhooksRouter); + +// Machine-readable API contract — see lib/openapi.ts. +app.get("/openapi.json", (_req, res) => res.json(openApiDocument)); + +// Human-readable interactive docs. Swagger UI injects inline scripts/styles and +// loads images as data: URIs, all of which the strict global CSP (default-src +// 'none') would block. We relax the policy for this subtree ONLY — everything +// served here is first-party Swagger UI, no third-party origins. +app.use( + "/docs", + ( + _req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + res.setHeader( + "Content-Security-Policy", + [ + "default-src 'none'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "connect-src 'self'", + ].join("; "), + ); + next(); + }, + swaggerUi.serve, + swaggerUi.setup(openApiDocument, { + customSiteTitle: "Mike API reference", + }), +); + app.get("/health", (_req, res) => res.json({ ok: true })); app.get("/ready", async (_req, res) => { diff --git a/apps/api/src/core/apiKeys.ts b/apps/api/src/core/apiKeys.ts new file mode 100644 index 0000000000..b9f9221260 --- /dev/null +++ b/apps/api/src/core/apiKeys.ts @@ -0,0 +1,114 @@ +import crypto from "crypto"; + +/** + * Programmatic API-key primitives — pure crypto, no database or env access so + * they can be unit-tested in isolation and reused anywhere. + * + * SECURITY MODEL (why it looks like this): + * - A Mike API key is a *bearer secret*: whoever holds the string can act as + * the user. We therefore treat it exactly like a password. + * - We NEVER store the raw key. We store a SHA-256 hash of it. If the + * database leaks, the hashes cannot be replayed against the API because the + * server only ever compares hashes — it never needs the original. + * (SHA-256 — not bcrypt/argon2 — is appropriate here because the secret has + * ~238 bits of entropy. Slow password hashes exist to defeat brute force of + * low-entropy human passwords; a 40-char random base62 string is not + * brute-forceable, so a fast hash is the correct, cheaper choice.) + * - We also store a short, non-secret PREFIX (e.g. `mike_sk_Ab3xK9`) so the + * UI can show users which key is which without ever revealing the secret. + * - Verification uses a constant-time comparison (see `verifyApiKeyHash`) to + * avoid leaking, via response timing, how many leading bytes of a guessed + * hash were correct. + */ + +/** Human-readable, greppable prefix. `sk` = "secret key" (Stripe convention). */ +export const API_KEY_PREFIX = "mike_sk_"; + +/** Number of random characters in the secret body. 40 base62 chars ≈ 238 bits. */ +const SECRET_BODY_LENGTH = 40; + +/** + * How many characters of the random body we keep in the stored, non-secret + * prefix. Enough to disambiguate keys in a list UI, far too few to guess the + * rest of the secret. + */ +const PREFIX_BODY_CHARS = 6; + +const BASE62 = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +/** + * Cryptographically-secure random base62 string. + * + * We map random bytes into the 62-char alphabet using rejection sampling: + * bytes >= 248 are discarded so the remaining range (0..247) is an exact + * multiple of 62. Without this, `byte % 62` would make the first few + * characters of the alphabet slightly more likely (modulo bias). + */ +export function randomBase62(length: number): string { + let result = ""; + while (result.length < length) { + const bytes = crypto.randomBytes(length); + for (const byte of bytes) { + if (result.length >= length) break; + if (byte < 248) result += BASE62[byte % 62]; + } + } + return result; +} + +export type GeneratedApiKey = { + /** The full secret, shown to the user exactly once. */ + token: string; + /** Short non-secret identifier stored in the clear for display + lookup. */ + prefix: string; + /** SHA-256 hex digest of the token — the only representation we persist. */ + hash: string; +}; + +/** Returns true if a bearer credential is a Mike API key (vs a Supabase JWT). */ +export function isApiKeyToken(value: string): boolean { + return value.startsWith(API_KEY_PREFIX); +} + +/** SHA-256 hex digest of the full key. Deterministic — same input, same hash. */ +export function hashApiKey(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +/** + * The non-secret prefix we store and index on. Looking a key up by prefix lets + * us fetch the (usually single) candidate row cheaply, then constant-time + * compare the full hash — instead of scanning every hash in the table. + */ +export function apiKeyPrefix(token: string): string { + return token.slice(0, API_KEY_PREFIX.length + PREFIX_BODY_CHARS); +} + +/** Mint a brand-new key. The caller persists `{prefix, hash}` and returns `token` once. */ +export function generateApiKey(): GeneratedApiKey { + const token = `${API_KEY_PREFIX}${randomBase62(SECRET_BODY_LENGTH)}`; + return { token, prefix: apiKeyPrefix(token), hash: hashApiKey(token) }; +} + +/** + * Constant-time check that `token` hashes to `expectedHash`. + * + * WHAT TIMING-SAFE COMPARE DEFENDS AGAINST: a naive `a === b` on strings + * returns as soon as it finds a differing byte. An attacker measuring response + * latency could therefore discover the secret one byte at a time. crypto's + * `timingSafeEqual` always compares the full buffers, so the time taken does + * not depend on *where* the mismatch is. + */ +export function verifyApiKeyHash(token: string, expectedHash: string): boolean { + const actual = Buffer.from(hashApiKey(token), "hex"); + let expected: Buffer; + try { + expected = Buffer.from(expectedHash, "hex"); + } catch { + return false; + } + // Length guard: timingSafeEqual throws if the buffers differ in length. + if (actual.length !== expected.length) return false; + return crypto.timingSafeEqual(actual, expected); +} diff --git a/apps/api/src/core/webhookSignature.ts b/apps/api/src/core/webhookSignature.ts new file mode 100644 index 0000000000..5cfb61b9a1 --- /dev/null +++ b/apps/api/src/core/webhookSignature.ts @@ -0,0 +1,50 @@ +import crypto from "crypto"; + +/** + * Webhook payload signing — pure crypto so it can be unit-tested and copied + * verbatim into a receiver's codebase. + * + * WHAT AN HMAC SIGNATURE PROVES: an HMAC (Hash-based Message Authentication + * Code) is computed from the request body AND a shared secret. Because only + * Mike and the endpoint owner know the secret, a valid signature proves two + * things at once: + * 1. Authenticity — the request really came from Mike (nobody else can + * produce the signature without the secret). + * 2. Integrity — the body was not modified in transit (any change alters the + * signature). + * It is NOT encryption: the payload is still plaintext JSON. It is a tamper- + * evident seal, the same idea behind the download tokens elsewhere in this + * codebase (`core/downloadTokens.ts`). + */ + +/** Prefix for generated endpoint secrets — `whsec` = "webhook secret". */ +export const WEBHOOK_SECRET_PREFIX = "whsec_"; + +/** Mint a random per-endpoint signing secret. */ +export function generateWebhookSecret(): string { + return `${WEBHOOK_SECRET_PREFIX}${crypto.randomBytes(24).toString("hex")}`; +} + +/** Hex-encoded HMAC-SHA256 of the exact body bytes under the endpoint secret. */ +export function signWebhookPayload(payload: string, secret: string): string { + return crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex"); +} + +/** + * Constant-time verification a receiver can use. Exposed so docs can point at a + * canonical implementation and so it is covered by the same tests as signing. + * + * The constant-time compare matters for the same reason as API-key hashes: a + * byte-by-byte `===` would let an attacker forge a signature incrementally by + * timing the rejection. + */ +export function verifyWebhookSignature( + payload: string, + signature: string, + secret: string, +): boolean { + const expected = Buffer.from(signWebhookPayload(payload, secret), "utf8"); + const provided = Buffer.from(signature, "utf8"); + if (expected.length !== provided.length) return false; + return crypto.timingSafeEqual(expected, provided); +} diff --git a/apps/api/src/lib/__tests__/apiKeys.test.ts b/apps/api/src/lib/__tests__/apiKeys.test.ts new file mode 100644 index 0000000000..e35d678297 --- /dev/null +++ b/apps/api/src/lib/__tests__/apiKeys.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from "vitest"; +import { + API_KEY_PREFIX, + apiKeyPrefix, + generateApiKey, + hashApiKey, + isApiKeyToken, + randomBase62, + verifyApiKeyHash, +} from "../../core/apiKeys"; +import { authenticateApiKey, revokeApiKey } from "../apiKeys"; + +/** + * Minimal chainable stand-in for a Supabase query builder. Every builder method + * returns the chain; awaiting the chain (or calling `.single()`) resolves to + * whatever `handler` returns. This lets us drive the DB layer with canned rows + * without a live database. + */ +function makeDb( + handler: (table: string) => { data: unknown; error: unknown }, +): never { + const builder = (table: string): Record => { + const chain: Record = {}; + const passthrough = [ + "select", + "insert", + "update", + "delete", + "eq", + "is", + "in", + "order", + ]; + for (const m of passthrough) chain[m] = () => builder(table); + chain.single = () => Promise.resolve(handler(table)); + chain.maybeSingle = () => Promise.resolve(handler(table)); + chain.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(handler(table)).then(resolve, reject); + return chain; + }; + return { from: (table: string) => builder(table) } as never; +} + +describe("randomBase62", () => { + it("returns a string of the requested length", () => { + expect(randomBase62(40)).toHaveLength(40); + expect(randomBase62(1)).toHaveLength(1); + }); + + it("uses only base62 characters", () => { + expect(randomBase62(200)).toMatch(/^[0-9A-Za-z]+$/); + }); + + it("is overwhelmingly unlikely to repeat", () => { + const a = randomBase62(40); + const b = randomBase62(40); + expect(a).not.toBe(b); + }); +}); + +describe("generateApiKey", () => { + it("produces a token with the mike_sk_ prefix", () => { + const { token } = generateApiKey(); + expect(token.startsWith(API_KEY_PREFIX)).toBe(true); + expect(isApiKeyToken(token)).toBe(true); + }); + + it("derives a prefix that the token starts with", () => { + const { token, prefix } = generateApiKey(); + expect(token.startsWith(prefix)).toBe(true); + // Prefix is short and non-secret — must NOT reveal the whole token. + expect(prefix.length).toBeLessThan(token.length); + }); + + it("derives a hash equal to the SHA-256 of the token", () => { + const { token, hash } = generateApiKey(); + expect(hash).toBe(hashApiKey(token)); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("never stores the raw secret in the hash", () => { + const { token, hash } = generateApiKey(); + expect(hash).not.toContain(token); + }); +}); + +describe("apiKeyPrefix", () => { + it("is stable for a given token", () => { + const { token } = generateApiKey(); + expect(apiKeyPrefix(token)).toBe(apiKeyPrefix(token)); + }); +}); + +describe("verifyApiKeyHash", () => { + it("accepts the matching token", () => { + const { token, hash } = generateApiKey(); + expect(verifyApiKeyHash(token, hash)).toBe(true); + }); + + it("rejects a different token", () => { + const a = generateApiKey(); + const b = generateApiKey(); + expect(verifyApiKeyHash(a.token, b.hash)).toBe(false); + }); + + it("rejects a tampered token", () => { + const { token, hash } = generateApiKey(); + const tampered = `${token}x`; + expect(verifyApiKeyHash(tampered, hash)).toBe(false); + }); + + it("rejects a malformed (non-hex) stored hash without throwing", () => { + const { token } = generateApiKey(); + expect(verifyApiKeyHash(token, "not-a-valid-hash")).toBe(false); + }); + + it("rejects a hash of the wrong length", () => { + const { token } = generateApiKey(); + expect(verifyApiKeyHash(token, "abcd")).toBe(false); + }); +}); + +describe("authenticateApiKey (DB layer)", () => { + it("returns the owner + scopes for a valid, active key", async () => { + const { token, hash } = generateApiKey(); + const db = makeDb(() => ({ + data: [ + { + id: "key-1", + user_id: "user-42", + key_hash: hash, + scopes: ["read", "write"], + }, + ], + error: null, + })); + const result = await authenticateApiKey(token, db); + expect(result).toEqual({ + keyId: "key-1", + userId: "user-42", + scopes: ["read", "write"], + }); + }); + + it("rejects a token whose prefix has no matching row (revoked or unknown)", async () => { + const { token } = generateApiKey(); + // Simulate the `.is('revoked_at', null)` filter excluding a revoked key. + const db = makeDb(() => ({ data: [], error: null })); + expect(await authenticateApiKey(token, db)).toBeNull(); + }); + + it("rejects when the stored hash does not match the presented token", async () => { + const presented = generateApiKey(); + const other = generateApiKey(); + const db = makeDb(() => ({ + data: [ + { + id: "key-1", + user_id: "user-42", + key_hash: other.hash, // a different key's hash + scopes: ["read", "write"], + }, + ], + error: null, + })); + expect(await authenticateApiKey(presented.token, db)).toBeNull(); + }); + + it("rejects a non-API-key bearer (e.g. a Supabase JWT) without touching the DB", async () => { + let queried = false; + const db = makeDb(() => { + queried = true; + return { data: [], error: null }; + }); + expect(await authenticateApiKey("eyJhbGciOi.jwt.token", db)).toBeNull(); + expect(queried).toBe(false); + }); +}); + +describe("revokeApiKey (DB layer)", () => { + it("returns true when a row was revoked", async () => { + const db = makeDb(() => ({ data: [{ id: "key-1" }], error: null })); + expect(await revokeApiKey("user-42", "key-1", db)).toBe(true); + }); + + it("returns false when nothing matched (wrong owner or already revoked)", async () => { + const db = makeDb(() => ({ data: [], error: null })); + expect(await revokeApiKey("user-42", "key-1", db)).toBe(false); + }); +}); diff --git a/apps/api/src/lib/__tests__/webhookSignature.test.ts b/apps/api/src/lib/__tests__/webhookSignature.test.ts new file mode 100644 index 0000000000..62b48cbf2c --- /dev/null +++ b/apps/api/src/lib/__tests__/webhookSignature.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { + WEBHOOK_SECRET_PREFIX, + generateWebhookSecret, + signWebhookPayload, + verifyWebhookSignature, +} from "../../core/webhookSignature"; + +const SECRET = "whsec_test_0123456789abcdef"; +const PAYLOAD = JSON.stringify({ id: "evt_1", type: "document.uploaded" }); + +describe("generateWebhookSecret", () => { + it("returns a whsec_-prefixed secret", () => { + expect(generateWebhookSecret().startsWith(WEBHOOK_SECRET_PREFIX)).toBe(true); + }); + + it("returns a fresh value each call", () => { + expect(generateWebhookSecret()).not.toBe(generateWebhookSecret()); + }); +}); + +describe("signWebhookPayload", () => { + it("is a 64-char hex HMAC-SHA256 digest", () => { + expect(signWebhookPayload(PAYLOAD, SECRET)).toMatch(/^[0-9a-f]{64}$/); + }); + + it("is deterministic for identical input", () => { + expect(signWebhookPayload(PAYLOAD, SECRET)).toBe( + signWebhookPayload(PAYLOAD, SECRET), + ); + }); + + it("changes when the payload changes (integrity)", () => { + const a = signWebhookPayload(PAYLOAD, SECRET); + const b = signWebhookPayload(PAYLOAD + " ", SECRET); + expect(a).not.toBe(b); + }); + + it("changes when the secret changes (authenticity)", () => { + const a = signWebhookPayload(PAYLOAD, SECRET); + const b = signWebhookPayload(PAYLOAD, "whsec_other_secret"); + expect(a).not.toBe(b); + }); +}); + +describe("verifyWebhookSignature", () => { + it("accepts a signature it produced", () => { + const sig = signWebhookPayload(PAYLOAD, SECRET); + expect(verifyWebhookSignature(PAYLOAD, sig, SECRET)).toBe(true); + }); + + it("rejects a signature made with the wrong secret", () => { + const sig = signWebhookPayload(PAYLOAD, "whsec_attacker"); + expect(verifyWebhookSignature(PAYLOAD, sig, SECRET)).toBe(false); + }); + + it("rejects a tampered payload", () => { + const sig = signWebhookPayload(PAYLOAD, SECRET); + expect(verifyWebhookSignature(PAYLOAD + "tamper", sig, SECRET)).toBe(false); + }); + + it("rejects a signature of the wrong length without throwing", () => { + expect(verifyWebhookSignature(PAYLOAD, "abc", SECRET)).toBe(false); + }); +}); diff --git a/apps/api/src/lib/__tests__/webhooks.test.ts b/apps/api/src/lib/__tests__/webhooks.test.ts new file mode 100644 index 0000000000..033e97467d --- /dev/null +++ b/apps/api/src/lib/__tests__/webhooks.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { decryptString } from "../mcp/client"; +import { createWebhookEndpoint } from "../webhooks"; + +describe("webhook endpoint secrets", () => { + let previousSecret: string | undefined; + + beforeEach(() => { + previousSecret = process.env.USER_API_KEYS_ENCRYPTION_SECRET; + process.env.USER_API_KEYS_ENCRYPTION_SECRET = + "test-webhook-encryption-secret-at-least-32-bytes"; + }); + + afterEach(() => { + if (previousSecret === undefined) { + delete process.env.USER_API_KEYS_ENCRYPTION_SECRET; + } else { + process.env.USER_API_KEYS_ENCRYPTION_SECRET = previousSecret; + } + }); + + it("returns the secret once and stores only authenticated ciphertext", async () => { + let inserted: Record | null = null; + const db = { + from(table: string) { + expect(table).toBe("webhook_endpoints"); + return { + insert(row: Record) { + inserted = row; + return { + select() { + return { + single: async () => ({ + data: { + id: "endpoint-1", + created_at: "2026-07-12T00:00:00.000Z", + updated_at: "2026-07-12T00:00:00.000Z", + ...row, + }, + error: null, + }), + }; + }, + }; + }, + }; + }, + } as Parameters[3]; + + const result = await createWebhookEndpoint( + "user-1", + "https://hooks.example.test/mike", + ["document.uploaded"], + db, + ); + + expect(result.secret).toMatch(/^whsec_/); + expect(inserted).not.toHaveProperty("secret"); + expect(inserted?.encrypted_secret).not.toBe(result.secret); + expect( + decryptString( + inserted?.encrypted_secret as string, + inserted?.secret_iv as string, + inserted?.secret_tag as string, + ), + ).toBe(result.secret); + }); +}); diff --git a/apps/api/src/lib/apiKeys.ts b/apps/api/src/lib/apiKeys.ts new file mode 100644 index 0000000000..ca9dc8ac4c --- /dev/null +++ b/apps/api/src/lib/apiKeys.ts @@ -0,0 +1,190 @@ +import { createServerSupabase } from "./supabase"; +import { logger } from "./logger"; +import { + apiKeyPrefix, + generateApiKey, + isApiKeyToken, + verifyApiKeyHash, +} from "../core/apiKeys"; + +/** + * Database layer for programmatic API keys. The pure crypto lives in + * `core/apiKeys.ts`; this module is the thin bridge between that and Postgres. + * + * Every public function takes an optional `db` so callers (and tests) can + * inject a client — the same dependency-injection pattern used by + * `userApiKeys.ts`. + */ + +type Db = ReturnType; + +/** + * Scope model. Keys default to both scopes (full parity with an interactive + * session). `read` covers safe GET/HEAD requests; `write` covers everything + * that mutates. Enforcement happens in the auth middleware by mapping the HTTP + * method to the required scope — see `middleware/auth.ts`. + */ +export const API_KEY_SCOPES = ["read", "write"] as const; +export type ApiKeyScope = (typeof API_KEY_SCOPES)[number]; +export const DEFAULT_API_KEY_SCOPES: ApiKeyScope[] = ["read", "write"]; + +/** Non-secret representation returned by the list/create management endpoints. */ +export type ApiKeySummary = { + id: string; + name: string; + key_prefix: string; + scopes: ApiKeyScope[]; + last_used_at: string | null; + created_at: string; +}; + +/** Result of authenticating an incoming `Authorization: Bearer mike_sk_...`. */ +export type AuthenticatedApiKey = { + keyId: string; + userId: string; + scopes: ApiKeyScope[]; +}; + +type ApiKeyRow = { + id: string; + user_id: string; + name: string; + key_prefix: string; + key_hash: string; + scopes: string[] | null; + last_used_at: string | null; + created_at: string; + revoked_at: string | null; +}; + +function toSummary(row: ApiKeyRow): ApiKeySummary { + return { + id: row.id, + name: row.name, + key_prefix: row.key_prefix, + scopes: (row.scopes ?? DEFAULT_API_KEY_SCOPES) as ApiKeyScope[], + last_used_at: row.last_used_at, + created_at: row.created_at, + }; +} + +/** + * Mint and persist a new key. Returns the one-time plaintext `token` plus the + * stored summary. The raw token is deliberately NOT persisted — only its hash + * and prefix are — so this is the only moment the caller can ever see it. + */ +export async function createApiKey( + userId: string, + name: string, + scopes: ApiKeyScope[] = DEFAULT_API_KEY_SCOPES, + db: Db = createServerSupabase(), +): Promise<{ token: string; apiKey: ApiKeySummary }> { + const { token, prefix, hash } = generateApiKey(); + + const { data, error } = await db + .from("api_keys") + .insert({ + user_id: userId, + name: name.trim() || "Untitled key", + key_prefix: prefix, + key_hash: hash, + scopes, + }) + .select("*") + .single(); + if (error || !data) { + throw error ?? new Error("Failed to create API key"); + } + + return { token, apiKey: toSummary(data as ApiKeyRow) }; +} + +/** List a user's active (non-revoked) keys, newest first. Secrets never leave the DB. */ +export async function listApiKeys( + userId: string, + db: Db = createServerSupabase(), +): Promise { + const { data, error } = await db + .from("api_keys") + .select("*") + .eq("user_id", userId) + .is("revoked_at", null) + .order("created_at", { ascending: false }); + if (error) throw error; + return (data ?? []).map((row: ApiKeyRow) => toSummary(row)); +} + +/** + * Revoke (soft-delete) a key. We keep the row — setting `revoked_at` — rather + * than hard-deleting so `last_used_at` and creation history survive for audit. + * Returns true if a row was revoked, false if it did not exist / wasn't owned. + */ +export async function revokeApiKey( + userId: string, + keyId: string, + db: Db = createServerSupabase(), +): Promise { + const { data, error } = await db + .from("api_keys") + .update({ revoked_at: new Date().toISOString() }) + .eq("id", keyId) + .eq("user_id", userId) + .is("revoked_at", null) + .select("id"); + if (error) throw error; + return (data ?? []).length > 0; +} + +/** + * Verify an incoming bearer token. Returns the owning user + scopes, or null. + * + * Lookup strategy: filter by the cheap, indexed, non-secret prefix to fetch the + * (almost always single) candidate row, then constant-time compare the full + * hash. This avoids both a full-table scan AND comparing the secret with a + * timing-unsafe SQL equality on the hash column. + */ +export async function authenticateApiKey( + token: string, + db: Db = createServerSupabase(), +): Promise { + if (!isApiKeyToken(token)) return null; + + const { data, error } = await db + .from("api_keys") + .select("id, user_id, key_hash, scopes") + .eq("key_prefix", apiKeyPrefix(token)) + .is("revoked_at", null); + if (error || !data) return null; + + for (const row of data as Pick< + ApiKeyRow, + "id" | "user_id" | "key_hash" | "scopes" + >[]) { + if (verifyApiKeyHash(token, row.key_hash)) { + return { + keyId: row.id, + userId: row.user_id, + scopes: (row.scopes ?? DEFAULT_API_KEY_SCOPES) as ApiKeyScope[], + }; + } + } + return null; +} + +/** + * Record that a key was just used. Best-effort and intentionally fire-and- + * forget: a failed analytics write must never block or fail a real request. + */ +export async function touchApiKeyLastUsed( + keyId: string, + db: Db = createServerSupabase(), +): Promise { + try { + await db + .from("api_keys") + .update({ last_used_at: new Date().toISOString() }) + .eq("id", keyId); + } catch (err) { + logger.warn({ keyId, err }, "[api-keys] failed to update last_used_at"); + } +} diff --git a/apps/api/src/lib/openapi.ts b/apps/api/src/lib/openapi.ts new file mode 100644 index 0000000000..f6523263e5 --- /dev/null +++ b/apps/api/src/lib/openapi.ts @@ -0,0 +1,456 @@ +import { WEBHOOK_EVENT_TYPES } from "./webhooks"; + +/** + * OpenAPI 3.1 description of Mike's public API surface. + * + * WHAT AN OPENAPI SPEC IS FOR: it's a machine-readable contract describing every + * endpoint, its inputs and its outputs. From one document you can render + * interactive docs (Swagger UI / Redoc), generate typed client SDKs, drive + * contract tests, and let tools like Postman import the whole API. It is the + * single source of truth that humans and machines agree on. + * + * WHY HAND-AUTHORED (vs generated from Zod): this repo runs Zod v4. The popular + * `@asteasolutions/zod-to-openapi` generator targets Zod v3's API, so wiring it + * in cleanly would mean either pinning an older Zod or shimming every schema — + * more moving parts than a focused, readable spec for the handful of public + * endpoints. We therefore curate this document by hand and treat keeping it in + * sync as part of the definition of done. The trade-off is recorded in + * `docs/adr/0001-developer-platform.md`, along with "generate SDKs FROM this + * spec" as the natural next step. + */ + +const bearerAuth = { bearerAuth: [] as string[] }; + +function jsonResponse(description: string, schema: unknown) { + return { + description, + content: { "application/json": { schema } }, + }; +} + +const ErrorSchema = { + type: "object", + properties: { + detail: { type: "string" }, + error: { + type: "object", + properties: { + code: { type: "string" }, + message: { type: "string" }, + }, + }, + }, +}; + +export const openApiDocument = { + openapi: "3.1.0", + info: { + title: "Mike API", + version: "1.0.0", + description: + "Public REST API for Mike, the AI legal-document assistant. Authenticate " + + "with a programmatic API key (`Authorization: Bearer mike_sk_...`) created " + + "from the Developer settings page, or with a Supabase session JWT.", + license: { name: "AGPL-3.0-only" }, + }, + servers: [ + { url: "http://localhost:3001", description: "Local development" }, + ], + // Applied to every operation unless overridden. + security: [bearerAuth], + tags: [ + { name: "API Keys", description: "Manage programmatic API keys." }, + { name: "Webhooks", description: "Register endpoints and inspect deliveries." }, + { name: "Projects", description: "Matter/case projects." }, + { name: "Documents", description: "Standalone documents." }, + { name: "Chat", description: "Assistant chats." }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + description: + "A Mike API key (`mike_sk_...`) or a Supabase session JWT. Key " + + "management and webhook endpoints require a session JWT.", + }, + }, + schemas: { + Error: ErrorSchema, + ApiKey: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + key_prefix: { + type: "string", + description: "Non-secret prefix for display, e.g. mike_sk_Ab3xK9.", + }, + scopes: { + type: "array", + items: { type: "string", enum: ["read", "write"] }, + }, + last_used_at: { type: ["string", "null"], format: "date-time" }, + created_at: { type: "string", format: "date-time" }, + }, + }, + ApiKeyCreateRequest: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", maxLength: 100 }, + scopes: { + type: "array", + items: { type: "string", enum: ["read", "write"] }, + description: "Defaults to ['read','write'].", + }, + }, + }, + ApiKeyCreateResponse: { + allOf: [ + { $ref: "#/components/schemas/ApiKey" }, + { + type: "object", + properties: { + key: { + type: "string", + description: + "The full secret. Shown ONCE on creation and never again.", + }, + }, + }, + ], + }, + WebhookEndpoint: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + url: { type: "string", format: "uri" }, + enabled: { type: "boolean" }, + event_types: { + type: "array", + items: { type: "string", enum: [...WEBHOOK_EVENT_TYPES] }, + }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + }, + }, + WebhookEndpointCreateRequest: { + type: "object", + required: ["url", "event_types"], + properties: { + url: { type: "string", format: "uri", description: "HTTPS in production." }, + event_types: { + type: "array", + minItems: 1, + items: { type: "string", enum: [...WEBHOOK_EVENT_TYPES] }, + }, + }, + }, + WebhookEndpointCreateResponse: { + allOf: [ + { $ref: "#/components/schemas/WebhookEndpoint" }, + { + type: "object", + properties: { + secret: { + type: "string", + description: + "HMAC signing secret (whsec_...). Shown ONCE on creation.", + }, + }, + }, + ], + }, + WebhookDelivery: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + endpoint_id: { type: "string", format: "uuid" }, + event_type: { type: "string" }, + status: { type: "string", enum: ["pending", "succeeded", "failed"] }, + attempts: { type: "integer" }, + response_status: { type: ["integer", "null"] }, + last_error: { type: ["string", "null"] }, + created_at: { type: "string", format: "date-time" }, + delivered_at: { type: ["string", "null"], format: "date-time" }, + }, + }, + Project: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + cm_number: { type: ["string", "null"] }, + shared_with: { type: "array", items: { type: "string" } }, + created_at: { type: "string", format: "date-time" }, + }, + }, + ProjectCreateRequest: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + cm_number: { type: "string" }, + shared_with: { type: "array", items: { type: "string", format: "email" } }, + }, + }, + Document: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + project_id: { type: ["string", "null"], format: "uuid" }, + status: { type: "string" }, + created_at: { type: "string", format: "date-time" }, + }, + }, + Chat: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + title: { type: ["string", "null"] }, + created_at: { type: "string", format: "date-time" }, + }, + }, + }, + responses: { + Unauthorized: jsonResponse("Missing or invalid credentials", { + $ref: "#/components/schemas/Error", + }), + Forbidden: jsonResponse("Authenticated but not permitted", { + $ref: "#/components/schemas/Error", + }), + NotFound: jsonResponse("Resource not found", { + $ref: "#/components/schemas/Error", + }), + }, + }, + paths: { + "/v1/api-keys": { + get: { + tags: ["API Keys"], + summary: "List active API keys", + description: "Returns prefixes and metadata only — never the secret.", + responses: { + "200": jsonResponse("List of keys", { + type: "array", + items: { $ref: "#/components/schemas/ApiKey" }, + }), + "401": { $ref: "#/components/responses/Unauthorized" }, + "403": { $ref: "#/components/responses/Forbidden" }, + }, + }, + post: { + tags: ["API Keys"], + summary: "Create an API key", + description: + "Requires a session JWT — you cannot mint a key with another key.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiKeyCreateRequest" }, + }, + }, + }, + responses: { + "201": jsonResponse("Created — includes the one-time secret", { + $ref: "#/components/schemas/ApiKeyCreateResponse", + }), + "401": { $ref: "#/components/responses/Unauthorized" }, + "403": { $ref: "#/components/responses/Forbidden" }, + }, + }, + }, + "/v1/api-keys/{id}": { + delete: { + tags: ["API Keys"], + summary: "Revoke an API key", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + responses: { + "204": { description: "Revoked" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + "/v1/webhooks/events": { + get: { + tags: ["Webhooks"], + summary: "List subscribable event types", + responses: { + "200": jsonResponse("Event catalogue", { + type: "object", + properties: { + event_types: { type: "array", items: { type: "string" } }, + }, + }), + }, + }, + }, + "/v1/webhooks/endpoints": { + get: { + tags: ["Webhooks"], + summary: "List webhook endpoints", + responses: { + "200": jsonResponse("Endpoints (no secrets)", { + type: "array", + items: { $ref: "#/components/schemas/WebhookEndpoint" }, + }), + }, + }, + post: { + tags: ["Webhooks"], + summary: "Register a webhook endpoint", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/WebhookEndpointCreateRequest", + }, + }, + }, + }, + responses: { + "201": jsonResponse("Created — includes the one-time signing secret", { + $ref: "#/components/schemas/WebhookEndpointCreateResponse", + }), + "400": jsonResponse("Validation error", { + $ref: "#/components/schemas/Error", + }), + }, + }, + }, + "/v1/webhooks/endpoints/{id}": { + delete: { + tags: ["Webhooks"], + summary: "Delete a webhook endpoint", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + responses: { + "204": { description: "Deleted" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + "/v1/webhooks/deliveries": { + get: { + tags: ["Webhooks"], + summary: "List recent webhook deliveries", + parameters: [ + { + name: "endpoint_id", + in: "query", + required: false, + schema: { type: "string", format: "uuid" }, + }, + { + name: "limit", + in: "query", + required: false, + schema: { type: "integer", maximum: 200 }, + }, + ], + responses: { + "200": jsonResponse("Deliveries", { + type: "array", + items: { $ref: "#/components/schemas/WebhookDelivery" }, + }), + }, + }, + }, + "/projects": { + get: { + tags: ["Projects"], + summary: "List projects", + responses: { + "200": jsonResponse("Projects", { + type: "array", + items: { $ref: "#/components/schemas/Project" }, + }), + }, + }, + post: { + tags: ["Projects"], + summary: "Create a project", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ProjectCreateRequest" }, + }, + }, + }, + responses: { + "201": jsonResponse("Created", { $ref: "#/components/schemas/Project" }), + }, + }, + }, + "/projects/{projectId}": { + get: { + tags: ["Projects"], + summary: "Get a project", + parameters: [ + { + name: "projectId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + responses: { + "200": jsonResponse("Project", { $ref: "#/components/schemas/Project" }), + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + "/single-documents": { + get: { + tags: ["Documents"], + summary: "List standalone documents", + responses: { + "200": jsonResponse("Documents", { + type: "array", + items: { $ref: "#/components/schemas/Document" }, + }), + }, + }, + }, + "/chat": { + get: { + tags: ["Chat"], + summary: "List chats", + parameters: [ + { + name: "limit", + in: "query", + required: false, + schema: { type: "integer" }, + }, + ], + responses: { + "200": jsonResponse("Chats", { + type: "array", + items: { $ref: "#/components/schemas/Chat" }, + }), + }, + }, + }, + }, +} as const; + +export type OpenApiDocument = typeof openApiDocument; diff --git a/apps/api/src/lib/webhooks.ts b/apps/api/src/lib/webhooks.ts new file mode 100644 index 0000000000..2f39fe868c --- /dev/null +++ b/apps/api/src/lib/webhooks.ts @@ -0,0 +1,334 @@ +import { createServerSupabase } from "./supabase"; +import { logger } from "./logger"; +import { + generateWebhookSecret, + signWebhookPayload, +} from "../core/webhookSignature"; +import { decryptString, encryptString, guardedFetch } from "./mcp/client"; + +/** + * Webhooks subsystem: lets developers register HTTPS endpoints that Mike calls + * when something happens to their data. This is the "push" half of the + * developer platform — the REST API is "pull". + * + * Delivery is deliberately IN-PROCESS (setTimeout-based retries) rather than a + * Redis/BullMQ queue. For a self-hostable, single-service app that is the right + * amount of infrastructure; moving to a durable queue is documented as future + * work in the ADR. The trade-off: deliveries scheduled but not yet sent are + * lost on a process restart. We mitigate by persisting every delivery row up + * front so history/▸replay is always inspectable. + */ + +type Db = ReturnType; + +/** + * Event catalogue. Each value maps to a real moment in the product. Keep this + * list and the `data` shapes documented in `docs/developer-platform.md`. + */ +export const WEBHOOK_EVENT_TYPES = [ + "document.uploaded", + "document.analysed", + "chat.message", + "workflow.completed", +] as const; +export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; + +export function isWebhookEventType(value: string): value is WebhookEventType { + return (WEBHOOK_EVENT_TYPES as readonly string[]).includes(value); +} + +/** How many times we attempt a single delivery before marking it failed. */ +const MAX_ATTEMPTS = 5; + +/** + * Exponential backoff between attempts (milliseconds). Attempt N waits + * RETRY_DELAYS_MS[N-1] before firing. The curve (0s, 5s, 30s, 2m, 10m) spreads + * retries out so a briefly-down receiver recovers without us hammering it. + */ +const RETRY_DELAYS_MS = [0, 5_000, 30_000, 120_000, 600_000]; + +/** Per-request timeout so one slow receiver can't pin a delivery open forever. */ +const DELIVERY_TIMEOUT_MS = 10_000; + +export type WebhookEndpointSummary = { + id: string; + url: string; + enabled: boolean; + event_types: WebhookEventType[]; + created_at: string; + updated_at: string; +}; + +type WebhookEndpointRow = WebhookEndpointSummary & { + user_id: string; + encrypted_secret: string; + secret_iv: string; + secret_tag: string; +}; + +export type WebhookDeliverySummary = { + id: string; + endpoint_id: string; + event_type: string; + status: "pending" | "succeeded" | "failed"; + attempts: number; + response_status: number | null; + last_error: string | null; + created_at: string; + updated_at: string; + delivered_at: string | null; +}; + +function toEndpointSummary(row: WebhookEndpointRow): WebhookEndpointSummary { + return { + id: row.id, + url: row.url, + enabled: row.enabled, + event_types: row.event_types, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +// ── Endpoint management ────────────────────────────────────────────────────── + +/** + * Register an endpoint. We mint a signing secret here and return it to the + * caller exactly once (mirroring API-key creation) — the receiver needs it to + * verify `X-Mike-Signature`. + */ +export async function createWebhookEndpoint( + userId: string, + url: string, + eventTypes: WebhookEventType[], + db: Db = createServerSupabase(), +): Promise<{ endpoint: WebhookEndpointSummary; secret: string }> { + const secret = generateWebhookSecret(); + const encrypted = encryptString(secret); + const { data, error } = await db + .from("webhook_endpoints") + .insert({ + user_id: userId, + url, + encrypted_secret: encrypted.encrypted, + secret_iv: encrypted.iv, + secret_tag: encrypted.tag, + enabled: true, + event_types: eventTypes, + }) + .select("*") + .single(); + if (error || !data) throw error ?? new Error("Failed to create endpoint"); + return { endpoint: toEndpointSummary(data as WebhookEndpointRow), secret }; +} + +export async function listWebhookEndpoints( + userId: string, + db: Db = createServerSupabase(), +): Promise { + const { data, error } = await db + .from("webhook_endpoints") + .select("*") + .eq("user_id", userId) + .order("created_at", { ascending: false }); + if (error) throw error; + return (data ?? []).map((r: WebhookEndpointRow) => toEndpointSummary(r)); +} + +export async function deleteWebhookEndpoint( + userId: string, + endpointId: string, + db: Db = createServerSupabase(), +): Promise { + const { data, error } = await db + .from("webhook_endpoints") + .delete() + .eq("id", endpointId) + .eq("user_id", userId) + .select("id"); + if (error) throw error; + return (data ?? []).length > 0; +} + +export async function listWebhookDeliveries( + userId: string, + opts: { endpointId?: string; limit?: number } = {}, + db: Db = createServerSupabase(), +): Promise { + let query = db + .from("webhook_deliveries") + .select( + "id, endpoint_id, event_type, status, attempts, response_status, last_error, created_at, updated_at, delivered_at", + ) + .eq("user_id", userId); + if (opts.endpointId) query = query.eq("endpoint_id", opts.endpointId); + const { data, error } = await query + .order("created_at", { ascending: false }) + .limit(Math.min(opts.limit ?? 50, 200)); + if (error) throw error; + return (data ?? []) as WebhookDeliverySummary[]; +} + +// ── Event emission + delivery ──────────────────────────────────────────────── + +/** + * Fan an event out to every enabled endpoint that subscribed to it. Creates one + * `webhook_deliveries` row per endpoint, then kicks off async delivery. + * + * This is fire-and-forget by design: a webhook failure must NEVER break the + * product action that triggered it (e.g. a document upload). Callers should + * `void emitWebhookEvent(...)` and not await it. + */ +export async function emitWebhookEvent( + userId: string, + eventType: WebhookEventType, + data: Record, + db: Db = createServerSupabase(), +): Promise { + try { + const { data: endpoints, error } = await db + .from("webhook_endpoints") + .select("id") + .eq("user_id", userId) + .eq("enabled", true) + .contains("event_types", [eventType]); + if (error) throw error; + if (!endpoints || endpoints.length === 0) return; + + for (const endpoint of endpoints as { id: string }[]) { + const { data: delivery, error: insErr } = await db + .from("webhook_deliveries") + .insert({ + user_id: userId, + endpoint_id: endpoint.id, + event_type: eventType, + payload: data, + status: "pending", + attempts: 0, + }) + .select("id") + .single(); + if (insErr || !delivery) { + logger.warn( + { userId, eventType, err: insErr }, + "[webhooks] failed to enqueue delivery", + ); + continue; + } + scheduleDelivery((delivery as { id: string }).id, 1); + } + } catch (err) { + logger.warn({ userId, eventType, err }, "[webhooks] emit failed"); + } +} + +/** Schedule attempt `attempt` of a delivery after the backoff delay. */ +function scheduleDelivery(deliveryId: string, attempt: number): void { + const delay = RETRY_DELAYS_MS[attempt - 1] ?? RETRY_DELAYS_MS.at(-1)!; + const timer = setTimeout(() => { + void attemptDelivery(deliveryId, attempt); + }, delay); + // Don't let a pending retry keep the process alive on shutdown. + if (typeof timer.unref === "function") timer.unref(); +} + +/** + * Perform one delivery attempt. On a non-2xx / network error it reschedules the + * next attempt (up to MAX_ATTEMPTS) with the next backoff step. Every outcome + * is written back to the delivery row so history is complete and auditable. + */ +async function attemptDelivery( + deliveryId: string, + attempt: number, + db: Db = createServerSupabase(), +): Promise { + const { data: delivery } = await db + .from("webhook_deliveries") + .select("id, user_id, endpoint_id, event_type, payload") + .eq("id", deliveryId) + .single(); + if (!delivery) return; + + const { data: endpoint } = await db + .from("webhook_endpoints") + .select("url, encrypted_secret, secret_iv, secret_tag, enabled") + .eq("id", delivery.endpoint_id) + .single(); + if (!endpoint || endpoint.enabled === false) return; + + // The signed body is the canonical event envelope. The delivery id doubles + // as the event id, giving receivers a natural idempotency key. + const body = JSON.stringify({ + id: delivery.id, + type: delivery.event_type, + created_at: new Date().toISOString(), + data: delivery.payload ?? {}, + }); + const secret = decryptString( + endpoint.encrypted_secret as string, + endpoint.secret_iv as string, + endpoint.secret_tag as string, + ); + if (!secret) { + await db + .from("webhook_deliveries") + .update({ + status: "failed", + attempts: attempt, + last_error: "Webhook signing secret could not be decrypted", + updated_at: new Date().toISOString(), + }) + .eq("id", deliveryId); + return; + } + const signature = signWebhookPayload(body, secret); + + let responseStatus: number | null = null; + let responseBody: string | null = null; + let lastError: string | null = null; + let ok = false; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS); + try { + const fetchWebhook = + process.env.NODE_ENV === "production" ? guardedFetch : fetch; + const res = await fetchWebhook(endpoint.url as string, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "Mike-Webhooks/1.0", + "X-Mike-Event": String(delivery.event_type), + "X-Mike-Delivery-Id": String(delivery.id), + "X-Mike-Signature": signature, + }, + body, + signal: controller.signal, + redirect: "manual", + }); + responseStatus = res.status; + responseBody = (await res.text()).slice(0, 1000); // cap stored body + ok = res.ok; + if (!ok) lastError = `Endpoint returned HTTP ${res.status}`; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } finally { + clearTimeout(timeout); + } + + const willRetry = !ok && attempt < MAX_ATTEMPTS; + await db + .from("webhook_deliveries") + .update({ + status: ok ? "succeeded" : willRetry ? "pending" : "failed", + attempts: attempt, + response_status: responseStatus, + response_body: responseBody, + last_error: lastError, + delivered_at: ok ? new Date().toISOString() : null, + updated_at: new Date().toISOString(), + }) + .eq("id", deliveryId); + + if (willRetry) scheduleDelivery(deliveryId, attempt + 1); +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 489563e948..70e34f5df9 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -3,6 +3,12 @@ import { getAdminClient } from "../lib/supabase"; import { sendError } from "../lib/http"; import { logger } from "../lib/logger"; import { syncProfileEmail } from "../lib/userLookup"; +import { isApiKeyToken } from "../core/apiKeys"; +import { + authenticateApiKey, + touchApiKeyLastUsed, + type ApiKeyScope, +} from "../lib/apiKeys"; /** * The /user/profile (and /users/profile alias) endpoint must stay reachable @@ -76,6 +82,63 @@ async function enforceLoginMfaIfEnabled( return true; } +/** + * Map an HTTP method to the API-key scope it requires. Safe, side-effect-free + * verbs need only `read`; anything that can mutate state needs `write`. + */ +function requiredScopeForMethod(method: string): ApiKeyScope { + return method === "GET" || method === "HEAD" ? "read" : "write"; +} + +/** + * API-key authentication branch. Handles `Authorization: Bearer mike_sk_...`. + * Returns true when the request is fully authenticated and may proceed to + * `next()`, false when a 401/403 response has already been sent. + * + * Note: API keys deliberately bypass the interactive MFA-on-login gate. MFA + * protects *browser sessions* (something you know + an authenticator); a + * long-lived programmatic credential is a distinct factor (something you hold) + * that the user explicitly minted and can revoke at any time. Forcing aal2 on + * a headless key would make it unusable. This is documented in the ADR. + */ +async function authenticateWithApiKey( + req: Request, + res: Response, + token: string, +): Promise { + const result = await authenticateApiKey(token); + if (!result) { + sendError(res, 401, "UNAUTHORIZED", "Invalid or revoked API key"); + return false; + } + + const needed = requiredScopeForMethod(req.method); + if (!result.scopes.includes(needed)) { + sendError( + res, + 403, + "FORBIDDEN", + `This API key is missing the '${needed}' scope required for ${req.method} requests`, + ); + return false; + } + + res.locals.userId = result.userId; + res.locals.apiKeyId = result.keyId; + res.locals.authMethod = "api_key"; + + // Downstream routes (project sharing, exports) key off the caller's email. + // Resolve it from the user record so API-key callers behave like JWT callers. + const { data: userData } = await getAdminClient().auth.admin.getUserById( + result.userId, + ); + res.locals.userEmail = userData?.user?.email?.toLowerCase() ?? ""; + + // Best-effort, non-blocking usage tracking — never delays the request. + void touchApiKeyLastUsed(result.keyId); + return true; +} + export async function requireAuth( req: Request, res: Response, @@ -88,6 +151,14 @@ export async function requireAuth( } const token = auth.slice(7).trim(); + // Branch on the credential type. Programmatic API keys are self-describing + // (`mike_sk_` prefix); anything else is treated as a Supabase JWT, preserving + // the original behaviour byte-for-byte. + if (isApiKeyToken(token)) { + if (await authenticateWithApiKey(req, res, token)) next(); + return; + } + const { data } = await getAdminClient().auth.getUser(token); if (!data.user) { sendError(res, 401, "UNAUTHORIZED", "Invalid or expired token"); @@ -158,3 +229,27 @@ export async function requireMfaIfEnrolled( next(); } + +/** + * Guard for management routes that must be driven by a real, interactive user + * session — never by a programmatic API key. This is what stops a key from + * minting *more* keys (privilege escalation) or editing webhook endpoints. + * + * Mount it AFTER `requireAuth`, which has already populated `res.locals`. + */ +export function requireUserSession( + _req: Request, + res: Response, + next: NextFunction, +): void { + if (res.locals.authMethod === "api_key") { + sendError( + res, + 403, + "FORBIDDEN", + "This endpoint requires a logged-in user session and cannot be used with an API key", + ); + return; + } + next(); +} diff --git a/apps/api/src/modules/projects/projects.documents.ts b/apps/api/src/modules/projects/projects.documents.ts index 7301dcaa23..88d7922484 100644 --- a/apps/api/src/modules/projects/projects.documents.ts +++ b/apps/api/src/modules/projects/projects.documents.ts @@ -16,6 +16,7 @@ import { shouldConvertToPdf, } from "../../lib/documentTypes"; import { checkProjectAccess, resolveContentOrgId } from "../../lib/access"; +import { emitWebhookEvent } from "../../lib/webhooks"; import { type Db, type Log, @@ -439,6 +440,18 @@ export async function processProjectDocumentUpload( active_version_number: 1, } : updated; + + // Notify any registered webhook endpoints. Fire-and-forget: a webhook + // problem must never fail the upload the user just performed. + void emitWebhookEvent(userId, "document.uploaded", { + document_id: docId, + project_id: projectId, + filename, + file_type: suffix, + size_bytes: content.byteLength, + page_count: pageCount, + }); + return { ok: true, doc: responseDoc }; } catch (e) { await db.from("documents").update({ status: "error" }).eq("id", doc.id); diff --git a/apps/api/src/routes/apiKeys.ts b/apps/api/src/routes/apiKeys.ts new file mode 100644 index 0000000000..f4634184d4 --- /dev/null +++ b/apps/api/src/routes/apiKeys.ts @@ -0,0 +1,59 @@ +import { Router } from "express"; +import { z } from "zod"; +import { requireAuth, requireUserSession } from "../middleware/auth"; +import { parseBody, sendError } from "../lib/http"; +import { + DEFAULT_API_KEY_SCOPES, + createApiKey, + listApiKeys, + revokeApiKey, + type ApiKeyScope, +} from "../lib/apiKeys"; + +/** + * Management API for programmatic keys, mounted at `/v1/api-keys`. + * + * These routes are guarded by `requireUserSession` (a real logged-in user), + * NOT just `requireAuth` — you cannot use one API key to mint another. That + * closes an obvious privilege-escalation path: a leaked key can call the data + * API but can never enlarge or replace itself. + */ +export const apiKeysRouter = Router(); + +apiKeysRouter.use(requireAuth, requireUserSession); + +const createSchema = z.object({ + name: z.string().trim().min(1, "name is required").max(100), + scopes: z.array(z.enum(["read", "write"])).nonempty().optional(), +}); + +// POST /v1/api-keys — mint a key. The plaintext secret is returned exactly once. +apiKeysRouter.post("/", async (req, res) => { + const body = parseBody(createSchema, req, res); + if (!body) return; + + const userId = res.locals.userId as string; + const scopes = (body.scopes ?? DEFAULT_API_KEY_SCOPES) as ApiKeyScope[]; + const { token, apiKey } = await createApiKey(userId, body.name, scopes); + + // `key` is the full secret — clients must store it now; we can never show it + // again because only its hash is persisted. + res.status(201).json({ ...apiKey, key: token }); +}); + +// GET /v1/api-keys — list active keys (prefixes only; no secrets). +apiKeysRouter.get("/", async (_req, res) => { + const userId = res.locals.userId as string; + res.json(await listApiKeys(userId)); +}); + +// DELETE /v1/api-keys/:id — revoke a key. +apiKeysRouter.delete("/:id", async (req, res) => { + const userId = res.locals.userId as string; + const revoked = await revokeApiKey(userId, req.params.id); + if (!revoked) { + sendError(res, 404, "NOT_FOUND", "API key not found"); + return; + } + res.status(204).send(); +}); diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts new file mode 100644 index 0000000000..6fa0bbd607 --- /dev/null +++ b/apps/api/src/routes/webhooks.ts @@ -0,0 +1,122 @@ +import { Router } from "express"; +import { z } from "zod"; +import { requireAuth, requireUserSession } from "../middleware/auth"; +import { parseBody, sendError } from "../lib/http"; +import { env } from "../lib/env"; +import { validateRemoteMcpUrl } from "../lib/mcpConnectors"; +import { + WEBHOOK_EVENT_TYPES, + createWebhookEndpoint, + deleteWebhookEndpoint, + isWebhookEventType, + listWebhookDeliveries, + listWebhookEndpoints, + type WebhookEventType, +} from "../lib/webhooks"; + +/** + * Webhook management API, mounted at `/v1/webhooks`. Like the API-key routes, + * these require a real user session (`requireUserSession`), so a programmatic + * key cannot reconfigure where a user's events are sent. + */ +export const webhooksRouter = Router(); + +webhooksRouter.use(requireAuth, requireUserSession); + +const createEndpointSchema = z.object({ + url: z.string().url(), + event_types: z + .array(z.string()) + .nonempty("at least one event type is required"), +}); + +// GET /v1/webhooks/events — the catalogue of subscribable event types. +webhooksRouter.get("/events", (_req, res) => { + res.json({ event_types: WEBHOOK_EVENT_TYPES }); +}); + +// POST /v1/webhooks/endpoints — register an endpoint; secret returned once. +webhooksRouter.post("/endpoints", async (req, res) => { + const body = parseBody(createEndpointSchema, req, res); + if (!body) return; + + // Require HTTPS in production: a webhook secret + payload sent over plain HTTP + // could be read off the wire. Localhost HTTP is allowed in dev for testing. + // Parse the URL (rather than regex-matching) to avoid ReDoS and host spoofing. + const parsed = new URL(body.url); + const isHttps = parsed.protocol === "https:"; + const isLocalhost = + parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; + if (!isHttps && !(env.NODE_ENV !== "production" && isLocalhost)) { + sendError(res, 400, "VALIDATION_ERROR", "Webhook URL must use HTTPS"); + return; + } + if (env.NODE_ENV === "production") { + try { + // Webhook delivery is server-side egress. Apply the same DNS-rebinding, + // metadata-host and private-network protections used by MCP connectors. + await validateRemoteMcpUrl(body.url); + } catch (error) { + sendError( + res, + 400, + "VALIDATION_ERROR", + error instanceof Error ? error.message : "Webhook URL is not allowed", + ); + return; + } + } + + const invalid = body.event_types.filter((e) => !isWebhookEventType(e)); + if (invalid.length > 0) { + sendError( + res, + 400, + "VALIDATION_ERROR", + `Unknown event type(s): ${invalid.join(", ")}`, + ); + return; + } + + const userId = res.locals.userId as string; + const { endpoint, secret } = await createWebhookEndpoint( + userId, + body.url, + body.event_types as WebhookEventType[], + ); + res.status(201).json({ ...endpoint, secret }); +}); + +// GET /v1/webhooks/endpoints — list endpoints (secrets are never returned). +webhooksRouter.get("/endpoints", async (_req, res) => { + const userId = res.locals.userId as string; + res.json(await listWebhookEndpoints(userId)); +}); + +// DELETE /v1/webhooks/endpoints/:id +webhooksRouter.delete("/endpoints/:id", async (req, res) => { + const userId = res.locals.userId as string; + const deleted = await deleteWebhookEndpoint(userId, req.params.id); + if (!deleted) { + sendError(res, 404, "NOT_FOUND", "Webhook endpoint not found"); + return; + } + res.status(204).send(); +}); + +// GET /v1/webhooks/deliveries — recent delivery attempts (audit / debugging). +webhooksRouter.get("/deliveries", async (req, res) => { + const userId = res.locals.userId as string; + const endpointId = + typeof req.query.endpoint_id === "string" + ? req.query.endpoint_id + : undefined; + const limit = + typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + res.json( + await listWebhookDeliveries(userId, { + endpointId, + limit: Number.isFinite(limit) ? limit : undefined, + }), + ); +}); diff --git a/apps/web/src/app/(pages)/account/developer/page.tsx b/apps/web/src/app/(pages)/account/developer/page.tsx new file mode 100644 index 0000000000..293bb109e3 --- /dev/null +++ b/apps/web/src/app/(pages)/account/developer/page.tsx @@ -0,0 +1,458 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AlertTriangle, Check, Copy, Loader2, Plus, Trash2 } from "lucide-react"; +import { Input } from "@/app/components/ui/input"; +import { + type ApiKey, + type WebhookDelivery, + type WebhookEndpoint, + type WebhookEventType, + createApiKey, + createWebhookEndpoint, + deleteWebhookEndpoint, + listApiKeys, + listWebhookDeliveries, + listWebhookEndpoints, + listWebhookEventTypes, + revokeApiKey, +} from "@/app/lib/mikeApi"; +import { + accountGlassDangerButtonClassName, + accountGlassInputClassName, + accountGlassPrimaryButtonClassName, +} from "../accountStyles"; +import { AccountSection } from "../AccountSection"; + +/** + * Developer settings: mint programmatic API keys, register webhook endpoints, + * and inspect recent deliveries. Mirrors the styling of the sibling account + * pages (AccountSection + accountGlass* classes). + */ +export default function DeveloperPage() { + return ( +
+
+

+ Developer +

+

+ Build on Mike programmatically. Create an API key to call the + REST API from scripts or CI, and register webhooks to receive + events. See the{" "} + + interactive API reference + + . +

+
+ + + +
+ ); +} + +// ── A one-time secret reveal box (used for both keys and webhook secrets) ───── + +function SecretReveal({ + label, + secret, + onDismiss, +}: { + label: string; + secret: string; + onDismiss: () => void; +}) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + await navigator.clipboard.writeText(secret); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ + {label} — copy it now. You won't be able to see it again. +
+
+ + {secret} + + +
+ +
+ ); +} + +// ── API keys ────────────────────────────────────────────────────────────────── + +function ApiKeysSection() { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [newSecret, setNewSecret] = useState(null); + + const refresh = useCallback(async () => { + try { + setKeys(await listApiKeys()); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const create = async () => { + if (!name.trim()) return; + setCreating(true); + try { + const created = await createApiKey({ name: name.trim() }); + setNewSecret(created.key); + setName(""); + await refresh(); + } catch { + alert("Failed to create API key."); + } finally { + setCreating(false); + } + }; + + const revoke = async (id: string) => { + if (!confirm("Revoke this API key? Any client using it will stop working.")) + return; + try { + await revokeApiKey(id); + await refresh(); + } catch { + alert("Failed to revoke key."); + } + }; + + return ( +
+

API keys

+

+ Use a key as a bearer token:{" "} + + Authorization: Bearer mike_sk_… + +

+ +
+
+ + setName(e.target.value)} + placeholder="e.g. CI pipeline" + className={accountGlassInputClassName} + spellCheck={false} + /> +
+ +
+ + {newSecret && ( + setNewSecret(null)} + /> + )} + +
+ {loading ? ( +
+ Loading… +
+ ) : keys.length === 0 ? ( +

+ No API keys yet. +

+ ) : ( + keys.map((key) => ( +
+
+
+ {key.name} +
+
+ {key.key_prefix}…{" · "} + {key.scopes.join(", ")} + {" · "} + {key.last_used_at + ? `last used ${new Date(key.last_used_at).toLocaleDateString()}` + : "never used"} +
+
+ +
+ )) + )} +
+
+
+ ); +} + +// ── Webhooks ──────────────────────────────────────────────────────────────── + +function WebhooksSection() { + const [endpoints, setEndpoints] = useState([]); + const [deliveries, setDeliveries] = useState([]); + const [eventTypes, setEventTypes] = useState([]); + const [loading, setLoading] = useState(true); + const [url, setUrl] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [creating, setCreating] = useState(false); + const [newSecret, setNewSecret] = useState(null); + + const refresh = useCallback(async () => { + try { + const [eps, dels] = await Promise.all([ + listWebhookEndpoints(), + listWebhookDeliveries({ limit: 20 }), + ]); + setEndpoints(eps); + setDeliveries(dels); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void listWebhookEventTypes().then(setEventTypes).catch(() => {}); + void refresh(); + }, [refresh]); + + const toggleEvent = (event: WebhookEventType) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(event)) next.delete(event); + else next.add(event); + return next; + }); + }; + + const create = async () => { + if (!url.trim() || selected.size === 0) return; + setCreating(true); + try { + const created = await createWebhookEndpoint({ + url: url.trim(), + event_types: [...selected], + }); + setNewSecret(created.secret); + setUrl(""); + setSelected(new Set()); + await refresh(); + } catch { + alert("Failed to create endpoint. URLs must use HTTPS in production."); + } finally { + setCreating(false); + } + }; + + const remove = async (id: string) => { + if (!confirm("Delete this webhook endpoint?")) return; + try { + await deleteWebhookEndpoint(id); + await refresh(); + } catch { + alert("Failed to delete endpoint."); + } + }; + + return ( +
+

Webhooks

+

+ Receive a signed POST when events happen. Verify the{" "} + X-Mike-Signature header + with the secret shown once at creation. +

+ +
+
+ + setUrl(e.target.value)} + placeholder="https://example.com/webhooks/mike" + className={accountGlassInputClassName} + spellCheck={false} + /> +
+
+ {eventTypes.map((event) => ( + + ))} +
+
+ +
+
+ + {newSecret && ( + setNewSecret(null)} + /> + )} + +
+ {loading ? ( +
+ Loading… +
+ ) : endpoints.length === 0 ? ( +

+ No webhook endpoints yet. +

+ ) : ( + endpoints.map((endpoint) => ( +
+
+
+ {endpoint.url} +
+
+ {endpoint.event_types.join(", ")} +
+
+ +
+ )) + )} +
+
+ + {deliveries.length > 0 && ( +
+

+ Recent deliveries +

+ + {deliveries.map((delivery) => ( +
+ + {delivery.event_type} + + + {delivery.status} + {delivery.response_status + ? ` (${delivery.response_status})` + : ""} + {" · "} + {delivery.attempts} attempt + {delivery.attempts === 1 ? "" : "s"} + +
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(pages)/account/layout.tsx b/apps/web/src/app/(pages)/account/layout.tsx index 8c4a187a67..afaa725066 100644 --- a/apps/web/src/app/(pages)/account/layout.tsx +++ b/apps/web/src/app/(pages)/account/layout.tsx @@ -24,6 +24,7 @@ const TABS: TabDef[] = [ { id: "models", label: "Model Preferences", href: "/account/models" }, { id: "api-keys", label: "API Keys", href: "/account/api-keys" }, { id: "connectors", label: "Connectors", href: "/account/connectors" }, + { id: "developer", label: "Developer", href: "/account/developer" }, ]; export default function AccountLayout({ diff --git a/docs/adr/0001-developer-platform.md b/docs/adr/0001-developer-platform.md new file mode 100644 index 0000000000..6d77737ad0 --- /dev/null +++ b/docs/adr/0001-developer-platform.md @@ -0,0 +1,146 @@ +# ADR 0001 — Developer Platform: programmatic API keys, OpenAPI spec & webhooks + +- **Status:** Accepted +- **Date:** 2026-06-29 +- **Deciders:** Mike maintainers +- **Related docs:** [Developer Platform guide](../developer-platform.md), [API](../api.md), [SDK](../sdk.md) + +## Context + +Mike ships TypeScript and Python SDKs, but both authenticate with a short-lived +**Supabase session JWT** — the credential a browser obtains after login. That is +fine for a logged-in web user and useless for the things developers actually +want to do: + +- call the API from a **script, cron job, or CI pipeline** where there is no + interactive login and no token-refresh loop; +- receive **push notifications** when something happens to their data, instead + of polling; +- discover the API from a **machine-readable contract** rather than reading + source. + +The fork ecosystem points the same direction: many forks add BYOK / provider +integrations, MCP connectors, and developer onboarding tours. The natural +platform layer underneath all of that — the thing that turns "an app with an +API" into "a platform you can build on" (the Dub-inspired brainstorm) — is a +first-class **programmatic credential + event delivery + published contract**. + +This ADR records the decisions made while building that layer. + +## Decision + +Add three cohesive capabilities, plus the SDK and UI surface to use them. + +### 1. Opaque, hashed API keys (not JWTs, not stored secrets) + +A Mike API key is an **opaque bearer token** formatted `mike_sk_` +(~238 bits of entropy). The auth middleware accepts **either** a Supabase JWT +(unchanged) **or** an API key, branching on the `mike_sk_` prefix. + +- We store **only a SHA-256 hash** of the key plus a short non-secret + **prefix** (e.g. `mike_sk_Ab3xK9`) for display and fast lookup. The full + secret is returned **exactly once**, at creation. +- Verification is **constant-time** (`crypto.timingSafeEqual`). +- Keys carry **scopes** (`read`, `write`); the middleware maps HTTP method → + required scope. +- Management routes (`/v1/api-keys`) require a **real user session** — you + cannot mint a key with a key. + +### 2. OpenAPI 3.1 as a published contract + +A 3.1 document describes the public surface (API-key + webhook routes plus the +existing projects / documents / chat endpoints the SDKs already call). It is +served at `GET /openapi.json` and rendered at `GET /docs` (Swagger UI). + +### 3. Webhooks with HMAC signatures and in-process retries + +`webhook_endpoints` (url, secret, enabled, event types) and +`webhook_deliveries` (event, payload, status, attempts, response). A delivery +service signs each payload with **HMAC-SHA256** (`X-Mike-Signature`), retries +with **exponential backoff**, and records every attempt. At least one real emit +point is wired (`document.uploaded`). + +## Alternatives considered + +### Credential: opaque hashed token vs long-lived JWT vs stored secret + +| Option | Verdict | +| --- | --- | +| **Long-lived JWT** | Rejected. JWTs can't be revoked without a denylist (they're valid until expiry), and a long expiry is exactly what you don't want for a credential that lives in CI logs. | +| **Store the key as-is / reversibly encrypted** | Rejected. A DB leak would expose live credentials. We never need the original, so we don't keep it. | +| **Opaque token, store only a SHA-256 hash** | **Chosen.** Instantly revocable (flip `revoked_at`), unusable if the DB leaks, and cheap to verify. | + +Why **SHA-256, not bcrypt/argon2**: slow password hashes defend *low-entropy* +human passwords against brute force. A 40-char random base62 secret is not +brute-forceable, so a fast hash is the correct, cheaper choice — the same +reasoning Stripe/GitHub use for their `sk_`-style keys. + +### Contract: hand-authored vs generated from Zod + +The repo runs **Zod v4**. The popular `@asteasolutions/zod-to-openapi` +generator targets Zod v3's API, so integrating it cleanly would mean pinning an +older Zod or shimming every schema — more moving parts than a curated spec for a +handful of public endpoints. We **hand-authored** a typed OpenAPI 3.1 document +(`apps/api/src/lib/openapi.ts`) and treat keeping it in sync as part of "done". +**Generating SDKs *from* this spec** is listed as future work — the inverse of +generating the spec from code, and the higher-leverage direction. + +### Webhook delivery: in-process vs durable queue (Redis/BullMQ) + +We deliberately keep delivery **in-process** (`setTimeout` retries). Mike is +self-hostable as a single service; bolting on Redis/BullMQ would raise the +operational bar for every self-hoster to serve a feature most won't use on day +one. We mitigate the main downside — deliveries scheduled but unsent are lost on +restart — by **persisting every delivery row up front**, so history and +(future) replay are always available. Moving to a durable queue is documented as +future work. + +## Consequences + +**Positive** + +- Scripts, CI, and third-party tools get a real, revocable credential. +- Existing JWT auth, MFA, and every current route are **unchanged** — the new + branch is purely additive. +- One published contract drives docs today and SDK generation tomorrow. +- Webhooks turn integrations from polling into push. + +**Negative / trade-offs** + +- The OpenAPI document is maintained by hand (mitigated: small surface, typed, + part of review). +- In-process delivery is not durable across restarts (mitigated: rows persisted; + queue is future work). +- API keys bypass interactive MFA (see below) — an intentional trade-off. + +## Security considerations + +- **Why hash, not store:** a leaked database must not yield usable credentials. + We persist only `sha256(token)`; the API only ever compares hashes. +- **Why constant-time compare:** a byte-by-byte `===` leaks, via timing, how + many leading bytes of a guess were right — enough to forge a secret + incrementally. `crypto.timingSafeEqual` removes that signal. +- **Prefix lookup:** we index and look keys up by the **non-secret** prefix, then + constant-time compare the full hash — avoiding both a table scan and a + timing-unsafe SQL equality on the secret. +- **Scope model:** `read` for `GET`/`HEAD`, `write` for everything else. Keys + default to both (session parity); a narrower key is a least-privilege upgrade. +- **MFA and API keys:** keys **bypass** the interactive MFA-on-login gate by + design. MFA protects browser sessions; a programmatic key is a distinct factor + (possession) that the user explicitly minted and can revoke instantly. Forcing + `aal2` on a headless key would make it unusable. Management routes still run + inside a full MFA-enforced session. +- **Webhook HMAC:** receivers verify `X-Mike-Signature` (HMAC-SHA256 over the + exact body) with their per-endpoint secret — proving authenticity + integrity. + HTTPS is required for endpoints in production. +- **RLS:** all three tables get RLS enabled + a deny-all policy and have direct + privileges revoked from `anon`/`authenticated`, consistent with the repo's + default-deny posture. All access flows through the backend service role. + +## Future work + +- Durable, queued delivery (BullMQ) with dead-letter handling and manual replay. +- OAuth 2.1 for third-party apps acting on behalf of users. +- Usage analytics per key (rate, error rate, top endpoints). +- **SDK auto-generation from the OpenAPI spec** (TS + Python), closing the loop. +- Per-route scope granularity beyond read/write. diff --git a/docs/api.md b/docs/api.md index d1c119857e..5244ee78d8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -22,6 +22,27 @@ const projects = await listProjects(); Public request and response shapes should be defined in `packages/core` before they are consumed by API handlers, the web app, or SDKs. +## Authentication + +Protected routes accept **either**: + +- a **Supabase session JWT** (the web app's credential), or +- a **programmatic API key** — `Authorization: Bearer mike_sk_...` — created + under **Settings → Developer**. + +API keys are opaque, hashed at rest, revocable, and carry `read`/`write` scopes. +Key and webhook **management** routes (`/v1/api-keys`, `/v1/webhooks`) require a +session JWT, not a key. Full details in the +[Developer Platform guide](./developer-platform.md) and +[ADR 0001](./adr/0001-developer-platform.md). + +## OpenAPI contract + +The public surface is described by an OpenAPI 3.1 document: + +- `GET /openapi.json` — the machine-readable spec (source of truth). +- `GET /docs` — interactive Swagger UI rendered from it. + ## OpenAI-Compatible Gateways The API can route OpenAI-model requests through an OpenAI-compatible gateway by diff --git a/docs/developer-platform.md b/docs/developer-platform.md new file mode 100644 index 0000000000..f50f5976f8 --- /dev/null +++ b/docs/developer-platform.md @@ -0,0 +1,190 @@ +# Developer Platform + +Build on Mike programmatically: mint a long-lived API key, call the REST API +from the SDKs, explore the machine-readable contract, and receive signed +webhook events. + +> Design rationale lives in [ADR 0001](./adr/0001-developer-platform.md). + +## 1. Create an API key + +Go to **Settings → Developer** in the web app, give the key a name, and click +**Create**. The full secret (`mike_sk_…`) is shown **once** — copy it +immediately. Mike stores only a SHA-256 hash, so it can never show it again. + +A key looks like: + +``` +mike_sk_Ab3xK9p2Qw7r... ← full secret (shown once) +mike_sk_Ab3xK9 ← prefix shown in the UI afterwards +``` + +Keys can be **revoked** at any time from the same page; a revoked key stops +working immediately. + +### Scopes + +| Scope | Grants | +| ------- | --------------------------------------- | +| `read` | Safe `GET` / `HEAD` requests | +| `write` | Everything that mutates (POST/PATCH/…) | + +Keys default to both. The server maps the HTTP method to the required scope, so +a `read`-only key is rejected on a `POST` with `403`. + +## 2. Authenticate + +Send the key as a standard bearer token: + +```http +Authorization: Bearer mike_sk_Ab3xK9p2Qw7r... +``` + +### TypeScript / JavaScript SDK + +```ts +import { MikeClient } from "@mike/sdk-js"; + +const mike = new MikeClient({ + baseUrl: "https://api.example.com", + apiKey: "mike_sk_Ab3xK9p2Qw7r...", // a Mike API key OR a Supabase JWT +}); + +const projects = await mike.projects.list(); + +// Manage keys & webhooks (requires a logged-in user session, not a key): +const key = await mike.apiKeys.create({ name: "CI", scopes: ["read"] }); +console.log(key.key); // the one-time secret +``` + +### Python SDK + +```python +from mike import MikeClient + +mike = MikeClient(base_url="https://api.example.com", api_key="mike_sk_...") +projects = mike.projects.list() + +created = mike.api_keys.create(name="CI") +print(created.key) # one-time secret +``` + +### curl + +```bash +curl https://api.example.com/projects \ + -H "Authorization: Bearer mike_sk_Ab3xK9p2Qw7r..." +``` + +> **Key management endpoints require a user session, not a key.** You cannot +> mint or revoke keys, or configure webhooks, using an API key — only a +> logged-in user (Supabase JWT) can. This prevents a leaked key from escalating +> its own privileges. + +## 3. Explore the API contract + +- **`GET /openapi.json`** — the OpenAPI 3.1 document (the source of truth). +- **`GET /docs`** — interactive Swagger UI rendered from that document. + +Point Postman, an SDK generator, or your editor at `/openapi.json` to get typed +clients and request validation for free. + +## 4. Webhooks + +Register an endpoint on **Settings → Developer**, choose the events you care +about, and copy the **signing secret** (`whsec_…`) shown once at creation. + +### Event catalogue + +| Event | Fires when… | +| -------------------- | --------------------------------------------- | +| `document.uploaded` | a document finishes uploading & processing | +| `document.analysed` | a document analysis completes *(reserved)* | +| `chat.message` | an assistant message is produced *(reserved)* | +| `workflow.completed` | a workflow run finishes *(reserved)* | + +`document.uploaded` is wired today; the others are catalogued and ready to wire. +Fetch the live list from `GET /v1/webhooks/events`. + +### Delivery format + +Each delivery is a `POST` with a JSON envelope: + +```json +{ + "id": "8f3c…", + "type": "document.uploaded", + "created_at": "2026-06-29T12:00:00.000Z", + "data": { + "document_id": "…", + "project_id": "…", + "filename": "contract.pdf", + "file_type": "pdf", + "size_bytes": 12345, + "page_count": 7 + } +} +``` + +Headers: + +| Header | Meaning | +| -------------------- | -------------------------------------------- | +| `X-Mike-Event` | the event type | +| `X-Mike-Delivery-Id` | unique id — use as an **idempotency key** | +| `X-Mike-Signature` | hex HMAC-SHA256 of the raw body | + +Failed deliveries are retried with exponential backoff (up to 5 attempts: +0s, 5s, 30s, 2m, 10m). Inspect attempts and responses under **Recent +deliveries** or via `GET /v1/webhooks/deliveries`. + +### Verifying the signature + +Always verify `X-Mike-Signature` before trusting a payload. Compute +`HMAC-SHA256(rawBody, yourEndpointSecret)` and compare in constant time. + +**Node.js / Express:** + +```ts +import crypto from "crypto"; +import express from "express"; + +const app = express(); + +// IMPORTANT: verify against the RAW body bytes, not a re-serialized object. +app.post( + "/webhooks/mike", + express.raw({ type: "application/json" }), + (req, res) => { + const signature = req.header("X-Mike-Signature") ?? ""; + const expected = crypto + .createHmac("sha256", process.env.MIKE_WEBHOOK_SECRET!) + .update(req.body) // req.body is a Buffer here + .digest("hex"); + + const a = Buffer.from(signature); + const b = Buffer.from(expected); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { + return res.status(400).send("bad signature"); + } + + const event = JSON.parse(req.body.toString("utf8")); + // …handle event.type / event.data, ack fast, do work async… + res.sendStatus(200); + }, +); +``` + +**Python:** + +```python +import hashlib +import hmac + +def verify(raw_body: bytes, signature: str, secret: str) -> bool: + expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature) +``` + +Respond `2xx` quickly (do heavy work asynchronously); any non-2xx or timeout +triggers a retry. diff --git a/docs/sdk.md b/docs/sdk.md index 6774d39d3a..0204b6e727 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -8,12 +8,25 @@ import { MikeClient } from "@mike/sdk-js"; const mike = new MikeClient({ baseUrl: "https://api.example.com", - apiKey: "user-or-service-token", + // `apiKey` accepts a programmatic Mike API key (`mike_sk_...`) or a Supabase + // session JWT. Either is sent as `Authorization: Bearer `. + apiKey: "mike_sk_...", }); const projects = await mike.projects.list(); ``` +## API-key authentication + +Create a long-lived key under **Settings → Developer** (or `mike.apiKeys.create`) +and pass it as `apiKey`. Keys are revocable and carry `read`/`write` scopes. The +SDK also exposes `mike.apiKeys` and `mike.webhooks` for managing them — see the +[Developer Platform guide](./developer-platform.md). The Python SDK takes the +same credential via `MikeClient(base_url=..., api_key="mike_sk_...")`. + +The machine-readable contract is published at `GET /openapi.json` (rendered at +`GET /docs`); SDK generation from it is planned. + The SDK should stay thin. Add shared types and stable contracts to `packages/core`, low-level endpoint calls to `packages/api-client`, and ergonomic workflows to `packages/sdk-js`. diff --git a/package-lock.json b/package-lock.json index abcaee4f46..05765d4248 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "pino": "^10.3.1", "pino-http": "^11.0.0", "prom-client": "^15.1.3", + "swagger-ui-express": "^5.0.1", "undici": "^6.27.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^4.4.3" @@ -72,6 +73,7 @@ "@types/node": "^22.14.1", "@types/pino-http": "^5.8.4", "@types/supertest": "^7.2.0", + "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^8.59.4", "@typescript-eslint/parser": "^8.59.4", "@vitest/coverage-v8": "^4.1.7", @@ -8138,6 +8140,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sentry/core": { "version": "8.55.2", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.2.tgz", @@ -10902,6 +10911,17 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/tedious": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", @@ -23360,6 +23380,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.8", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", + "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index 166fe0b966..8be1e1e200 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "tmp": "^0.2.6", "form-data@>=4.0.0 <4.0.6": "^4.0.6", "form-data@<2.5.6": "^2.5.6", - "@tiptap/core": "3.27.3" + "@tiptap/core": "3.27.3", + "@types/express": "^4.17.21", + "@types/serve-static": "^1.15.7" }, "engines": { "node": ">=22" diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 01eb3458ea..d9c1119c58 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,6 +1,9 @@ import type { AssistantEvent, + ApiKey, + ApiKeyCreateResponse, ApiKeyProvider, + ApiKeyScope, ApiKeySource, Chat, ChatDetailOut, @@ -12,6 +15,10 @@ import type { OpenSourceWorkflowContributorMode, OpenSourceWorkflowResponse, Project, + WebhookDelivery, + WebhookEndpoint, + WebhookEndpointCreateResponse, + WebhookEventType, Workflow, WorkflowContributor, TabularReview, @@ -19,6 +26,15 @@ import type { } from "@mike/core"; export type { ApiKeyProvider, ApiKeySource } from "@mike/core"; +export type { + ApiKey, + ApiKeyCreateResponse, + ApiKeyScope, + WebhookDelivery, + WebhookEndpoint, + WebhookEndpointCreateResponse, + WebhookEventType, +} from "@mike/core"; // MERGE-REVIEW: the fork's createMikeApiClient helper (below) references // Mike-prefixed type names; @mike/core exports the unprefixed types the rest of @@ -897,6 +913,74 @@ async function getChatWithConfig( return { chat: raw.chat, messages }; } +// --------------------------------------------------------------------------- +// Developer platform — programmatic API keys & webhooks +// +// These management endpoints require an interactive user session (a Supabase +// JWT). You cannot mint a key, or reconfigure webhooks, using an API key. +// --------------------------------------------------------------------------- + +export async function listApiKeys(): Promise { + return apiRequest("/v1/api-keys"); +} + +export async function createApiKey(payload: { + name: string; + scopes?: ApiKeyScope[]; +}): Promise { + return apiRequest("/v1/api-keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function revokeApiKey(id: string): Promise { + await apiRequest(`/v1/api-keys/${id}`, { method: "DELETE" }); +} + +export async function listWebhookEventTypes(): Promise { + const result = await apiRequest<{ event_types: WebhookEventType[] }>( + "/v1/webhooks/events", + ); + return result.event_types; +} + +export async function listWebhookEndpoints(): Promise { + return apiRequest("/v1/webhooks/endpoints"); +} + +export async function createWebhookEndpoint(payload: { + url: string; + event_types: WebhookEventType[]; +}): Promise { + return apiRequest( + "/v1/webhooks/endpoints", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); +} + +export async function deleteWebhookEndpoint(id: string): Promise { + await apiRequest(`/v1/webhooks/endpoints/${id}`, { method: "DELETE" }); +} + +export async function listWebhookDeliveries(options?: { + endpointId?: string; + limit?: number; +}): Promise { + const params = new URLSearchParams(); + if (options?.endpointId) params.set("endpoint_id", options.endpointId); + if (options?.limit) params.set("limit", String(options.limit)); + const query = params.toString(); + return apiRequest( + `/v1/webhooks/deliveries${query ? `?${query}` : ""}`, + ); +} + export function createMikeApiClient(config: MikeApiClientConfig = {}) { const scopedConfig = resolveMikeApiClientConfig(config, { baseUrl: DEFAULT_API_BASE, @@ -978,6 +1062,72 @@ export function createMikeApiClient(config: MikeApiClientConfig = {}) { uploadStandalone: (file: File) => uploadStandaloneDocumentWithConfig(scopedConfig, file), }, + apiKeys: { + list: () => + apiRequestWithConfig(scopedConfig, "/v1/api-keys"), + create: (payload: { name: string; scopes?: ApiKeyScope[] }) => + apiRequestWithConfig( + scopedConfig, + "/v1/api-keys", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ), + revoke: (id: string) => + apiRequestWithConfig( + scopedConfig, + `/v1/api-keys/${id}`, + { method: "DELETE" }, + ), + }, + webhooks: { + listEventTypes: async () => { + const result = await apiRequestWithConfig<{ + event_types: WebhookEventType[]; + }>(scopedConfig, "/v1/webhooks/events"); + return result.event_types; + }, + listEndpoints: () => + apiRequestWithConfig( + scopedConfig, + "/v1/webhooks/endpoints", + ), + createEndpoint: (payload: { + url: string; + event_types: WebhookEventType[]; + }) => + apiRequestWithConfig( + scopedConfig, + "/v1/webhooks/endpoints", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ), + deleteEndpoint: (id: string) => + apiRequestWithConfig( + scopedConfig, + `/v1/webhooks/endpoints/${id}`, + { method: "DELETE" }, + ), + listDeliveries: (options?: { + endpointId?: string; + limit?: number; + }) => { + const params = new URLSearchParams(); + if (options?.endpointId) + params.set("endpoint_id", options.endpointId); + if (options?.limit) params.set("limit", String(options.limit)); + const query = params.toString(); + return apiRequestWithConfig( + scopedConfig, + `/v1/webhooks/deliveries${query ? `?${query}` : ""}`, + ); + }, + }, }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1e89e12f03..ed62a320c0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -673,6 +673,59 @@ export interface Workflow { open_source_submission?: WorkflowOpenSourceSubmission | null; } +// Developer platform — programmatic API keys & webhooks + +export type ApiKeyScope = "read" | "write"; + +/** A programmatic API key as returned by list/create (never includes the secret). */ +export interface ApiKey { + id: string; + name: string; + /** Non-secret prefix for display, e.g. `mike_sk_Ab3xK9`. */ + key_prefix: string; + scopes: ApiKeyScope[]; + last_used_at: string | null; + created_at: string; +} + +/** Create response — the only time the full `key` secret is ever returned. */ +export interface ApiKeyCreateResponse extends ApiKey { + key: string; +} + +export type WebhookEventType = + | "document.uploaded" + | "document.analysed" + | "chat.message" + | "workflow.completed"; + +export interface WebhookEndpoint { + id: string; + url: string; + enabled: boolean; + event_types: WebhookEventType[]; + created_at: string; + updated_at: string; +} + +/** Create response — the only time the signing `secret` is ever returned. */ +export interface WebhookEndpointCreateResponse extends WebhookEndpoint { + secret: string; +} + +export interface WebhookDelivery { + id: string; + endpoint_id: string; + event_type: string; + status: "pending" | "succeeded" | "failed"; + attempts: number; + response_status: number | null; + last_error: string | null; + created_at: string; + updated_at: string; + delivered_at: string | null; +} + // API helpers export interface ChatDetailOut { diff --git a/packages/sdk-js/src/index.ts b/packages/sdk-js/src/index.ts index e49e04d9fb..f7b2e5a30c 100644 --- a/packages/sdk-js/src/index.ts +++ b/packages/sdk-js/src/index.ts @@ -5,6 +5,11 @@ export * from "@mike/api-client"; export type MikeClientOptions = { baseUrl?: string; + /** + * A bearer credential. This can be a programmatic Mike API key + * (`mike_sk_...`, created on the Developer settings page) or a Supabase + * session JWT. Either way it is sent as `Authorization: Bearer `. + */ apiKey?: string; getAuthHeaders?: AuthHeaderProvider; fetchImpl?: typeof fetch; @@ -88,4 +93,62 @@ export class MikeClient { > ) => this.client.documents.uploadStandalone(...args), }; + + /** Manage programmatic API keys (requires a logged-in user session). */ + apiKeys = { + list: ( + ...args: Parameters< + ReturnType["apiKeys"]["list"] + > + ) => this.client.apiKeys.list(...args), + create: ( + ...args: Parameters< + ReturnType["apiKeys"]["create"] + > + ) => this.client.apiKeys.create(...args), + revoke: ( + ...args: Parameters< + ReturnType["apiKeys"]["revoke"] + > + ) => this.client.apiKeys.revoke(...args), + }; + + /** Manage webhook endpoints and inspect deliveries. */ + webhooks = { + listEventTypes: ( + ...args: Parameters< + ReturnType< + typeof createMikeApiClient + >["webhooks"]["listEventTypes"] + > + ) => this.client.webhooks.listEventTypes(...args), + listEndpoints: ( + ...args: Parameters< + ReturnType< + typeof createMikeApiClient + >["webhooks"]["listEndpoints"] + > + ) => this.client.webhooks.listEndpoints(...args), + createEndpoint: ( + ...args: Parameters< + ReturnType< + typeof createMikeApiClient + >["webhooks"]["createEndpoint"] + > + ) => this.client.webhooks.createEndpoint(...args), + deleteEndpoint: ( + ...args: Parameters< + ReturnType< + typeof createMikeApiClient + >["webhooks"]["deleteEndpoint"] + > + ) => this.client.webhooks.deleteEndpoint(...args), + listDeliveries: ( + ...args: Parameters< + ReturnType< + typeof createMikeApiClient + >["webhooks"]["listDeliveries"] + > + ) => this.client.webhooks.listDeliveries(...args), + }; } diff --git a/sdks/python/mike/__init__.py b/sdks/python/mike/__init__.py index 016a81e813..2846399277 100644 --- a/sdks/python/mike/__init__.py +++ b/sdks/python/mike/__init__.py @@ -12,6 +12,8 @@ StreamError, ) from ._models import ( + ApiKey, + ApiKeyCreateResponse, ApiKeyStatus, ApiKeyStatusResponse, Chat, @@ -28,6 +30,9 @@ TabularListItem, TabularReview, UserProfile, + WebhookDelivery, + WebhookEndpoint, + WebhookEndpointCreateResponse, Workflow, WorkflowListItem, WorkflowStep, @@ -67,6 +72,12 @@ "UserProfile", "ApiKeyStatus", "ApiKeyStatusResponse", + # Developer platform + "ApiKey", + "ApiKeyCreateResponse", + "WebhookEndpoint", + "WebhookEndpointCreateResponse", + "WebhookDelivery", # Streaming "SyncStream", "AsyncStream", diff --git a/sdks/python/mike/_client.py b/sdks/python/mike/_client.py index ce6047558b..addbfbbb8c 100644 --- a/sdks/python/mike/_client.py +++ b/sdks/python/mike/_client.py @@ -5,14 +5,33 @@ import httpx from ._exceptions import _raise_for_status +from .resources.api_keys import ApiKeysResource, AsyncApiKeysResource from .resources.chat import ChatResource, AsyncChatResource from .resources.documents import DocumentsResource, AsyncDocumentsResource from .resources.projects import ProjectsResource, AsyncProjectsResource from .resources.tabular import TabularResource, AsyncTabularResource from .resources.user import UserResource, AsyncUserResource +from .resources.webhooks import WebhooksResource, AsyncWebhooksResource from .resources.workflows import WorkflowsResource, AsyncWorkflowsResource +def _build_headers( + access_token: str | None, api_key: str | None +) -> dict[str, str]: + """Assemble the default request headers. + + ``api_key`` is the preferred, first-class credential: a programmatic Mike + API key (``mike_sk_...``) sent as a standard ``Authorization: Bearer`` + header. ``access_token`` (a Supabase session JWT) is also supported and + sent the same way; when both are supplied the API key takes precedence. + """ + headers: dict[str, str] = {"Content-Type": "application/json"} + token = api_key or access_token + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + class MikeClient: """Synchronous client for the Mike legal AI API.""" @@ -22,11 +41,14 @@ class MikeClient: tabular: TabularResource workflows: WorkflowsResource user: UserResource + api_keys: ApiKeysResource + webhooks: WebhooksResource def __init__( self, *, base_url: str, + api_key: str | None = None, access_token: str | None = None, timeout: float = 60.0, http_client: httpx.Client | None = None, @@ -35,18 +57,18 @@ def __init__( Args: base_url: Root URL of the Mike API. + api_key: Programmatic Mike API key (``mike_sk_...``). Sent as an + ``Authorization: Bearer `` header; preferred over + ``access_token`` when both are given. access_token: Supabase access token (JWT). Sent as an ``Authorization: Bearer `` header, matching the API's auth middleware. """ self._base_url = base_url.rstrip("/") - headers: dict[str, str] = {"Content-Type": "application/json"} - if access_token: - headers["Authorization"] = f"Bearer {access_token}" self._http = http_client or httpx.Client( base_url=self._base_url, - headers=headers, + headers=_build_headers(access_token, api_key), timeout=timeout, ) @@ -56,6 +78,8 @@ def __init__( self.tabular = TabularResource(self) self.workflows = WorkflowsResource(self) self.user = UserResource(self) + self.api_keys = ApiKeysResource(self) + self.webhooks = WebhooksResource(self) def _request( self, @@ -100,11 +124,14 @@ class AsyncMikeClient: tabular: AsyncTabularResource workflows: AsyncWorkflowsResource user: AsyncUserResource + api_keys: AsyncApiKeysResource + webhooks: AsyncWebhooksResource def __init__( self, *, base_url: str, + api_key: str | None = None, access_token: str | None = None, timeout: float = 60.0, http_client: httpx.AsyncClient | None = None, @@ -113,18 +140,18 @@ def __init__( Args: base_url: Root URL of the Mike API. + api_key: Programmatic Mike API key (``mike_sk_...``). Sent as an + ``Authorization: Bearer `` header; preferred over + ``access_token`` when both are given. access_token: Supabase access token (JWT). Sent as an ``Authorization: Bearer `` header, matching the API's auth middleware. """ self._base_url = base_url.rstrip("/") - headers: dict[str, str] = {"Content-Type": "application/json"} - if access_token: - headers["Authorization"] = f"Bearer {access_token}" self._http = http_client or httpx.AsyncClient( base_url=self._base_url, - headers=headers, + headers=_build_headers(access_token, api_key), timeout=timeout, ) @@ -134,6 +161,8 @@ def __init__( self.tabular = AsyncTabularResource(self) self.workflows = AsyncWorkflowsResource(self) self.user = AsyncUserResource(self) + self.api_keys = AsyncApiKeysResource(self) + self.webhooks = AsyncWebhooksResource(self) async def _request( self, diff --git a/sdks/python/mike/_models.py b/sdks/python/mike/_models.py index 837cffde74..2918268937 100644 --- a/sdks/python/mike/_models.py +++ b/sdks/python/mike/_models.py @@ -166,3 +166,47 @@ class ApiKeyStatus(BaseModel): class ApiKeyStatusResponse(BaseModel): keys: list[ApiKeyStatus] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Developer platform — programmatic API keys & webhooks +# --------------------------------------------------------------------------- + +class ApiKey(BaseModel): + id: str + name: str + key_prefix: str + scopes: list[str] = Field(default_factory=list) + last_used_at: Optional[datetime] = None + created_at: Optional[datetime] = None + + +class ApiKeyCreateResponse(ApiKey): + # The full secret — present ONLY in the create response, never again. + key: str + + +class WebhookEndpoint(BaseModel): + id: str + url: str + enabled: bool = True + event_types: list[str] = Field(default_factory=list) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class WebhookEndpointCreateResponse(WebhookEndpoint): + # The HMAC signing secret — present ONLY in the create response. + secret: str + + +class WebhookDelivery(BaseModel): + id: str + endpoint_id: str + event_type: str + status: Literal["pending", "succeeded", "failed"] + attempts: int = 0 + response_status: Optional[int] = None + last_error: Optional[str] = None + created_at: Optional[datetime] = None + delivered_at: Optional[datetime] = None diff --git a/sdks/python/mike/resources/__init__.py b/sdks/python/mike/resources/__init__.py index 9ba857e35f..cb19be40da 100644 --- a/sdks/python/mike/resources/__init__.py +++ b/sdks/python/mike/resources/__init__.py @@ -1,11 +1,15 @@ +from .api_keys import ApiKeysResource, AsyncApiKeysResource from .chat import ChatResource, AsyncChatResource from .documents import DocumentsResource, AsyncDocumentsResource from .projects import ProjectsResource, AsyncProjectsResource from .tabular import TabularResource, AsyncTabularResource +from .webhooks import WebhooksResource, AsyncWebhooksResource from .workflows import WorkflowsResource, AsyncWorkflowsResource from .user import UserResource, AsyncUserResource __all__ = [ + "ApiKeysResource", + "AsyncApiKeysResource", "ChatResource", "AsyncChatResource", "DocumentsResource", @@ -14,6 +18,8 @@ "AsyncProjectsResource", "TabularResource", "AsyncTabularResource", + "WebhooksResource", + "AsyncWebhooksResource", "WorkflowsResource", "AsyncWorkflowsResource", "UserResource", diff --git a/sdks/python/mike/resources/api_keys.py b/sdks/python/mike/resources/api_keys.py new file mode 100644 index 0000000000..e6ec9713d8 --- /dev/null +++ b/sdks/python/mike/resources/api_keys.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .._models import ApiKey, ApiKeyCreateResponse + +if TYPE_CHECKING: + from .._client import AsyncMikeClient, MikeClient + + +class ApiKeysResource: + """Manage programmatic API keys. + + Note: these endpoints require an interactive user session (a Supabase JWT). + You cannot mint a key using another API key. + """ + + def __init__(self, client: "MikeClient") -> None: + self._client = client + + def list(self) -> list[ApiKey]: + response = self._client._request("GET", "/v1/api-keys") + return [ApiKey.model_validate(item) for item in response.json()] + + def create( + self, *, name: str, scopes: list[str] | None = None + ) -> ApiKeyCreateResponse: + body: dict[str, Any] = {"name": name} + if scopes is not None: + body["scopes"] = scopes + response = self._client._request("POST", "/v1/api-keys", json=body) + return ApiKeyCreateResponse.model_validate(response.json()) + + def revoke(self, key_id: str) -> None: + self._client._request("DELETE", f"/v1/api-keys/{key_id}") + + +class AsyncApiKeysResource: + def __init__(self, client: "AsyncMikeClient") -> None: + self._client = client + + async def list(self) -> list[ApiKey]: + response = await self._client._request("GET", "/v1/api-keys") + return [ApiKey.model_validate(item) for item in response.json()] + + async def create( + self, *, name: str, scopes: list[str] | None = None + ) -> ApiKeyCreateResponse: + body: dict[str, Any] = {"name": name} + if scopes is not None: + body["scopes"] = scopes + response = await self._client._request("POST", "/v1/api-keys", json=body) + return ApiKeyCreateResponse.model_validate(response.json()) + + async def revoke(self, key_id: str) -> None: + await self._client._request("DELETE", f"/v1/api-keys/{key_id}") diff --git a/sdks/python/mike/resources/webhooks.py b/sdks/python/mike/resources/webhooks.py new file mode 100644 index 0000000000..84b34b43e9 --- /dev/null +++ b/sdks/python/mike/resources/webhooks.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .._models import ( + WebhookDelivery, + WebhookEndpoint, + WebhookEndpointCreateResponse, +) + +if TYPE_CHECKING: + from .._client import AsyncMikeClient, MikeClient + + +class WebhooksResource: + """Register webhook endpoints and inspect deliveries.""" + + def __init__(self, client: "MikeClient") -> None: + self._client = client + + def list_event_types(self) -> list[str]: + response = self._client._request("GET", "/v1/webhooks/events") + return list(response.json().get("event_types", [])) + + def list_endpoints(self) -> list[WebhookEndpoint]: + response = self._client._request("GET", "/v1/webhooks/endpoints") + return [WebhookEndpoint.model_validate(item) for item in response.json()] + + def create_endpoint( + self, *, url: str, event_types: list[str] + ) -> WebhookEndpointCreateResponse: + body: dict[str, Any] = {"url": url, "event_types": event_types} + response = self._client._request("POST", "/v1/webhooks/endpoints", json=body) + return WebhookEndpointCreateResponse.model_validate(response.json()) + + def delete_endpoint(self, endpoint_id: str) -> None: + self._client._request("DELETE", f"/v1/webhooks/endpoints/{endpoint_id}") + + def list_deliveries( + self, *, endpoint_id: str | None = None, limit: int | None = None + ) -> list[WebhookDelivery]: + params: dict[str, Any] = {} + if endpoint_id is not None: + params["endpoint_id"] = endpoint_id + if limit is not None: + params["limit"] = limit + response = self._client._request( + "GET", "/v1/webhooks/deliveries", params=params or None + ) + return [WebhookDelivery.model_validate(item) for item in response.json()] + + +class AsyncWebhooksResource: + def __init__(self, client: "AsyncMikeClient") -> None: + self._client = client + + async def list_event_types(self) -> list[str]: + response = await self._client._request("GET", "/v1/webhooks/events") + return list(response.json().get("event_types", [])) + + async def list_endpoints(self) -> list[WebhookEndpoint]: + response = await self._client._request("GET", "/v1/webhooks/endpoints") + return [WebhookEndpoint.model_validate(item) for item in response.json()] + + async def create_endpoint( + self, *, url: str, event_types: list[str] + ) -> WebhookEndpointCreateResponse: + body: dict[str, Any] = {"url": url, "event_types": event_types} + response = await self._client._request( + "POST", "/v1/webhooks/endpoints", json=body + ) + return WebhookEndpointCreateResponse.model_validate(response.json()) + + async def delete_endpoint(self, endpoint_id: str) -> None: + await self._client._request( + "DELETE", f"/v1/webhooks/endpoints/{endpoint_id}" + ) + + async def list_deliveries( + self, *, endpoint_id: str | None = None, limit: int | None = None + ) -> list[WebhookDelivery]: + params: dict[str, Any] = {} + if endpoint_id is not None: + params["endpoint_id"] = endpoint_id + if limit is not None: + params["limit"] = limit + response = await self._client._request( + "GET", "/v1/webhooks/deliveries", params=params or None + ) + return [WebhookDelivery.model_validate(item) for item in response.json()] diff --git a/sdks/python/tests/test_developer_platform.py b/sdks/python/tests/test_developer_platform.py new file mode 100644 index 0000000000..a546a8ceb9 --- /dev/null +++ b/sdks/python/tests/test_developer_platform.py @@ -0,0 +1,145 @@ +"""Tests for the developer-platform resources and API-key authentication.""" + +from __future__ import annotations + +import httpx +import respx + +from mike import MikeClient +from mike._client import _build_headers + +BASE_URL = "https://api.example.com" + + +# --------------------------------------------------------------------------- +# Authentication header construction +# --------------------------------------------------------------------------- + +def test_api_key_sets_bearer_header(): + headers = _build_headers(access_token=None, api_key="mike_sk_abc123") + assert headers["Authorization"] == "Bearer mike_sk_abc123" + + +def test_access_token_still_supported(): + headers = _build_headers(access_token="sess-1", api_key=None) + assert headers["Authorization"] == "Bearer sess-1" + + +def test_api_key_takes_precedence_over_access_token(): + headers = _build_headers(access_token="sess-1", api_key="mike_sk_abc123") + assert headers["Authorization"] == "Bearer mike_sk_abc123" + + +def test_client_uses_api_key_on_requests(): + client = MikeClient(base_url=BASE_URL, api_key="mike_sk_abc123") + with respx.mock: + route = respx.get(f"{BASE_URL}/v1/api-keys").mock( + return_value=httpx.Response(200, json=[]) + ) + client.api_keys.list() + assert route.calls.last.request.headers["Authorization"] == ( + "Bearer mike_sk_abc123" + ) + + +# --------------------------------------------------------------------------- +# API keys +# --------------------------------------------------------------------------- + +@respx.mock +def test_create_api_key_returns_secret_once(): + client = MikeClient(base_url=BASE_URL, access_token="t") + respx.post(f"{BASE_URL}/v1/api-keys").mock( + return_value=httpx.Response( + 201, + json={ + "id": "k1", + "name": "CI", + "key_prefix": "mike_sk_Ab3xK9", + "scopes": ["read", "write"], + "last_used_at": None, + "created_at": "2026-06-29T00:00:00Z", + "key": "mike_sk_thefullsecret", + }, + ) + ) + created = client.api_keys.create(name="CI") + assert created.key == "mike_sk_thefullsecret" + assert created.key_prefix == "mike_sk_Ab3xK9" + + +@respx.mock +def test_list_api_keys(): + client = MikeClient(base_url=BASE_URL, access_token="t") + respx.get(f"{BASE_URL}/v1/api-keys").mock( + return_value=httpx.Response( + 200, + json=[ + { + "id": "k1", + "name": "CI", + "key_prefix": "mike_sk_Ab3xK9", + "scopes": ["read"], + "last_used_at": None, + "created_at": "2026-06-29T00:00:00Z", + } + ], + ) + ) + keys = client.api_keys.list() + assert keys[0].id == "k1" + assert keys[0].scopes == ["read"] + + +# --------------------------------------------------------------------------- +# Webhooks +# --------------------------------------------------------------------------- + +@respx.mock +def test_create_webhook_endpoint_returns_secret(): + client = MikeClient(base_url=BASE_URL, access_token="t") + respx.post(f"{BASE_URL}/v1/webhooks/endpoints").mock( + return_value=httpx.Response( + 201, + json={ + "id": "wh1", + "url": "https://example.com/hook", + "enabled": True, + "event_types": ["document.uploaded"], + "created_at": "2026-06-29T00:00:00Z", + "updated_at": "2026-06-29T00:00:00Z", + "secret": "whsec_abc", + }, + ) + ) + endpoint = client.webhooks.create_endpoint( + url="https://example.com/hook", event_types=["document.uploaded"] + ) + assert endpoint.secret == "whsec_abc" + assert endpoint.event_types == ["document.uploaded"] + + +@respx.mock +def test_list_webhook_deliveries(): + client = MikeClient(base_url=BASE_URL, access_token="t") + respx.get(f"{BASE_URL}/v1/webhooks/deliveries").mock( + return_value=httpx.Response( + 200, + json=[ + { + "id": "d1", + "endpoint_id": "wh1", + "event_type": "document.uploaded", + "status": "succeeded", + "attempts": 1, + "response_status": 200, + "last_error": None, + "created_at": "2026-06-29T00:00:00Z", + "delivered_at": "2026-06-29T00:00:01Z", + } + ], + ) + ) + deliveries = client.webhooks.list_deliveries() + assert deliveries[0].status == "succeeded" + assert deliveries[0].response_status == 200 diff --git a/supabase/migrations/20260711000001_developer_platform.sql b/supabase/migrations/20260711000001_developer_platform.sql new file mode 100644 index 0000000000..1ec06f3454 --- /dev/null +++ b/supabase/migrations/20260711000001_developer_platform.sql @@ -0,0 +1,110 @@ +-- Developer platform: programmatic API keys + webhooks. +-- +-- WHY THIS EXISTS: +-- The SDKs previously authenticated only with a short-lived Supabase JWT — fine +-- for a browser, useless for a script, cron job, or CI pipeline. This migration +-- adds the storage for two new capabilities: +-- 1. api_keys — long-lived programmatic credentials. +-- 2. webhook_endpoints — where to push events, + the HMAC signing secret. +-- webhook_deliveries — the recorded history/result of every send. +-- +-- SECURITY NOTES baked into the schema: +-- - We store only a SHA-256 *hash* of each API key (key_hash), never the +-- secret. A DB leak therefore can't be replayed against the API. A short, +-- non-secret key_prefix is stored in the clear purely for display + lookup. +-- - Every table gets RLS enabled with a deny-all policy and has direct +-- privileges revoked from anon/authenticated. All access goes through the +-- backend service role — consistent with the rest of this schema (see +-- 20260524000000_rls_deny_all.sql). + +-- ── api_keys ──────────────────────────────────────────────────────────────── +create table if not exists public.api_keys ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + name text not null, + key_prefix text not null, + key_hash text not null unique, + scopes text[] not null default array['read','write'], + last_used_at timestamptz, + created_at timestamptz not null default now(), + revoked_at timestamptz +); + +-- Partial index: authentication looks keys up by prefix, and only live keys +-- (revoked_at is null) are ever candidates, so the index stays small + fast. +create index if not exists idx_api_keys_prefix_active + on public.api_keys(key_prefix) + where revoked_at is null; +create index if not exists idx_api_keys_user + on public.api_keys(user_id); + +-- ── webhook_endpoints ──────────────────────────────────────────────────────── +create table if not exists public.webhook_endpoints ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + url text not null, + encrypted_secret text not null, + secret_iv text not null, + secret_tag text not null, + enabled boolean not null default true, + event_types text[] not null default array[]::text[], + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_webhook_endpoints_user + on public.webhook_endpoints(user_id); + +-- ── webhook_deliveries ─────────────────────────────────────────────────────── +create table if not exists public.webhook_deliveries ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + endpoint_id uuid not null references public.webhook_endpoints(id) on delete cascade, + event_type text not null, + payload jsonb not null default '{}'::jsonb, + status text not null default 'pending' + check (status in ('pending', 'succeeded', 'failed')), + attempts integer not null default 0, + response_status integer, + response_body text, + last_error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + delivered_at timestamptz +); + +create index if not exists idx_webhook_deliveries_endpoint + on public.webhook_deliveries(endpoint_id, created_at desc); +create index if not exists idx_webhook_deliveries_user + on public.webhook_deliveries(user_id, created_at desc); + +-- ── RLS: enable + deny-all (matches the repo's default-deny posture) ───────── +alter table public.api_keys enable row level security; +alter table public.webhook_endpoints enable row level security; +alter table public.webhook_deliveries enable row level security; + +do $$ +declare + t text; +begin + foreach t in array array['api_keys', 'webhook_endpoints', 'webhook_deliveries'] + loop + if not exists ( + select 1 from pg_policies + where schemaname = 'public' and tablename = t + ) then + execute format( + 'create policy deny_all_fallback on public.%I + for all to anon, authenticated + using (false) with check (false)', + t + ); + end if; + end loop; +end +$$; + +-- Backend service role only — no direct client access. +revoke all on public.api_keys from anon, authenticated; +revoke all on public.webhook_endpoints from anon, authenticated; +revoke all on public.webhook_deliveries from anon, authenticated;