Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
16 KiB
Appliance registration → download → install flow
Status: Design • Date: 2026-06-05
Goal
Let a customer register for an on-prem (appliance) AlgaPSA install, download a
generic ISO, and have the installed appliance come up already bound to a
tenant identity that was minted upstream at registration — so licenses are
bound from first boot and even free (essentials) installs get a real
tenant_registry record.
The single organizing idea: the appliance image is generic (we don't build a per-customer ISO), so per-tenant identity travels separately from the download, carried by a short, one-time install code that the appliance redeems at setup. The code is the per-customer artifact; the ISO is not.
What exists today (grounded)
The pieces this flow reuses already exist:
tenant_registry(alga-license/migrations/01_tenant_registry.cjs) — the global directory:tenant_id uuid(minted here,gen_random_uuid()),edition,deployment_type,status(registered → installed → active …),company_name/contact_email,stripe_customer_id. This is the source of truth for "a tenant exists, who owns it, what edition."/register(alga-license/src/routes/register.ts) — redeems aclaim_code, mints a per-appliance license JWT bound to a tenant via theaudclaim (signLicense({ …, aud })), issues an appliance credential, returns{ appliance_credential, first_jwt, check_in_url }. Today it requires an entitlement (getEntitlementById(row.entitlement_id)→ 500 if none) and takestenant_idas caller-supplied input./claim-codes(alga-license/src/routes/claimCodes.ts) — mints a code for an entitlement, keyed bystripe_sub_id(paid only). Revokes prior unclaimed codes for that entitlement first (rebind path)./check-in(alga-license/src/routes/checkIn.ts) — refreshes the bound license daily, preservingappliance.tenant_idasaudacross re-signs. No change needed.claim_codes(alga-license/migrations/03_claim_codes.cjs) —entitlement_idisNOT NULLwith an FK toentitlements.- Appliance tenant creation (
ee/server/src/lib/testing/tenant-creation.ts, used byserver/scripts/create-tenant.ts) —createTenantinserts intotenantswithout atenantvalue, so the DB generates the UUID (gen_random_uuid()) and returns it (.returning('tenant'), line 80–84). - Appliance consumer (
server/src/lib/actions/licenseManagementActions.ts) —connectAppliancePOSTs{ claim_code, appliance_id }to/registerand seedslicense_statewithfirst_jwt/appliance_credential/check_in_url. Runs after the tenant already exists.
The gap
Today the appliance generates its own tenant UUID at setup, then optionally
connects a license later. The tenant identity is never known upstream, so a
license issued before install can't be bound, and essentials installs leave no
registry record. The flow below inverts the direction: mint tenant_id
upstream at registration; the install code carries it downstream; the appliance
adopts it.
The four surfaces
nm-store alga-license (C4) object store appliance
──────── ───────────────── ──────────── ─────────
register ──POST───► /register-tenant
form • tenant_registry row (tenant_id)
• (paid) entitlement
• mint install code ──carries──► tenant_id
• presign ISO URL ◄──────────────┐
◄──{code, tenant_id, download_url} │
confirm page │
+ email │
│
download ──────────────────────presigned GET────────────┘──────────► ISO
│
install: setup UI "enter install code" ▼
──POST {claim_code, appliance_id}──► /register
• look up code → tenant_id (registry-minted)
• (paid) mint license bound aud=tenant_id
◄──{ tenant_id, edition, first_jwt?, credential?, check_in_url? }
│
create-tenant INITIAL_TENANT_ID=tenant_id ◄─────────────────┘
seed license_state (edition; + token/credential if paid)
registry status → installed
-
Registration (nm-store → alga-license). A new service-authed endpoint
POST /register-tenanton alga-license creates thetenant_registryrow (deployment_type=appliance,status=registered), and for paid tiers creates the entitlement (Stripe checkout drives this, as the hosted flow does today). It mints an install code carrying thattenant_id(and theentitlement_idfor paid), and returns{ tenant_id, install_code, download_url }. nm-store shows the code on the confirmation page and emails it. nm-store stays DB-less — alga-license owns the registry write, the code, and the presigned URL. -
Download (presigned, all tiers).
download_urlis a time-boxed presigned URL to the current generic appliance ISO in the in-cluster object store (MinIO). Presigned for everyone — essentials included — so downloads are gated by registration, not public. The ISO carries no identity. -
Install (appliance redeems the code). The first-boot setup UI gains an "enter install code" step. The host-service redeems it via the existing
/register, which now returns the registry-mintedtenant_idandedition(plus, for paid,first_jwt/appliance_credential/check_in_url). Bootstrap then runscreate-tenantwith a newINITIAL_TENANT_IDso the localtenantsrow is created under the pre-minted UUID, and seedslicense_state(edition always; license token + credential + check-in URL for paid). alga-license flips the registry row tostatus=installed. -
Re-issue / recovery (in scope). Install codes are one-time. A new service-authed
POST /install-codes/reissueon alga-license resolves an existing registry tenant (bycontact_email, ortenant_id), revokes any unconsumed codes for it, and mints a fresh install code for the sametenant_id(+ current entitlement) with a fresh presigneddownload_url. nm-store exposes this as a portal "re-issue install code" action. This is what makes a wipe-and-reinstall recover the same tenant identity rather than stranding it.
Data model changes (alga-license)
A single migration extending claim_codes so one code type serves both paid and
essentials, carrying the registry tenant:
ALTER claim_codes:
entitlement_id → NULLABLE -- essentials codes carry no entitlement
+ tenant_id uuid NOT NULL FK → tenant_registry(tenant_id)
+ index on tenant_id
- Paid code:
entitlement_idset (license path unchanged) andtenant_idset (soaudcomes from the registry, not appliance input). - Essentials code:
entitlement_idnull,tenant_idset. No license minted.
tenant_registry already has everything else needed (edition, company_name,
contact_email, status); no change there beyond writing status=installed at
install.
API changes (alga-license)
/register (extend, don't replace). Today it 500s when the code has no
entitlement and binds aud from caller-supplied tenant_id. New behavior:
- Resolve
tenant_idfrom the code (row.tenant_id), not the request body — this is the registry-minted identity. (Keep accepting a bodytenant_idonly as a legacy fallback for pre-registry appliances; the registry path ignores it.) - If
row.entitlement_idis null → essentials: skip license minting; still upsert the appliance row (credential,tenant_id, no token). - Look up
tenant_registrybytenant_idforedition+company_name/contact_email. - Response gains
tenant_id,edition, and (for essentials) omitsfirst_jwt. SoRegisterResponsebecomes:{ tenant_id, edition, company_name, contact_email, appliance_credential?, first_jwt?, check_in_url? }— credential/token/url present only when there's a license (paid).
POST /register-tenant (new, service-authed). Body: company/contact,
edition, deployment_type, optional Stripe linkage. Creates the registry row,
(paid) entitlement, mints the install code carrying tenant_id, presigns the
ISO, returns { tenant_id, install_code, download_url }. Reuses
createRegistryTenant + the claim-code minting (generalized to accept a
tenant_id and optional entitlement_id).
POST /install-codes/reissue (new, service-authed). Body:
{ contact_email | tenant_id }. Resolves the registry tenant, revokes its
unconsumed codes, mints a fresh code + presigned download_url. Returns
{ install_code, download_url }.
The INITIAL_TENANT_ID seam (appliance)
The change is small and localized at the one place the UUID is born:
createTenant(tenant-creation.ts) gains an optionaltenantIdon its input; when present, settenantInsert.tenant = input.tenantIdinstead of letting the DB default it. Thread the option throughcreateTenantComplete.server/scripts/create-tenant.tsreadsINITIAL_TENANT_ID(env, alongside the existingINITIAL_ADMIN_PASSWORD) and passes it down.- The appliance bootstrap (the configmap/script that runs
create-tenantat first boot) setsINITIAL_TENANT_IDto thetenant_idthe redeem returned.
When INITIAL_TENANT_ID is unset, behavior is unchanged (DB-generated UUID) — so
this is additive and safe for non-registry installs.
The install-time redeem consumer (appliance)
connectAppliance already knows how to call /register and seed license_state.
The install path generalizes it:
- Setup UI collects the install code + admin password (the password is set here, at install — it never travels through registration or email).
- Host-service POSTs
{ claim_code, appliance_id }to/register, receivestenant_id+edition(+ license bits if paid). - Bootstrap runs
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 that a token is always present).
Essentials installs simply run at edition=essentials with a license_state row
and no token — consistent with "always seed license_state" (the appliance reads
edition from license_state, not from the presence of a token).
Error handling
- Code invalid / expired / already consumed —
/registeralready returnsinvalid_claim_code/expired_claim_code/consumed_claim_code; the setup UI surfaces these and points the user to portal re-issue. - Consumed code on reinstall — expected; the user re-issues. Re-issue revokes stragglers so only the newest code is live.
- Presigned URL expired — re-issue mints a fresh one; the download link and the code travel together.
- Registry/license-service unreachable at install — setup blocks with a clear "can't reach license service" error (the appliance can't self-mint a registry identity; that's by design).
- Idempotent install — if
create-tenantreruns with the sameINITIAL_TENANT_ID, it must no-op/short-circuit rather than duplicate (the existing admin-user create already guards on(email, tenant)).
Security considerations
- The install code is single-use (
consumeClaimCodeis atomic under concurrency) and short-lived (claimCodeTtlSeconds); re-issue is the only way to get another, and it's behind portal auth on nm-store. - Per-tenant binding is preserved end-to-end: the registry mints
tenant_id, the code carries it,/registerstamps it asaud,/check-inkeeps it across refreshes — so a leaked license still can't activate on another tenant (the binding work already shipped; this flow just sourcesaudfrom the registry instead of appliance input). - Presigned-for-all means the ISO isn't a public URL, but note the ISO itself is not a secret — the install code is the gate. Presigning is registration gating + link expiry, not ISO confidentiality.
- The admin password is set at install, never in registration/email.
Testing
Following the repo's light-automated-then-smoke convention:
- Automated (alga-license): the
claim_codesextension +/registeressentials path +/register-tenant+/install-codes/reissueget db-layer and route tests against a throwaway Postgres (the existing pattern: gated onDB_HOST,DELETEnotTRUNCATEfor the least-priv role). Cover: essentials code (no entitlement) redeems and returnstenant_id/editionwith no token; paid code returns a license bound to the registrytenant_id; reissue revokes prior codes and reuses the sametenant_id. - Automated (appliance):
createTenanthonorsINITIAL_TENANT_ID(creates thetenantsrow under the supplied UUID; unchanged when unset). - Smoke (live, on the VM): full loop — register on nm-store → download →
enter code in setup UI → confirm the appliance comes up under the minted
tenant_id,license_stateseeded, paid tier licensed/bound, then a wipe-and-reinstall via re-issue recovers the same tenant.
Phasing
The four decisions collapsed the original two-phase split: re-issue is in scope now, so this is one coherent build rather than essentials-first then paid. Suggested 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.
Open questions (for spec review)
- Presigned URL owner. Lean: alga-license mints the presigned URL (holds the
MinIO creds; nm-store stays DB-less and just relays). Confirm MinIO is the ISO
store and the appliance release process publishes the "current" ISO to a known
key. Current appliance release metadata is published by the
~/nm-kube-configArgo workflow to OCI, not from local files inalga-psa. /register-tenantvs Stripe ordering for paid. Does nm-store call/register-tenantbefore or after Stripe checkout completes? Lean: create the registry row at form submit (status=registered), attach the entitlement oncheckout.session.completed, mint the code at that point.- Edition source for essentials at
/register. Confirmed viatenant_registry.edition(the code → tenant_id → registry lookup); no edition on the code itself. OK? - Code format. Reuse
generateClaimCode()as-is (same friendly format), or a distinct visual format for install codes? Lean: reuse — one machinery.