fix(api): /classify accepts API keys; harden keys page reveal UX#243
Merged
Conversation
The /api/v1/classify route was wired to get_current_user (JWT-only),
which rejected every API key with `{"detail": "Invalid token"}`
because jwt.decode raises on wot_<hex> payloads. Any developer
with a Pro API key was silently blocked from the paid endpoint.
Switch the dependency to require_scope("wot:classify"). The handler
now fetches the key owner tier from app_user so the Pro/Enterprise
gate still applies, and billing attribution prefers the principal
org_id (set by require_scope from the key row) over the user row.
Tests added in tests/test_api_classify.py:
- TestClassifyRouteWiring: non-DB sentinel that introspects the
route dependency tree, fails fast if get_current_user is ever
reintroduced. Runs in any environment.
- TestClassifyAPIKeyAuth: DB-backed integration via ASGITransport.
Mints a real api_key row, asserts 200 with wot:classify scope and
403 scope_missing with wot:read-only.
The sentinel was cycled red -> green locally. The DB-backed tests
require a reachable Postgres matching the .env DATABASE_URL; they
were authored against the test_wot schema fixtures in conftest.py
but not executed in this environment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three problems users hit on the keys page: 1. The one-time raw-key banner was easy to miss - it rendered above the fold, with no scroll-to, no forced acknowledgment, and reset to null on the next refresh. 2. The code block was hardcoded bg-white text-foreground, which made the key invisible in dark mode. 3. No copy button - users had to triple-click to select. This change layers three improvements: - Modal on creation (base-ui Dialog). disablePointerDismissal so an outside click does not silently discard the key; only the explicit "I have saved this key" button closes it. - Sticky reveal banner persists after the modal closes for the rest of the browser tab session, backed by sessionStorage under the key wot:keys:revealed. Survives accidental refresh. Cleared on tab close and on explicit dismiss. - Copy button in both the modal and the banner (lucide Copy/Check swap, 1.8s reset). The raw key is never sent to the server after creation. sessionStorage is tab-scoped, so the persistence layer adds no DB-side regression from the bcrypt-only hashed storage we already have on api_key. Also replaced hardcoded amber-50 / red-50 / bg-white with theme tokens (bg-muted, border-border, bg-amber-500/10, border-destructive) so the page renders correctly in both light and dark themes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/api/v1/classifynow accepts developer API keys. The route was wired toget_current_user(JWT-only), so everywot_<hex>/aix_<hex>key was silently rejected with{"detail": "Invalid token"}fromjwt.decode. Switched toDepends(require_scope("wot:classify")). Pro/Enterprise tier gate preserved, billing attribution prefers the key's org./developers/keys: a forced-acknowledgment modal on key creation, a sticky reveal banner that survives accidental refresh (viasessionStorage), a Copy button, and theme-aware colors so the key is readable in dark mode.Two independent commits so they can be reviewed and (if desired) split:
186dec3 fix(api): /classify accepts developer API keys via require_scoped908add feat(frontend): harden API key reveal UX on /developers/keysWhy the bug existed
require_scopewas defined inworld_of_taxonomy/api/deps.pybut never used in production routers. Every paid endpoint that needed API-key auth was instead usingget_current_user, which only knows how to decode JWTs. An API key passed asAuthorization: Bearer wot_<hex>would hitjwt.decode, raisejwt.InvalidTokenError, and return 401 - the same response regardless of whether the key was valid, expired, revoked, or just the wrong shape.What changed in the handler
The handler still 403s a free-tier user even if they hold a
wot:classifykey - the scope-issuance path doesn't yet enforce tier, so the endpoint-level check is the gate that prevents free users from consuming paid features.Frontend: why not just "mask + eye toggle to reveal later"
Storing the raw key so an eye toggle has something to reveal would require either plaintext or reversibly-encrypted storage server-side, which is a meaningful downgrade from today's bcrypt-only hash on
api_key.key_hash. Stripe, AWS, GitHub, Anthropic all show the secret exactly once for this reason.sessionStoragegives the practical benefit (survive refresh, copy any time) without ever sending the raw key back to the server. Tab-scoped, cleared on close, no DB regression.Test plan
tests/test_api_classify.py- all 18 tests green against the real WoT Postgres (wot-postgrescontainer, schematest_wot). Includes:TestClassifyRouteWiring::test_classify_route_uses_require_scope_dependency- non-DB sentinel, cycled red -> greenTestClassifyAPIKeyAuth::test_api_key_with_classify_scope_authenticates- DB-backed, Pro user + wot:classify scope -> 200TestClassifyAPIKeyAuth::test_api_key_without_classify_scope_is_403- DB-backed, wot:read-only -> 403 scope_missingtests/test_keys_issue_validate.py,tests/test_keys_logic.py,tests/test_auth.py,tests/test_api_developers.py- 53 pass, 1 pre-existing failure (test_keys_create_returns_raw_key_once_with_correct_prefix, CSRF-token-mismatch). The failure reproduces identically onorigin/mainwith this PR's files reverted; not caused by this PR. Worth a separate fix.next buildpasses on the frontend; tsc and eslint clean./developers/keysin both light and dark themes after deploy, generate a key, confirm the modal forces acknowledgment, the banner persists across refresh, the Copy button works, and the key text is legible in both themes.Follow-up (not in this PR)
require_principal(scope)dependency that accepts cookie / JWT / API key and returns a normalizedPrincipalso the dashboard and programmatic clients can hit the same endpoint with the same auth model. Separate PR.test_keys_create_returns_raw_key_once_with_correct_prefix(pre-existing CSRF test failure on main). Separate PR.🤖 Generated with Claude Code