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

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_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_idNULLABLE (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.