v1 Auth & User URL Rename — PRD
Context
The v1 auth and user endpoints already ship — they are the mobile-app API
(CLAUDE.md:671: v1 = Mobile API (Android, iOS)), consumed by native iOS/Android
builds (bruno v1/Auth, v1/User; requests carry x-platform, x-device-id,
x-build-version). All 14 endpoints in this rename live in a single file,
src/api/v1/user/routes.py, split across two routers declared there:
auth_router(prefix="/auth",routes.py:84) — mounted on the ungatedpublic_router(src/routes/v1.py:203). Holds the 7 auth endpoints. Not re-included by any other sub-app, so it is v1-only already.router(prefix="/users",routes.py:79) — mounted on the JWT-gatedprotected_router(src/routes/v1.py:179) and re-included by the av1 admin sub-app (src/routes/av1.py:254, same router object). Holds the 7 user endpoints — which is why today’s/v1/users/*paths also resolve under/av1/users/*.
This PRD is a routing delta, not a feature. Each of the 14 endpoints gets a
second, new-convention URL added alongside its current one; both serve the
identical handler / service logic. The old URL is marked deprecated=True
with a sunset note but stays live — removal is a separate, adoption-gated
future PRD (shipped mobile builds cannot be force-updated). It does not
re-spec any endpoint’s behaviour, request/response schemas, status codes, payload
encryption, OTP/token/SSO flows, or the av1/v2 surfaces.
Scope guardrail (decided). Every change here is routing + one self-id check. No request or response contract changes. No behaviour changes. The only new runtime behaviour anywhere is the
{_id} == token.subguard on the new user routes (a 403 that cannot fire under today’s usage, since today’s callers are always self).
Self-scope (decided). Every new
/v1/users/{_id}/...route — includingDELETE /v1/users/{_id}— requires the path{_id}to equal the authenticated token’s user id (ClientJWTClaims.sub); a mismatch is a hard 403 FORBIDDEN. No route can act on another user. This exactly preserves today’s token-only behaviour, where the acting user is alwaysPydanticObjectId(jwt_claims.sub)and never came from the URL.
v1-only (decided). The new user routes must not appear under the av1 admin sub-app. Because the current
/usersrouterobject is shared with av1, the new self-scoped routes are registered on a new, v1-only router included only insrc/routes/v1.py. The old/usersroutes stay on the shared router (unchanged behaviour, just deprecated).
Dual-serve, no removal (decided). This PRD adds new URLs and marks old ones deprecated; it removes nothing. Old-URL retirement is deferred to a later PRD, gated on mobile-app build adoption (tracked via the
x-build-versionheader).
Scope
MVP — add new-convention URLs for all 14 endpoints, keep the old ones live:
- Auth surface renamed (7) — a pure path rename: insert an
/o/operation segment (and flatten the two SSO login paths). Same method (all POST/GET as today), same params, same per-endpoint auth. Implemented as a second stacked route decorator on each existingauth_routerhandler. - User surface renamed (7) — each new URL gains a self-scoped
{_id}path segment; six also gain the/o/segment (the self-delete stays a plain RESTfulDELETE /v1/users/{_id}), andPATCH /infobecomesPOST .../o/update-info. Implemented as new v1-only handlers that self-validate{_id}and delegate to the same underlying service logic as the (now deprecated) old handlers. - Old URLs deprecated, not removed — every old route stays live with
deprecated=Trueand a description pointing at its replacement. - Path-keyed middleware follows the rename — any middleware allowlist that matches an old path by string is extended to the new path (see M1: the SSO login payload-encryption allowlist).
M1 — Auth surface renamed (7, pure path rename)
All 7 handlers already exist on auth_router (src/api/v1/user/routes.py). Each
gets a second @auth_router.<verb>(...) decorator with the new path and a new
unique operation_id; the existing decorator is marked deprecated=True with a
sunset note. No new handler, no signature change, no {_id} — the new path serves
the same function with the same params and the same per-endpoint dependencies.
Stacked-decorator aliasing is the established idiom (verified). FastAPI runs one handler under multiple route decorators; this exact pattern is used for migrations in
src/api/v2/plan/routes.py:32-49,src/api/v2/years/routes.py:106-123, andsrc/api/av1/years/routes.py:39-56(old pathdeprecated=True+ new path, same function).router.add_api_routeis used nowhere. Each decorator must carry a uniqueoperation_id(repo convention — collisions break OpenAPI/MCP).
| # | Old route | New route | Method | Auth (unchanged) | Handler (routes.py) |
|---|---|---|---|---|---|
| 1 | POST /v1/auth/send-email-otp | POST /v1/auth/o/send-email-otp | POST | none (public) | initiate_otp_login_email (:95) |
| 2 | POST /v1/auth/verify-email-otp | POST /v1/auth/o/verify-email-otp | POST | none (public) | verify_otp_login_email (:197) |
| 3 | POST /v1/auth/login/apple/mobile | POST /v1/auth/o/login-apple-mobile | POST | none (public) | login_apple_mobile (:332) |
| 4 | POST /v1/auth/login/google/mobile | POST /v1/auth/o/login-google-mobile | POST | none (public) | login_google_mobile (:412) |
| 5 | POST /v1/auth/refresh | POST /v1/auth/o/refresh | POST | none — refresh token in body | refresh_access_token (:506) |
| 6 | POST /v1/auth/logout | POST /v1/auth/o/logout | POST | client_jwt_validator | logout (:571) |
| 7 | GET /v1/auth/me | GET /v1/auth/o/me | GET | client_jwt_validator | get_current_user (:610) |
SSO middleware allowlist MUST be extended (verified — most likely miss). The path-string-keyed
v1_client_payload_encryptionmiddleware lists the old SSO login paths —src/core/middlewares/v1_client_payload_encryption.py:27(/v1/auth/login/apple/mobile) and:28(.../google/mobile). The new paths (/v1/auth/o/login-apple-mobile,.../o/login-google-mobile) must be added to that allowlist, or encrypted-payload SSO logins silently fail on the new URLs. Any other path-keyed allowlist referencing a renamed path gets the same treatment. This is the one place a “pure rename” can break behaviour.
M2 — User surface renamed (7, self-scoped {_id})
Each new URL is a new handler on a new v1-only router. The new handler takes
the {_id} path param, enforces self-scope ({_id} == token.sub, else 403), and
delegates to the same service call the old handler makes today — the old
handler’s body is extracted into a shared private helper so both call one source
of truth (business logic is provably identical). Old handlers stay on the shared
/users router, unchanged except deprecated=True + a replacement note.
{_id}today comes only from the token (verified). All 7 current handlers derive the user asPydanticObjectId(jwt_claims.sub)(orconvert_to_pydantic_object_id(claims.sub)for the phone routes) and take no id from the URL. The new{_id}is therefore a new, redundant source that the self-scope guard reconciles against the token. When{_id} == sub(the only case real clients produce), behaviour is byte-for-byte identical to today.
| # | Old route | New route | Method | Change vs old | Handler (routes.py) |
|---|---|---|---|---|---|
| 8 | GET /v1/users/mcq-daily/sync | GET /v1/users/{_id}/o/mcq-daily-sync | GET | +{_id} self-scope | sync_user_stats (:704) |
| 9 | DELETE /v1/users | DELETE /v1/users/{_id} | DELETE | +{_id} self-scope; no /o/ (RESTful delete) | delete (:810) |
| 10 | PATCH /v1/users/info | POST /v1/users/{_id}/o/update-info | POST | +{_id} self-scope; method PATCH→POST | update_user_info (:847) |
| 11 | POST /v1/users/profile-picture | POST /v1/users/{_id}/o/set-profile-picture | POST | +{_id} self-scope; multipart unchanged | upload_profile_picture (:961) |
| 12 | POST /v1/users/send-phone-otp | POST /v1/users/{_id}/o/send-phone-otp | POST | +{_id} self-scope | send_phone_otp (:1006) |
| 13 | POST /v1/users/verify-phone-otp | POST /v1/users/{_id}/o/verify-phone-otp | POST | +{_id} self-scope | verify_phone_otp_validation (:1074) |
| 14 | POST /v1/users/report | POST /v1/users/{_id}/o/report | POST | +{_id} self-scope | create_report (:1148) |
Self-validation dependency — literal
{_id}(decided; impl note). A purpose-built self-scope dependency already exists —client_jwt_self_resource_validator(src/core/dependencies/auth_resource_owner.py) — but it binds a path param nameduser_id, whereas this spec uses the literal{_id}(kept exactly as requested; note the repo’s other params are named, e.g.{user_id},{docket_id}). Reconcile by adding a thin sibling dependency that reads{_id}(or parameterising the existing one). This is a naming/wiring detail, not a behaviour choice — the rule (path id == subelse 403) is identical either way.
Data Model (requirements-level)
No data-model change — routing only. No collection, document, field, index, or projection is added, removed, or altered. No request or response schema changes. No migration or backfill (see Migration).
Delivery Milestones
| # | Milestone | Outcome | Status | Plan |
|---|---|---|---|---|
| 1 | Auth surface renamed | 7 new /v1/auth/o/* routes live via add_api_route re-registration of the existing handlers in a new routes_new.py (unique operation_ids v1_auth_o_*); SSO payload-encryption allowlist extended (ADD, not replace) to the 2 new login paths. Old routes unchanged — not deprecated (see deviations D-a/D-b). No behaviour/schema change. | ✅ complete | plan |
| 2 | User surface renamed | 7 new self-scoped /v1/users/{_id}/... routes live on a new v1-only router in routes_new.py (add_api_route reuse of existing handlers + {_id}==sub self-scope guard via new client_jwt_self_resource_validator_by_id; rejection = HTTP 200 + error.code=FORBIDDEN, D-f). Verified absent from /av1. Old routes unchanged (routes.py untouched). No behaviour/schema change. | ✅ complete | plan |
Plan key:
BE= backend plan tasks in keystone (keystone/docs/superpowers/specs/+…/plans/), authored from this PRD via/plan.
Sequencing. M1 and M2 are independent (different routers, different handler-shape work) and can ship as one PR or two. Neither depends on the other; within each milestone the endpoints are independent. M1 carries the middleware allowlist update; M2 carries the v1-only-router + self-validator work.
Open Questions
All resolved at implementation (see Technical Details as built):
NewResolved — shipped exactly as proposed:operation_idnaming scheme.v1_auth_o_*(auth),v1_user_o_*(user),v1_user_delete_self(self-delete). All globally unique; verified by an app-wide uniqueness test.Resolved — added a thin{_id}self-validator wiring.{_id}-binding siblingclient_jwt_self_resource_validator_by_id(mirrorsclient_jwt_self_resource_validator). Literal{_id}kept; the dependency carries the path param (handlers declare no_id).- Everything else resolved during PRD review: v1-only;
{_id}self-scoped + FORBIDDEN on mismatch (HTTP 200 +error.code, D-f); dual-serve (no removal);DELETE /v1/users/{_id}(no/o/);PATCH→POSTonupdate-info; literal{_id}kept. Two review decisions changed at build time (see deviations D-a/D-b): old routes are notdeprecated, and the mechanism isadd_api_routere-registration (not stacked decorators) — both becauseroutes.pywas kept untouched.
Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
SSO middleware allowlist missed. New /o/login-apple-mobile / .../o/login-google-mobile not added to v1_client_payload_encryption → encrypted-payload SSO login breaks on the new URL only. | Med | High — new SSO login fails in prod | Explicit M1 requirement + risk callout; add both new paths at v1_client_payload_encryption.py:27-28; test encrypted-payload login on both new paths. |
New user route leaks into av1. New routes accidentally added to the shared /users router instead of a v1-only router → exposed under /av1/users. | Med | Med — unintended admin-surface exposure | New handlers on a v1-only router included only in src/routes/v1.py; assert the new paths are absent from the av1 OpenAPI schema in a test. |
operation_id collision. A reused/duplicate operation_id across old+new routes breaks OpenAPI/MCP generation at startup. | Med | High — app fails to boot / docs break | Every new route gets a unique operation_id (repo convention); import src.main smoke + OpenAPI generation check. |
| Client on old build. Renaming would break shipped mobile builds if old URLs stopped working. | High (if removed) | High — logins/actions fail for un-updated users | Dual-serve; no removal in this PRD; old routes stay live + deprecated; removal deferred to an adoption-gated PRD. |
Self-scope guard fires for real users. Client sends an {_id} ≠ its token sub and gets a 403 where the old URL had no id. | Low | Med — request rejected | Clients are self-only today (id came from token); mobile passes its own id. Document the contract; verify the app sends sub as {_id}. |
| Behaviour drift between old and new handler. New user handler diverges from the deprecated one over time. | Low | Med — inconsistent behaviour across URLs | Both call one shared private helper (single source of truth); no logic duplicated in the new handler. |
Technical Details (as built)
Status: ✅ complete — shipped in PR #804 (open, base dev). Both milestones built as
described below. Key build-time deviations from the original PRD are called out in
Decisions & deviations — chiefly that src/api/v1/user/routes.py was left completely
untouched (all new routes live in a new routes_new.py), so old routes are not
flagged deprecated.
- PR: #804 (open, base
dev). - Branch:
feature/v1-url-rename-auth-user. - Commits:
71469186(routes_new.py + dependency + middleware + v1.py + tests),c8f3a5fc(Bruno requests for the new URLs),e1677529(spec + plan docs). - Spec / Plan:
keystone/docs/superpowers/specs/2026-07-01-v1-url-rename-design.mdkeystone/docs/superpowers/plans/2026-07-01-v1-url-rename.md.
- Built by:
/planexecution in a separate session; as-built re-verified against code + tests here. - Verification:
src/tests/v1/test_v1_url_rename_auth.py+test_v1_url_rename_user.py(11 tests) pass — new paths present on v1, new user paths absent from av1, app-wideoperation_iduniqueness, self-scope rejection on{_id} != sub(HTTP 200 + bodyerror.code == FORBIDDEN— see D-f), SSO allowlist covers old + new.v1_app/av1_appOpenAPI builds clean. (Fullsrc/tests/v1/suite: 179 passed.) - Surfaces (as built): v1 mobile API only — new file
src/api/v1/user/routes_new.py(both new routers); new dependencyclient_jwt_self_resource_validator_by_idinsrc/core/dependencies/auth_resource_owner.py; +2 allowlist entries insrc/core/middlewares/v1_client_payload_encryption.py; +1 import, +2include_routerinsrc/routes/v1.py. Untouched:src/api/v1/user/routes.py,src/routes/av1.py.
Naming: PRD vs code
| PRD term | Code |
|---|---|
| Auth router (v1-only) | auth_router = APIRouter(prefix="/auth", tags=["Auth"]) (src/api/v1/user/routes.py:84), on public_router (src/routes/v1.py:203) |
| User router (v1-only — PRD premise corrected) | router = APIRouter(prefix="/users", tags=["User"]) (src/api/v1/user/routes.py:79), on protected_router (src/routes/v1.py:179) only. NOT shared with av1 — av1.py:35 imports src.api.av1.user (a different module aliased to the same name v1_user_routes); av1 serves its own separate user router (src/api/av1/user/routes.py:32). So the PRD’s “shared with av1 / resolves under /av1/users/*” claim was false; av1 isolation is automatic. |
| New v1-only user router (built) | user_router_new in src/api/v1/user/routes_new.py, included only in src/routes/v1.py:179 (protected_router). New auth router auth_router_new in the same file, on public_router (v1.py:203). |
| Version mount | app.mount("/v1", v1_app) (src/main.py:90); each version is a separate sub-app |
| Self-scope dependency (built) | New sibling client_jwt_self_resource_validator_by_id(_id, claims) (src/core/dependencies/auth_resource_owner.py) — mirrors client_jwt_self_resource_validator but binds literal {_id}; enforces claims.sub == _id, else raises BaseError(ErrorCode.FORBIDDEN) (surfaced as HTTP 200 + error.code, D-f). |
| Token claims | ClientJWTClaims (src/core/authentication_utils/jwt.py:51); .sub = user id, .jti = device-token id |
| Aliasing idiom (as built — deviation) | router.add_api_route(path, existing_fn, methods=[...], response_model=..., dependencies=[...], operation_id=...) re-registering the existing handlers under new paths. Not stacked decorators (which would require editing routes.py) — chosen precisely to keep routes.py untouched. Verified on FastAPI 0.115.5. |
| SSO payload-encryption allowlist (built) | src/core/middlewares/v1_client_payload_encryption.py:26-31 — added /v1/auth/o/login-apple-mobile + /v1/auth/o/login-google-mobile (kept the 2 old entries). Request decryption there is not path-gated, so the new paths auto-inherit it. |
| Auth op-ids: old → new (as built) | |
v1_auth_post_initiate_otp_login_email (:95) | → v1_auth_o_send_email_otp |
v1_auth_post_verify_otp_login_email (:197) | → v1_auth_o_verify_email_otp |
v1_auth_post_login_apple_mobile (:332) | → v1_auth_o_login_apple_mobile |
v1_auth_post_login_google_mobile (:412) | → v1_auth_o_login_google_mobile |
v1_auth_post_refresh_access_token (:506) | → v1_auth_o_refresh |
v1_auth_post_logout (:571) | → v1_auth_o_logout |
v1_auth_get_get_current_user (:610) | → v1_auth_o_me |
| User op-ids: old → planned new | |
v1_user_me_mcq_daily_sync (:704) | → v1_user_o_mcq_daily_sync |
v1_user_delete_delete (:810) | → v1_user_delete_self (no /o/) |
v1_user_info_update (:847) | → v1_user_o_update_info (POST) |
v1_user_post_upload_profile_picture (:961) | → v1_user_o_set_profile_picture |
v1_user_post_phone_otp (:1006) | → v1_user_o_send_phone_otp |
v1_user_post_verify_phone_otp_validation (:1074) | → v1_user_o_verify_phone_otp |
v1_user_admin_report_create (:1148) | → v1_user_o_report |
All 14 new operation_ids shipped exactly as listed; old op_ids unchanged (routes.py untouched). App-wide uniqueness asserted by test.
Decisions & deviations from the PRD (as built)
- D-a —
routes.pyleft completely untouched (new deviation, owner-directed). The PRD assumed the existing handlers would be edited (stacked decorators for auth; body extracted into shared helpers for user). Instead, all new routes live in a new filesrc/api/v1/user/routes_new.pythat imports the existing handler functions and re-registers them under the new paths viaadd_api_route. The same function object backs both old and new routes → single source of truth, zero logic duplication, androutes.pyhas a zero-line diff. Reason: owner requested the original file stay untouched (lowest-risk, cleanly separable, trivially revertable). - D-b — old routes are NOT
deprecated. The PRD specified dual-serve with old routesdeprecated=True. Because thedeprecatedflag lives on the old decorators inroutes.py(which is untouched — D-a), the old routes stay live but are not flagged deprecated in OpenAPI. Reason: direct consequence of D-a; owner accepted. Deprecation is deferred to the adoption-gated retirement PRD. - D-c — mechanism is
add_api_route, not stacked decorators. Follows from D-a. The PRD’s precedent note (“stacked decorators;add_api_routeused nowhere”) no longer applies to this change;add_api_routereuse is the correct tool when the source file must not be edited. - D-d — no shared-helper /
self_routes.pysplit. Intermediate plan drafts proposed a_shared.pyhelpers module and aself_routes.py; the final build needs neither — reusing the existing handler functions directly already guarantees a single source of truth. - D-e — av1-isolation risk was moot. The PRD’s “new user route leaks into av1” risk rested on
the (false) premise that the
/usersrouter is shared with av1. It is not (see Naming table), so isolation is automatic. The absence-from-av1 test is kept as a regression guard. - D-f — self-scope rejection is surfaced as HTTP 200, not 403 (clarification). The PRD (and the
spec/plan) described the mismatch as a “403 FORBIDDEN”. In this codebase the global
handle_base_error(src/core/handlers/__init__.py) returns HTTP 200 for everyBaseError, with the real error in the body:{"status":"failure","error":{"code": FORBIDDEN (2501), ...}}. There is noErrorCode→HTTP-status mapping. So the guard is semantically FORBIDDEN but transported as 200 +error.code == FORBIDDEN— which is what the shipped test asserts. No new error code. - No behaviour/schema/status-code/data-model change, as specified. Self-scope mismatch reuses the
existing
ErrorCode.FORBIDDEN(see D-f for transport).
Surface & semantics
| Surface | Routes | Notes |
|---|---|---|
| Auth (new) | POST /v1/auth/o/{send-email-otp,verify-email-otp,login-apple-mobile,login-google-mobile,refresh,logout}, GET /v1/auth/o/me | v1-only (auth_router_new on public_router); same per-endpoint auth as old; existing handlers re-registered via add_api_route. |
| Auth (old, unchanged) | POST /v1/auth/{send-email-otp,verify-email-otp,login/apple/mobile,login/google/mobile,refresh,logout}, GET /v1/auth/me | Live, unchanged (not deprecated — routes.py untouched, D-b). |
| User (new) | GET /v1/users/{_id}/o/mcq-daily-sync, DELETE /v1/users/{_id}, POST /v1/users/{_id}/o/{update-info,set-profile-picture,send-phone-otp,verify-phone-otp,report} | New v1-only user_router_new; existing handlers re-registered via add_api_route (same function, new path/method) + {_id}==sub else FORBIDDEN (HTTP 200 + error.code, D-f). Verified absent from /av1. |
| User (old, unchanged) | GET /v1/users/mcq-daily/sync, DELETE /v1/users, PATCH /v1/users/info, POST /v1/users/{profile-picture,send-phone-otp,verify-phone-otp,report} | Live on router, unchanged (not deprecated, D-b). v1-only (do not resolve under /av1/users/* — av1 has its own separate user router). |
- No new error codes. Self-scope mismatch reuses the existing
ErrorCode.FORBIDDENraised by the self-resource validator — surfaced as HTTP 200 +error.code == FORBIDDEN(D-f), not a 403 status.
Migration
- Code-only — no data migration, no backfill, no collection change. This is a routing change; storage is untouched.
- No client-forced migration in this PRD. Old URLs remain live (unchanged; not
flagged
deprecated— D-b); mobile clients migrate to the new URLs on their own schedule. Old-URL removal — and the OpenAPIdeprecatedflagging — is a separate future PRD, gated onx-build-versionadoption.