Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
14 KiB
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_idupstream at registration (in thetenant_registry) and carry it to the appliance via a one-time install code. - Reuse the existing
/registerclaim-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_idat install via a newINITIAL_TENANT_IDseam (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 whereaudis 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
- Install code redeemed via
/register— reuse the existing claim-code →/registermachinery for the short, friendly code (no separate redeem path). - Extend
claim_codes— makeentitlement_idnullable and add atenant_idFK totenant_registry. One code type serves essentials (no entitlement) and paid (with entitlement). (Chosen over a newinstall_tokenstable.) - Presigned for all downloads, essentials included — gated by registration, not public; the ISO itself is not a secret, the code is the gate.
- 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
- nm-store (DB-less Next.js): registration form + confirmation/email + portal re-issue. Calls alga-license; never writes the registry directly.
- alga-license (C4): registry writes, install-code mint/redeem, presigned-URL
mint, license signing. Routes live in
src/routes/. - Object store (MinIO): holds the current generic appliance ISO at a known key; alga-license presigns time-boxed GET URLs.
- Appliance (
alga-psa): setup UI install-code step, install-time redeem consumer,INITIAL_TENANT_IDtenant adoption,license_stateseeding.
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 uuidNULLABLE FK →tenant_registry(tenant_id)(ON DELETE CASCADE), indexed. Nullable (not NOT NULL as first drafted): the legacy/claim-codespath and any existing rows carry no registry tenant, and/registerfalls back to the appliance-supplied bodytenant_idfor those. Implemented asmigrations/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_idfrom the code row (row.tenant_id), not the request body (the registry-minted identity). Keep accepting bodytenant_idonly as a legacy fallback for pre-registry appliances. - If
row.entitlement_idis null → essentials: skip license minting; stillupsertAppliance(credential +tenant_id, no token). - Look up
tenant_registrybytenant_idforedition+company_name+contact_email. - Response gains
tenant_id,edition,company_name,contact_email;appliance_credential/first_jwt/check_in_urlbecome 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):
createTenantgains optionaltenantId; when set,tenantInsert.tenant = input.tenantId. Thread throughcreateTenantComplete.server/scripts/create-tenant.tsreadsINITIAL_TENANT_ID(env, next to the existingINITIAL_ADMIN_PASSWORDalready wired in the licval WIP) and passes it down.- Appliance bootstrap (configmap/script running
create-tenantat first boot) setsINITIAL_TENANT_IDfrom 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; receivestenant_id+edition(+ license bits if paid). - Run
create-tenantwithINITIAL_TENANT_ID = tenant_id. - Seed
license_state:editionalways; for paid alsolicense_token(first_jwt),appliance_credential,check_in_url(the existingconnectAppliancewrite, minus the assumption a token is always present). - Essentials:
license_staterow, no token; appliance reads edition fromlicense_state(consistent with "always seedlicense_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 oncheckout.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_urlis delivered by email to the contact after registration/purchase, alongside the install code — registration-gated, not a public link. (/register-tenantreturns 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_urlpoints there (impl detail under F041). - Presigning provides registration gating + link expiry, not ISO confidentiality.
12. Security / permissions
- Install code is single-use (
consumeClaimCodeis 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 →/registerstampsaud→/check-inpreservesaudacross re-signs. A leaked license still can't activate on another tenant. /register-tenantand/install-codes/reissuerequire 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
/registercodes (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_IDmust 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:
- alga-license schema +
/registerextension (essentials path + tenant_id from the code + richer response) — the seam everything hangs off. INITIAL_TENANT_IDincreateTenant/create-tenant.ts/ bootstrap./register-tenant+/install-codes/reissue+ presigned-URL minting.- Setup-UI install-code step + install-time redeem/seed consumer.
- nm-store registration + confirmation/email + portal re-issue.
16. Resolved decisions (was open — settled 2026-06-05)
- 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.)
- Stripe vs. registration ordering (paid). Create the
tenant_registryrow at form submit (status='registered'); attach the entitlement + mint the install code oncheckout.session.completed. - Edition source for essentials at
/register. Fromtenant_registry.editionvia code →tenant_id→ registry lookup; no edition stored on the code. - 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_idatedition=essentials, with alicense_staterow and no token; registrystatus='installed'. - A paid registration additionally yields a license bound to that
tenant_id(aud), seeded intolicense_state, and the appliance shows licensed at the purchased tier;/check-inkeeps the binding on refresh. - A wipe-and-reinstall using a re-issued code recovers the same
tenant_id. INITIAL_TENANT_IDunset → appliance behavior unchanged (DB-generated UUID).- Smoke loop passes on the VM; the small automated set is green.