Type: Reverse-engineered Status: Draft Last synced with code: 2026-04-11 Hexagonal scope: Presentation + Application + Infrastructure Related plan: ./plan.md
Endpoints for authenticated users to manage their own API keys: create, list, revoke, and view usage statistics. It is OpenArg's developer portal. The keys are used in the public API (007-public-api/). One key per user by default; creating a new one automatically revokes the previous one.
As a developer, I want to create an API key from the portal and see the plaintext token only once when I create it (I cannot recover it later).
As a developer, I want to see my existing keys (masked, with key_prefix only).
As a developer, I want to disable a compromised or old key.
As a developer, I want to see how many requests I made, how many tokens I consumed, and what my current limit is.
- FR-001: The system MUST generate tokens with format
oarg_sk_<random>(random enough to be unguessable). - FR-002: The system MUST persist only the SHA-256 hash, never the plaintext.
- FR-003: The system MUST return the plaintext only in the POST /keys response (once).
- FR-004: The system MUST store the
key_prefix(first ~8 chars) for display. - FR-005: POST MUST revoke (set
is_active=false) the user's existing key before creating the new one (1 key per user). - FR-006: DELETE MUST set
is_active=false(soft delete), not delete the record. - FR-007: GET /keys MUST return masked keys (only prefix + metadata).
- FR-008: GET /usage MUST aggregate statistics from
api_usage. - FR-009: All endpoints MUST authenticate with
Authorization: Bearer <google_id_token>(validated byGoogleJwtAuthMiddlewareagainst Google's JWKS per FIX-005) plus the shared service tokenX-API-Key: <BACKEND_API_KEY>. The user identity is read from the verified Google JWTemailclaim, never from a header the frontend can set directly.
- SC-001: Create key responds in <500ms.
- SC-002: List keys responds in <300ms.
- SC-003: Usage summary responds in <1s.
- SC-004: Zero key plaintexts persisted in logs or DB.
- Users understand that the key is shown once and they must save it.
BACKEND_API_KEYis shared between frontend and backend as a secret.
- Multiple keys per user — only one for now.
- Key expiration — keys do not expire. If expiration is ever needed, it becomes a new feature with its own FR + migration + UI (see DEBT-003 below, which tracks the former dead-code state and the 2026-04-11 cleanup).
- Billing / plan upgrade.
- Key scopes / permissions — all keys have the same permissions.
- Webhook notifications for anomalous usage.
- [RESOLVED CL-001] —
FIXED 2026-04-11 via deletion. The column + entity field + mapping + validation check have been removed (Alembic migration 0030). See DEBT-003 below for the rationale — §0 "delete before you add" + "abstractions pay rent only when a second caller exists". Same change closed the siblingexpires_atis dead code never written by any endpoint007-public-api/CL-001. - [NEEDS CLARIFICATION CL-002] — Why 1 key per user? Plan to allow more?
- [RESOLVED CL-003] —
BACKEND_API_KEY: currently NOT rotated. Manual rotation on demand (requires coordinated backend + frontend restart). Accepted debt, not a priority. If compromised, it is rotated manually by editing env vars and restarting services.
- [DEBT-001] — Key prefix stored for display — no rotation mechanism.
- [DEBT-002] — Global cap checked only against Redis — fail-open allows over-limit.
- [DEBT-003] —
FIXED 2026-04-11 via deletion: the column is gone (Alembic 0030), theexpires_atis dead codeApiKey.expires_atentity field is removed, theapi_key_service.verify_api_keycheck that compared it tonow()is removed, and theapi_key_mappingsColumn is removed. Rationale: the field was being read but never written, so every key was persisted withexpires_at = NULLand the check atapi_key_service.py:69always short-circuited on theapi_key.expires_at and ...guard — pure dead validation. If expiration ever becomes a real feature, it should be re-introduced with a UI flow that actually sets it (see the Out of Scope section above). - [DEBT-004] — No soft delete audit — revoking a key leaves no trace of "who revoked it when".
End of spec.md