Skip to main content

Self-serve signup

A company can create its own workspace at /signup, with no operator involved. They fill a form, confirm their email with a six-digit code, choose their own password, and land in /dashboard a few seconds later with a fully seeded workspace behind them.

Before this, the only way in was a platform operator filling the drawer at /admin/tenants and passing a temporary password along by hand. That path still exists and is unchanged — it is how you onboard someone who has been sold to, rather than someone who found you.

The flow

Two calls, because the proof has to come before the mint. A workspace is permanent — there is no delete endpoint — so an email check bolted on after provisioning and auto-login would be a notification, not a gate.

POST /api/public/signup ← creates NOTHING
├─ bean validation on the whole form
├─ pre-flight uniqueness check ─────────► 409 naming the field that clashed
├─ SignupVerificationService.issue(email)
│ └─ 6-digit code, SHA-256 digest held in memory, 15-minute life
│ └─ inside the 60s cooldown? ────► 400 "a code was sent moments ago"
├─ mail it (template: signup-verification)
└─ 202 { expiresInMinutes, resendAfterSeconds }

POST /api/public/signup/verify ← the same form, plus the code
├─ SignupVerificationService.verify(email, code)
│ └─ anything but VERIFIED ──────────► 400, and nothing below runs
├─ pre-flight uniqueness check again (minutes have passed)
├─ TenantProvisioningService.createTenant(ProvisioningOptions.selfServeTrial(...))
│ ├─ tx 1 (global) reserve the tenant, SUSPENDED, with trial_ends_at already set
│ ├─ tx 2 (tenant) seed roles, settings, reference data, holidays, the admin user
│ └─ tx 3 (global) release it to TRIAL
├─ load the principal (global scope, as login does)
├─ narrow TenantContext to the new tenant ← everything below is a write
├─ issue access + refresh tokens
├─ audit row: Tenant.selfServeSignup
└─ 201 { accessToken, refreshToken, user } → the client stores it exactly like a login

Resending is the first call again. The form re-posts, the per-address cooldown decides.

PieceWhere
Controller (both calls)controller/PublicSignupController.java
Orchestrationservice/provisioning/SelfServeSignupService.java
The codes themselvesservice/provisioning/SignupVerificationService.java
Request DTOsdto/tenant/TenantSignupRequest.java, dto/tenant/TenantSignupVerifyRequest.java
What step one returnsdto/tenant/SignupChallengeResponse.java
What differs between the two ways inservice/provisioning/ProvisioningOptions.java
Page (both steps)frontend/src/pages/auth/Signup.tsx

Why a code in memory, and not a token in a table

The codes live in a ConcurrentHashMap in SignupVerificationService, alongside the ALTCHA signature registry and the rate-limit buckets, for the same reason those do: one backend instance per deployment, and a pending code lives fifteen minutes. A table would mean a migration, an entity, a TenantCoverageTest entry and a purge job, to hold rows whose useful life is shorter than a lunch break.

The cost is stated plainly: a backend restart forgets every pending code. The recovery is the button already on the form — request another. If this ever runs multi-instance, this is the first thing that breaks, and the fix is the refresh_tokens pattern (V75__auth_hardening.sql): a global table, SHA-256 of the secret, expires_at, and a scheduled sweep.

Four properties do the actual work, and they hold under concurrent requests — an anonymous endpoint has to assume a burst, so the counter is taken before the guess is evaluated and the atomic removal of the entry is what returns VERIFIED:

  • Five wrong guesses invalidate the code — five evaluated guesses, not five recorded ones. Six digits is a million, and a rate limit alone would not stop a patient walk through it.
  • A code redeems at most once, even raced. Ten parallel requests carrying the one correct code produce one workspace; the nine losers see MISSING.
  • Reissuing replaces, and resets attempts. A new code is a new secret. The 60-second cooldown prices asking; a daily cap of 8 codes per address bounds what one inbox can be made to receive, no matter how many IPs are asking on its behalf.
  • Every terminal outcome removes the entry — keyed on the exact entry the request read, so a late verify of a superseded code cannot destroy the code that replaced it.

One consequence to know: a deployment with MAIL_ENABLED=false answers step one with 503 rather than accepting a signup that can never complete — the code would go nowhere, and the person would die waiting at "check your email". The client compose template ships without Mailpit, so a production stack must configure a real relay before its signup form works.

Outcomes are counted at motorph.auth.signup.verifications{outcome}sent, resent, cooldown, capped, verified, missing, expired, invalid, exhausted. Sudden exhausted or invalid volume is someone guessing.

Why the customer chooses their own password

The operator path generates a password, mails it, and forces a change on first sign-in — a real step down from ADR-0004's "never recoverable" posture, because a mailed password comes to rest in a mailbox indefinitely.

Self-serve does not have that problem. The person signing up types the password themselves, so nobody else has ever seen it, nothing needs mailing, and mustChangePassword stays false — there is nothing to replace. The confirmation email carries their username and nothing secret at all.

This is also what made the feature possible without an invite-token flow: there is no credential to deliver, so there is no delivery problem to solve.

TRIAL

V105__tenant_trial.sql adds TRIAL to the status constraint and a nullable trial_ends_at. A self-serve workspace is born on trial; an operator-created one is not (trial_ends_at IS NULL).

Expiry is enforced by the clock, not by a job. Tenant.isActive() returns true for a TRIAL only while trial_ends_at is in the future, and UserDetailsServiceImpl consults it on every principal load — which is every request. A trial that lapsed a minute ago is already locked out. TrialExpiryScheduler (nightly, 03:30 Asia/Manila) moves lapsed trials to SUSPENDED so the registry agrees with reality, but it is bookkeeping. Consequences worth knowing:

  • The grid can lag enforcement by up to a day. Read "has this trial ended?" from trialEndsAt, not from status.
  • TenantScopedExecutor.forEachActiveTenant serves ACTIVE and open TRIAL tenants — a company evaluating the product needs next year's holidays as much as a paying one — and skips lapsed ones.
  • Repairing a half-built self-serve tenant releases it back to TRIAL, not ACTIVE, while its window is still open. Otherwise finishing a failed signup would hand out a permanent free workspace.
  • Trial length is motorph.tenant.trial-days (TENANT_TRIAL_DAYS, default 14). Changing it affects only later signups; existing trials carry the window they were created with.

Converting a trial to a paying customer is manual today: Reactivate in /admin/tenants releases it to ACTIVE. billing_subscription is not tenant-filtered and the payment webhook cannot resolve a tenant (ADR-0013 defers both), so nothing automates this yet.

Guard rails

Both paths are unauthenticated by virtue of /api/public/**. The filters are keyed by exact path — a change to either mapping must be made in the filters too:

  • AltchaVerificationFilter — proof-of-work on /api/public/signup only. /verify is deliberately not guarded: the emailed code is itself a strong single-use credential, the same reasoning that leaves /api/auth/refresh unguarded, and demanding a second solved challenge minutes after the first punishes the human the first one already identified. Note ALTCHA_ENABLED is false in dev and must stay false for the e2e suite.
  • LoginRateLimitFilter6 per hour per IP on /api/public/signup, and login's 10 per minute on /verify. They are priced for what they each do. The first sends mail to an address the caller chose, which is an amplification vector with our sender reputation attached; six covers a human who fixes a typo and presses resend twice. The second is a guess at a six-digit code, which is exactly what a credential attempt is — though the five-wrong-guesses cap on the code itself is the real defense there, not the bucket.

The two buckets are independent (LoginRateLimitFilter keys on IP and path), which matters: exhausting the send-me-a-code budget must not stop someone redeeming the code they already have, or the resend button becomes a way to lock yourself out.

There is deliberately no availability-check endpoint. It would be a third public path to defend and an enumeration oracle over the customer list. The 409 carries a field instead, so the form can put the error on the right input without substring-matching prose.

Things that will bite

  • One email, one workspace. users.email and users.username are globally unique across every tenant by design (V98; login resolves an account before it knows a company). An accountant who administers three client companies cannot sign up three times. The 409 says so plainly and links to sign-in, which is the honest answer, not a fix. Per-tenant email uniqueness is a real follow-up.
  • Provisioning is inline and takes seconds. Nine seeders plus two years of holidays across three transactions. The form shows a "Setting up your workspace…" state; any proxy in front of the backend needs a read timeout above it.
  • The public handler runs in TenantContext.GLOBAL, where both Hibernate's discriminator and row-level security are off. SelfServeSignupService touches no tenant-owned repository directly and narrows scope before issuing tokens or writing the audit row. Keep it that way.
  • No password recovery exists anywhere in the product. A self-serve admin who forgets their password has no path back; the signup form says so. This is the most valuable follow-up — and SignupVerificationService is now most of what it would need, minus persistence.
  • A backend restart forgets every pending code. Anyone mid-signup gets "that code is no longer valid" and has to request another. Acceptable for a fifteen-minute window on a single-instance deployment; the first thing to fix if either of those stops being true.
  • Verification proves the address, not the person. Someone can still verify an address they control and put any company name against it. What it stops is a workspace being minted against someone else's address, and the mail amplification that came with the one-call version.
  • A stranger can burn an address's pending code. /verify is unauthenticated, so five wrong guesses against someone else's address kill their pending code. This is inherent to email-keyed codes with no session: the recovery is the resend button, the cooldown bounds the nuisance, and the exhausted counter is where a campaign of it would show. Accepted, not fixed.
  • The two calls validate the form independently. The code is bound to the email only, so the other fields may legitimately change between them — that is what makes "fix a typo in the company name" not cost a new code.
  • A late slug clash burns the code, on purpose. /verify checks the code before it checks uniqueness, so someone who loses a race for a workspace address has already spent their code and needs a fresh one. Doing it the other way round would spare them that, but it would also put the uniqueness oracle — "does this email/username/slug exist?" — behind /verify's 10-per-minute budget instead of /signup's 6-per-hour. The rare burnt code is the cheaper of the two. The form handles it: it drops back to step one with the field marked.
  • ?plan= is display-only. Pricing passes it so the page can say which trial is starting; nothing persists it, because tenant has no plan column.

Verifying it

BODY='{"companyName":"Bell Trading","slug":"bell-trading","adminFullName":"Maria Santos",
"adminEmail":"[email protected]","adminUsername":"maria","password":"a-password-she-chose"}'

# Step one: 202, and nothing exists yet.
curl -s -X POST http://localhost:8081/api/public/signup \
-H 'Content-Type: application/json' -d "$BODY"
docker compose exec motorph_payroll_db psql -U motorph -d motorph \
-tAc "SELECT count(*) FROM tenant WHERE slug='bell-trading'" # 0

# Read the code out of Mailpit (http://127.0.0.1:8025), then:
curl -s -X POST http://localhost:8081/api/public/signup/verify \
-H 'Content-Type: application/json' \
-d "$(echo "$BODY" | sed 's/}$/,"code":"123456"}/')"

Expect 202 then 201 with tokens, a TRIAL row in tenant with created_by = 'self-serve', a seeded workspace (7 roles, payroll settings, 34 holidays, a company profile), two emails in Mailpit — the code, then a welcome carrying no password — and, the point of the whole exercise, GET /api/employees returning 0 for the new tenant while the default tenant still sees its own.

Worth checking the negative paths too, since they are the feature: a wrong code returns 400 and creates nothing, the same code twice fails the second time, five wrong guesses kill the code so even the right one is then refused, and an immediate resend is refused by the cooldown. Each shows up under motorph_auth_signup_verifications_total{outcome=...}.