1. What it is
users-auth-service decides who you are and what company you belong to: it signs
everyone in (admins by password or one-time code, employees by SMS/email code, Oper staff by
password plus authenticator app), and it owns the records every other service asks about —
companies, people, groups, work locations, job titles, plans, invoices and AI credits.
It is also the only service on the public ingress (auth.useoper.com), so it doubles as
the front door: the mobile app, the admin web app and the internal back office all talk to it, and
the back office's authoring screens are proxied straight through it to authoring-service-v2.
| Who uses it | Tenant admin web app · mobile app (employees) · Oper staff back-office console · every other backend service · one k8s CronJob and four in-pod loops |
| Runtime | FastAPI + uvicorn · Deployment users-auth-service (1 replica, k8s/users-auth-deployment.yaml:19) · same image also runs Deployments otp-worker and onboarding-enrichment-worker |
| Database | Postgres users_auth on the shared platform instance users-auth-postgres; 31 alembic revisions; the only writer (ARCHITECTURE.md §3.4) |
| Redis | users-auth-redis DB 0 — admin and staff sessions, OTP/registration rate limits, tenant and token-validation caches |
| NATS streams | Owns AUTH (auth.>, users.*, tenants.*; k8s/nats-screams-cm.yaml:24, created by nats/init.sh:19). Also publishes into MICRO_LEARNING (admin notification envelopes) and AUTHORING_USAGE (cost ledger) |
| External APIs | Stripe (checkout, portal, invoices, webhook) · Twenty CRM · Mailgun + Twilio (otp-worker) · OpenRouter (enrichment worker only) · Sentry. The OTP link itself is answered by the Cloudflare edge worker cloudflare-workers/link-otp-worker/ |
| Entry points | 147 HTTP routes in the scan (+3 the scanner misses, see §4) · 7 consumed NATS subjects · 20 published subjects · 5 background tasks + 1 CronJob |
The two satellite workers live here on purpose: their code is
users-auth-service/app/otp_worker/ and
users-auth-service/app/onboarding_enrichment/, they ship in this service's image, and
they only get their own Deployment (k8s/otp-worker-deployment.yaml:61,
k8s/onboarding-enrichment-worker-deployment.yaml) so a slow Mailgun call or an LLM call
can never sit on the API's event loop. The top-level otp-worker/ and
onboarding-enrichment-worker/ directories are empty shells — no
tracked source, only __pycache__, tests and stale k8s copies (ARCHITECTURE.md:44).
Documenting them here, with this service, is therefore the whole story.
2. Feature map
flowchart LR ADMIN["Tenant admin (web app)"] EMP["Employee (mobile app)"] STAFF["Oper staff (back office)"] SVC["Other backend services"] SCHED["Schedulers (cron + in-pod loops)"] ADMIN --> IDENT["Sign-in, 2FA, sign-up, onboarding (/v1/admin/*)"] ADMIN --> PEOPLE["People, groups, locations (/v1/admin/:tenant/*)"] ADMIN --> MONEY["Plans, invoices, AI credits (billing, credits)"] ADMIN --> COMPLY["GDPR export, deletion queue (exports, deletion-requests)"] EMP --> MOBILE["Employee OTP, profile, update gate (/v1/mobile/*)"] STAFF --> BACKOFFICE["Tenant 360, billing ops, authoring proxy (/v1/oper-admin/*)"] SVC --> INTERNAL["Token check, tenant + people lookups, credit gate (/v1/internal/*, JWKS)"] SCHED --> JOBS["OTP worker, enrichment worker, 3 reconcilers, GDPR cron"]
3. Features
Admin
Admin sign-in and session live
An admin signs in with their email and password, or asks for a one-time code and types it in. Either way they end up with a browser session and a 30-minute access token; the session cookie outlives the token, so the panel silently renews it in the background and the admin is not thrown out mid-task. Signing out kills the session and denylists the token.
- Entry points
POST /v1/admin/login,POST /v1/admin/sessions,POST /v1/admin/sessions/verify,POST /v1/admin/token/refresh,POST /v1/admin/logout,GET /v1/admin/me,POST|PATCH /v1/admin/password- Touches
users,otp_challenges,user_audit_logs; Redisadmin:sess:*+ denylist; publishesauth.admin.otp_started- Related
- OTP delivery (§3 Background) · token-lifetime defect
- Evidence
- app/routes/admin_login.py:498, :572, :728, :918, :865, :1038 · guard app/routes/admin_login.py:102 · docs/admin-frontend-integration.md:129 · docs/admin-token-refresh-integration.md:157
Two-factor sign-in (authenticator app) live
An admin turns on 2FA under Account → Security: the panel shows a QR code, the
admin scans it and confirms one 6-digit code. From then on password login answers
401 TOTP_REQUIRED until a valid code is supplied. They can switch it off again.
- Entry points
GET /v1/admin/totp/status,POST /v1/admin/totp/enroll,POST /v1/admin/totp/verify,POST /v1/admin/totp/disable- Touches
users.totp_secret(alembic012_admin_totp_and_phone_unique)- Related
- The staff console has its own copy of this flow (see Oper staff)
- Evidence
- app/routes/admin_totp.py:48, :66, :120, :165 · docs/admin-frontend-integration.md:227-295
Company sign-up live
Anyone can register a company from the marketing site: they give a company name and their email or phone, receive a one-time code, verify it and choose a password. That creates the tenant and its first OWNER admin in one step, and starts the free trial. Re-submitting the form resumes the half-finished tenant instead of creating a duplicate.
- Entry points
POST /v1/admin/registration/register,/verify-otp,/resend-otp,/set-password- Touches
tenants,users,otp_challenges,tenant_billing(trial); publishesauth.admin.otp_started- Related
- The deprecated
/v1/public/admin/*alias is the same router mounted twice (§4) · rate limiting added for bug-hunt #16 - Evidence
- app/routes/public_registration.py:284, :541, :619, :727 · mounted app/main.py:304 · docs/admin-frontend-integration.md:36-44
Onboarding wizard, with AI pre-fill live
After the first login the admin walks a five-step wizard: company profile, the job titles they employ, an optional intro meeting. To save typing, they can paste their website and the service hands the job to the enrichment worker, which reads the site and proposes an industry, company size and a list of roles for the admin to accept or edit. The wizard remembers where they stopped.
- Entry points
POST /v1/admin/onboarding/start,/steps/{step_key},/enrich,GET /enrichment,POST /company-profile,GET /role-recommendations,POST /roles,POST /meeting-bookedsuspect- Touches
tenants.onboarding(JSONB),roles; publishesauth.admin.onboarding.enrich_requestedandauth.admin.onboarding.enriched; Twenty CRM- Related
- onboarding-enrichment worker (Background) · known defect: the onboarding blob is a read-modify-write race between the worker callback and the admin (bug-hunt #8)
- Evidence
- app/routes/admin_onboarding.py:92, :101, :148, :211, :254, :344, :390, :436 · docs/admin-onboarding-integration.md:177-387
Company settings and brand live
An admin edits the company name, website, industry, size, logo and brand colour from Settings, and picks the industry from a fixed list the API supplies. Saving announces the change to the rest of the platform.
- Entry points
PUT /v1/admin/company,GET /v1/admin/company/industries- Touches
tenants(incl.brand_color, alembic025_tenant_brand_color); publishestenants.updated- Related
- Before this existed the only writer was an internal
X-Oper-Keyendpoint (docs/fe-admin-company-settings.md:4-7) - Evidence
- app/routes/admin_company.py:142, :152 · docs/fe-admin-company-settings.md:17, :94
An admin's own profile live
An admin changes their own name, date of birth and profile picture. This is the admin-side twin of the mobile profile screen, which they cannot use because their session does not carry the employee scope.
- Entry points
PUT /v1/admin/me- Touches
users(name, DOB,icon_url)- Related
- Picture upload goes to asset-manager, not here (docs/fe-admin-profile.md §3)
- Evidence
- app/routes/admin_login.py:1177 · docs/fe-admin-profile.md:5, :97
People directory: employees, admins and educators live
One screen family creates, lists, searches, edits, disables and removes every kind of person in the company. Inviting someone sends them a one-time link; an admin can resend it. What a caller may do depends on their role (OWNER, ADMIN, EDUCATOR) and on the role of the person they are editing, so an educator cannot promote themselves. Job titles are a separate small list the group rules read from.
- Entry points
GET|POST /v1/admin/{tenant_id}/users,GET|PATCH|DELETE /users/{user_id},POST /users/{user_id}/status,/resend-invite,GET|POST /v1/admin/{tenant_id}/roles, and four/users/bulk/*endpoints suspect- Touches
users,roles,group_members,user_audit_logs; publishesusers.created,users.updated,users.deleted,auth.user.invitation- Related
- Role model:
New-Design/SDD-multi-admin.md§4-6, resolved bylibs/auth/oper_auth/scopes.py· bulk endpoints 500 on a malformed id (bug-hunt #18) - Evidence
- app/routes/users.py:1189, :1208, :1347, :1744, :2227, :2300, :2346, :2403, :2448 · docs/fe-multi-admin-team.md:124, :250, :307 · docs/fe-admin-role-permissions.md:355
One learner's detail page live
Opening a learner shows four cards: their training and engagement totals, their assignments, a timeline of recent activity and their certificates. Only the identity half of that lives here — the rest is fetched live from micro-learning and assignment-service while the admin waits, and a dead source shows as an error card rather than a blank page.
- Entry points
GET /v1/admin/{tenant_id}/users/{user_id}/training-summary,/assignments,/activity,/certificates- Touches
users; HTTP to micro-learning and assignment-service (edges H19, H20)- Related
- Fan-out client
app/clients/training_clients.py:120-167 - Evidence
- app/routes/user_details.py:86, :146, :238, :335 · guard :65 · docs/fe-admin-user-details.md:23, :86, :103, :142, :172
Groups: hand-picked or rule-driven live
An admin groups people to assign training to them. A static group is a list they curate by hand; a dynamic group is a rule ("every cashier in Berlin") and the service keeps its membership correct by itself — immediately when someone is hired, changes job title or leaves, and again on a periodic sweep in case an event was missed. Before saving a rule the admin can preview who it would catch, and re-resolve a group on demand.
- Entry points
GET|POST /v1/admin/{tenant_id}/groups,GET|PUT|DELETE /groups/{group_id}, member add/update/remove plusmembers:bulk,groups:preview-membership,groups/{group_id}:resolve,groups/role-titles- Touches
groups,group_members; consumesusers.*; publishesgroups.updated- Related
- Resolver internals
app/services/groups_resolver.py· phone-only employees used to break membership (bug-hunt #5, fixed by alembic020_group_members_email_nullable) · a failing group no longer discards the whole sweep (bug-hunt #10) - Evidence
- app/routes/groups.py:346, :505, :623, :662, :695, :871, :923, :1030, :1239, :1382 · docs/admin-groups-integration.md:16, :319, :647
Work locations live
An admin maintains the company's stores, sites and offices — address, map pin, active or disabled — and can delete several at once. Employees pick their location on the mobile app, and group rules can target one.
- Entry points
GET|POST /v1/admin/{tenant_id}/locations,PATCH|DELETE /locations/{location_id},POST /locations/bulk-delete- Touches
locations,users.store_id,tenant_audit_logs- Related
- Mobile side:
GET /v1/mobile/locations· bulk delete shares bug-hunt #18 - Evidence
- app/routes/locations.py:192, :278, :386, :456, :495 · docs/admin-locations-integration.md:94, :204, :271
Subscription, invoices and seats live
An admin sees which plan the company is on, how many monthly active learners it has used, and what the next invoice looks like; they can switch plan, start a Stripe checkout, open the Stripe billing portal and download past invoices. Stripe tells the service about payments through a signed webhook, and a periodic sweep keeps the usage figure honest between invoices.
- Entry points
GET /v1/admin/{tenant_id}/billing/overview,/plans,/invoices,POST /billing/checkout,/billing/portal,PUT /billing/plan,POST /v1/billing/stripe/webhook- Touches
billing_plans,tenant_billing,billing_invoices,billing_events; Stripe; HTTP to micro-learning for active-learner counts (H19)- Related
- Pricing model in docs/fe-admin-billing.md §TL;DR · trials and Enterprise contracts in
New-Design/SDD-trial-and-enterprise.md - Evidence
- app/routes/admin_billing.py:266, :336, :370, :414, :443, :458, :528 · docs/fe-admin-billing.md:46-51, :236
AI credits live
Course generation costs credits. An admin sees the balance, the history of what spent it, and can top up. The same ledger is what authoring and micro-learning check before starting an AI run, so a company that runs out is told before the run rather than after.
- Entry points
GET /v1/admin/{tenant_id}/credits/balance,/credits/history,POST /credits/topup- Touches
credit_balances,credit_ledger; Stripe (top-up)- Related
- Internal gate below · design
New-Design/SDD-credit-ledger.md - Evidence
- app/routes/credits.py:212, :223, :254 · authoring-service-v2/docs/fe-import-estimate.md:111-113 · New-Design/SDD-credit-ledger.md:64-66
Answering a data-access request (GDPR) live
An admin asks for everything the platform holds about one learner. The request is queued, not answered on the spot: overnight a job collects the learner's profile, groups, preferences, training history, assignments, feed activity and notifications from five services into one bundle, tells the admins it is ready, and the admin downloads it.
- Entry points
POST /v1/admin/users/{user_id}/export,GET /v1/admin/exports,/exports/{job_id},/exports/{job_id}/download- Touches
gdpr_export_jobs; HTTPGET /v1/internal/export/{user_id}on micro-learning, assignment, feed and notification-worker (H23); publishes an admin notification envelope- Related
- CronJob
users-auth-process-gdpr-exports(Background) · a missing source URL marks the bundle incomplete rather than failing - Evidence
- app/routes/admin_export.py:88, :196, :242, :272 · app/services/gdpr_export.py:144-156, :350 · docs/fe-admin-gdpr-export.md:13, :81, :113
Deletion queue live
Learners can ask to have their account deleted, but the decision stays with the company: admins see the open requests with the stated reason and either process one (which soft-deletes the account and tells the rest of the platform) or cancel it.
- Entry points
GET /v1/admin/deletion-requests,POST /deletion-requests/{request_id}/process,/cancel- Touches
account_deletion_requests,users.deleted_at; publishesusers.deleted- Related
- Mobile side below · docs/fe-account-deletion.md
- Evidence
- app/routes/account_deletion.py:235, :313, :404 · docs/fe-account-deletion.md:92, :132-133
Oper staff (back office)
Staff sign-in live
Oper's own people sign in to the internal console with a password and a 6-digit code from their authenticator app. A staff identity belongs to no company, which is exactly why the console needs the proxy below to author anything.
- Entry points
POST /v1/oper-admin/session,/token/refresh,/logout,GET /v1/oper-admin/me,GET|POST /v1/oper-admin/totp/status|enroll|verify- Touches
staff_users(alembic028_staff_users); Redis staff sessions- Related
- The first staff account is created by the bootstrap Job
k8s/job-bootstrap-staff-master.yaml - Evidence
- app/routes/oper_staff_auth.py:202, :241, :281, :313, :335, :349, :380 · docs/fe-backoffice.md:15, :38, :61, :79-81
Tenant 360 live
One read-only screen per customer: who they are, their plan and status, how much they use the product, their credit balance and burn rate, their people, and the state of their course generation runs. Staff answer "is this customer healthy?" without touching the customer's own panel. Nothing on this screen mutates anything.
- Entry points
GET /v1/oper-admin/tenants,/tenants/{tenant_id},/usage,/credits,/users,/pipeline,/pipeline/runs/{run_id}- Touches
- reads
tenants,users,tenant_billing,credit_*; HTTP to micro-learning analytics, notification-worker devices and authoring-v2 pipeline stats - Related
- Aggregation helpers
app/services/backoffice.py, clientsapp/clients/backoffice_clients.py:93-294 - Evidence
- app/routes/oper_backoffice.py:51, :94, :139, :164, :179, :211, :280 · docs/fe-backoffice.md:123-129
Creating and suspending customers live
Staff create a tenant with its first admin, rename it, suspend or reactivate it, and (rarely) delete it outright. Suspension is what stops a non-paying company from using the product.
- Entry points
POST /v1/oper-admin/tenants,PATCH|DELETE /v1/oper-admin/tenants/{tenant_id},POST /tenants/{tenant_id}/status- Touches
tenants,users,tenant_audit_logs; publishestenants.created,tenants.updated,tenants.deleted- Related
- Known defect: the hard-delete audit row is cascade-deleted in the same transaction, so the deletion is never recorded (bug-hunt #9)
- Evidence
- app/routes/admin.py:71, :201, :286, :338 · docs/fe-backoffice.md:124
Manual billing and Enterprise contracts live
Not every customer pays by card. Staff can set a tenant's plan and seat allowance by hand, record an Enterprise contract with its monthly credit allowance, raise and amend manual invoices, and edit the plan catalogue. Putting a tenant on an Enterprise contract also triggers a welcome email and the first month's credit grant.
- Entry points
GET|PUT /v1/oper-admin/tenants/{tenant_id}/billing,POST /billing/enterprise,POST /tenants/{tenant_id}/invoices,PATCH /v1/oper-admin/invoices/{invoice_id},PUT /v1/oper-admin/billing/plans/{plan_key}- Touches
tenant_billing,billing_plans,billing_invoices,credit_ledger; publishesauth.admin.enterprise_welcome- Related
- Monthly grants after the first are the entitlements reconciler's job (Background)
- Evidence
- app/routes/admin_billing.py:655, :698, :733, :846, :885, :918 · app/services/entitlements.py:226 · docs/fe-admin-billing.md:218-268 · docs/fe-backoffice.md:142-143
Authoring the catalogue through the proxy live
Oper's own team writes the courses the marketplace sells. Rather than a second
authoring API, the console calls /v1/oper-admin/authoring/... and this service forwards
the call verbatim to authoring-service-v2 — the module shelf, the draft tree, every builder
tool, publishing, run polling and the creation chat. A staff operator has no company, so the proxy
mints a five-minute admin token for one fixed internal tenant per hop; it can never reach a
customer's content, whatever path is asked for.
- Entry points
GET|POST|PUT|PATCH|DELETE /v1/oper-admin/authoring/{path:path}(catch-all),POST /v1/oper-admin/authoring/socket-tokensuspect- Touches
- reads one ADMIN row of the internal tenant (cached 60s); HTTP to authoring-service-v2
/v1/admin/authoring/*(edge H22) over a pooled client opened in the lifespan - Related
- Two staff reads bypass the proxy and go to micro-learning instead (next block) · the websocket is not proxied (docs/fe-backoffice.md:186)
- Evidence
- app/routes/oper_authoring.py:369, :398, :300 · pool opened app/main.py:119 · docs/fe-backoffice.md:130, :153-190
Is this module published, and in which languages? live
Two questions the authoring API cannot answer because the answer lives in micro-learning: which translations of a published module exist, and which version is currently live. The console asks this service and it forwards the read.
- Entry points
GET /v1/oper-admin/modules/{module_id}/translations,GET /v1/oper-admin/modules/{root_id}/latest- Touches
- HTTP to micro-learning (
TRAINING_SERVICE_URL, edge H19) - Related
- Retrying a failed translation happens through the authoring proxy (docs/fe-backoffice.md:133-135)
- Evidence
- app/routes/oper_training.py:105, :121, target :53-60 · docs/fe-backoffice.md:131, :136
Employee (mobile)
Sign in with a one-time code live
An employee types their phone number or email into the app, gets a code by SMS or email, and is signed in — no password to remember, which is the point for frontline staff. Logging out denylists the token so it cannot be reused.
- Entry points
POST /v1/mobile/sessions/otp/start,/otp/verify,POST /v1/mobile/logout- Touches
users,otp_challenges; Redis rate limits + denylist; publishesauth.user.otp_started,auth.employee_login_success,auth.employee_logout- Related
- Known defects: the lookup is not tenant-scoped, so a dual-employed person cannot choose their company (bug-hunt #6); verify does not re-check that the account is still active (bug-hunt #3, #7)
- Evidence
- app/routes/mobile_login.py:251, :328, :725 · in-repo caller global-configs/management_scripts/login_employee_otp.py:35 (the app itself is not in this repo)
Who am I, and my profile live
Every app open asks this service who the user is: name, company, job title, work location, language, and a small training summary for the home screen. The same screen family lets the employee edit their name, birthday, language, picture and work location; finishing onboarding notifies the company's admins that a new learner is ready.
- Entry points
GET /v1/mobile/me,PUT /v1/mobile/me- Touches
users,locations; HTTP to micro-learning for the summary (H19); publishes an admin notification envelope on first completion- Related
- Hot path: the DB connection is now released before the training fan-out, which used to exhaust the pool during a micro-learning slowdown (bug-hunt #11, fixed)
- Evidence
- app/routes/mobile_login.py:624 · app/routes/mobile_profile.py:273, publish :216 · docs/fe-admin-profile.md:5
Forced app update live
The app never decides for itself whether it is too old. It sends its build number on every call; the server answers with "fine", "please update" or "you must update", and every mobile call from an unsupported build is refused with 426 until the user updates. Operators raise the floor through an internal endpoint, so the policy changes without shipping an app release.
- Entry points
GET /v1/mobile/config(exempt from the gate), the 426 middleware on all other/v1/mobile/*,GET|PUT /v1/internal/app-versions[/{platform}]- Touches
app_versions,app_version_events(alembic024_app_versions)- Related
- Fail-open by design;
APP_UPDATE_FORCE_STATUSis a QA override that shouts at boot if left armed - Evidence
- app/main.py:215-233 · app/routes/mobile_config.py:50 · app/routes/internal_app_versions.py:133, :147 · docs/fe-app-update-gate.md:54, :150-170
Where I work live
The profile screen needs the company's list of stores and sites so the employee can say which one they work at. This is the read-only employee view of the admin's locations list.
- Entry points
GET /v1/mobile/locations- Touches
locations- Related
- Admin side: Work locations
- Evidence
- app/routes/mobile_locations.py:18 · in-repo caller global-configs/management_scripts/mobile_profile_update.py:74
Notification preferences suspect
An employee chooses which categories of notification (training, feed) they want by push or email. Every notification sender checks these preferences before delivering, so switching a category off actually stops the pushes.
- Entry points
GET|PUT /v1/mobile/notification/preferencessuspect,GET /v1/admin/{tenant_id}/users/{user_id}/notification/preferenceslive,POST /v1/internal/notification-preferences/batchlive- Touches
notification_preferences(alembic009,011)- Related
- Readers: notification-worker, assignment-service, micro-learning
- Evidence
- app/routes/notification_preferences.py:123, :166, :237 · readers notification-worker/app/services/preferences_service.py:87, assignment-service/app/services/users_service.py:28, :102 · no in-repo caller or FE doc for the two mobile routes (searched users-auth-service/docs/*.md and New-Design/*.md); only
scripts/smoke_test.py:431exercises them
Asking for my account to be deleted live
An employee can file a deletion request with a reason and see its state. The account is not deleted on the spot — an admin has to process it — and a cancelled request tells the app the user may file again.
- Entry points
POST /v1/mobile/me/deletion-request,GET /v1/mobile/me/deletion-request- Touches
account_deletion_requests(alembic027)- Related
- Admin side: Deletion queue
- Evidence
- app/routes/account_deletion.py:121, :206 · docs/fe-account-deletion.md:16, :30-33, :80
Internal (other services)
Is this token real, and who does it belong to? live
Every other service asks this service to vouch for a caller's token before doing anything for them. There are two ways: a direct check here (which also honours logout, because a revoked token is on a denylist a signature check cannot see), or verifying the signature locally against the published signing keys. If this endpoint is down, nobody can serve an authenticated request — it is the platform's single busiest dependency.
- Entry points
POST /v1/internal/validate-token,GET /v1/.well-known/jwks.json- Touches
users,tenants; Redis denylist + a 2s in-process cache; JWT keys mounted from thejwt-keyssecret- Related
- Shared client
libs/auth/oper_auth/token_validation.py:77· known defects: the validation cache is unbounded (bug-hunt #13) and the admin-scope test is a substring match (bug-hunt #4/#20) - Evidence
- app/routes/internal.py:149 · app/routes/jwks.py:10 · callers assignment-service/app/services/users_service.py:121, authoring-service-v2/app/core/security.py:56, notification-worker/app/services/auth_service.py:51, libs/auth/oper_auth/service/fastapi.py:41; JWKS URL configured in asset-manager-service/app/core/auth.py:37, assignment-service/app/core/config.py:35, authoring-service-v2/app/core/config.py:55, feed-service/app/core/config.py:23
Which company is this? live
Services that render or bill on behalf of a company need its name, slug, status and onboarding facts. They ask by tenant id; the answer is cached in Redis because it is read on hot paths.
- Entry points
GET /v1/internal/tenants/{tenant_id},GET /v1/internal/tenants/slug/{slug}dead- Touches
tenants; Redis tenant cache- Related
- Known defect: an unguarded Redis call turns a Redis blip into 500s for every calling service even though a DB fallback exists (bug-hunt #12)
- Evidence
- app/routes/internal.py:258, :285 · caller authoring-service-v2/app/core/http.py:489, :514
Who works here, and what are they called? live
Four services render names, avatars and job titles next to content they own but do not store people for. They fetch a whole company roster, a directory, a batch of profiles by id, the company's admins, or a registration-over-time series for the admin dashboard. Answers are gzip-compressed because the directory is unpaginated.
- Entry points
GET /v1/internal/tenants/{tenant_id}/users,/directory,/admins,/users/registration-series,POST /v1/internal/users/profiles/batch- Touches
users,locations,roles- Related
- Known defects: the profile batch is not tenant-scoped (bug-hunt #19); the roster path has a shadowed duplicate handler (§8)
- Evidence
- app/routes/internal.py:331, :384, :503, :730, :830 · callers assignment-service/app/services/users_service.py:66, :194, micro-learning-service-v2/app/clients/user_service_client.py:89, :129, :142, :191, authoring-service-v2/app/core/http.py:542, feed-service/app/clients/users_auth_client.py:120, :162, notification-worker/app/services/admins_client.py:73
Who is in this group? live
When an admin assigns training to a group, assignment-service asks this service to expand the group into actual people, and later asks for several groups' details at once to label them. This is the seam that turns "the Berlin cashiers" into a list of learners to notify.
- Entry points
GET /v1/internal/tenants/{tenant_id}/groups/{group_id}/members,POST /v1/internal/groups/batch- Touches
groups,group_members,users- Related
- The member expansion is an N+1 per group on the assignment path (ARCHITECTURE.md §3.2 H24)
- Evidence
- app/routes/internal.py:452, :572 · callers assignment-service/app/services/groups_service.py:27, :46, :56
Can this company afford this AI run? live
Before an expensive generation starts, authoring quotes it and debits the company's credits here; if the run fails or settles cheaper, it refunds the difference. Micro-learning does the same at publish. A company with no credits is stopped at the gate instead of being surprised by a bill.
- Entry points
GET /v1/internal/credits/balance,POST /v1/internal/credits/debit,/refund,/grantdead- Touches
credit_balances,credit_ledger- Related
- Admin-facing balance and top-up above ·
New-Design/SDD-credit-ledger.md - Evidence
- app/routes/credits.py:136, :148, :168, :188 · callers authoring-service-v2/app/core/http.py:573, :599, :645, :667 and micro-learning-service-v2/app/clients/credits_client.py:52, :66, :93, :113
Notification preferences in bulk live
Before a fan-out of hundreds of pushes, the sender asks once for all the recipients' preferences instead of one call per person. Without this, a group notification would be one HTTP round-trip per learner on the sender's event loop.
- Entry points
POST /v1/internal/notification-preferences/batch,GET /v1/admin/{tenant_id}/users/{user_id}/notification/preferences- Touches
notification_preferences- Related
- notification-worker caches the answer for 120s (
notification-worker/app/core/config.py) - Evidence
- app/routes/internal.py:646 · app/routes/notification_preferences.py:237 · callers assignment-service/app/services/users_service.py:28, :102, micro-learning-service-v2/app/clients/user_service_client.py:157, notification-worker/app/services/preferences_service.py:87
Enrichment write-back live
The enrichment worker cannot reach the database, so when its AI job finishes it posts the proposal (or a failure) back through this endpoint, which stores it on the tenant's onboarding record for the wizard to show.
- Entry points
POST /v1/internal/admin/onboarding/enrichment-result- Touches
tenants.onboarding- Related
- Edge H27 · races the admin's own wizard writes (bug-hunt #8)
- Evidence
- app/routes/admin_onboarding.py:475 · caller app/onboarding_enrichment/result_client.py:25, :76 · docs/onboarding-enrichment-worker.md:26
Background
Delivering codes, invitations and alerts (otp-worker) live
Nothing in the API pod ever talks to Mailgun or Twilio. When someone asks for a code, is invited, or an admin has an alert worth emailing, the API publishes an event and this worker delivers it — email or SMS, with retries and a dead-letter record. That is why a Twilio outage slows deliveries instead of failing logins.
- Entry points
- 5 push durables on
AUTH:auth.admin.otp_started,auth.user.otp_started,auth.user.invitation,auth.admin.alert,auth.admin.enterprise_welcome - Touches
- no database; Mailgun, Twilio; publishes
auth.otp_failedon give-up - Related
- The link in the message is answered by the Cloudflare worker
cloudflare-workers/link-otp-worker/; invitation URLs/i/{slug}currently hit that worker's 404 (ARCHITECTURE.md §4.1(8)) · DLQ loss risk inbug-hunt-reports/otp-worker.md#2 - Evidence
- app/otp_worker/processor.py:119-141 (subscribe), :157-168 (dispatch), :389 (DLQ) · subjects app/otp_worker/config.py:32-47 · k8s/otp-worker-deployment.yaml:61 · docs/otp-worker.md:16-21
Reading a company's website so the admin types less (onboarding-enrichment-worker) live
One job: take a company's website, read it, and propose an industry, a size and a list of job titles. It is the only component in the identity stack that calls an LLM, and it holds the OpenRouter key so the API pod does not have to. Results come back over HTTP; a run that keeps failing is marked FAILED so the wizard stops waiting.
- Entry points
- 1 durable
onboarding-enrichment-workeronauth.admin.onboarding.enrich_requested - Touches
- no database; website fetch + OpenRouter; HTTP back to
/v1/internal/admin/onboarding/enrichment-result; publishesauthoring.usage.eventfor the cost ledger - Related
- Admin-facing wizard above · docs/onboarding-enrichment-worker.md
- Evidence
- app/onboarding_enrichment/processor.py:110-133 (subscribe), :188 (heartbeat), :364 (usage) · config app/onboarding_enrichment/config.py:11-32 · k8s/onboarding-enrichment-worker-deployment.yaml:35
Keeping dynamic groups true live
A rule-based group has to stay correct without anyone pressing a button. Two layers do that: an event subscriber re-resolves affected groups seconds after a person is hired, edited or removed, and a periodic sweep re-resolves everything in case an event was lost or the pod was down during a deploy. An admin sees membership that matches the rule, not a snapshot.
- Entry points
- pull durable
users_auth_groups_resolveronusers.*;dynamic-groupsreconciler loop - Touches
groups,group_members,users- Related
- The blocking DB work runs on a worker thread because this pod also serves JWKS and token validation · each group now resolves inside its own SAVEPOINT (bug-hunt #10, fixed)
- Evidence
- app/services/groups_events_subscriber.py:143, :196, :36-47, :50-58 · started app/main.py:135 · app/services/reconcilers.py:294
Money that has no webhook: trials and monthly contracts live
Two money facts nothing reactive can tell us: a free trial's end date arriving, and each later month of an Enterprise contract's credit allowance. An hourly sweep owns both, so an expired trial does not read as full access for the rest of the day and a contract's credits keep arriving. A third sweep keeps the monthly-active-learner figure fresh between Stripe invoices.
- Entry points
entitlementsandbilling-seatsreconciler loops- Touches
tenant_billing,credit_balances,credit_ledger; micro-learning active-learner counts; Stripe- Related
- Both are idempotent and single-flight behind a Postgres advisory lock, so extra replicas skip rather than double-grant · the trial sweep deliberately runs even with Stripe dark
- Evidence
- app/services/reconcilers.py:196, :242, :300, :306 · started app/main.py:145-146 · New-Design/SDD-trial-and-enterprise.md
Building the export bundles overnight live
At 02:00 UTC a job works through the queued data-access requests: for each learner it collects this service's own records and asks micro-learning, assignment-service, feed-service and notification-worker for theirs, stores one bundle, and tells the company's admins it is ready. A source that is unreachable is recorded as missing rather than failing the whole export.
- Entry points
- CronJob
users-auth-process-gdpr-exports→scripts/cron_process_gdpr_exports.py - Touches
gdpr_export_jobs; HTTPGET /v1/internal/export/{user_id}×4 (H23); publishes an admin notification envelope- Related
- Admin-facing export screens above · the schedule must stay in sync with
GDPR_EXPORT_WINDOW_HOUR_UTCor admins are told the wrong time (k8s/cron-process-gdpr-exports.yaml:12-16) - Evidence
- k8s/cron-process-gdpr-exports.yaml:16, :34 · app/services/gdpr_export.py:144-156, :175, :350 · kept as a CronJob on purpose: app/services/reconcilers.py:35-38
4. API reference
Every route the scanner found, in scan order (147 rows — the row count matches
tools/feature-docs/out/users-auth-service.json). “Auth” is what the handler
actually enforces, not what the prefix suggests: the admin panel authenticates with the
oper_sess session cookie (not a bearer token), and mutating admin routes additionally
require a multi-admin scope resolved from the caller's role
(app/routes/admin_login.py:102-161, libs/auth/oper_auth/scopes.py).
Registration is mounted twice. public_registration_router is included
at the canonical /v1/admin/registration (app/main.py:304) and again at the
deprecated, schema-hidden /v1/public/admin (app/main.py:306). Both prefixes
reach the same four handlers; the alias rows carry their own verdict.
| Method | Path | Auth | Feature | Callers | Verdict |
|---|---|---|---|---|---|
| GET | / | public | Service root | none found | suspect |
| POST | /debug/observability | public | Debug ping | none (DEBUG=false in prod) | dead |
| GET | /healthz | public | Health | k8s probes | live |
| GET | /v1/.well-known/jwks.json | public | JWKS | asset-manager, assignment, asv2, feed | live |
| PUT | /v1/admin/company | admin session cookie + settings.write | Company settings | admin web app | live |
| GET | /v1/admin/company/industries | admin session cookie | Company settings | admin web app | live |
| GET | /v1/admin/deletion-requests | admin session cookie | Deletion queue | admin web app | live |
| POST | /v1/admin/deletion-requests/{request_id}/cancel | admin session cookie + users.delete | Deletion queue | admin web app | live |
| POST | /v1/admin/deletion-requests/{request_id}/process | admin session cookie + users.delete | Deletion queue | admin web app | live |
| GET | /v1/admin/exports | admin session cookie + export.read | GDPR export | admin web app | live |
| GET | /v1/admin/exports/{job_id} | admin session cookie + export.read | GDPR export | admin web app | live |
| GET | /v1/admin/exports/{job_id}/download | admin session cookie + export.read | GDPR export | admin web app | live |
| POST | /v1/admin/login | public (password) | Admin sign-in | admin web app | live |
| POST | /v1/admin/logout | admin session cookie | Admin sign-in | admin web app | live |
| GET | /v1/admin/me | admin session cookie | Admin sign-in | admin web app | live |
| PUT | /v1/admin/me | admin session cookie | Admin sign-in | admin web app | live |
| POST | /v1/admin/onboarding/company-profile | admin session cookie + settings.write | Onboarding wizard | admin web app | live |
| POST | /v1/admin/onboarding/enrich | admin session cookie + settings.write | Onboarding wizard | admin web app | live |
| GET | /v1/admin/onboarding/enrichment | admin session cookie | Onboarding wizard | admin web app | live |
| POST | /v1/admin/onboarding/meeting-booked | admin session cookie + settings.write | Onboarding wizard | none found | suspect |
| GET | /v1/admin/onboarding/role-recommendations | admin session cookie | Onboarding wizard | admin web app | live |
| POST | /v1/admin/onboarding/roles | admin session cookie + settings.write | Onboarding wizard | admin web app | live |
| POST | /v1/admin/onboarding/start | admin session cookie + settings.write | Onboarding wizard | admin web app | live |
| POST | /v1/admin/onboarding/steps/{step_key} | admin session cookie + settings.write | Onboarding wizard | admin web app | live |
| PATCH | /v1/admin/password | admin session cookie | Admin sign-in | admin web app | live |
| POST | /v1/admin/password | admin session cookie | Admin sign-in | admin web app | live |
| POST | /v1/admin/registration/register | public | Company registration | admin web app | live |
| POST | /v1/admin/registration/resend-otp | public | Company registration | admin web app | live |
| POST | /v1/admin/registration/set-password | public | Company registration | admin web app | live |
| POST | /v1/admin/registration/verify-otp | public | Company registration | admin web app | live |
| POST | /v1/admin/sessions | public (OTP start) | Admin sign-in | admin web app | live |
| POST | /v1/admin/sessions/verify | public (OTP verify) | Admin sign-in | admin web app | live |
| POST | /v1/admin/token/refresh | admin session cookie | Admin sign-in | admin web app | live |
| POST | /v1/admin/totp/disable | admin session cookie | Admin TOTP | admin web app | live |
| POST | /v1/admin/totp/enroll | admin session cookie | Admin TOTP | admin web app | live |
| GET | /v1/admin/totp/status | admin session cookie | Admin TOTP | admin web app | live |
| POST | /v1/admin/totp/verify | admin session cookie | Admin TOTP | admin web app | live |
| POST | /v1/admin/users/{user_id}/export | admin session cookie + export.read | GDPR export | admin web app | live |
| POST | /v1/admin/{tenant_id}/billing/checkout | admin session cookie + billing.write | Billing & plans | admin web app | live |
| GET | /v1/admin/{tenant_id}/billing/invoices | admin session cookie + billing.write | Billing & plans | admin web app | live |
| GET | /v1/admin/{tenant_id}/billing/overview | admin session cookie + billing.write | Billing & plans | admin web app | live |
| PUT | /v1/admin/{tenant_id}/billing/plan | admin session cookie + billing.write | Billing & plans | admin web app | live |
| GET | /v1/admin/{tenant_id}/billing/plans | admin session cookie + billing.write | Billing & plans | admin web app | live |
| POST | /v1/admin/{tenant_id}/billing/portal | admin session cookie + billing.write | Billing & plans | admin web app | live |
| GET | /v1/admin/{tenant_id}/credits/balance | admin session cookie + credits.read | AI credits | admin web app | live |
| GET | /v1/admin/{tenant_id}/credits/history | admin session cookie + credits.read | AI credits | admin web app | live |
| POST | /v1/admin/{tenant_id}/credits/topup | admin session cookie + billing.write | AI credits | admin web app | live |
| GET | /v1/admin/{tenant_id}/groups | admin session cookie | Groups | admin web app | live |
| POST | /v1/admin/{tenant_id}/groups | admin session cookie + users.write | Groups | admin web app | live |
| GET | /v1/admin/{tenant_id}/groups/role-titles | admin session cookie | Groups | admin web app | live |
| DELETE | /v1/admin/{tenant_id}/groups/{group_id} | admin session cookie + users.write | Groups | admin web app | live |
| GET | /v1/admin/{tenant_id}/groups/{group_id} | admin session cookie | Groups | admin web app | live |
| PUT | /v1/admin/{tenant_id}/groups/{group_id} | admin session cookie + users.write | Groups | admin web app | live |
| GET | /v1/admin/{tenant_id}/groups/{group_id}/members | admin session cookie | Groups | admin web app | live |
| POST | /v1/admin/{tenant_id}/groups/{group_id}/members | admin session cookie + users.write | Groups | admin web app | live |
| DELETE | /v1/admin/{tenant_id}/groups/{group_id}/members/{member_id} | admin session cookie + users.write | Groups | admin web app | live |
| PUT | /v1/admin/{tenant_id}/groups/{group_id}/members/{member_id} | admin session cookie + users.write | Groups | admin web app | live |
| DELETE | /v1/admin/{tenant_id}/groups/{group_id}/members:bulk | admin session cookie + users.write | Groups | admin web app | live |
| POST | /v1/admin/{tenant_id}/groups/{group_id}/members:bulk | admin session cookie + users.write | Groups | admin web app | live |
| POST | /v1/admin/{tenant_id}/groups/{group_id}:resolve | admin session cookie + users.write | Groups | admin web app | live |
| POST | /v1/admin/{tenant_id}/groups:preview-membership | admin session cookie + users.write | Groups | admin web app | live |
| GET | /v1/admin/{tenant_id}/locations | admin session cookie | Locations | admin web app | live |
| POST | /v1/admin/{tenant_id}/locations | admin session cookie + settings.write | Locations | admin web app | live |
| POST | /v1/admin/{tenant_id}/locations/bulk-delete | admin session cookie + settings.write | Locations | admin web app | live |
| DELETE | /v1/admin/{tenant_id}/locations/{location_id} | admin session cookie + settings.write | Locations | admin web app | live |
| PATCH | /v1/admin/{tenant_id}/locations/{location_id} | admin session cookie + settings.write | Locations | admin web app | live |
| GET | /v1/admin/{tenant_id}/roles | admin session cookie | Job titles (roles) | admin web app | live |
| POST | /v1/admin/{tenant_id}/roles | admin session cookie + users.write | Job titles (roles) | admin web app | live |
| GET | /v1/admin/{tenant_id}/users | admin session cookie | Employee & admin directory | admin web app | live |
| POST | /v1/admin/{tenant_id}/users | admin session cookie + scope by target type | Employee & admin directory | admin web app | live |
| POST | /v1/admin/{tenant_id}/users/bulk/delete | admin session cookie + users.delete | Employee directory (bulk) | admin web app | suspect |
| POST | /v1/admin/{tenant_id}/users/bulk/role | admin session cookie + users.write | Employee directory (bulk) | admin web app | suspect |
| POST | /v1/admin/{tenant_id}/users/bulk/status | admin session cookie + users.write | Employee directory (bulk) | admin web app | suspect |
| POST | /v1/admin/{tenant_id}/users/bulk/store | admin session cookie + users.write | Employee directory (bulk) | admin web app | suspect |
| DELETE | /v1/admin/{tenant_id}/users/{user_id} | admin session cookie | Employee & admin directory | admin web app | live |
| GET | /v1/admin/{tenant_id}/users/{user_id} | admin session cookie | Employee & admin directory | admin web app | live |
| PATCH | /v1/admin/{tenant_id}/users/{user_id} | admin session cookie | Employee & admin directory | admin web app | live |
| GET | /v1/admin/{tenant_id}/users/{user_id}/activity | admin session cookie | User detail page | admin web app | live |
| GET | /v1/admin/{tenant_id}/users/{user_id}/assignments | admin session cookie | User detail page | admin web app | live |
| GET | /v1/admin/{tenant_id}/users/{user_id}/certificates | admin session cookie | User detail page | admin web app | live |
| GET | /v1/admin/{tenant_id}/users/{user_id}/notification/preferences | admin JWT (admin.panel) | Notification preferences | assignment, micro-learning, notification-worker | live |
| POST | /v1/admin/{tenant_id}/users/{user_id}/resend-invite | admin session cookie | Employee & admin directory | admin web app | live |
| POST | /v1/admin/{tenant_id}/users/{user_id}/status | admin session cookie | Employee & admin directory | admin web app | live |
| GET | /v1/admin/{tenant_id}/users/{user_id}/training-summary | admin session cookie | User detail page | admin web app | live |
| POST | /v1/auth/refresh | token in body | Mobile token refresh | mobile app (out of repo) | live |
| POST | /v1/auth/token | X-Oper-Key | Machine token mint | none found | dead |
| POST | /v1/billing/stripe/webhook | Stripe signature | Billing & plans | Stripe (external) | live |
| POST | /v1/internal/admin/onboarding/enrichment-result | X-Oper-Key | Onboarding wizard | onboarding-enrichment-worker | live |
| PUT | /v1/internal/app-versions/{platform} | X-Oper-Key | App-update gate | operators (X-Oper-Key) | live |
| POST | /v1/internal/auth/token | X-Oper-Key | Machine token mint | none found | dead |
| GET | /v1/internal/credits/balance | X-Oper-Key | AI credits | authoring-v2, micro-learning | live |
| POST | /v1/internal/credits/debit | X-Oper-Key | AI credits | authoring-v2, micro-learning | live |
| POST | /v1/internal/credits/grant | X-Oper-Key | AI credits | none found | dead |
| POST | /v1/internal/credits/refund | X-Oper-Key | AI credits | authoring-v2, micro-learning | live |
| POST | /v1/internal/groups/batch | X-Oper-Key | Groups for fan-out | assignment-service | live |
| POST | /v1/internal/notification-preferences/batch | X-Oper-Key | Notification preferences | assignment-service | live |
| GET | /v1/internal/tenants/slug/{slug} | X-Oper-Key | Tenant lookup | none found | dead |
| GET | /v1/internal/tenants/{tenant_id} | X-Oper-Key | Tenant lookup | authoring-v2 | live |
| GET | /v1/internal/tenants/{tenant_id}/admins | X-Oper-Key | Tenant admins | notification-worker | live |
| GET | /v1/internal/tenants/{tenant_id}/directory | X-Oper-Key | Employee directory (internal) | micro-learning | live |
| GET | /v1/internal/tenants/{tenant_id}/groups/{group_id}/members | X-Oper-Key | Groups for fan-out | assignment-service | live |
| GET | /v1/internal/tenants/{tenant_id}/users | X-Oper-Key | Employee directory (internal) | AS, ML, feed | live |
| GET | /v1/internal/tenants/{tenant_id}/users | X-Oper-Key | Employee directory (internal) — duplicate | unreachable — shadowed | dead |
| GET | /v1/internal/tenants/{tenant_id}/users/registration-series | X-Oper-Key | Registration series | micro-learning | live |
| POST | /v1/internal/users/profiles/batch | X-Oper-Key | Profile batch | assignment, authoring-v2, feed, micro-learning | live |
| POST | /v1/internal/validate-token | X-Oper-Key | Token validation | assignment, authoring-v2, notification-worker, libs/auth | live |
| GET | /v1/mobile/config | public | App-update gate | mobile app | live |
| GET | /v1/mobile/locations | employee JWT | Work-location picker | mobile app | live |
| POST | /v1/mobile/logout | employee JWT | Employee OTP sign-in | mobile app | live |
| GET | /v1/mobile/me | employee JWT | Mobile identity & profile | mobile app | live |
| PUT | /v1/mobile/me | employee JWT | Mobile identity & profile | mobile app | live |
| GET | /v1/mobile/me/deletion-request | employee JWT | Self-service deletion | mobile app | live |
| POST | /v1/mobile/me/deletion-request | employee JWT | Self-service deletion | mobile app | live |
| GET | /v1/mobile/notification/preferences | employee JWT | Notification preferences | none found | suspect |
| PUT | /v1/mobile/notification/preferences | employee JWT | Notification preferences | none found | suspect |
| POST | /v1/mobile/sessions/otp/start | public (OTP start) | Employee OTP sign-in | mobile app | live |
| POST | /v1/mobile/sessions/otp/verify | public (OTP verify) | Employee OTP sign-in | mobile app | live |
| POST | /v1/oper-admin/authoring/socket-token | staff JWT | Authoring proxy | none found | suspect |
| PUT | /v1/oper-admin/billing/plans/{plan_key} | staff JWT or X-Oper-Key | Staff billing ops | staff console | live |
| PATCH | /v1/oper-admin/invoices/{invoice_id} | staff JWT or X-Oper-Key | Staff billing ops | staff console | live |
| POST | /v1/oper-admin/logout | staff JWT | Staff sign-in | staff console | live |
| GET | /v1/oper-admin/me | staff JWT | Staff sign-in | staff console | live |
| GET | /v1/oper-admin/modules/{module_id}/translations | staff JWT | Staff module lookup | staff console | live |
| GET | /v1/oper-admin/modules/{root_id}/latest | staff JWT | Staff module lookup | staff console | live |
| POST | /v1/oper-admin/session | public (password + TOTP) | Staff sign-in | staff console | live |
| POST | /v1/oper-admin/tenants | staff JWT or X-Oper-Key | Tenant lifecycle | staff console | live |
| DELETE | /v1/oper-admin/tenants/{tenant_id} | staff JWT or X-Oper-Key | Tenant lifecycle | staff console | live |
| GET | /v1/oper-admin/tenants/{tenant_id} | staff JWT | Tenant 360 | staff console | live |
| PATCH | /v1/oper-admin/tenants/{tenant_id} | staff JWT or X-Oper-Key | Tenant lifecycle | staff console | live |
| GET | /v1/oper-admin/tenants/{tenant_id}/billing | staff JWT or X-Oper-Key | Staff billing ops | staff console | live |
| PUT | /v1/oper-admin/tenants/{tenant_id}/billing | staff JWT or X-Oper-Key | Staff billing ops | staff console | live |
| POST | /v1/oper-admin/tenants/{tenant_id}/billing/enterprise | staff JWT or X-Oper-Key | Staff billing ops | staff console | live |
| GET | /v1/oper-admin/tenants/{tenant_id}/credits | staff JWT | Tenant 360 | staff console | live |
| POST | /v1/oper-admin/tenants/{tenant_id}/invoices | staff JWT or X-Oper-Key | Staff billing ops | staff console | live |
| GET | /v1/oper-admin/tenants/{tenant_id}/pipeline | staff JWT | Tenant 360 | staff console | live |
| GET | /v1/oper-admin/tenants/{tenant_id}/pipeline/runs/{run_id} | staff JWT | Tenant 360 | staff console | live |
| POST | /v1/oper-admin/tenants/{tenant_id}/status | staff JWT or X-Oper-Key | Tenant lifecycle | staff console | live |
| GET | /v1/oper-admin/tenants/{tenant_id}/usage | staff JWT | Tenant 360 | staff console | live |
| GET | /v1/oper-admin/tenants/{tenant_id}/users | staff JWT | Tenant 360 | staff console | live |
| POST | /v1/oper-admin/token/refresh | staff session cookie | Staff sign-in | staff console | live |
| POST | /v1/oper-admin/totp/enroll | staff JWT | Staff sign-in | staff console | live |
| GET | /v1/oper-admin/totp/status | staff JWT | Staff sign-in | staff console | live |
| POST | /v1/oper-admin/totp/verify | staff JWT | Staff sign-in | staff console | live |
| POST | /v1/public/admin/register | public | Company registration (deprecated alias) | none found | suspect |
| POST | /v1/public/admin/resend-otp | public | Company registration (deprecated alias) | none found | suspect |
| POST | /v1/public/admin/set-password | public | Company registration (deprecated alias) | none found | suspect |
| POST | /v1/public/admin/verify-otp | public | Company registration (deprecated alias) | none found | suspect |
Three live routes the scan misses
The scanner resolves neither empty-string paths (@router.get(""), which inherit the
router prefix whole) nor @router.api_route, so these three are absent from the JSON
above and are listed separately to keep that table row-for-row with the contract. One of them is
the single most-used entry point on the staff console.
| Method | Path | Auth | Feature | Callers | Verdict |
|---|---|---|---|---|---|
| GET | /v1/internal/app-versions | X-Oper-Key | App-update gate | operators (X-Oper-Key) | live |
| GET | /v1/oper-admin/tenants | staff JWT | Tenant directory | staff console | live |
| GET POST PUT PATCH DELETE | /v1/oper-admin/authoring/{path:path} | staff JWT | Authoring proxy (catch-all) | staff console | live |
Evidence: app/routes/internal_app_versions.py:133 (operator read of the current
floors, paired with the PUT above and named in
docs/fe-app-update-gate.md:157) ·
app/routes/oper_backoffice.py:51 (the staff tenant directory,
docs/fe-backoffice.md:123, :195) ·
app/routes/oper_authoring.py:369 (the back-office authoring catch-all,
docs/fe-backoffice.md:130, :153-190).
5. Async contracts
All identity traffic rides one stream, AUTH (auth.>,
users.*, tenants.*), which this service creates at boot
(nats/init.sh:19-31, ConfigMap k8s/nats-screams-cm.yaml:24). The
TENANTS and USERS stream definitions still sitting in that ConfigMap are
never created — the init script says so in as many words (nats/init.sh:32).
The otp-worker mounts this service's init script rather than shipping its own
(k8s/otp-worker-deployment.yaml:43).
Consumes
| Subject | Stream | Durable | Published by | Feature | Verdict |
|---|---|---|---|---|---|
| auth.admin.otp_started | AUTH | otp-worker-auth-admin-otp_started | this service (app/routes/admin_login.py:320, app/routes/public_registration.py:157) | Admin sign-in code / sign-up code by email or SMS | live |
| auth.user.otp_started | AUTH | otp-worker-auth-user-otp_started | this service (app/routes/mobile_login.py:86) | Employee sign-in code | live |
| auth.user.invitation | AUTH | otp-worker-auth-user-invitation | this service (app/routes/users.py:227) | Invitation email/SMS for a new person | live |
| auth.admin.alert | AUTH | otp-worker-auth-admin-alert | notification-worker (app/services/admin_notification_service.py:206, subject app/core/config.py:155) | Email mirror of an admin inbox notification | live |
| auth.admin.enterprise_welcome | AUTH | otp-worker-auth-admin-enterprise_welcome | this service (app/routes/admin_billing.py:199; subject app/services/entitlements.py:226) | Welcome email when staff put a tenant on an Enterprise contract | live |
| auth.admin.onboarding.enrich_requested | AUTH | onboarding-enrichment-worker | this service (app/routes/admin_onboarding.py:49) | AI pre-fill of the onboarding wizard | live |
| users.* | AUTH | users_auth_groups_resolver | this service (app/routes/users.py:222, :234, :249) | Keep dynamic-group membership correct when people are hired, changed or removed | live |
The users.* consumer reacts to five of the subjects it receives
(created, updated, enabled, disabled,
deleted) and acks the rest
(app/services/groups_events_subscriber.py:36-47). Its durable is a pull consumer with
max_deliver=4; anything that ends up undelivered is picked up by the dynamic-groups
reconciler. The five otp-worker durables are derived from the subject name at subscribe time
(app/otp_worker/processor.py:122), so the names above are exact, not templates.
Publishes
| Subject | Consumed by | Feature | Verdict |
|---|---|---|---|
| auth.admin.otp_started | otp-worker (this repo) | Admin sign-in / sign-up code | live |
| auth.user.otp_started | otp-worker | Employee sign-in code | live |
| auth.user.invitation | otp-worker | Invitation link | live |
| auth.admin.enterprise_welcome | otp-worker | Enterprise welcome email | live |
| auth.admin.onboarding.enrich_requested | onboarding-enrichment-worker | Onboarding AI pre-fill | live |
| auth.admin.onboarding.enriched | nobody | “Enrichment finished” announcement | dead |
| auth.admin_login_success | nobody | Admin sign-in audit event | dead |
| auth.admin_logout | nobody | Admin sign-out audit event | dead |
| auth.employee_login_success | nobody | Employee sign-in audit event | dead |
| auth.employee_logout | nobody | Employee sign-out audit event | dead |
| auth.otp_failed | nobody | otp-worker dead-letter record for an undeliverable code | dead |
| users.created | this service's groups resolver (users_auth_groups_resolver) | Dynamic-group membership | live |
| users.updated | same | Dynamic-group membership | live |
| users.deleted | same | Dynamic-group membership | live |
| tenants.created | nobody | New company announcement | dead |
| tenants.updated | nobody | Company details / settings changed | dead |
| tenants.deleted | nobody | Company removed | dead |
| groups.updated | nobody | Group rule changed | dead |
| training.notifications.ten.*.admin.* | notification-worker durable notification_worker_admin (filter training.notifications.ten.*.admin.>) | Admin inbox: “a learner joined”, “the export you asked for is ready” | live |
| authoring.usage.event | authoring-service-v2 usage_consumer | Cost ledger entry for the onboarding LLM call | live |
Evidence, publishers: app/routes/admin_login.py:320, :565,
:861 · app/routes/mobile_login.py:86, :100,
:108 · app/routes/users.py:222, :227,
:234, :249 · app/routes/admin.py:67,
:197, :282 · app/routes/admin_company.py:134 ·
app/routes/admin_onboarding.py:49, :56 ·
app/routes/admin_billing.py:199 ·
app/routes/public_registration.py:157 ·
app/routes/groups.py:851 ·
app/routes/mobile_profile.py:218 ·
app/services/gdpr_export.py:350 ·
app/otp_worker/processor.py:389 (DLQ) ·
app/onboarding_enrichment/processor.py:364.
Consumers: notification-worker/app/core/config.py:115 (admin envelopes),
authoring-service-v2/app/consumers/register.py:68 (usage),
app/services/groups_events_subscriber.py:143 (users.*).
The two training.notifications.ten.*.admin.* rows in the scan output are one
f-string subject each — the literal subject is
training.notifications.ten.<tenant_id>.admin.<type>, which the
notification-worker filter matches token-for-token.
users.* also reaches feed-service's event mapper, but feed consumes nothing at all
(ARCHITECTURE.md §4.1(5)), so that edge does not make anything live.
Background jobs
| Job | Schedule | What it does | Verdict |
|---|---|---|---|
otp-worker (app/otp_worker/main.py, Deployment otp-worker) | always on; 5 push durables | Turns the five auth.* events into real emails (Mailgun) and SMS (Twilio), with in-band retry and a heartbeat so JetStream does not redeliver mid-send. The link in the message is answered by the Cloudflare worker link-otp-worker. | live |
onboarding-enrichment-worker (app/onboarding_enrichment/main.py, own Deployment) | always on; 1 durable, max_deliver=4 | Fetches the tenant's website, asks OpenRouter for an industry/size/role proposal, POSTs the result back over HTTP (edge H27) and files a cost-ledger event. This is the only place in the identity stack that calls an LLM. | live |
_heartbeat_loop (app/onboarding_enrichment/processor.py:188) | per in-flight job | Marks the NATS message in-progress while a slow LLM call runs, so a 90s ack-wait does not cause a duplicate enrichment. | live |
_shutdown handlers (app/otp_worker/main.py:38, app/onboarding_enrichment/main.py:44) | on SIGTERM | Signal handlers, not jobs: they drain the consumer and close NATS so a rollout does not abandon an in-flight send. Scanned as background tasks because they are create_tasked. | live |
_consume_loop — dynamic-groups event subscriber (app/services/groups_events_subscriber.py:196) | always on, inside the API pod | Pull-consumes users.* and re-resolves every dynamic group the change could affect. The blocking DB work runs on a worker thread, because this pod also serves JWKS and token validation for the whole platform. | live |
dynamic-groups reconciler (app/services/reconcilers.py:294) | 30s after boot, then every DYNAMIC_GROUPS_RECONCILE_HOURS (default 6) | Re-resolves every dynamic group in every tenant, to correct anything the event path lost to a DLQ or a restart. Single-flight behind a Postgres advisory lock. | live |
billing-seats reconciler (app/services/reconcilers.py:300) | same pattern, BILLING_SEATS_RECONCILE_HOURS (default 6) | Refreshes each tenant's monthly-active-learner overage between Stripe invoices, so the figure an admin sees is never a day stale and a missed webhook costs one interval, not a cycle. | live |
entitlements reconciler (app/services/reconcilers.py:306) | same pattern, hourly by default | Owns the two transitions nothing else triggers: ending a trial when its date arrives, and granting each later month of an Enterprise contract's credits. | live |
GDPR export CronJob users-auth-process-gdpr-exports (k8s/cron-process-gdpr-exports.yaml) | 0 2 * * * UTC, concurrencyPolicy: Forbid, 1h deadline | Builds each queued subject-access bundle by fanning out to five services, stores it, and notifies the admins. Deliberately left as a CronJob rather than an in-pod loop: it is a heavy off-peak batch with a legal deadline (app/services/reconcilers.py:35-38). | live |
All three reconcilers replaced earlier k8s CronJobs and are started from the app lifespan
(app/main.py:144-146); the groups subscriber at app/main.py:135. Every
start is wrapped so a bring-up failure cannot block the API, which also means a silently unstarted
loop looks identical to a healthy one in the logs.
6. Data it owns
Postgres database users_auth, 31 alembic revisions, and—unusually for this
platform—no second writer: every other service reads this data over HTTP
(ARCHITECTURE.md §3.4). The Postgres and Redis instances themselves are platform-shared despite
their auth-scoped names, and this service's k8s manifests are what create them.
| Table | What it holds | Written by |
|---|---|---|
tenants | One row per customer company: name, slug, website, industry, size, logo, brand colour, status, onboarding progress (JSONB), Twenty CRM ids, meeting-booked flag | this service only (staff tenant ops, company settings, onboarding) |
users | Every person: employees, tenant admins (with admin_role OWNER/ADMIN/EDUCATOR), password hash, TOTP secret, phone/email, job title, work location, profile, soft-delete marker | this service only |
staff_users | Oper's own back-office identities — deliberately not rows in users, because staff belong to no tenant | this service only (plus the bootstrap Job) |
otp_challenges | Live one-time-code challenges with attempt counters, for every sign-in and sign-up flow | this service only |
qr_logins | QR sign-in attempts (migration 001). No writer anywhere — only the GDPR export still reads it | nobody |
roles | The tenant's job titles, which group rules target | this service only |
locations | Stores, sites and offices: address, coordinates, status | this service only |
groups | Static and dynamic groups, including the membership rule and status | this service only |
group_members | Resolved membership, tagged manual or rule-sourced. employee_email became nullable in 020 so phone-only employees can be members | this service only (routes + resolver + NATS subscriber + reconciler) |
notification_preferences | Per-user, per-category push/email opt-ins that every notification sender checks | this service only |
billing_plans | The plan catalogue: price, MAU allowance, Stripe price ids | this service only (staff billing ops, scripts/bootstrap_stripe_catalog.py) |
tenant_billing | Each tenant's plan, trial window, Enterprise contract, Stripe customer/subscription, seat and MAU counters | this service only (admin billing, staff ops, Stripe webhook, reconcilers) |
billing_invoices | Invoices, both Stripe-issued and staff-created manual ones | this service only |
billing_events | Raw Stripe webhook deliveries, for idempotency and audit | this service only |
credit_balances | Current AI-credit balance per tenant — the number the authoring run gate reads | this service only (admin top-up, internal debit/refund/grant, entitlements sweep) |
credit_ledger | Append-only credit movements with the reference that caused them | this service only |
app_versions | Per-platform minimum supported build, recommended build, store URL and gate message | this service only (operator endpoint) |
app_version_events | Audit of every floor change, with who made it | this service only |
gdpr_export_jobs | Queued and completed subject-access exports, their scheduled window and the stored bundle | this service only (admin routes + GDPR CronJob) |
account_deletion_requests | Learner-filed deletion requests with reason and resolution | this service only |
user_audit_logs | Who changed which person, and how | this service only |
tenant_audit_logs | Company-level admin actions. tenant_id is ON DELETE CASCADE, which is why the hard-delete audit row never survives (bug-hunt #9) | this service only |
No table here is written by another service, and this service writes into no other database. The
shared postgres-db-init-job.yaml in this repo's k8s/ directory still
creates three databases nothing references (certificates, progress,
authoring_db — ARCHITECTURE.md §3.4).
7. Dependencies
flowchart LR ML["micro-learning"] --> UA AS["assignment-service"] --> UA AV2["authoring-v2"] --> UA NW["notification-worker"] --> UA FS["feed-service"] --> UA OEW["onboarding-enrichment-worker"] --> UA UA["users-auth-service"] --> ML UA --> AS UA --> NW UA --> FS UA --> AV2 UA --> EXT["Stripe · Twenty CRM · Sentry"] OTP["otp-worker"] --> SEND["Mailgun · Twilio"] UA -. "AUTH stream" .-> OTP UA -. "AUTH stream" .-> OEW
Inbound (all with X-Oper-Key): micro-learning for users, directory, preferences and the
credit debit at publish (H11-12); assignment-service for groups, members, profiles and token
validation (H15); authoring-v2 for tenant info, profile batch and the credit gate (H4);
notification-worker for tenant admins and preferences (H25); feed-service for users and profiles;
the onboarding worker for its result POST (H27). Outbound: micro-learning analytics, billing and
training stats (H19, also on the GDPR cron and the mobile /me path);
assignment-service user assignments and activity (H20); notification-worker active devices (H21);
authoring-v2 through the back-office proxy (H22) and the pipeline reads; and the GDPR export
fan-out to four services (H23). Note the prefix trap: authoring-v2 exposes
/internal/v1/* while everyone else uses /v1/internal/*
(ARCHITECTURE.md §3.2).
8. Dead-code verdicts
Every entry point with no in-repo caller. Deleting is a separate decision — see the hub roll-up.
| Entry point | Kind | Verdict | Evidence |
|---|---|---|---|
GET / | route | suspect | Status stub. Both k8s probes point at /healthz (k8s/users-auth-deployment.yaml:189, :202); no in-repo caller of /. It sits on the public ingress, so an external uptime check may well hit it — unknowable from this repo. |
POST /debug/observability | route | dead | Both halves: no caller anywhere in the repo, and the handler refuses unless settings.debug (app/main.py:243) while every deployed workload sets DEBUG="false" (k8s/users-auth-deployment.yaml:169-170, k8s/cron-process-gdpr-exports.yaml:57-58). Unreachable as deployed. |
POST /v1/auth/token | route | dead | Both halves: gated behind X-Oper-Key (app/routes/auth.py:117), so no browser or app can reach it; and no machine caller exists — the handler's own docstring says “No first-party client calls it” (app/routes/auth.py:128), confirmed by grep (only tests/test_auth.py and scripts/smoke_test.py). bug-hunt #1 rejected the claimed bypass and recommended deleting the endpoint. |
POST /v1/internal/auth/token | route | dead | Internal-only (X-Oper-Key) mint of an internal.service token. Repo-wide grep for internal/auth/token finds only tests/test_auth.py:119, scripts/smoke_test.py:322 and an archived summary; no service calls it. Services authenticate to each other with the shared key directly. |
POST /v1/internal/credits/grant | route | dead | Internal-only, no caller: the only clients of the internal credit API build /v1/internal/credits/{op} with op in debit/refund (authoring-service-v2/app/core/http.py:599, :645, :667; micro-learning-service-v2/app/clients/credits_client.py:66, :93, :113). Grants that do happen are made in-process by the entitlements reconciler, not over HTTP. Only New-Design/SDD-credit-ledger.md:60 mentions the route. |
GET /v1/internal/tenants/slug/{slug} | route | dead | Internal-only, no caller: repo-wide grep for tenants/slug finds the handler (app/routes/internal.py:284), scripts/smoke_test.py:175, :196 and one archived summary. Every service resolves tenants by id via the sibling route. |
GET /v1/internal/tenants/{tenant_id}/users (app/routes/users.py:1417) | route (duplicate) | dead | The same method+path is registered twice. internal_router from app/routes/internal.py is included first (app/main.py:282), so get_all_tenant_users (internal.py:331) wins every request and list_employees_internal — a richer, filterable, paginated handler — is unreachable. Its query parameters silently do nothing for callers (assignment-service, micro-learning, feed-service). |
POST /v1/admin/onboarding/meeting-booked | route | suspect | Admin-facing, and the admin web app is not in this repo. Searched every users-auth-service/docs/*.md and New-Design/*.md for meeting-booked/meeting_booked: no integration doc names it (the onboarding guide documents the wizard's optional book_meeting step instead). Only tests/ calls it. |
POST /v1/admin/{tenant_id}/users/bulk/status | route | suspect | No in-repo caller and no FE doc: docs/fe-multi-admin-team.md documents the single-user routes only, and grep for users/bulk/ across all docs finds nothing. Only tests/test_users.py. |
POST /v1/admin/{tenant_id}/users/bulk/role | route | suspect | Same search as above — tests only. |
POST /v1/admin/{tenant_id}/users/bulk/store | route | suspect | Same search as above — tests only. |
POST /v1/admin/{tenant_id}/users/bulk/delete | route | suspect | Same search as above — tests only. All four also share the malformed-UUID 500 (bug-hunt #18). |
GET /v1/mobile/notification/preferences | route | suspect | Mobile-facing; the app is not in this repo. Searched users-auth-service/docs/*.md, New-Design/*.md and micro-learning-service-v2/docs/*.md for notification/preferences: no mobile integration doc names the route. Only scripts/smoke_test.py:431 exercises it. |
PUT /v1/mobile/notification/preferences | route | suspect | Same search as above. |
POST /v1/oper-admin/authoring/socket-token | route | suspect | Staff-console-facing; that console is not in this repo. Its contract doc still tells the console the websocket is not proxied and to keep polling (docs/fe-backoffice.md:186-190), so no documented client mints a socket token yet. Only tests/test_oper_authoring_proxy.py:465. The endpoint is deliberate and newer than the doc — not dead, just unadopted. |
POST /v1/public/admin/register | route (deprecated alias) | suspect | The same handler as /v1/admin/registration/register, mounted again at a hidden prefix “kept for backward compatibility; remove once clients migrate” (app/main.py:305-306). The FE was told to cut over (docs/admin-frontend-integration.md:36-44); whether it has is unknowable here. Only scripts/smoke_public_admin_registration.py:64 still uses this prefix. |
POST /v1/public/admin/verify-otp | route (deprecated alias) | suspect | As above; scripts/smoke_public_admin_registration.py:82. |
POST /v1/public/admin/set-password | route (deprecated alias) | suspect | As above; scripts/smoke_public_admin_registration.py:96. |
POST /v1/public/admin/resend-otp | route (deprecated alias) | suspect | The weakest of the four: resend-otp was added after the alias was deprecated, so it never had a client on this prefix (it is absent from the old-path column of docs/admin-frontend-integration.md:41) and nothing in the repo calls it. Reachable in principle, hence suspect rather than dead. |
auth.admin.onboarding.enriched | subject | dead | Published at app/routes/admin_onboarding.py:56. No consumer filter in the platform matches it: repo-wide grep finds the publisher, the constant (:44) and New-Design/SDD-admin-notification-centre.md:125 (a plan, not an implementation). notification-worker's admin consumer listens on training.notifications.ten.*.admin.>, not on auth.>. |
auth.admin_login_success | subject | dead | Published at app/routes/admin_login.py:565. Grep finds no subscriber anywhere — only the publisher, tests/test_admin.py:794 and an archived summary. Retained in the AUTH stream for 7 days and then discarded. |
auth.admin_logout | subject | dead | Published at app/routes/admin_login.py:861; no subscriber in the repo. |
auth.employee_login_success | subject | dead | Published at app/routes/mobile_login.py:100; no subscriber (only tests/test_mobile.py:170 and New-Design/SDD-admin-notification-centre.md:102, a plan). |
auth.employee_logout | subject | dead | Published at app/routes/mobile_login.py:108; no subscriber. |
auth.otp_failed | subject | dead | The otp-worker's dead-letter subject (app/otp_worker/processor.py:389, configured at app/otp_worker/config.py:58). Nothing consumes or alerts on it, so a permanently failed OTP is recorded where no one looks — and the publish happens after the ack, so a publish failure loses the event outright (bug-hunt-reports/otp-worker.md #2). |
tenants.created | subject | dead | Published at app/routes/admin.py:67. The AUTH stream captures tenants.*, but no consumer in any service filters on it (repo-wide grep: publishers, archived docs and tests only). |
tenants.updated | subject | dead | Published from two places (app/routes/admin.py:197, app/routes/admin_company.py:134) so a company rename or settings change is announced twice over — to nobody. |
tenants.deleted | subject | dead | Published at app/routes/admin.py:282; no consumer. Combined with bug-hunt #9 (the audit row is cascade-deleted), a tenant hard-delete currently leaves no durable trace at all. |
groups.updated | subject | dead | Published at app/routes/groups.py:851 when a group's rule changes. No consumer exists; membership convergence happens in-process instead, so nothing depends on it. |
TENANTS and USERS stream definitions | config | dead | k8s/nats-screams-cm.yaml:7-50 still carries both JSONs, but the init script creates only AUTH and says so: “Legacy streams TENANTS/USERS are no longer created” (nats/init.sh:32, ConfigMap copy k8s/users-auth-nats-init-script.yaml:36). They also overlap AUTH's subjects, so creating them would be rejected. |
qr_logins table | data | dead | Created by migration 001_init_users_auth_schema. Repo-wide grep finds no INSERT INTO qr_logins and no write path of any kind; the only reference is the GDPR export reading it (app/services/gdpr_export.py:123-127), so every export carries an always-empty section. |
app/routes/users.py:1417 list_employees_internal | function | dead | Unreachable for the routing reason above; with it, its status/user_type/date filters and pagination are unreachable code. |
Two report findings are now stale rather than open, which is worth recording so nobody re-opens
them: bug-hunt #14 (“NATS groups subscriber runs sync psycopg2 on the event loop”) no
longer matches the code — the blocking work is on a worker thread and the docstring explains
why (app/services/groups_events_subscriber.py:50-58); and the report's premise that the
dynamic-groups sweep is a k8s CronJob (#10, #17) is superseded by the in-process reconcilers
(app/services/reconcilers.py:1-38), though the SAVEPOINT fix it asked for is in place.
Everything else in that report is still open and is linked from the feature it belongs to.
9. Sources
tools/feature-docs/out/users-auth-service.json— the scanned entry-point contract (147 routes, 7 consumers, 20 published subjects, 5 background tasks).- ARCHITECTURE.md §1 (ingress), §2 (service inventory, empty shells), §3.2 (HTTP edges H4, H11-12, H15, H19-23, H25, H27), §3.3 (
AUTHstream), §3.4-3.5 (databases, Redis indices), §4.1 (live defects). - bug-hunt-reports/users-auth-service.md — 20 findings; #2, #5, #10, #11, #16 fixed, the rest open. Cross-referenced, not re-derived.
- bug-hunt-reports/otp-worker.md — #2, the DLQ publish-after-ack loss.
- docs/admin-frontend-integration.md — sign-in, TOTP, registration, and the
/v1/public/admin→/v1/admin/registrationdeprecation table. - docs/admin-token-refresh-integration.md — silent refresh, and “don't call
/v1/auth/refreshfrom the admin panel; it exists for the mobile flow”. - docs/admin-onboarding-integration.md — the five-step wizard and the enrichment contract.
- docs/admin-groups-integration.md — static vs dynamic groups, preview and resolve.
- docs/admin-locations-integration.md — locations list, filters, bulk delete.
- docs/fe-multi-admin-team.md and docs/fe-admin-role-permissions.md — OWNER/ADMIN/EDUCATOR, one endpoint family for every kind of user, and per-surface permissions.
- docs/fe-admin-user-details.md — the four cards on a learner's page.
- docs/fe-admin-billing.md — the MAU pricing model, admin billing screens and the staff billing endpoints.
- docs/fe-admin-company-settings.md and docs/fe-admin-profile.md — company and personal settings.
- docs/fe-admin-gdpr-export.md and docs/fe-account-deletion.md — the two privacy workflows.
- docs/fe-app-update-gate.md — the 426 gate contract and the operator runbook.
- docs/fe-backoffice.md — the staff console: tenant directory, tenant 360, and the authoring proxy (including what is not proxied).
- docs/otp-worker.md and docs/onboarding-enrichment-worker.md — the two satellite workers that live in this service's image.
New-Design/SDD-multi-admin.md,New-Design/SDD-credit-ledger.md,New-Design/SDD-trial-and-enterprise.md,New-Design/SDD-admin-notification-centre.md— designs of record for roles, credits, trials and the admin inbox.- Caller evidence read directly from source:
authoring-service-v2/app/core/http.py,micro-learning-service-v2/app/clients/*.py,assignment-service/app/services/{users,groups}_service.py,notification-worker/app/services/{admins_client,preferences_service,admin_notification_service}.py,feed-service/app/clients/users_auth_client.py,libs/auth/oper_auth/token_validation.py.