ADR 0020 — Self-Serve Onboarding and Unified Web Origin
Status: Accepted Context date: 2026-07-23
Context
EthioConnect has all the machinery a customer needs to run real workloads — tenants, users, API keys, rooms, signaling, media, billing — but no public path for a new customer to get started on their own. Tenant creation is a platform-operator action (POST /admin/v1/tenants) behind the admin control plane, and the public POST /v1/auth/register endpoint requires a tenant_id that must already exist. In practice a prospect cannot become a paying tenant without an EthioConnect operator manually provisioning them first. That is a hard blocker for self-serve growth.
The web surfaces are also split. The documentation site (Docusaurus, ADR 0018) and the tenant admin dashboard (Next.js) are served from separate origins. A developer reading the docs who wants to sign in or manage their tenant has to jump to a different host, and because the two live on different origins they cannot share a session cookie. Onboarding a new customer therefore means bouncing between two sites with no shared login.
Forces at play:
- Who owns provisioning. Creating a tenant plus its first user must be atomic, and whatever creates them must also be able to log the customer in. Only the Auth service owns
auth_dband can mint JWTs. - Control-plane blast radius. The admin gateway is the operator control plane. Adding public write endpoints there widens the attack surface of the most privileged component and, structurally, it cannot issue user tokens.
- One session across docs and dashboard. A shared login between docs and dashboard is only possible if they share an origin so a single cookie applies to both.
- Abuse. A public "create a tenant" endpoint is an obvious target for scripted signups and slug squatting and must ship with rate limiting and naming controls from day one.
Decision
1. Public self-serve provisioning lives on the Auth service
Add a new public POST /v1/signup endpoint to the Auth service. Auth owns auth_db, so in a single ACID transaction it:
- Creates the tenant (
status = 'trial'). - Creates the first user with
platform_role = tenant_admin. - Issues an access + refresh JWT so the response logs the customer straight in.
If any step fails the whole transaction rolls back, so a signup never leaves a half-provisioned tenant or an orphaned user.
This was chosen over adding the endpoint to the admin gateway. The gateway is the operator control plane; a public write there would widen the attack surface of the most privileged service, and the gateway cannot mint user tokens — so it could create a tenant but not log the customer in, defeating the purpose of self-serve.
New tenants start in status = 'trial'. Plan and trial metadata live in the existing tenants.settings JSONB column — no new plan column is introduced. This keeps the schema change to the minimum the flow actually needs.
On the web tier, a public passthrough POST /admin/v1/auth/signup on the dashboard's backing gateway calls Auth's /v1/signup and sets the tenant_token cookie on success, exactly as the existing tenant-login path already does. The browser gets a session cookie the same way it does after a normal login.
2. API keys remain the credential model for using features
Self-serve does not introduce a new credential type. Tenants use EthioConnect features with tenant API keys / access tokens minted from the existing POST /v1/tenants/{tenant_id}/api-keys. A newly created key is shown once, in the form cb_{prefix}.{secret}, and is never retrievable again.
To make that concrete for a new customer, onboarding lands the freshly-created tenant admin on an "API Keys" screen whose first job is to mint their first key. The customer leaves onboarding with a working credential in hand rather than an empty dashboard.
3. A single web origin serves docs and dashboard
One public host serves docs at / and the dashboard at /dashboard (Next.js basePath), routed by ingress-nginx path rules. Because both live under the same host, a single host-only session cookie is shared across docs and dashboard — the shared login that separate origins made impossible.
Only the dashboard server is publicly reachable. Auth and admin-gateway stay ClusterIP and are never exposed to the internet; the dashboard server is the only public door into the control plane. The docs navbar links Sign in / Sign up into the dashboard, so the documentation reader and the authenticated customer are the same journey on one origin.
4. Abuse controls ship with the endpoint
- Rate limiting at two layers: ingress (nginx) and the application.
- Slug governance: a reserved-slug denylist plus slug format rules, so common and system-reserved names cannot be squatted and slugs stay well-formed.
- Trial-active-then-verify email posture: new tenants are active immediately so a customer can try the product without waiting on an email round-trip. A new
users.email_verified_atcolumn records verification and gates future privilege escalation rather than blocking initial use.
Consequences
- Positive: A prospect can go from the docs to a live, logged-in
trialtenant with a working API key without any operator involvement — the self-serve growth blocker is removed. - Positive: Provisioning is atomic and owned by the one service that can both write
auth_dband mint tokens; no cross-service two-phase dance. - Positive: Keeping the admin gateway free of public write endpoints preserves the control-plane blast radius, and keeping Auth/admin-gateway
ClusterIPmeans the dashboard server is the only public entry to the control plane. - Positive: One origin gives docs and dashboard a shared session cookie, so Sign in / Sign up from the docs is a single continuous journey.
- Positive: Storing plan/trial data in
tenants.settingsJSONB avoids a schema migration for a shape that is still evolving. - Negative / accepted: "Trial-active-then-verify" lets an unverified email use the product immediately. This is deliberate; the
email_verified_atgate on escalation bounds the risk, but the actual verification-sending flow is not built yet (see follow-ups). - Negative / accepted: Rate limiting and the reserved-slug denylist reduce but do not eliminate scripted-signup abuse. Captcha is a follow-up.
Follow-ups (not yet built)
- Email verification sending flow — the
email_verified_atcolumn exists and gates escalation, but the send/confirm flow that populates it is not implemented. - Turnstile captcha on the signup form as a second abuse layer.
- Event-driven billing provisioning — emit a
tenant.provisionedevent on signup so Billing (and other consumers) provision asynchronously instead of relying on synchronous coupling. - Automatic session-refresh cookie — the refresh JWT is issued but there is no silent cookie-refresh yet; sessions currently expire without transparent renewal.
- Retire the admin gateway's direct
auth_dbwrite intenants.Create. With Auth owning provisioning via/v1/signup, the operator path should call Auth rather than writingauth_dbdirectly, restoring single-writer ownership.