Owner Burhan

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 ungated public_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-gated protected_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.sub guard 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 — including DELETE /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 always PydanticObjectId(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 /users router object is shared with av1, the new self-scoped routes are registered on a new, v1-only router included only in src/routes/v1.py. The old /users routes 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-version header).

Scope

MVP — add new-convention URLs for all 14 endpoints, keep the old ones live:

  1. 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 existing auth_router handler.
  2. 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 RESTful DELETE /v1/users/{_id}), and PATCH /info becomes POST .../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.
  3. Old URLs deprecated, not removed — every old route stays live with deprecated=True and a description pointing at its replacement.
  4. 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, and src/api/av1/years/routes.py:39-56 (old path deprecated=True + new path, same function). router.add_api_route is used nowhere. Each decorator must carry a unique operation_id (repo convention — collisions break OpenAPI/MCP).

#Old routeNew routeMethodAuth (unchanged)Handler (routes.py)
1POST /v1/auth/send-email-otpPOST /v1/auth/o/send-email-otpPOSTnone (public)initiate_otp_login_email (:95)
2POST /v1/auth/verify-email-otpPOST /v1/auth/o/verify-email-otpPOSTnone (public)verify_otp_login_email (:197)
3POST /v1/auth/login/apple/mobilePOST /v1/auth/o/login-apple-mobilePOSTnone (public)login_apple_mobile (:332)
4POST /v1/auth/login/google/mobilePOST /v1/auth/o/login-google-mobilePOSTnone (public)login_google_mobile (:412)
5POST /v1/auth/refreshPOST /v1/auth/o/refreshPOSTnone — refresh token in bodyrefresh_access_token (:506)
6POST /v1/auth/logoutPOST /v1/auth/o/logoutPOSTclient_jwt_validatorlogout (:571)
7GET /v1/auth/meGET /v1/auth/o/meGETclient_jwt_validatorget_current_user (:610)

SSO middleware allowlist MUST be extended (verified — most likely miss). The path-string-keyed v1_client_payload_encryption middleware 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 as PydanticObjectId(jwt_claims.sub) (or convert_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 routeNew routeMethodChange vs oldHandler (routes.py)
8GET /v1/users/mcq-daily/syncGET /v1/users/{_id}/o/mcq-daily-syncGET+{_id} self-scopesync_user_stats (:704)
9DELETE /v1/usersDELETE /v1/users/{_id}DELETE+{_id} self-scope; no /o/ (RESTful delete)delete (:810)
10PATCH /v1/users/infoPOST /v1/users/{_id}/o/update-infoPOST+{_id} self-scope; method PATCH→POSTupdate_user_info (:847)
11POST /v1/users/profile-picturePOST /v1/users/{_id}/o/set-profile-picturePOST+{_id} self-scope; multipart unchangedupload_profile_picture (:961)
12POST /v1/users/send-phone-otpPOST /v1/users/{_id}/o/send-phone-otpPOST+{_id} self-scopesend_phone_otp (:1006)
13POST /v1/users/verify-phone-otpPOST /v1/users/{_id}/o/verify-phone-otpPOST+{_id} self-scopeverify_phone_otp_validation (:1074)
14POST /v1/users/reportPOST /v1/users/{_id}/o/reportPOST+{_id} self-scopecreate_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 named user_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 == sub else 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

#MilestoneOutcomeStatusPlan
1Auth surface renamed7 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.✅ completeplan
2User surface renamed7 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.✅ completeplan

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):

  • New operation_id naming scheme. Resolved — shipped exactly as proposed: v1_auth_o_* (auth), v1_user_o_* (user), v1_user_delete_self (self-delete). All globally unique; verified by an app-wide uniqueness test.
  • {_id} self-validator wiring. Resolved — added a thin {_id}-binding sibling client_jwt_self_resource_validator_by_id (mirrors client_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→POST on update-info; literal {_id} kept. Two review decisions changed at build time (see deviations D-a/D-b): old routes are not deprecated, and the mechanism is add_api_route re-registration (not stacked decorators) — both because routes.py was kept untouched.

Risks

RiskLikelihoodImpactMitigation
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.MedHigh — new SSO login fails in prodExplicit 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.MedMed — unintended admin-surface exposureNew 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.MedHigh — app fails to boot / docs breakEvery 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 usersDual-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.LowMed — request rejectedClients 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.LowMed — inconsistent behaviour across URLsBoth 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.md
    • keystone/docs/superpowers/plans/2026-07-01-v1-url-rename.md.
  • Built by: /plan execution 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-wide operation_id uniqueness, self-scope rejection on {_id} != sub (HTTP 200 + body error.code == FORBIDDEN — see D-f), SSO allowlist covers old + new. v1_app/av1_app OpenAPI builds clean. (Full src/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 dependency client_jwt_self_resource_validator_by_id in src/core/dependencies/auth_resource_owner.py; +2 allowlist entries in src/core/middlewares/v1_client_payload_encryption.py; +1 import, +2 include_router in src/routes/v1.py. Untouched: src/api/v1/user/routes.py, src/routes/av1.py.

Naming: PRD vs code

PRD termCode
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 av1av1.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 mountapp.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 claimsClientJWTClaims (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-31added /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.py left 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 file src/api/v1/user/routes_new.py that imports the existing handler functions and re-registers them under the new paths via add_api_route. The same function object backs both old and new routes → single source of truth, zero logic duplication, and routes.py has 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 routes deprecated=True. Because the deprecated flag lives on the old decorators in routes.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_route used nowhere”) no longer applies to this change; add_api_route reuse is the correct tool when the source file must not be edited.
  • D-d — no shared-helper / self_routes.py split. Intermediate plan drafts proposed a _shared.py helpers module and a self_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 /users router 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 every BaseError, with the real error in the body: {"status":"failure","error":{"code": FORBIDDEN (2501), ...}}. There is no ErrorCode→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

SurfaceRoutesNotes
Auth (new)POST /v1/auth/o/{send-email-otp,verify-email-otp,login-apple-mobile,login-google-mobile,refresh,logout}, GET /v1/auth/o/mev1-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/meLive, unchanged (not deprecatedroutes.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.FORBIDDEN raised 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 OpenAPI deprecated flagging — is a separate future PRD, gated on x-build-version adoption.