Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

274 lines
14 KiB
Markdown

# 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.