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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
274 lines
14 KiB
Markdown
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.
|