In one sentence
Nobody in the product has a password: you type an email or phone number, a one-time code is emailed or texted to you, and you exchange that code for a token. Everything else on this page — invitations, new-company registration, tenant enrichment — is a variation on getting someone to that first code.
One service owns all of it: users-auth-service. It is the only service on the public ingress, and it runs two side workers from the same codebase: the OTP worker (sends the email/SMS) and the onboarding-enrichment worker (looks up a new company on the web).
Admin sign-in (web back-office)
sequenceDiagram autonumber participant W as Admin web participant UA as users-auth participant OTP as otp worker participant M as Mailgun / Twilio W->>UA: POST /v1/admin/auth/login-otp/start UA-->>OTP: auth.admin.otp_started OTP->>M: send code M-->>W: email or SMS arrives W->>UA: POST /v1/admin/auth/login-otp/verify UA-->>W: access + refresh token Note over UA: optional second factor W->>UA: POST /v1/admin/totp/verify
- The admin asks for a code. users-auth records the challenge and publishes
auth.admin.otp_started— users-auth-service/app/routes/admin_login.py:320. - The OTP worker is subscribed to that subject and sends the message through Mailgun or Twilio — users-auth-service/app/otp_worker/processor.py:134. It runs as its own Deployment so a stalled email provider can never block a login handler.
- The admin submits the code; users-auth verifies it and issues a token. A tenant may additionally require TOTP.
- Session events
auth.admin_login_successandauth.admin_logoutare published for audit — admin_login.py:565, :861. No service consumes them today.
Employee sign-in (mobile app)
The same shape, a different subject and a different link domain. The employee enters their phone or email in the app; users-auth publishes auth.user.otp_started (mobile_login.py:86); the OTP worker sends the code. The email variant contains a link, and that link does not come back to this cluster — it is served by a Cloudflare edge worker, link-otp-worker, which is what makes one-tap sign-in work from a phone's mail client.
After sign-in the app calls GET /v1/mobile/me, which is the single call that hydrates the whole app: profile, tenant, assignments and training stats, gathered by users-auth from three other services.
Invitations
An admin adds a person to the tenant. users-auth creates the user and publishes auth.user.invitation (users-auth-service/app/routes/users.py:227); the OTP worker picks the template by the payload's template field and sends either an employee-created message or an admin invite. Admin-side invitations are email-only.
A new company registers
sequenceDiagram autonumber participant V as Visitor participant UA as users-auth participant OTP as otp worker participant OEW as onboarding enrichment worker participant LLM as Web + OpenRouter V->>UA: POST /v1/admin/registration/... UA-->>OTP: auth.admin.otp_started UA-->>OEW: auth.admin.onboarding.enrich_requested OEW->>LLM: fetch site, summarise OEW->>UA: POST enrichment result Note over UA: tenant onboarding record updated
Public registration creates the tenant and its first admin, then two things happen in parallel: the admin gets a verification code, and the enrichment worker goes and reads the company's website so the onboarding screens can be pre-filled. The worker consumes auth.admin.onboarding.enrich_requested (users-auth-service/app/onboarding_enrichment/processor.py:115, published at app/routes/admin_onboarding.py:49) and posts its result back over HTTP rather than by event.
Enrichment is best-effort by design: the tenant exists and the admin can sign in whether or not it succeeds. It carries heavy dependencies (web fetching, an LLM), which is why it is a separate image and Deployment even though the code lives in the users-auth repo directory.
Where it breaks today
| Issue | Detail |
|---|---|
| Enrichment result event has no consumer | users-auth publishes auth.admin.onboarding.enriched (admin_onboarding.py:56) and nothing subscribes to it. The real result path is the worker's HTTP POST back. ARCHITECTURE.md §6 M3 already proposes deleting the subject. |
| Audit events go nowhere | auth.admin_login_success, auth.admin_logout, auth.employee_login_success, auth.employee_logout and auth.otp_failed have no consumer. Harmless, but they are stored and never read — see the users-auth verdicts. |
| Invitation links 404 at the edge | ARCHITECTURE.md §4.1(8): the Cloudflare worker has a divergent duplicate wrangler.toml, and invitation URLs of the form /i/{slug} fall through to its 404 handler. See satellites. |