Skip to content

feat(api): add API keys with bearer auth and a per-key rate limit - #228

Merged
josue-commits merged 8 commits into
developfrom
feat/api-v1-api-keys
Oct 13, 2026
Merged

josue-commits merged 8 commits into
developfrom
feat/api-v1-api-keys

Conversation

@josue-commits

Copy link
Copy Markdown
Contributor

Summary

Adds API keys to API v1 so integrations can call the API without a browser session. Members create and revoke keys per workspace, send them as Authorization: Bearer dforms_..., and each key gets its own rate limit. Forms now record whether they were created from the web app or the API.

Changes

  • db: api_key table (only the hash is stored, key_hash is unique-indexed), list/create/resolve/revoke helpers, a cap of 10 active keys per member, and form.created_via written on create and duplicate.
  • api: Bearer key authentication for dforms_ tokens. Session callers take the exact same path as before. The workspace header must match the key's account; a revoked key or an inactive member gets 401.
  • api: /v1/api-keys routes (list, create, revoke) with role and workspace rules. The key in clear appears only in the POST response.
  • api: a route allowlist for key callers (KEY_NOT_ALLOWED on other routes), enforced from real route metadata with a drift test.
  • api: per-key limit reusing the public limiter; the 121st call in a row returns 429 with Retry-After.
  • api: created_via: 'api' on forms and via: 'api' on form_created / form_published events.
  • docs: OpenAPI documents the bearer scheme and the key routes.
  • Changeset for the published packages.

How it was tested

  • Typecheck and lint green.
  • Full pnpm test on SQLite green (api 776, db 363, web 1248).
  • Full @quill/db suite on Postgres 16.
  • New race test: 15 parallel creates for one member hold the 10-key cap on both dialects.
  • commitlint and DCO sign-off on all commits; publish-gate passed.

Review notes

An independent review found one issue, now fixed (3e78c77): on Postgres, the INSERT ... SELECT ... WHERE count < 10 was not atomic under READ COMMITTED, so parallel creates could exceed the per-member key cap. The insert now runs in a transaction behind a per-member advisory lock, and a race test covers it.

Left as follow-ups (not required by the spec):

  • Keys of a disabled or removed member still list as active, and re-enabling the member makes them work again (they return 401 while the member is inactive). Whether disabling should revoke keys is a product decision.
  • Unknown dforms_ keys are not rate limited, since the per-key limiter runs after a key resolves.
  • On a route outside the allowlist, an invalid key gets 403 KEY_NOT_ALLOWED before the 401 check (fails closed).

🤖 Generated with Claude Code

josue-commits and others added 8 commits October 12, 2026 17:47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Josue Hernandez <josue@daptatech.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Josue Hernandez <josue@daptatech.com>
resolveHost resolves Authorization: Bearer dforms_... to the key's member and workspace
with the current role and via api_key. A dead key or an inactive member is a 401
INVALID_API_KEY, another x-quill-workspace a 403, and past 120 calls a minute a 429
RATE_LIMITED with Retry-After. last_used_at_ms moves at most once a minute. A key is never
forwarded to the identity service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Josue Hernandez <josue@daptatech.com>
GET, POST and DELETE /v1/api-keys, session only, with the spec contract: role visibility,
the key in clear once, 409 KEY_LIMIT on the 11th. An app-wide guard answers 403
KEY_NOT_ALLOWED to a key on any admin route outside the allowlist, so new routes fail
closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Josue Hernandez <josue@daptatech.com>
…blished

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Josue Hernandez <josue@daptatech.com>
…penapi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Josue Hernandez <josue@daptatech.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Josue Hernandez <josue@daptatech.com>
INSERT ... SELECT ... WHERE count < limit is atomic on SQLite but not
under Postgres READ COMMITTED: concurrent creates all counted nine and
all inserted (15 parallel creates left 11 to 15 active keys). On
Postgres the insert now runs in a transaction behind an advisory lock
on the member in the workspace, so the next create counts again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Josue Hernandez <josue@daptatech.com>
@josue-commits
josue-commits merged commit 074367b into develop Oct 13, 2026
6 checks passed
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