Module kit / Identity and auth
User profile and account
identity-auth/user-profile-and-account
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod |
| Reusable | yes |
| Stack | fastapi-0.118, sqlalchemy-sync, postgres, pydantic-2 |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/error-envelope |
| Frontends | identity-auth/ui/profile-and-passkeys |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Version | 1 |
Purpose
A signed-in person reads and edits their own account (display name, whether a password is set, verification state), sees what their account is entitled to (token quota, metal tank, the first-month billing prompt) and reads the brand essence an agency wrote for them. The invariant that makes it trustworthy is that every route acts on current_user.id from the auth dependency and takes no user id from the request, so a profile call cannot read or write another account, and every field the response carries comes from the users row of the caller.
The recipe also specifies the account-erasure contract that DELETE /api/user/account is a stub for at fd8558f, because a customer building this module needs the real contract, not the placeholder.
When to use
Include it when a product needs a "my account" surface for a person who already holds a session. Trigger phrases: "profile page", "change my name", "does this account have a password", "how many tokens are left", "delete my account", "the brand essence for the signed-in user".
Do not use it for signing in (identity-auth/session-login-refresh-logout, B2), for the password change and reset flows that revoke sessions (identity-auth/password-reset, B6: POST /api/auth/change-password and /reset-password), for passkeys (identity-auth/webauthn-passkeys-multi-rp, B7), for the sessions list, or for admin edits of another account (identity-auth/admin-user-management-and-impersonation, B16). Billing entitlement is display only here: D2 (2026-09-11) makes the prepaid credit in Prima's ledger the one truth for AI spend, so the token-quota and metal figures this router returns are the pre-D2 tanks and a new product does not copy them.
Stack assumptions
Pinned at fd8558f (2026-09-12): fastapi==0.118.2 (requirements.txt 32), SQLAlchemy sync sessions through DatabaseSession, Pydantic v2 (requirements.txt 80: pydantic==2.12.0; UserProfile uses from_attributes, schemas/user_schema.py lines 32 to 33). The Postgres version is not pinned in the reference: the only version declared at fd8558f is postgres:14 in the test compose (docker-compose.test.yml 6, 21 and 115), and the deployed database is a managed instance whose compose block is commented out (deploy/infomaniak/docker-compose.infomaniak.yml 101 to 110), so no deployed version is verifiable from the repo. Nothing in this module's SQL needs a version above 13. Foundations assumed: S4 (the router is mounted once, app_fastapi.py line 875: mount_router(app, user_router.router, prefix="/api/user", tags=["User"], surfaces=LABS_SURFACES)), S5 (ActiveUser on every route), S7 (refusals render as the Labs envelope).
Environment names this module reads (core/config.py): RESEND_API_KEY (an Optional[SecretStr], line 437) is the gate the code-based password change uses to decide whether to mail at all (user_router.py line 230); EMAIL_TRANSPORT (default smtp, line 458) is what transport_is_configured() reads (services/email_service.py 66 to 77) and what the rest of the platform gates on. The difference is a Gotcha. No other configuration: the router has no feature flag.
Data model
Columns of users this module reads or writes (models/user_model.py lines 57 to 122; the migrations are S8's baseline):
users (
id uuid PRIMARY KEY,
email varchar(255) UNIQUE NOT NULL, username varchar(100) UNIQUE NOT NULL,
password_hash varchar(255) NULL, -- NULL for passwordless accounts; has_password = password_hash IS NOT NULL
first_name varchar(100), last_name varchar(100), display_name varchar(200),
is_active boolean, is_verified boolean, subscription_tier varchar(50) DEFAULT 'free',
client_status client_status NOT NULL DEFAULT 'new', -- enum new | in_project | full (lines 45 to 55)
ai_trial_started_at timestamp, ai_trial_ends_at timestamp,
token_quota integer DEFAULT 100000, tokens_used_current_period integer DEFAULT 0,
quota_reset_date timestamp, voucher_tokens integer DEFAULT 0,
metal_tier metal_tier NOT NULL DEFAULT 'gratis', metal_tank_ml integer DEFAULT 0,
metal_topup_ml integer DEFAULT 0, metal_welcome_ml integer DEFAULT 0,
metal_used_total_ml integer DEFAULT 0, metal_period_start timestamp, metal_period_end timestamp,
has_seen_first_month_offer boolean DEFAULT false, first_month_offer_shown_at timestamp,
created_at timestamp, updated_at timestamp, last_login timestamp
);
brand_essence (models/brand_essence_model.py lines 15 to 23 onward): id uuid PRIMARY KEY, user_id uuid UNIQUE NOT NULL (one essence per user, indexed, no foreign key declared), the StoryBrand text columns and the JSONB lists (character_demographics, guide_credibility, plan_steps, voice_characteristics, and so on). Written by the admin router only (admin_router.py 1998 POST /brand-essence, which constructs the row at 2032, 2089 PUT /brand-essence/{user_id}, 2312 DELETE /brand-essence/{user_id}); this module reads it. Measured 2026-09-12: grep -rn "BrandEssence(" --include='*.py' backend/ outside models/ and schemas/ hits admin_router.py 2032, two tests and scripts/create_test_data.py 572, and nothing else.
Two tables exist for the usage figures and are written by nothing (models/token_usage_model.py; ai_token_usage at baseline_prod_schema_001.py line 484, daily_token_summary at 917): ai_token_usage(id, user_id FK CASCADE, input_tokens, output_tokens, total_tokens, model varchar(100) NOT NULL, cost float, endpoint, request_type, connection_id FK SET NULL, created_at) with three indexes, and daily_token_summary(id, user_id, date, total_tokens, input_tokens, output_tokens, total_requests, total_cost, model_breakdown varchar(2000), created_at, updated_at) with UNIQUE (user_id, date). Measured 2026-09-12, run from backend/: grep -rn "AITokenUsage\|DailyTokenSummary" --include='*.py' . outside the model file and the migrations returns six lines in two files and no writer: the import in user_router.py line 16, the model registration S8's models/__init__.py import rule requires (models/__init__.py 10, the import, and 135, the __all__ entry), and three TODO comments in the router (117, 134, 200). No call site constructs either class and nothing calls db.add on one; the router's own comment at 134 says "Implement AITokenUsage tracking when tables are created". The tables exist; the writer does not. The usage a person sees is users.tokens_used_current_period, and the daily series is synthesised (Application logic step 3).
What is not stored by this module: a password-change verification code (_password_change_codes, user_router.py line 30, is a process-local dict; Gotchas), the profile email a client sends on PUT (accepted by UserUpdateRequest line 37 and never written), and any audit row: no admin_actions entry is written for a self-service change.
The response shape UserProfile (schemas/user_schema.py 19 to 33): id, email, name, created_at, is_active, is_verified, subscription_tier, has_password (default True), client_status (default new), ai_trial_started_at, ai_trial_ends_at. Only the first eight are populated by the handlers (lines 58 to 67 and 96 to 104); the last three always carry their defaults.
API surface
All routes under /api/user, mounted on LABS_SURFACES (a prima_web or prima_chat session is 403 at the gate, S5), every one with ActiveUser (a disabled account is 403 "Inactive user"), no workspace header (the account is not a tenant resource):
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
GET /api/user/profile | ActiveUser | none | 200 UserProfile | name is first_name last_name trimmed, else username, else the local part of the email (54 to 56); has_password is bool(password_hash) |
PUT /api/user/profile | ActiveUser | none | 200 UserProfile; 422 on a malformed body | body {name?, email?}; name is split at the first space into first_name and last_name (82 to 86); email is ignored; the response omits has_password, so it reads true whatever the row says (Gotchas) |
GET /api/user/token-usage | ActiveUser | none | 200 dict | the 30-day window and the synthesised daily series (106 to 182) |
GET /api/user/token-usage/summary?days=7 | ActiveUser | none | 200 dict | total_tokens, average_tokens_per_day = tokens_used / max(days, 1) (184 to 209); days is unbounded and unvalidated |
POST /api/user/password/request-code | ActiveUser | none | 200 {message} | the second password-change flow, D8 (211 to 248) |
PUT /api/user/password | ActiveUser | none | 200 {"message": "Password updated successfully"} (at fd8558f the 200 writes no hash; Gotchas); 400 "No verification code requested. Please request a code first.", "Verification code has expired. Please request a new one.", "Invalid verification code"; 422 when the body carries current_password instead of a six-character verification_code | body {verification_code, new_password >= 8}; does not revoke sessions (251 to 290) |
GET /api/user/metal-status | ActiveUser | none | 200 dict; {"error": "user_not_found"} with 200 when the row vanished | MetalService.get_metal_status (services/metal_service.py 177 to 221) |
GET /api/user/billing-prompt-status | ActiveUser | none | 200 {has_seen_offer: bool} | 385 to 392 |
POST /api/user/mark-billing-prompt-shown | ActiveUser | none | 200 {message} | sets has_seen_first_month_offer = true and first_month_offer_shown_at = utcnow() (394 to 410); idempotent |
DELETE /api/user/account | ActiveUser | none | 200 {"message": "Account deletion not yet implemented"} | a stub (412 to 420); the contract is below |
GET /api/user/brand-essence | ActiveUser | none | 200 BrandEssenceResponse; 404 "Brand essence not found. Please contact your administrator." | read-only; keyed on user_id, not workspace (423 to 442) |
Error shape: HTTPException with a string detail, rendered by S7 as {error, code, request_id, timestamp}.
The erasure contract for DELETE /api/user/account, to be built where the stub is: the route takes a confirmation (the account's own email in the body, compared case-insensitively, or a fresh password for accounts that have one) and answers 202; it refuses with 409 while the caller owns a workspace that has other active members (workspaces.owner_id = user, workspace_users with is_active and user_id <> owner), naming the workspace ids, because ownership must be transferred first (B15); it then, in one transaction, sets users.is_active = false, revokes every session (revoke_session per row, and the jti denylist when the flag is on, S5), deactivates every api_keys row of the user (which the key-sync outbox carries to Prima, B12), revokes every user_login_emails row (revoked_at, B20: revoked, never deleted, because acting_email is stamped on desk rows forever), writes an admin_actions row with action_type = "account_erasure_requested" (the column is action_type varchar(100) NOT NULL, models/admin_action_model.py 20; there is no action column) carrying the user id as both admin_id (19) and target_user_id (21) and the free-text reason in the JSONB details (24), and enqueues the hard erasure for the scheduler (S12) after a 30-day grace. The grace-period sign-in refusal is a requirement this contract places on B2, not on this module: login() (auth_router.py 462 onward) must read users.erasure_requested_at before its is_active check at 468 and answer 403 "This account is scheduled for deletion."; identity-auth/session-login-refresh-logout (B2) records no such check at fd8558f, so until B2 carries it the observable refusal for an erasure-marked account is B2's existing is_active one, 403 "Account is disabled" (auth_router.py 468 to 472). Hard erasure replaces email with deleted+<id>@invalid and username with deleted-<id>, nulls first_name, last_name, display_name, password_hash, phone_number and the fb_* attribution columns, and leaves the row in place: measured 2026-09-12 at fd8558f from backend/, counting both quote styles the reference uses, grep -rhE "ForeignKey\((\"|')users\.id" models/*.py returns 126 declarations, of which 50 are ON DELETE CASCADE, 37 ON DELETE SET NULL and 39 carry no ondelete and therefore refuse the delete. (The double-quoted form alone, grep -rn 'ForeignKey("users.id"' models/*.py, is 119 of those, split 47 / 33 / 39; the seven single-quoted declarations are the difference, and the 39 is the same number either way.) So a physical DELETE FROM users cannot succeed against the schema as declared and would take bookkeeping, tickets and orders with it where it could. The row stays; the person is gone from it.
Application logic
GET /profile: the dependency resolves the user (S5); the handler buildsnamefrom the row and returnsUserProfilewithhas_password=bool(password_hash). No query of its own.PUT /profile:select(User).where(User.id == current_user.id)thenscalar_one(); whennameis present, split once at the first space;commit,refresh, return the rebuiltUserProfile(withouthas_password). Nothing else on the body is read.GET /token-usage:TokenQuotaService.get_quota_info(db, user.id)(services/token_quota_service.py20 to 65) returnstoken_quota,tokens_used,tokens_remaining = max(0, token_quota - tokens_used + voucher_tokens),voucher_tokens,quota_reset_date,subscription_tier,has_quota; on any exception it logs and returns zeros with anerrorkey rather than raising. The handler then reads three keys the service never sets (unlimited,percentage_used,days_until_reset), sounlimitedis alwaysFalse,percentage_usedalways0,days_until_resetalways30(lines 125 to 131, 170 to 177).model_breakdownis one hard-coded entry nameddatarus-r1carrying the whole period's usage (135 to 142);daily_usagespreadstokens_usedevenly over the last seven days with the remainder on today (144 to 160);input_tokensandoutput_tokensaretokens_used // 2and the rest (164 to 165). Everycostis0.0.GET /token-usage/summary: the same quota call; returns the period arithmetic and nothing per day.POST /password/request-code: six digits fromsecrets.randbelow(1000000), stored in the module dict under the user id with a ten-minute expiry, mailed throughemail_service._send_via_resendwhenRESEND_API_KEYis set, otherwise logged at INFO with a[DEV]prefix and the code in clear (245).PUT /password: look up the dict, refuse missing, expired or mismatched (a plain!=, notcompare_digest, line 275), pop the entry,hash_password, assign it tocurrent_user.password_hashand commitdb(285 to 287). Sessions stay live, and at fd8558f the password does not change either:current_useris a detached instance, so the commit flushes nothing and the route answers 200 over a row it never wrote (Gotchas). A service built to this recipe re-selects theUserin the route's owndbbefore assigning, the patternPUT /profileuses at 77 to 78.GET /metal-status:get_metal_statusreads the sixmetal_*columns, looks the tier up inMETAL_TIERS(core/metal_config.pyline 30) and returns tank, top-up and welcome millilitres, the capacity,percentage_usedof the subscription tank only,liters_remaining, the period bounds andprice_chf.GET /billing-prompt-statusreads one boolean;POST /mark-billing-prompt-shownre-selects the row, sets the flag and the timestamp, commits.DELETE /account: returns the placeholder without touching the database.GET /brand-essence:select(BrandEssence).where(user_id == current_user.id),scalar_one_or_none, 404 when absent, elseto_dict()into the response model.
Invariants
- No handler parameter is a user id: every read and write is keyed on
current_user.idfrom the dependency (grep -c "user_id: UUID\|user_id: str" api/routers/user_router.pyprints0). - Every route declares
ActiveUser; the router carries noOptionalUserand no API-key-specific branch, so an API key reaches it only through the S5 method matrix. has_passwordis derived (password_hash IS NOT NULL) and never stored; a passwordless account (magic link only, desk shared account, B20) readsfalseonGET /profile.PUT /profilechangesfirst_nameandlast_nameand nothing else;emailandusernameare not writable here (an email change is a verification flow, B1).- The usage figures are display only: nothing in this router decrements a quota, and
deduct_tokens(services/token_quota_service.pyline 90, and the SQL functionapi/middleware/usage_tracking.pyline 424 calls) is not reachable from it. brand_essence.user_idis unique, soscalar_one_or_nonecannot raiseMultipleResultsFound.- The billing-prompt flag moves in one direction; there is no route that clears it.
- A 200 from
PUT /passwordmeans the new password logs in atPOST /api/auth/loginand the old one answers 401. The reference fails this at fd8558f: the assignment lands on a detachedcurrent_userand nothing is written (Gotchas, and the same defect measured onPOST /api/auth/change-passwordin B6).
Frontend
identity-auth/ui/profile-and-passkeys /settings/profile ProfileWithUsage: reads the signed-in user from
AuthContext (/api/auth/me, not GET /profile), PUT /profile through
api.updateProfile(name) (services/api.ts 234 to 237), the code-based
password change through requestPasswordChangeCode and updatePassword
(239 to 249). No account-deletion control exists on the page at
fd8558f (`grep -rn "user/account\|deleteAccount" frontend/src`
hits only services/accountingApi.ts 305, an unrelated
accounting-accounts delete, and F5's own section tree lists no
deletion section); the erasure contract in API surface is what
F5 grows a control for
Other SPA readers at fd8558f (2026-09-12): hooks/useMetalStatus.ts line 47 and pages/billing/SubscriptionManager.tsx line 90 read /api/user/metal-status (the hook has no call site; the page is routed at /billing/manage, App.tsx 273); pages/Essence.tsx line 100 and pages/Brandbook.tsx line 144 read /api/user/brand-essence (/essence, /brandbook). services/api.ts 187 to 190 and 229 to 232 wrap GET /profile and GET /token-usage; grep -rn "getProfile()\|getTokenUsage(" src --include='*.tsx' outside services/api.ts returns nothing, and billing-prompt-status, mark-billing-prompt-shown and token-usage/summary have no frontend caller at all. client_status reaches the SPA through /api/auth/me (contexts/AuthContext.tsx 105, 140, 204), not through this router.
Minimum admin and debug surface: GET /api/user/profile with the person's own session, and for an operator the users list of the admin console (identity-auth/ui/admin-users-console, F4) which shows is_active, is_verified, the tier and the quota columns this router displays.
Security & tenancy
Auth dependency: ActiveUser (S5) on all eleven routes. Workspace derivation: none; the account is the resource and the caller is the owner. Fail direction: closed on identity (no user, no route); open on the usage figures (get_quota_info swallows exceptions and returns zeros with an error key the handler does not surface, so a database error reads as "no usage"). Credentials at rest: password_hash is bcrypt (S5, 12 rounds); the six-digit code is in process memory only, never persisted, and dies with the worker.
Rate limits: none on this router. The code-based password change is therefore an unbounded online guess at six digits with no attempt counter and no lockout: measured 2026-09-12, PUT /password compares with != and pops the code only on success or expiry, so a wrong guess costs nothing and the ten-minute window admits as many attempts as the caller can send (the only ceiling is the global 1000 per minute per IP of identity-auth/rate-limiting, B18, which excludes nothing under /api/user). What the guess is worth at fd8558f is nothing: a correct code answers 200 and writes no hash (Gotchas), so the exposure today is a spendable 200 rather than a stolen password, and the attacker already had to hold the victim's session to reach the route at all. The rate-limit defect stands for any build that repairs the write, where a guessed code does change the password. A new build keeps only POST /api/auth/change-password (B6), which asks for the current password and revokes sessions.
Attacker-controlled inputs: name (split on a space, stored up to 100 characters per half by the column, longer values raise a database error that S7 renders as 500), days on the summary route (any integer, including zero and negatives; division is guarded by max(days, 1)), verification_code and new_password (length-checked by Pydantic), and the profile email (ignored). GET /brand-essence returns whatever the agency wrote, including JSONB lists, unescaped; the SPA renders it as text.
Tenancy of the brand essence: keyed on user_id, written by an admin for a user, so a person with memberships in several workspaces has one essence, not one per workspace. A new build keys it on the workspace (S6) and reads it through WorkspaceID.
Tests
Backend, alchemy_labs/backend/tests/test_api_endpoints.py::TestUserEndpoints (lines 166 to 208), run 2026-09-12 at fd8558f against the :5434 Postgres with python -m pytest tests/test_api_endpoints.py -k TestUserEndpoints -q --no-cov -o addopts="": 1 failed, 4 passed. Passing: test_get_user_profile (email echoed), test_update_user_profile (name round-trips), test_delete_user_account (asserts the stub's 200, with the comment "Endpoint exists but returns placeholder message"), test_get_user_without_auth (401 or 403). Failing: test_update_user_settings sends {current_password, new_password} to PUT /api/user/password and asserts 200; the route's PasswordUpdateRequest (user_router.py 38 to 41) requires verification_code, so the answer is 422 (Gotchas). tests/test_surface_scoped_access.py line 390 lists GET /api/user/profile among the routes an API-only session is refused on (S5 Tests).
What a new build adds, in S9's layout (tests/test_identity_user_profile.py): a passwordless account reads has_password: false on GET and on the response of PUT; PUT /profile with {"email": "other@example.com"} leaves users.email unchanged; a name of one word lands in first_name with an empty last_name; GET /brand-essence for a user with none is 404 and for a user with one returns the row's brand_name; the erasure contract's three refusals (wrong confirmation 400, owner with members 409, second call during grace 202 and idempotent) and its effects (sessions inactive, keys inactive, login-email rows revoked, one admin_actions row). Known-answer vectors: none on the wire (no signature or hash leaves this module). Hermetic throughout; no Playwright gate (the page is F5's).
Deploy & ops
Config keys per app heredoc (S13): none of its own. RESEND_API_KEY is the gate of the code flow and is empty on an Infomaniak-only host by policy (the transport is EMAIL_TRANSPORT=smtp, email_service.py 1156 to 1160 comment), so on such a host the code is logged and never mailed (Gotchas). Migrations order: users and brand_essence are in the baseline (baseline_prod_schema_001.py); the erasure contract needs one additive revision for users.erasure_requested_at timestamptz NULL (S8). Scheduler jobs: none today; the erasure contract adds one daily job under the S12 lock. Argus checks: none specific; GET /api/user/profile with a known session returning 200 is a fair liveness probe for the Labs surface gate plus a database read. Rollback: none needed for reads; the erasure job is the one write with a grace window, and cancelling it before the window ends is UPDATE users SET erasure_requested_at = NULL.
Gotchas
- 2026-09-12 (measured at fd8558f, D8 gotcha):
PUT /api/user/passwordanswers 200Password updated successfullyand writes no hash.ActiveUserresolves throughget_current_user, which loads the row withauth_service.get_user_by_id(api/dependencies.py231;services/auth_service.py1299 to 1305) insideget_db_context(), whosefinallycallssession.expunge_all()before closing (core/database.py458 to 461), socurrent_userreaches the handler detached.update_passwordassignspassword_hashon that detached object and commits the route's ownDatabaseSession(user_router.py285 to 287), which holds no pending change, so the row is unchanged and the old password still logs in.PUT /profiledoes not have the defect because it re-selects theUserindbfirst (77 to 78). B6 measures the same chain onPOST /api/auth/change-passwordand records the hermetic run. Fix (D8, recorded, not applied in this wave): re-select theUserin the route'sdbbefore assigning (the pattern at 77 to 78, ordb.merge(current_user)), plus a test that logs in with the new password after the 200 rather than asserting the status code alone. The retirement of this flow (next Gotcha) removes the route rather than repairing it, but the repair lands first onPOST /api/auth/change-password, which the profile page is pointed at, or the retirement ships a card that answers success and changes nothing. - 2026-09-12 (measured at fd8558f, D8 gotcha, the fix is to delete the flow): two password-change flows exist.
POST /api/user/password/request-codeplusPUT /api/user/passwordkeep the code in_password_change_codes, a dict in the worker process (line 30), so withWEB_CONCURRENCY=2(Dockerfileline 77) a code requested on one worker is "No verification code requested" on the other, roughly half the time; the code is compared with!=and never rate-limited; a successful change revokes no session and (previous Gotcha) writes no hash.POST /api/auth/change-password(auth_router.py845 to 877) checks the current password and revokes every session, and shares the detached-instance defect. The fix is to delete the two routes and pointProfileWithUsage.tsx(231 to 274) at/api/auth/change-password. - 2026-09-12 (measured at fd8558f, D8 gotcha, red test):
tests/test_api_endpoints.py::test_update_user_settings(189 to 195) sendscurrent_passwordtoPUT /api/user/passwordand asserts 200; the route answers 422. The test was written against the S5-style body and never updated when the code flow replaced it. It goes with the flow above. - 2026-09-12 (measured at fd8558f): the code mail is gated on
settings.RESEND_API_KEY(line 230), not ontransport_is_configured(). With the key empty the code is written to the log at INFO in clear (line 245) and nobody receives it;user_router.pyis on the direct-call ledger oftests/test_email_transport_funnel.py(line 47) precisely because it calls_send_via_resenddirectly. The magic-link route fixed the same gate on 2026-09-03 (auth_router.pycomment at 1095 to 1099); this one was not touched. - 2026-09-12 (measured at fd8558f):
PUT /profilebuilds its response withouthas_password(96 to 104), so the field readstruefor a passwordless account right after a name change, whileGET /profilea moment later readsfalse. The SPA readshas_passwordfrom neither at fd8558f (grep -rn has_password srchits only the SMTP and IMAP cards, a different field), so nothing renders wrong today; a new build passes the derived value in both. - 2026-09-12 (measured at fd8558f):
client_status,ai_trial_started_atandai_trial_ends_atare onUserProfile(28 to 30) and populated by no handler;GET /profilealways saysclient_status: "new". The SPA reads the real value from/api/auth/me. Either drop the three fields from the schema or pass them. - 2026-09-12 (measured at fd8558f):
GET /token-usagereadsunlimited,percentage_usedanddays_until_resetfrom a dict that never carries them (get_quota_inforeturns seven keys, 47 to 55), so the three are constants; the daily series is synthesised from one number;model_breakdownnames a model literal. The endpoint is a chart of one integer. A new build meters through the gateway (D2) and reads the ledger; theai_token_usageanddaily_token_summarytables are dropped, not populated. - 2026-09-12 (measured at fd8558f):
GET /metal-statusreturns{"error": "user_not_found"}with HTTP 200 when the row is gone (metal_service.py185 to 186), which cannot happen behindActiveUserbut is the wrong shape if the service is ever called elsewhere; S7 says a missing resource is 404. - 2026-09-12 (measured at fd8558f):
DELETE /api/user/accountreturns 200 with a message and does nothing (412 to 420); the SPA offers no control for it. The contract in API surface is what replaces it. Until then a person who asks to be deleted is handled by an admin through B16 (deactivate) and by hand. - 2026-09-12 (measured at fd8558f):
UserUpdateRequest.email(user_schema.py37) is accepted and dropped; a client that sends a new email gets 200 and no change. Remove the field or return 422 for it. - 2026-09-12 (measured at fd8558f): the commented-out
POST /onboarding(292 to 373) writesonboarding_progressthrough raw SQL; it is dead code carrying a table name, kept in the file. Delete it rather than uncomment it.
Reference implementation
alchemy_labs, branch d1a-token-source at fd8558f (2026-09-12; the backend is identical to feat/service-desk b1089b6). Paths relative to backend/:
api/routers/user_router.py(442 lines;wc -lprints 441 because the last line carries no newline): the code store (30),PasswordUpdateRequest(38 to 41), router (43 to 46),get_profile(48 to 67),update_profile(69 to 104),get_token_usage(106 to 182),get_token_usage_summary(184 to 209),request_password_change_code(211 to 248),update_password(251 to 290), the dead onboarding route (292 to 373),get_metal_status(375 to 382),get_billing_prompt_status(385 to 392),mark_billing_prompt_shown(394 to 410),delete_account(412 to 420),get_my_brand_essence(423 to 442, the last of them unterminated).schemas/user_schema.py:ClientStatusEnum(12 to 16),UserProfile(19 to 33),UserUpdateRequest(35 to 37), the unusedPasswordUpdateRequestandTokenUsageResponse(39 to 54; the router defines its ownPasswordUpdateRequestand returns a plain dict for usage).models/user_model.py:ClientStatus(45 to 55),User(57 to 155), the quota and metal columns (95 to 113).models/token_usage_model.py:AITokenUsage(9 to 40),DailyTokenSummary(42 to 70, the file's last line, unterminated), both unwritten; their registration inmodels/__init__.py10 and 135.models/brand_essence_model.py:BrandEssence(15 onward).services/token_quota_service.py:get_quota_info(20 to 65),deduct_tokens(90).services/metal_service.py:get_metal_status(177 to 221);core/metal_config.py:METAL_TIERS(30).app_fastapi.pyline 875: the mount.tests/test_api_endpoints.py166 to 208.- Frontend (
frontend/):src/services/api.ts187 to 190, 229 to 249;src/pages/settings/ProfileWithUsage.tsx(618 lines; profile save 231, code request 246, password update 274);src/hooks/useMetalStatus.ts47;src/pages/billing/SubscriptionManager.tsx90;src/pages/Essence.tsx100;src/pages/Brandbook.tsx144.
Deviations of the reference from this recipe, all dated 2026-09-12: the second password-change flow and its red test; the code mail gated on the Resend key; has_password missing from the PUT response; three schema fields never populated; the synthesised usage figures and the two unwritten tables; the 200-with-error shape in the metal service; the stub DELETE /account; the ignored email on PUT; the dead onboarding route; the brand essence keyed on the user rather than the workspace.
Acceptance checks
Run from alchemy_labs/backend at fd8558f with the :5434 test Postgres up (tooling/verify/labs-local.sh up gives a running app on 8020 and a session file), or from a new service built to this recipe. Expected observations are exact.
python -m pytest tests/test_api_endpoints.py -k TestUserEndpoints -q --no-cov -o addopts=""prints1 failed, 4 passedat fd8558f; on a build that removed the code flow and fixed the test it prints5 passed(or the new file's count).grep -c "user_id: UUID\|user_id: str" api/routers/user_router.pyprints0andgrep -c "current_user.id" api/routers/user_router.pyprints13(ten in live handlers, three in the commented-out onboarding route): no route takes a user id from the request.grep -c "ActiveUser" api/routers/user_router.pyprints13: eleven routes (grep -c "^@router\." api/routers/user_router.pyprints11), the import, and the commented-out route. Every live route carries the dependency.- With the app running and
$Ta session for a magic-link-only account (no password):curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8020/api/user/profile | python -c "import json,sys; d=json.load(sys.stdin); print(d['has_password'], d['client_status'])"printsFalse new; thencurl -s -X PUT -H "Authorization: Bearer $T" -H "Content-Type: application/json" -d '{"name":"Ada Lovelace","email":"other@example.com"}' http://127.0.0.1:8020/api/user/profile | python -c "import json,sys; d=json.load(sys.stdin); print(d['name'], d['email']!='other@example.com', d['has_password'])"printsAda Lovelace True Trueat fd8558f (thehas_passworddefect) andAda Lovelace True Falseon a build to this recipe; the email in the response is the account's own. curl -s -X PUT -H "Authorization: Bearer $T" -H "Content-Type: application/json" -d '{"current_password":"x","new_password":"NewStrongPass456!"}' http://127.0.0.1:8020/api/user/password -o /dev/null -w "%{http_code}"prints422at fd8558f; on a build to this recipe the route does not exist and the answer is404(S4: an unmounted path), andPOST /api/auth/change-passwordwith the same body is the only password change.curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8020/api/user/token-usage | python -c "import json,sys; d=json.load(sys.stdin); print(d['unlimited'], d['percentage_used'], d['days_until_reset'], d['model_breakdown'][0]['model'])"printsFalse 0 30 datarus-r1for any account at fd8558f, whatever its usage; a build to this recipe does not ship the route.curl -s -X DELETE -H "Authorization: Bearer $T" http://127.0.0.1:8020/api/user/accountprints{"message":"Account deletion not yet implemented"}at fd8558f. On a build to this recipe: without a confirmation body the answer is 400; with{"confirm_email": "<the account's email>"}it is 202, a second identical call is 202,SELECT is_active FROM users WHERE id = '<id>'printsf,SELECT count(*) FROM sessions WHERE user_id = '<id>' AND is_activeprints0, andPOST /api/auth/loginfor the account is 403 with"This account is scheduled for deletion.".curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8020/api/user/brand-essence -o /dev/null -w "%{http_code}"prints404for a fresh account and200after an admin posts one throughPOST /api/admin/brand-essencefor that user id.- With a
prima_websession (create_session(..., surface="prima_web")in a shell):curl -s -H "Authorization: Bearer $P" http://127.0.0.1:8020/api/user/profileis 403 with"This session belongs to the 'prima_web' surface and cannot be used here."(the mount isLABS_SURFACES).
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.