Skip to content

feat: developer platform — programmatic API keys, OpenAPI spec & webhooks - #12

Open
amal66 wants to merge 13 commits into
mainfrom
feat/developer-platform
Open

feat: developer platform — programmatic API keys, OpenAPI spec & webhooks#12
amal66 wants to merge 13 commits into
mainfrom
feat/developer-platform

Conversation

@amal66

@amal66 amal66 commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Summary

Adds programmatic API keys, OpenAPI 3.1 documentation, signed outbound webhooks, TypeScript/Python SDK support, and a Developer settings page.

Review hardening

  • API keys are stored only as SHA-256 hashes and can be revoked.
  • Webhook signing secrets are encrypted with per-record AES-GCM material rather than stored as plaintext; a regression test proves the plaintext secret is returned once but never persisted.
  • Server-side webhook delivery goes through the existing guarded fetch (private-network, metadata-host, DNS-rebinding, and redirect protections).

Verification

Reconciled with current main (b3166dd, olp UI sync) via a clean merge — no conflicts, no adaptations required.

  • API-key + webhook-signature + webhook-secret suites: 30 tests pass.
  • SSRF (client.ssrf) + encryption (crypto) suites the delivery path relies on: 13 tests pass.
  • Full apps/api suite: 545 passed, 6 skipped (63 files). apps/api tsc build passes.
  • @mike/api-client (10 tests) and @mike/sdk-js (7 tests) pass; both build, as does @mike/core.
  • apps/web Next.js production build passes with the /account/developer route.
  • Python SDK tests were not run in this environment (no pytest available).

Webhook deliveries are persisted, but retry scheduling is still in-process; durable BullMQ delivery remains documented follow-up work.

Merge sequencing

Merge after #11. This PR's 20260711000001 migration intentionally follows the billing migration (20260711000000) added there.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC

@amal66
amal66 force-pushed the feat/developer-platform branch 6 times, most recently from d7b3e9e to 92f2498 Compare July 6, 2026 00:26
amal66 and others added 8 commits July 9, 2026 19:40
…s, webhook_deliveries

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) <noreply@anthropic.com>
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_<base62> 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) <noreply@anthropic.com>
…nt routes

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) <noreply@anthropic.com>
…l emit point

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) <noreply@anthropic.com>
…UI at /docs

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) <noreply@anthropic.com>
…Python SDKs

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) <noreply@anthropic.com>
…eries

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) <noreply@anthropic.com>
…cation cookbook

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) <noreply@anthropic.com>
@amal66
amal66 force-pushed the feat/developer-platform branch from 92f2498 to 5e46a70 Compare July 10, 2026 02:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant