Login & onboarding

How an admin and an employee get into the product, and what happens when a new company signs up · ← platform hub

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
  1. The admin asks for a code. users-auth records the challenge and publishes auth.admin.otp_startedusers-auth-service/app/routes/admin_login.py:320.
  2. 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.
  3. The admin submits the code; users-auth verifies it and issues a token. A tenant may additionally require TOTP.
  4. Session events auth.admin_login_success and auth.admin_logout are 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

IssueDetail
Enrichment result event has no consumerusers-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 nowhereauth.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 edgeARCHITECTURE.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.