# PRD: Appliance registration → download → install flow - Slug: `appliance-registration-install-flow` - Date: `2026-06-05` - Status: Draft - Source spec: `docs/superpowers/specs/2026-06-05-appliance-registration-install-flow-design.md` ## 1. Problem statement & user value A customer who wants on-prem (appliance) AlgaPSA must be able to register, download a generic ISO, install it, and have the appliance come up **already bound to a tenant identity that was minted upstream at registration**. Today the appliance generates its own tenant UUID at first boot and only *optionally* connects a license afterward, so: (a) a license issued before install can't be bound to the right tenant, and (b) free (essentials) installs leave no record in the global registry. The value: licenses are bound from first boot, essentials installs get a real `tenant_registry` row, and a wipe-and-reinstall recovers the *same* tenant. The organizing idea: the appliance image is **generic** — we do not build a per-customer ISO — so per-tenant identity travels separately from the download, carried by a short, one-time **install code** the appliance redeems at setup. The code is the per-customer artifact; the ISO is not. ## 2. Goals - Mint `tenant_id` **upstream** at registration (in the `tenant_registry`) and carry it to the appliance via a one-time install code. - Reuse the existing `/register` claim-code machinery for the redeem (one code format, one redeem path) — see §5. - Support **essentials** (free, no entitlement) and **paid** (entitlement + bound license) through the same flow. - Adopt the minted `tenant_id` at install via a new `INITIAL_TENANT_ID` seam (vs. today's DB-generated UUID), additive and safe when unset. - Presigned-for-all downloads (registration-gated, not public). - Re-issue / recovery in scope now: re-fetch a fresh code for the same tenant. ## 3. Non-goals - No per-customer ISO builds; the ISO stays generic. - No migration/backfill of existing hosted tenants into the registry (forward-only). - No CRM/relationship modeling — registry holds identity + entitlement state only. - No new license-binding mechanism — binding (`aud`) already shipped; this flow only changes where `aud` is *sourced* (registry vs. appliance input). - No observability/metrics/admin-tooling beyond what the flow needs to function. ## 4. Personas & primary flows - **Prospect/customer** (nm-store): fills the registration form → gets an install code + download link (confirmation page + email). - **Installer/admin** (appliance setup UI): downloads the ISO, boots it, enters the install code + sets the admin password → appliance comes up under the minted tenant. - **Returning admin** (reinstall/recovery): uses the portal "re-issue install code" to get a fresh code for the same tenant, then reinstalls. Primary flow (happy path): ``` register (nm-store) → /register-tenant mints tenant_id + install code + presigned URL → download generic ISO → setup UI: enter install code + admin password → /register returns tenant_id + edition (+ license if paid) → create-tenant with INITIAL_TENANT_ID → seed license_state → registry=installed ``` ## 5. Resolved design decisions 1. **Install code redeemed via `/register`** — reuse the existing claim-code → `/register` machinery for the short, friendly code (no separate redeem path). 2. **Extend `claim_codes`** — make `entitlement_id` nullable and add a `tenant_id` FK to `tenant_registry`. One code type serves essentials (no entitlement) and paid (with entitlement). (Chosen over a new `install_tokens` table.) 3. **Presigned for all** downloads, essentials included — gated by registration, not public; the ISO itself is not a secret, the code is the gate. 4. **Re-issue in scope now** — a portal "re-issue install code" action resolves an existing registry tenant and mints a fresh code for the *same* `tenant_id` (folded out of a later phase into this build). ## 6. Architecture: the four surfaces 1. **nm-store** (DB-less Next.js): registration form + confirmation/email + portal re-issue. Calls alga-license; never writes the registry directly. 2. **alga-license (C4)**: registry writes, install-code mint/redeem, presigned-URL mint, license signing. Routes live in `src/routes/`. 3. **Object store (MinIO)**: holds the current generic appliance ISO at a known key; alga-license presigns time-boxed GET URLs. 4. **Appliance** (`alga-psa`): setup UI install-code step, install-time redeem consumer, `INITIAL_TENANT_ID` tenant adoption, `license_state` seeding. The spine is a **direction inversion**: today `tenant_id` flows appliance→service (`/register` accepts it as input); the new flow mints it upstream and flows it service→appliance (the code carries it, `/register` returns it, the appliance adopts it). ## 7. Data model changes (alga-license) One migration extending `claim_codes` (current: `alga-license/migrations/03_claim_codes.cjs`, `entitlement_id NOT NULL`): - `entitlement_id` → **NULLABLE** (essentials codes carry no entitlement). - add `tenant_id uuid` **NULLABLE** FK → `tenant_registry(tenant_id)` (ON DELETE CASCADE), indexed. Nullable (not NOT NULL as first drafted): the legacy `/claim-codes` path and any existing rows carry no registry tenant, and `/register` falls back to the appliance-supplied body `tenant_id` for those. Implemented as `migrations/05_claim_codes_registry.cjs`. No change to `tenant_registry` (already has `edition`, `company_name`, `contact_email`, `status`, `stripe_customer_id`) beyond writing `status='installed'` + `installed_at` at install. DB accessors (`alga-license/src/db/db.ts`): `createRegistryTenant`, `getRegistryTenant`, `getRegistryTenantByEmail`, `getClaimCode`, `consumeClaimCode`, `upsertAppliance` (already takes `tenantId`) **exist**. Changes: `insertClaimCode` accepts `tenant_id` + optional `entitlement_id`; `getClaimCode`/`ClaimCodeRow` expose `tenant_id`; **new** `revokeClaimCodesForTenant` (reissue for essentials has no entitlement to key on). ## 8. API changes (alga-license) **`/register` (extend `src/routes/register.ts`):** - Source `tenant_id` from the **code row** (`row.tenant_id`), not the request body (the registry-minted identity). Keep accepting body `tenant_id` only as a legacy fallback for pre-registry appliances. - If `row.entitlement_id` is null → **essentials**: skip license minting; still `upsertAppliance` (credential + `tenant_id`, no token). - Look up `tenant_registry` by `tenant_id` for `edition` + `company_name` + `contact_email`. - Response gains `tenant_id`, `edition`, `company_name`, `contact_email`; `appliance_credential` / `first_jwt` / `check_in_url` become **optional** (present only for paid). - On success, set registry `status='installed'` + `installed_at`. **`POST /register-tenant` (new, service-authed):** body = company/contact, edition, deployment_type, optional Stripe linkage → `createRegistryTenant` (`status='registered'`); paid → create/link entitlement; mint install code carrying `tenant_id` (+ `entitlement_id` if paid); presign ISO; return `{ tenant_id, install_code, download_url }`. **`POST /install-codes/reissue` (new, service-authed):** body = `{ contact_email | tenant_id }` → resolve registry tenant → `revokeClaimCodesForTenant` → mint fresh code (same `tenant_id`, current entitlement) → fresh presigned `download_url`. Returns `{ install_code, download_url }`. ## 9. Appliance changes (alga-psa) **`INITIAL_TENANT_ID` seam** (the UUID is born in `ee/server/src/lib/testing/tenant-creation.ts` `createTenant`, line ~80, which inserts into `tenants` without a `tenant` value → DB default): - `createTenant` gains optional `tenantId`; when set, `tenantInsert.tenant = input.tenantId`. Thread through `createTenantComplete`. - `server/scripts/create-tenant.ts` reads `INITIAL_TENANT_ID` (env, next to the existing `INITIAL_ADMIN_PASSWORD` already wired in the licval WIP) and passes it down. - Appliance bootstrap (configmap/script running `create-tenant` at first boot) sets `INITIAL_TENANT_ID` from the redeem result. - Unset `INITIAL_TENANT_ID` = unchanged DB-generated behavior (additive/safe). **Install-time redeem consumer** (generalize `server/src/lib/actions/licenseManagementActions.ts` `connectAppliance`): - Setup UI collects install code + admin password (password set at install only, never in registration/email — the licval WIP already threads `INITIAL_ADMIN_PASSWORD`). - Host-service POSTs `{ claim_code, appliance_id }` to `/register`; receives `tenant_id` + `edition` (+ license bits if paid). - Run `create-tenant` with `INITIAL_TENANT_ID = tenant_id`. - Seed `license_state`: `edition` always; for paid also `license_token` (`first_jwt`), `appliance_credential`, `check_in_url` (the existing `connectAppliance` write, minus the assumption a token is always present). - Essentials: `license_state` row, no token; appliance reads edition from `license_state` (consistent with "always seed `license_state`"). ## 10. nm-store changes - Registration form (company, contact, edition) → `POST /register-tenant`. - **Email** the install code + presigned download link to the contact after registration/purchase (primary delivery); the confirmation page may also show the install code. - Paid: Stripe checkout ordering — create registry row at submit (`status='registered'`), attach entitlement + mint code on `checkout.session.completed` (§16.2). - Portal "re-issue install code" action (behind portal auth) → `/install-codes/reissue`. ## 11. Presigned download & ISO publishing - alga-license mints time-boxed presigned GET URLs; the object-store (MinIO) credentials are supplied to the service as **env vars** (§16.1). - The presigned `download_url` is **delivered by email** to the contact after registration/purchase, alongside the install code — registration-gated, not a public link. (`/register-tenant` returns it; the post-registration/checkout email step carries it to the user.) - The appliance release process publishes the **current** generic ISO to a known object-store key; `download_url` points there (impl detail under F041). - Presigning provides registration gating + link expiry, not ISO confidentiality. ## 12. Security / permissions - Install code is single-use (`consumeClaimCode` is atomic under concurrency) and short-lived (`claimCodeTtlSeconds`); re-issue is the only way to get another and is behind portal auth. - Per-tenant binding preserved end-to-end: registry mints `tenant_id` → code carries it → `/register` stamps `aud` → `/check-in` preserves `aud` across re-signs. A leaked license still can't activate on another tenant. - `/register-tenant` and `/install-codes/reissue` require service auth (`makeServiceAuthHook`), same as `/claim-codes`. - Admin password set at install, never transmitted via registration/email. ## 13. Error handling - Invalid / expired / consumed code → existing `/register` codes (`invalid_claim_code` / `expired_claim_code` / `consumed_claim_code`); setup UI surfaces them and points to portal re-issue. - Consumed code on reinstall is expected → re-issue (revokes stragglers, newest code wins). - Expired presigned URL → re-issue mints a fresh one (code + link travel together). - License service unreachable at install → setup blocks with a clear error (the appliance can't self-mint a registry identity, by design). - Idempotent install: rerun with the same `INITIAL_TENANT_ID` must no-op rather than duplicate (admin-user create already guards on `(email, tenant)`). ## 14. Testing approach (smoke-first) Per the project convention and explicit direction: **light on automated tests, mostly smoke**. A small automated set covers only the highest-risk seams (migration up/down, `/register` essentials vs. paid branch, reissue revoke+reuse, `INITIAL_TENANT_ID` honor + idempotency). Everything else is validated live on the appliance VM end-to-end (register → download → install → reinstall-via-reissue). See `tests.json` (smoke-weighted, not the usual tests-longer-than-features). ## 15. Rollout / phasing One coherent build (re-issue folded in). Build order, each independently verifiable: 1. alga-license schema + `/register` extension (essentials path + tenant_id from the code + richer response) — the seam everything hangs off. 2. `INITIAL_TENANT_ID` in `createTenant` / `create-tenant.ts` / bootstrap. 3. `/register-tenant` + `/install-codes/reissue` + presigned-URL minting. 4. Setup-UI install-code step + install-time redeem/seed consumer. 5. nm-store registration + confirmation/email + portal re-issue. ## 16. Resolved decisions (was open — settled 2026-06-05) 1. **Presign owner / delivery.** alga-license mints the time-boxed presigned GET URL, with the object-store (MinIO) credentials provided as **env vars**. The presigned URL is **delivered by email** to the contact after registration/purchase (carried alongside the install code), not handed out publicly. (Remaining implementation detail: which object-store key holds the "current" generic ISO and how the appliance release process publishes it — an impl task under F041, not a design fork.) 2. **Stripe vs. registration ordering (paid).** Create the `tenant_registry` row at form submit (`status='registered'`); attach the entitlement + mint the install code on `checkout.session.completed`. 3. **Edition source for essentials at `/register`.** From `tenant_registry.edition` via code → `tenant_id` → registry lookup; no edition stored on the code. 4. **Code format.** Reuse `generateClaimCode()` (existing 8-char unambiguous format) for install codes — one machinery, essentials and paid alike. ## 17. Acceptance criteria (Definition of Done) - A free registration on nm-store yields an install code + presigned download link; installing the generic ISO with that code brings the appliance up under the registry-minted `tenant_id` at `edition=essentials`, with a `license_state` row and no token; registry `status='installed'`. - A paid registration additionally yields a license bound to that `tenant_id` (`aud`), seeded into `license_state`, and the appliance shows licensed at the purchased tier; `/check-in` keeps the binding on refresh. - A wipe-and-reinstall using a re-issued code recovers the **same** `tenant_id`. - `INITIAL_TENANT_ID` unset → appliance behavior unchanged (DB-generated UUID). - Smoke loop passes on the VM; the small automated set is green.