From 94d98aaf04508c758fb0ad5ef7aa5124570b5b95 Mon Sep 17 00:00:00 2001 From: Amal Date: Mon, 29 Jun 2026 23:09:44 -0700 Subject: [PATCH 01/11] =?UTF-8?q?feat(api):=20add=20developer-platform=20t?= =?UTF-8?q?ables=20=E2=80=94=20api=5Fkeys,=20webhook=5Fendpoints,=20webhoo?= =?UTF-8?q?k=5Fdeliveries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY THIS MATTERS A "developer platform" needs durable storage for three things: the long-lived credentials developers mint, the endpoints they want events pushed to, and the history of every push attempt. This commit lays that foundation in both the full schema snapshot (schema.sql, used for fresh DBs) and a dated, idempotent migration (used to evolve existing DBs) so the two never drift. WHAT IS RLS DENY-ALL? Supabase exposes tables to clients over HTTP, gated by Row Level Security. A table with RLS enabled but NO policy denies everyone — a "default-deny firewall". Every Mike table follows this posture: the backend service role is the only thing that touches data. We mirror it here: RLS enabled + an explicit deny-all policy + privileges revoked from anon/authenticated. See the existing 20260524000000_rls_deny_all.sql for the pattern we follow. HOW IT WORKS - api_keys stores only key_hash (a SHA-256 digest) + a short non-secret key_prefix. The raw secret is never persisted, so a DB leak can't be replayed. A partial index on (key_prefix) WHERE revoked_at IS NULL keeps auth lookups fast and small. - webhook_endpoints holds the destination URL, the per-endpoint HMAC secret, enabled flag, and subscribed event_types. - webhook_deliveries records event/payload/status/attempts/response per send, so delivery history is always inspectable (and replayable in future). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/schema.sql | 72 ++++++++++++ .../20260701000005_developer_platform.sql | 108 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 supabase/migrations/20260701000005_developer_platform.sql diff --git a/apps/api/schema.sql b/apps/api/schema.sql index 6e730fb98b..0c6718ac6a 100644 --- a/apps/api/schema.sql +++ b/apps/api/schema.sql @@ -246,6 +246,75 @@ 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, + secret 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, @@ -1327,6 +1396,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/supabase/migrations/20260701000005_developer_platform.sql b/supabase/migrations/20260701000005_developer_platform.sql new file mode 100644 index 0000000000..9f144ece75 --- /dev/null +++ b/supabase/migrations/20260701000005_developer_platform.sql @@ -0,0 +1,108 @@ +-- 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, + secret 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; From 921d73967e23c96017338a76c09e3d4a364d9aef Mon Sep 17 00:00:00 2001 From: Amal Date: Mon, 29 Jun 2026 23:10:00 -0700 Subject: [PATCH 02/11] feat(api): add API-key and webhook-signature crypto primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY THIS MATTERS The security of the whole platform rests on a few small crypto helpers. Keeping them pure (no DB, no env, no Express) means they are trivial to reason about and to unit-test exhaustively — the riskiest code gets the most direct coverage. WHAT IS A HASHED, OPAQUE API KEY? An API key is a bearer secret: whoever holds the string can act as the user, so we treat it exactly like a password. We never store the raw key — only its SHA-256 hash. If the database leaks, the hashes can't be replayed because the server only ever compares hashes. SHA-256 (not bcrypt/argon2) is the right choice here: slow hashes defend LOW-entropy human passwords against brute force, but a 40-char random base62 secret has ~238 bits of entropy and isn't brute-forceable — so a fast hash is correct and cheaper (the Stripe/GitHub "sk_"-key model). WHAT IS A TIMING-SAFE COMPARE / AN HMAC SIGNATURE? - A naive a===b on secrets returns early at the first differing byte, leaking via response timing how many leading bytes a guess got right — enough to forge a secret incrementally. crypto.timingSafeEqual always compares the full buffers, removing that signal. - An HMAC signature is hash(secret, body). Because only Mike and the receiver share the secret, a valid signature proves authenticity (it came from Mike) AND integrity (the body wasn't altered). It is a tamper-evident seal, not encryption — the same idea as core/downloadTokens.ts. HOW IT WORKS - core/apiKeys.ts: generateApiKey() mints mike_sk_ via rejection sampling (no modulo bias), returns {token, prefix, hash}; verifyApiKeyHash() does a length-guarded timingSafeEqual. - core/webhookSignature.ts: signWebhookPayload()/verifyWebhookSignature() (HMAC-SHA256, constant-time) plus generateWebhookSecret(). - webhookSignature.test.ts covers determinism, integrity/authenticity changes, constant-time verification, and length-mismatch safety. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/core/apiKeys.ts | 114 ++++++++++++++++++ apps/api/src/core/webhookSignature.ts | 50 ++++++++ .../lib/__tests__/webhookSignature.test.ts | 65 ++++++++++ 3 files changed, 229 insertions(+) create mode 100644 apps/api/src/core/apiKeys.ts create mode 100644 apps/api/src/core/webhookSignature.ts create mode 100644 apps/api/src/lib/__tests__/webhookSignature.test.ts 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__/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); + }); +}); From bb7c7a85f93072b157aba73ae08dc1c999704922 Mon Sep 17 00:00:00 2001 From: Amal Date: Mon, 29 Jun 2026 23:10:15 -0700 Subject: [PATCH 03/11] feat(api): accept programmatic API keys in requireAuth + key-management routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY THIS MATTERS A real developer platform lets you authenticate WITHOUT an interactive browser login. This teaches the existing auth path one new trick — accept a long-lived API key — while leaving Supabase-JWT auth and MFA completely unchanged. Adding a capability without disturbing what works is the heart of safe evolution. WHAT IS THE AUTH BRANCH / WHY SESSION-ONLY MANAGEMENT? requireAuth now branches on the credential type: a token starting with mike_sk_ is a programmatic key; anything else is treated as a Supabase JWT exactly as before. The API-key branch sets res.locals.userId identically, so every downstream route works unchanged. Management routes (mint/list/revoke) are guarded by requireUserSession — they need a real logged-in user, NOT a key. That closes a privilege-escalation path: a leaked key can call the data API but can never mint MORE keys or replace itself. HOW IT WORKS - lib/apiKeys.ts is the DB layer (dependency-injectable `db`, matching userApiKeys.ts): createApiKey() returns the one-time token; authenticateApiKey() looks a key up by its non-secret prefix (cheap, indexed) then constant-time compares the full hash; revokeApiKey() soft-deletes via revoked_at; touchApiKeyLastUsed() is fire-and-forget so analytics never block a request. - middleware/auth.ts: API keys deliberately bypass the interactive MFA gate (a key is a possession factor the user minted and can revoke), and scope is enforced by mapping HTTP method -> read/write. - routes/apiKeys.ts: POST/GET/DELETE under /v1/api-keys, Zod-validated, secret returned exactly once on create. - apiKeys.test.ts: key format/hashing/timing-safe verify PLUS the DB layer accepting a valid key and rejecting a revoked, mismatched, or non-key bearer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/integration/chat.routes.test.ts | 2 + .../integration/dmsConnectors.routes.test.ts | 2 + .../documentsUpload.routes.test.ts | 2 + .../__tests__/integration/orgs.routes.test.ts | 2 + .../integration/projectChat.routes.test.ts | 2 + .../integration/projects.routes.test.ts | 2 + .../integration/tabular.routes.test.ts | 2 + .../__tests__/integration/user.routes.test.ts | 2 + apps/api/src/lib/__tests__/apiKeys.test.ts | 190 ++++++++++++++++++ apps/api/src/lib/apiKeys.ts | 190 ++++++++++++++++++ apps/api/src/middleware/auth.ts | 95 +++++++++ apps/api/src/routes/apiKeys.ts | 59 ++++++ 12 files changed, 550 insertions(+) create mode 100644 apps/api/src/lib/__tests__/apiKeys.test.ts create mode 100644 apps/api/src/lib/apiKeys.ts create mode 100644 apps/api/src/routes/apiKeys.ts 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/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/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/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/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(); +}); From c60f1a73ee2b20f6fdacdc59f4fe820aa5b33f38 Mon Sep 17 00:00:00 2001 From: Amal Date: Mon, 29 Jun 2026 23:10:31 -0700 Subject: [PATCH 04/11] =?UTF-8?q?feat(api):=20webhooks=20subsystem=20?= =?UTF-8?q?=E2=80=94=20signed=20delivery,=20backoff=20retries,=20real=20em?= =?UTF-8?q?it=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY THIS MATTERS Polling an API to ask "did anything happen yet?" is wasteful and slow. Webhooks invert that: Mike PUSHES a signed HTTP request to the developer the moment an event occurs. This is the "push" half of the platform (the REST API is "pull"). WHAT IS AT-LEAST-ONCE DELIVERY WITH BACKOFF? Receivers go down. Rather than drop an event, we retry with EXPONENTIAL BACKOFF (0s, 5s, 30s, 2m, 10m) so a briefly-unavailable endpoint recovers without being hammered. Because a retry can repeat a delivery, we send a stable X-Mike-Delivery-Id the receiver can use as an IDEMPOTENCY KEY. Every attempt's status/response is persisted, so history is always auditable. WHY IN-PROCESS (NOT REDIS/BULLMQ)? Mike is self-hostable as a single service; requiring a queue would raise the bar for every operator to serve a feature many won't use on day one. We keep delivery in-process (setTimeout) and persist every delivery row up front to limit the downside (a durable queue is documented as future work in the ADR). HOW IT WORKS - lib/webhooks.ts: WEBHOOK_EVENT_TYPES catalogue; endpoint CRUD (secret shown once on create, mirroring API keys); emitWebhookEvent() fans an event out to subscribed endpoints, inserts delivery rows, and schedules attemptDelivery(), which signs the body (HMAC-SHA256 -> X-Mike-Signature), POSTs with a timeout, and reschedules on failure up to 5 attempts. - routes/webhooks.ts: /v1/webhooks endpoints + deliveries + event catalogue, session-guarded, HTTPS-required in production (URL parsed, not regex-matched, to avoid ReDoS). - projects.routes.ts: the first REAL emit point — `document.uploaded` fires (fire-and-forget) after an upload succeeds, so a webhook problem can never fail the user's upload. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/lib/webhooks.ts | 308 ++++++++++++++++++ .../modules/projects/projects.documents.ts | 13 + apps/api/src/routes/webhooks.ts | 102 ++++++ 3 files changed, 423 insertions(+) create mode 100644 apps/api/src/lib/webhooks.ts create mode 100644 apps/api/src/routes/webhooks.ts diff --git a/apps/api/src/lib/webhooks.ts b/apps/api/src/lib/webhooks.ts new file mode 100644 index 0000000000..7248f731ad --- /dev/null +++ b/apps/api/src/lib/webhooks.ts @@ -0,0 +1,308 @@ +import { createServerSupabase } from "./supabase"; +import { logger } from "./logger"; +import { + generateWebhookSecret, + signWebhookPayload, +} from "../core/webhookSignature"; + +/** + * 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; + secret: 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 { data, error } = await db + .from("webhook_endpoints") + .insert({ + user_id: userId, + url, + secret, + 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, secret, 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 signature = signWebhookPayload(body, endpoint.secret as string); + + 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 res = await fetch(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, + }); + 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/modules/projects/projects.documents.ts b/apps/api/src/modules/projects/projects.documents.ts index b6e679b1c2..61f56f8cb8 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, @@ -436,6 +437,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/webhooks.ts b/apps/api/src/routes/webhooks.ts new file mode 100644 index 0000000000..eecc56c43c --- /dev/null +++ b/apps/api/src/routes/webhooks.ts @@ -0,0 +1,102 @@ +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 { + 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; + } + + 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, + }), + ); +}); From bf698eb532e7c0ccd16a9d57c98e2ac1e79bd413 Mon Sep 17 00:00:00 2001 From: Amal Date: Mon, 29 Jun 2026 23:10:46 -0700 Subject: [PATCH 05/11] feat(api): publish OpenAPI 3.1 contract at /openapi.json and Swagger UI at /docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY THIS MATTERS A platform you can build on needs a CONTRACT both humans and machines agree on. From one OpenAPI document you can render interactive docs, generate typed SDKs, drive contract tests, and import the whole API into Postman. It is the single source of truth for the public surface. WHAT IS OPENAPI — AND WHY HAND-AUTHORED HERE? OpenAPI is a standard, machine-readable description of every endpoint's inputs and outputs. The popular generator (@asteasolutions/zod-to-openapi) targets Zod v3, but this repo runs Zod v4, so wiring it cleanly would mean pinning an old Zod or shimming every schema. For a handful of public endpoints a curated, typed document is simpler and clearer — and keeping it in sync is part of "done". Generating SDKs FROM this spec is the planned next step (see ADR 0001). HOW IT WORKS - lib/openapi.ts exports a typed OpenAPI 3.1 object describing the API-key and webhook routes plus the existing projects/documents/chat endpoints the SDKs call, with a bearerAuth security scheme and reusable component schemas. - app.ts mounts the new routers under /v1, serves the raw spec at /openapi.json, and renders Swagger UI at /docs. Helmet's global CSP is default-src 'none', which would block Swagger UI's inline assets, so we relax the policy for the /docs subtree ONLY (all first-party, no third-party CDNs). - package.json: add swagger-ui-express; pin @types/express to v4 via a root override because the swagger types otherwise pull a conflicting v5 into the tree. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/package.json | 2 + apps/api/src/app.ts | 40 ++++ apps/api/src/lib/openapi.ts | 456 ++++++++++++++++++++++++++++++++++++ package-lock.json | 44 ++++ package.json | 4 +- 5 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/lib/openapi.ts 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/src/app.ts b/apps/api/src/app.ts index f4cf821bb3..359d47ea70 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -16,6 +16,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"; @@ -199,6 +203,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/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/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" From ba6d9f35f8b685ed7f9efb6090734c19a72c42e8 Mon Sep 17 00:00:00 2001 From: Amal Date: Mon, 29 Jun 2026 23:11:00 -0700 Subject: [PATCH 06/11] feat(sdk): make API keys a first-class auth option across the TS and Python SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY THIS MATTERS The platform is only useful if the SDKs can actually use it. This wires the new credential and management endpoints through every layer of the SDK stack so a developer can authenticate with a key and manage keys/webhooks programmatically — while keeping the existing token option working unchanged. WHAT IS THE LAYERING CONTRACT? The repo's convention is: stable shapes live in packages/core, low-level endpoint calls in packages/api-client, and an ergonomic facade in packages/sdk-js. We respect it: types first, then client functions, then the class facade — one source of truth, no duplication. HOW IT WORKS - @mike/core: add ApiKey/ApiKeyCreateResponse/WebhookEndpoint/WebhookDelivery (+ scope/event-type unions) as the canonical public shapes. - @mike/api-client: add listApiKeys/createApiKey/revokeApiKey and the webhook endpoint/delivery/event functions, plus matching resources on createMikeApiClient; re-export the new types. - @mike/sdk-js: MikeClient already forwards `apiKey` as a Bearer header, so a mike_sk_ key works out of the box — clarified in the option's docs — and the facade gains `apiKeys` and `webhooks` namespaces. - Python `mike`: the client now accepts api_key=... (sent as Authorization: Bearer) alongside the existing session_token, with new api_keys and webhooks resources + pydantic models. test_developer_platform.py asserts the bearer header is sent and that the new resources round-trip (20 tests pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/api-client/src/index.ts | 150 +++++++++++++++++++ packages/core/src/types.ts | 53 +++++++ packages/sdk-js/src/index.ts | 63 ++++++++ sdks/python/mike/__init__.py | 11 ++ sdks/python/mike/_client.py | 45 +++++- sdks/python/mike/_models.py | 44 ++++++ sdks/python/mike/resources/__init__.py | 6 + sdks/python/mike/resources/api_keys.py | 56 +++++++ sdks/python/mike/resources/webhooks.py | 90 +++++++++++ sdks/python/tests/test_developer_platform.py | 145 ++++++++++++++++++ 10 files changed, 655 insertions(+), 8 deletions(-) create mode 100644 sdks/python/mike/resources/api_keys.py create mode 100644 sdks/python/mike/resources/webhooks.py create mode 100644 sdks/python/tests/test_developer_platform.py diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index bf5cd8edd8..678c368f3e 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, @@ -11,6 +14,10 @@ import type { OpenSourceWorkflowContributorMode, OpenSourceWorkflowResponse, Project, + WebhookDelivery, + WebhookEndpoint, + WebhookEndpointCreateResponse, + WebhookEventType, Workflow, WorkflowContributor, TabularReview, @@ -18,6 +25,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 @@ -896,6 +912,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, @@ -977,6 +1061,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 59e05ea570..2f9230d406 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -656,6 +656,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 From 9e1d80e3e77c5623480eb4e00a7d6ce24cadb822 Mon Sep 17 00:00:00 2001 From: Amal Date: Mon, 29 Jun 2026 23:11:13 -0700 Subject: [PATCH 07/11] feat(web): add the Developer settings page for keys, webhooks & deliveries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY THIS MATTERS Most developers meet a platform through its dashboard, not its API. This adds a "Developer" tab under Settings where a user can mint an API key, register webhook endpoints, and watch deliveries — without ever touching curl. It reuses the existing account-page design system so it feels native. WHAT IS "SHOW THE SECRET ONCE"? Because the backend stores only a hash, a freshly-created key (or webhook signing secret) is the ONLY moment the plaintext exists. The UI surfaces it in a prominent amber box with a copy button and an explicit "you won't see this again" warning, then lets the user dismiss it — teaching the security model through the interface itself. HOW IT WORKS - account/layout.tsx: add the "Developer" tab to the settings nav. - account/developer/page.tsx: a client component with two sections — API keys (create with a name, list prefix/scopes/last-used, revoke) and Webhooks (create with URL + event-type checkboxes fetched from the live catalogue, list, delete, and a "Recent deliveries" panel with status colours). All calls go through the configured @mike/api-client functions re-exported by mikeApi. - A shared SecretReveal component implements the one-time copy-with-warning box. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/(pages)/account/developer/page.tsx | 458 ++++++++++++++++++ apps/web/src/app/(pages)/account/layout.tsx | 1 + 2 files changed, 459 insertions(+) create mode 100644 apps/web/src/app/(pages)/account/developer/page.tsx 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({ From 5e46a709baf063af65e66f2da31ec38455932bf4 Mon Sep 17 00:00:00 2001 From: Amal Date: Mon, 29 Jun 2026 23:11:27 -0700 Subject: [PATCH 08/11] docs: ADR + developer-platform guide, and an API-key signature-verification cookbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY THIS MATTERS A feature nobody can learn from is half-finished. This documents the platform at two altitudes: an ADR capturing WHY each decision was made (for maintainers and reviewers), and a user-facing guide showing HOW to use it (for developers). Good docs are also a security control — most webhook bugs are receivers that forget to verify the signature, so we ship a copy-paste-correct example. WHAT GOES WHERE - docs/adr/0001-developer-platform.md: an Architecture Decision Record — context, the decision, alternatives weighed (opaque hashed token vs JWT vs stored secret; hand-authored vs generated OpenAPI; in-process vs queued delivery), consequences, and a dedicated security section (why hash-not-store, why constant-time compare, the scope model, and why API keys bypass MFA). - docs/developer-platform.md: the guide — create a key, authenticate the SDKs and curl, find /openapi.json and /docs, the webhook event catalogue, the delivery format, and constant-time X-Mike-Signature verification in Node and Python. - docs/api.md & docs/sdk.md: short sections pointing at API-key auth and the OpenAPI endpoint, with links into the guide and ADR. - PR_BODY.md: the full PR description (summary, motivation, architecture diagram, design decisions, security/testing notes, a "how to try it" walkthrough, and future work) used as the pull-request body and kept in-tree as a record. Co-Authored-By: Claude Opus 4.8 (1M context) --- PR_BODY.md | 156 +++++++++++++++++++++++ docs/adr/0001-developer-platform.md | 146 +++++++++++++++++++++ docs/api.md | 21 +++ docs/developer-platform.md | 190 ++++++++++++++++++++++++++++ docs/sdk.md | 15 ++- 5 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 PR_BODY.md create mode 100644 docs/adr/0001-developer-platform.md create mode 100644 docs/developer-platform.md diff --git a/PR_BODY.md b/PR_BODY.md new file mode 100644 index 0000000000..c00ceb99d7 --- /dev/null +++ b/PR_BODY.md @@ -0,0 +1,156 @@ +# feat: developer platform — programmatic API keys, OpenAPI spec & webhooks + +## Summary + +This PR adds the missing **platform layer** that turns Mike from "an app with an +API" into "a platform you can build on": + +1. **Programmatic API keys** — long-lived, revocable, scoped `mike_sk_…` + credentials. The auth middleware now accepts **either** a Supabase JWT + (unchanged) **or** an API key. +2. **OpenAPI 3.1 contract** — served at `GET /openapi.json` and rendered as + interactive docs at `GET /docs`. +3. **Webhooks** — register endpoints, receive HMAC-signed events with + exponential-backoff retries and full delivery history. +4. **Developer portal UI** — a Settings → Developer page to manage keys, + webhooks, and inspect deliveries. +5. **SDKs wired to keys** — `@mike/api-client`, `@mike/sdk-js`, and the Python + `mike` package all treat an API key as a first-class auth option. + +Everything is **additive**: existing JWT auth, MFA, and every current route are +unchanged. + +## Motivation + +Mike already ships TS + Python SDKs, but both authenticate only with a +short-lived **Supabase session JWT** — the browser's credential. That blocks the +things developers actually want: calling the API from **scripts / CI**, getting +**push events** instead of polling, and discovering the API from a +**machine-readable contract**. + +This is the **"Inspired by Dub → API & Developer Platform"** direction. The fork +ecosystem corroborates it: forks keep adding BYOK/provider integrations, MCP +connectors, and developer-onboarding tours. The natural layer *underneath* all +of that is a first-class **programmatic credential + event delivery + published +contract** — exactly what this PR builds. + +## Architecture overview + +```mermaid +flowchart LR + subgraph Client + SDKjs["@mike/sdk-js"] + SDKpy["python mike"] + curl["curl / CI"] + end + Client -->|"Authorization: Bearer mike_sk_…"| MW["requireAuth\n(JWT or API key)"] + MW -->|JWT| JWT["Supabase getUser"] + MW -->|"mike_sk_*"| AK["authenticateApiKey\n(prefix lookup + timing-safe hash compare)"] + MW --> Routes["existing routes\n(res.locals.userId set identically)"] + Routes -->|"document.uploaded"| EMIT["emitWebhookEvent"] + EMIT --> DLV["delivery service\nHMAC-SHA256 + backoff retries"] + DLV -->|"POST + X-Mike-Signature"| Endpoint["developer's HTTPS endpoint"] + Spec["/openapi.json + /docs"] -.->|describes| Routes +``` + +Key pieces: + +- `apps/api/src/core/apiKeys.ts` — pure crypto: generate / hash / **timing-safe + verify** (no DB, fully unit-tested). +- `apps/api/src/core/webhookSignature.ts` — HMAC sign / verify / secret-gen. +- `apps/api/src/lib/apiKeys.ts`, `lib/webhooks.ts` — DB + delivery layers. +- `apps/api/src/middleware/auth.ts` — the additive API-key branch + scope check + + `requireUserSession` guard. +- `apps/api/src/lib/openapi.ts` — the hand-authored OpenAPI 3.1 document. +- `apps/api/src/routes/apiKeys.ts`, `routes/webhooks.ts` — management routes. + +## Design decisions + +The full rationale — alternatives, trade-offs, and security model — is in +[**ADR 0001: Developer Platform**](docs/adr/0001-developer-platform.md). +Highlights: + +- **Opaque hashed key, not a JWT or a stored secret.** Instantly revocable, + useless if the DB leaks. SHA-256 (not bcrypt) is correct for a high-entropy + random secret. +- **Hand-authored OpenAPI**, because the repo runs **Zod v4** and + `zod-to-openapi` targets v3 — a curated typed doc is cleaner than shimming + every schema. Generating SDKs *from* the spec is the planned next step. +- **In-process webhook delivery**, not Redis/BullMQ — Mike is self-hostable as a + single service. Every delivery row is persisted up front; a durable queue is + future work. + +## Security notes + +- **Hash, don't store** — only `sha256(token)` + a non-secret prefix are + persisted; the secret is shown once. +- **Constant-time verification** (`crypto.timingSafeEqual`) for both API keys and + webhook signatures, to defend against timing-based forgery. +- **Prefix lookup** avoids a table scan *and* a timing-unsafe SQL equality on the + hash. +- **Scopes** — `read` for GET/HEAD, `write` otherwise. +- **No privilege escalation** — key/webhook management requires a user session; + a key cannot mint a key. +- **API keys bypass interactive MFA by design** (a key is a possession factor the + user minted and can revoke) — rationale in the ADR. +- **Webhook HMAC** (`X-Mike-Signature`) proves authenticity + integrity; HTTPS + required in production. +- **RLS** enabled + deny-all on all three new tables, privileges revoked from + `anon`/`authenticated`, matching the repo's default-deny posture. + +## Testing + +Ran in the worktree: + +- `npm run typecheck --workspaces` — **pass** (api + all packages; web `tsc + --noEmit` clean). +- `npm run build` for `@mike/core`, `@mike/api-client`, `@mike/sdk-js`, + `apps/api` — **pass**. +- `apps/api` Vitest — **180 passed, 1 skipped** (pre-existing skip), including new + suites: + - `apiKeys.test.ts` — key format, hashing, timing-safe verify, and the DB + layer accepting a valid key / rejecting a revoked / mismatched / non-key + bearer. + - `webhookSignature.test.ts` — deterministic HMAC, integrity + authenticity + changes, constant-time verify, length-mismatch safety. +- Python SDK — **20 passed** (`pytest`), including API-key bearer-header + construction and the new api-keys / webhooks resources. +- `eslint` on all new/changed API files — clean (replaced a flagged regex with + safe URL parsing). + +## How to try it + +1. **Run a migration** (or apply `apps/api/schema.sql` to a fresh DB): + `supabase/migrations/20260629000001_developer_platform.sql`. +2. **Create a key:** web app → **Settings → Developer** → name it → **Create** → + copy the `mike_sk_…` secret (shown once). +3. **Call the API:** + ```bash + curl http://localhost:3001/projects \ + -H "Authorization: Bearer mike_sk_..." + ``` + …or `new MikeClient({ baseUrl, apiKey: "mike_sk_..." })`. +4. **Explore the contract:** open `http://localhost:3001/docs` (or + `GET /openapi.json`). +5. **Register a webhook:** Settings → Developer → add an HTTPS URL + events → + copy the `whsec_…` secret. Upload a document and watch a `document.uploaded` + delivery appear under **Recent deliveries**. Verify `X-Mike-Signature` with + the snippet in [docs/developer-platform.md](docs/developer-platform.md). + +## Future work + +- Durable, queued delivery via **BullMQ** (dead-letter + 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). +- Per-route scope granularity beyond `read`/`write`. + +## Guided tour / further reading + +- [ADR 0001 — Developer Platform](docs/adr/0001-developer-platform.md) +- [Developer Platform guide](docs/developer-platform.md) +- [API docs](docs/api.md) · [SDK docs](docs/sdk.md) + +--- + +🤖 Generated with [Claude Code](https://claude.com/claude-code) 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`. From e19db40355d2f5dd252705f378a90220007bda9d Mon Sep 17 00:00:00 2001 From: Amal Date: Sun, 12 Jul 2026 13:16:27 -0700 Subject: [PATCH 09/11] fix(webhooks): encrypt secrets and block SSRF --- apps/api/schema.sql | 4 +- apps/api/src/lib/__tests__/webhooks.test.ts | 68 +++++++++++++++++++ apps/api/src/lib/webhooks.ts | 36 ++++++++-- apps/api/src/routes/webhooks.ts | 24 ++++++- .../20260701000005_developer_platform.sql | 4 +- 5 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/lib/__tests__/webhooks.test.ts diff --git a/apps/api/schema.sql b/apps/api/schema.sql index 0c6718ac6a..15e499e805 100644 --- a/apps/api/schema.sql +++ b/apps/api/schema.sql @@ -277,7 +277,9 @@ 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, - secret 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(), 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/webhooks.ts b/apps/api/src/lib/webhooks.ts index 7248f731ad..2f39fe868c 100644 --- a/apps/api/src/lib/webhooks.ts +++ b/apps/api/src/lib/webhooks.ts @@ -4,6 +4,7 @@ import { generateWebhookSecret, signWebhookPayload, } from "../core/webhookSignature"; +import { decryptString, encryptString, guardedFetch } from "./mcp/client"; /** * Webhooks subsystem: lets developers register HTTPS endpoints that Mike calls @@ -60,7 +61,9 @@ export type WebhookEndpointSummary = { type WebhookEndpointRow = WebhookEndpointSummary & { user_id: string; - secret: string; + encrypted_secret: string; + secret_iv: string; + secret_tag: string; }; export type WebhookDeliverySummary = { @@ -101,12 +104,15 @@ export async function createWebhookEndpoint( 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, - secret, + encrypted_secret: encrypted.encrypted, + secret_iv: encrypted.iv, + secret_tag: encrypted.tag, enabled: true, event_types: eventTypes, }) @@ -245,7 +251,7 @@ async function attemptDelivery( const { data: endpoint } = await db .from("webhook_endpoints") - .select("url, secret, enabled") + .select("url, encrypted_secret, secret_iv, secret_tag, enabled") .eq("id", delivery.endpoint_id) .single(); if (!endpoint || endpoint.enabled === false) return; @@ -258,7 +264,24 @@ async function attemptDelivery( created_at: new Date().toISOString(), data: delivery.payload ?? {}, }); - const signature = signWebhookPayload(body, endpoint.secret as string); + 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; @@ -268,7 +291,9 @@ async function attemptDelivery( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS); try { - const res = await fetch(endpoint.url as string, { + const fetchWebhook = + process.env.NODE_ENV === "production" ? guardedFetch : fetch; + const res = await fetchWebhook(endpoint.url as string, { method: "POST", headers: { "Content-Type": "application/json", @@ -279,6 +304,7 @@ async function attemptDelivery( }, body, signal: controller.signal, + redirect: "manual", }); responseStatus = res.status; responseBody = (await res.text()).slice(0, 1000); // cap stored body diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts index eecc56c43c..6fa0bbd607 100644 --- a/apps/api/src/routes/webhooks.ts +++ b/apps/api/src/routes/webhooks.ts @@ -3,6 +3,7 @@ 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, @@ -24,7 +25,9 @@ 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"), + event_types: z + .array(z.string()) + .nonempty("at least one event type is required"), }); // GET /v1/webhooks/events — the catalogue of subscribable event types. @@ -48,6 +51,21 @@ webhooksRouter.post("/endpoints", async (req, res) => { 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) { @@ -90,7 +108,9 @@ webhooksRouter.delete("/endpoints/:id", async (req, res) => { 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; + 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( diff --git a/supabase/migrations/20260701000005_developer_platform.sql b/supabase/migrations/20260701000005_developer_platform.sql index 9f144ece75..1ec06f3454 100644 --- a/supabase/migrations/20260701000005_developer_platform.sql +++ b/supabase/migrations/20260701000005_developer_platform.sql @@ -43,7 +43,9 @@ 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, - secret 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(), From a1a9f1a84eb94a98da0af6ce2ba92ae0e95b4a80 Mon Sep 17 00:00:00 2001 From: Amal Date: Sun, 12 Jul 2026 13:20:59 -0700 Subject: [PATCH 10/11] fix(migrations): give webhooks a unique version --- ...veloper_platform.sql => 20260711000001_developer_platform.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20260701000005_developer_platform.sql => 20260711000001_developer_platform.sql} (100%) diff --git a/supabase/migrations/20260701000005_developer_platform.sql b/supabase/migrations/20260711000001_developer_platform.sql similarity index 100% rename from supabase/migrations/20260701000005_developer_platform.sql rename to supabase/migrations/20260711000001_developer_platform.sql From 53121d262719d6f1327c5fa3a7577b0d593a67f3 Mon Sep 17 00:00:00 2001 From: Amal Date: Thu, 16 Jul 2026 18:27:14 -0700 Subject: [PATCH 11/11] chore: drop leftover PR_BODY.md authoring artifact Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC --- PR_BODY.md | 156 ----------------------------------------------------- 1 file changed, 156 deletions(-) delete mode 100644 PR_BODY.md diff --git a/PR_BODY.md b/PR_BODY.md deleted file mode 100644 index c00ceb99d7..0000000000 --- a/PR_BODY.md +++ /dev/null @@ -1,156 +0,0 @@ -# feat: developer platform — programmatic API keys, OpenAPI spec & webhooks - -## Summary - -This PR adds the missing **platform layer** that turns Mike from "an app with an -API" into "a platform you can build on": - -1. **Programmatic API keys** — long-lived, revocable, scoped `mike_sk_…` - credentials. The auth middleware now accepts **either** a Supabase JWT - (unchanged) **or** an API key. -2. **OpenAPI 3.1 contract** — served at `GET /openapi.json` and rendered as - interactive docs at `GET /docs`. -3. **Webhooks** — register endpoints, receive HMAC-signed events with - exponential-backoff retries and full delivery history. -4. **Developer portal UI** — a Settings → Developer page to manage keys, - webhooks, and inspect deliveries. -5. **SDKs wired to keys** — `@mike/api-client`, `@mike/sdk-js`, and the Python - `mike` package all treat an API key as a first-class auth option. - -Everything is **additive**: existing JWT auth, MFA, and every current route are -unchanged. - -## Motivation - -Mike already ships TS + Python SDKs, but both authenticate only with a -short-lived **Supabase session JWT** — the browser's credential. That blocks the -things developers actually want: calling the API from **scripts / CI**, getting -**push events** instead of polling, and discovering the API from a -**machine-readable contract**. - -This is the **"Inspired by Dub → API & Developer Platform"** direction. The fork -ecosystem corroborates it: forks keep adding BYOK/provider integrations, MCP -connectors, and developer-onboarding tours. The natural layer *underneath* all -of that is a first-class **programmatic credential + event delivery + published -contract** — exactly what this PR builds. - -## Architecture overview - -```mermaid -flowchart LR - subgraph Client - SDKjs["@mike/sdk-js"] - SDKpy["python mike"] - curl["curl / CI"] - end - Client -->|"Authorization: Bearer mike_sk_…"| MW["requireAuth\n(JWT or API key)"] - MW -->|JWT| JWT["Supabase getUser"] - MW -->|"mike_sk_*"| AK["authenticateApiKey\n(prefix lookup + timing-safe hash compare)"] - MW --> Routes["existing routes\n(res.locals.userId set identically)"] - Routes -->|"document.uploaded"| EMIT["emitWebhookEvent"] - EMIT --> DLV["delivery service\nHMAC-SHA256 + backoff retries"] - DLV -->|"POST + X-Mike-Signature"| Endpoint["developer's HTTPS endpoint"] - Spec["/openapi.json + /docs"] -.->|describes| Routes -``` - -Key pieces: - -- `apps/api/src/core/apiKeys.ts` — pure crypto: generate / hash / **timing-safe - verify** (no DB, fully unit-tested). -- `apps/api/src/core/webhookSignature.ts` — HMAC sign / verify / secret-gen. -- `apps/api/src/lib/apiKeys.ts`, `lib/webhooks.ts` — DB + delivery layers. -- `apps/api/src/middleware/auth.ts` — the additive API-key branch + scope check - + `requireUserSession` guard. -- `apps/api/src/lib/openapi.ts` — the hand-authored OpenAPI 3.1 document. -- `apps/api/src/routes/apiKeys.ts`, `routes/webhooks.ts` — management routes. - -## Design decisions - -The full rationale — alternatives, trade-offs, and security model — is in -[**ADR 0001: Developer Platform**](docs/adr/0001-developer-platform.md). -Highlights: - -- **Opaque hashed key, not a JWT or a stored secret.** Instantly revocable, - useless if the DB leaks. SHA-256 (not bcrypt) is correct for a high-entropy - random secret. -- **Hand-authored OpenAPI**, because the repo runs **Zod v4** and - `zod-to-openapi` targets v3 — a curated typed doc is cleaner than shimming - every schema. Generating SDKs *from* the spec is the planned next step. -- **In-process webhook delivery**, not Redis/BullMQ — Mike is self-hostable as a - single service. Every delivery row is persisted up front; a durable queue is - future work. - -## Security notes - -- **Hash, don't store** — only `sha256(token)` + a non-secret prefix are - persisted; the secret is shown once. -- **Constant-time verification** (`crypto.timingSafeEqual`) for both API keys and - webhook signatures, to defend against timing-based forgery. -- **Prefix lookup** avoids a table scan *and* a timing-unsafe SQL equality on the - hash. -- **Scopes** — `read` for GET/HEAD, `write` otherwise. -- **No privilege escalation** — key/webhook management requires a user session; - a key cannot mint a key. -- **API keys bypass interactive MFA by design** (a key is a possession factor the - user minted and can revoke) — rationale in the ADR. -- **Webhook HMAC** (`X-Mike-Signature`) proves authenticity + integrity; HTTPS - required in production. -- **RLS** enabled + deny-all on all three new tables, privileges revoked from - `anon`/`authenticated`, matching the repo's default-deny posture. - -## Testing - -Ran in the worktree: - -- `npm run typecheck --workspaces` — **pass** (api + all packages; web `tsc - --noEmit` clean). -- `npm run build` for `@mike/core`, `@mike/api-client`, `@mike/sdk-js`, - `apps/api` — **pass**. -- `apps/api` Vitest — **180 passed, 1 skipped** (pre-existing skip), including new - suites: - - `apiKeys.test.ts` — key format, hashing, timing-safe verify, and the DB - layer accepting a valid key / rejecting a revoked / mismatched / non-key - bearer. - - `webhookSignature.test.ts` — deterministic HMAC, integrity + authenticity - changes, constant-time verify, length-mismatch safety. -- Python SDK — **20 passed** (`pytest`), including API-key bearer-header - construction and the new api-keys / webhooks resources. -- `eslint` on all new/changed API files — clean (replaced a flagged regex with - safe URL parsing). - -## How to try it - -1. **Run a migration** (or apply `apps/api/schema.sql` to a fresh DB): - `supabase/migrations/20260629000001_developer_platform.sql`. -2. **Create a key:** web app → **Settings → Developer** → name it → **Create** → - copy the `mike_sk_…` secret (shown once). -3. **Call the API:** - ```bash - curl http://localhost:3001/projects \ - -H "Authorization: Bearer mike_sk_..." - ``` - …or `new MikeClient({ baseUrl, apiKey: "mike_sk_..." })`. -4. **Explore the contract:** open `http://localhost:3001/docs` (or - `GET /openapi.json`). -5. **Register a webhook:** Settings → Developer → add an HTTPS URL + events → - copy the `whsec_…` secret. Upload a document and watch a `document.uploaded` - delivery appear under **Recent deliveries**. Verify `X-Mike-Signature` with - the snippet in [docs/developer-platform.md](docs/developer-platform.md). - -## Future work - -- Durable, queued delivery via **BullMQ** (dead-letter + 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). -- Per-route scope granularity beyond `read`/`write`. - -## Guided tour / further reading - -- [ADR 0001 — Developer Platform](docs/adr/0001-developer-platform.md) -- [Developer Platform guide](docs/developer-platform.md) -- [API docs](docs/api.md) · [SDK docs](docs/sdk.md) - ---- - -🤖 Generated with [Claude Code](https://claude.com/claude-code)