# Scratchpad — Appliance registration → download → install flow - Plan slug: `appliance-registration-install-flow` - Created: `2026-06-05` - Source spec: `docs/superpowers/specs/2026-06-05-appliance-registration-install-flow-design.md` ## What This Is Working memory for the register → download → install (+ re-issue) flow. The spine is a **direction inversion**: today the appliance generates its own tenant UUID and sends it up at `/register`; the new flow mints `tenant_id` upstream at registration, carries it down via a one-time install code, and the appliance **adopts** it via a new `INITIAL_TENANT_ID`. ## Decisions - (2026-06-05) **Reuse `/register`** for the friendly install code (decision 1) — one redeem path, reuse the claim-code machinery. - (2026-06-05) **Extend `claim_codes`** (decision 2): `entitlement_id` → nullable, add `tenant_id` FK → `tenant_registry`. One code type for essentials (no entitlement) and paid. Chosen over a separate `install_tokens` table. - (2026-06-05) **Presigned for all** downloads (decision 3) — registration-gated, not public; ISO isn't a secret, the code is the gate. - (2026-06-05) **Re-issue in scope now** (decision 4) — portal action resolves an existing registry tenant and mints a fresh code for the same `tenant_id`. - (2026-06-05) Test weighting is **smoke-first** (user directive): small automated set for the riskiest seams, everything else validated live on the VM. This deliberately inverts the software-planner default (tests longer than features) — `tests.json` is shorter and smoke-weighted on purpose. - (2026-06-05) **The short code is the identity carrier for BOTH tiers** (not just paid). Essentials installs get a short code too and never type a raw tenant_id; the code's `tenant_id` is set for everyone, `entitlement_id` is null for essentials. One-liner framing: "a short code always resolves a tenant; for paid it additionally mints a bound license." - (2026-06-05) **§16 open questions all resolved:** (1) alga-license presigns with object-store creds via **env vars**; the presigned URL is **emailed** to the user after registration/purchase (alongside the code) — not a public link. (2) Paid ordering: registry row at form submit, entitlement + code on `checkout.session.completed`. (3) Essentials edition from `tenant_registry.edition` (no edition on the code). (4) Reuse `generateClaimCode()` for install codes. Remaining impl detail (not a fork): which object-store key holds the current generic ISO + how the release process publishes it (F041). ## Discoveries / Constraints - (2026-06-05) **`claim_codes` baseline is `entitlement_id NOT NULL`** (`alga-license/migrations/03_claim_codes.cjs`). Needs a new migration to relax it and add `tenant_id`. - (2026-06-05) **alga-license accessors that already exist** (`src/db/db.ts`): `createRegistryTenant`, `getRegistryTenant`, `getRegistryTenantByEmail`, `insertClaimCode`, `getClaimCode`, `consumeClaimCode`, `revokeClaimCodesForEntitlement`, `upsertAppliance` (already takes `tenantId`), `setEntitlementLicenseSub`, `getEntitlementById`. Signing helpers exist: `signLicense` (takes `aud`), `generateClaimCode`, `generateApplianceCredential`, `generateLicenseId`. - (2026-06-05) **New alga-license work:** the migration, `insertClaimCode` + `getClaimCode`/`ClaimCodeRow` for `tenant_id`/nullable entitlement, a new `revokeClaimCodesForTenant` (essentials reissue has no entitlement to key on), a `setRegistryTenantInstalled`, the `/register` essentials+response changes, the `/register-tenant` and `/install-codes/reissue` routes, and a MinIO presign helper. - (2026-06-05) **`/register` today** (`src/routes/register.ts`): requires an entitlement (`getEntitlementById` → 500 if none), binds `aud` from the **request body** `tenant_id`, returns `{ appliance_credential, first_jwt, check_in_url }`. The new flow sources `tenant_id` from the **code row** and returns it (+ edition) to the appliance. - (2026-06-05) **`/check-in`** (`src/routes/checkIn.ts`) already preserves `appliance.tenant_id` as `aud` across re-signs — no change needed. - (2026-06-05) **The appliance tenant UUID is born in** `ee/server/src/lib/testing/tenant-creation.ts` `createTenant` (~line 80): it inserts into `tenants` without a `tenant` value, so the DB default (`gen_random_uuid()`) generates it and it's returned via `.returning('tenant')`. That insert is the exact `INITIAL_TENANT_ID` seam. - (2026-06-05) **`connectAppliance`** (`server/src/lib/actions/licenseManagementActions.ts`, ~line 125) already POSTs `{ claim_code, appliance_id }` to `/register` and seeds `license_state` with `first_jwt`/`appliance_credential`/`check_in_url` — but AFTER the tenant exists. The install-time consumer generalizes this to run before create-tenant and adopt the returned `tenant_id`. - (2026-06-05) **Licval WIP already in the working tree** (uncommitted, NOT part of this plan's deltas — treat as precondition F001): `create-tenant.ts` + `tenant-creation.ts` thread `INITIAL_ADMIN_PASSWORD`/`args.password` through to `createAdminUser` (uses `input.password ?? generateSecurePassword()`), initialize `tenant_settings` (onboarding pending), and honor `DB_HOST`/`DB_PORT`/`DB_USER_ADMIN`. `INITIAL_TENANT_ID` lands in the same two files. Do not revert this WIP. ## Build-step 1 — alga-license schema + /register (DONE on branch `feat/registration-install-flow`) - (2026-06-05) **Two refinements discovered while implementing** (both fed back into the PRD/features): - `claim_codes.tenant_id` is **NULLABLE**, not NOT NULL. Migration alters `entitlement_id` NULLABLE via `knex.raw('ALTER TABLE … DROP NOT NULL')` (knex `.alter()` would try to rebuild the FK) and adds `tenant_id uuid` nullable FK + index (`migrations/05_claim_codes_registry.cjs`). `/register` resolves `tenant = row.tenant_id ?? body.tenant_id` — the body path is the clean legacy fallback, which only works because tenant_id is nullable. - **Essentials `/register` creates NO appliance row.** `appliances.entitlement_id` is `NOT NULL` (FK), and essentials has nothing to refresh (no license → no check-in), so the essentials branch returns `{ tenant_id, edition }` and does not call `upsertAppliance` or issue a credential. (Original F012 said "still upsertAppliance" — corrected.) - (2026-06-05) `createRegistryTenant` already accepted a pinned `tenantId` and returns the row — `/register-tenant` (build step 3) is mostly wiring. - (2026-06-05) `RegisterResponse` now returns `tenant_id` + `edition` + `company_name`/`contact_email`; `appliance_credential`/`first_jwt`/`check_in_url` are **optional** (paid only). The existing alga-psa `connectAppliance` destructures the three paid fields and ignores the rest, so paid back-compat holds. - (2026-06-05) **Validated:** `tsc --noEmit` clean; migration applies + rolls back + re-applies on a throwaway `postgres:16` (5433); jest **28/28** incl. 3 new seam tests (claim_codes tenant_id round-trip, `revokeClaimCodesForTenant`, `setRegistryTenantInstalled`). The repo's `signing.test.ts` IS the gated-on-DB_HOST pg integration suite — extend that block, don't add a parallel harness. - (2026-06-05) Test weighting note: `/register` HTTP behavior (T003/T004) is **smoke**, not Fastify route tests — validated in the build-step-4 live loop, per the light-automated directive. ## Build-step 2 — INITIAL_TENANT_ID seam (code in working tree, alga-psa; UNCOMMITTED w/ licval WIP) - (2026-06-05) Implemented F050/F051/F052/F054/F055: `createTenant` (`ee/server/src/lib/testing/tenant-creation.ts`) takes optional `tenantId` → sets `tenantInsert.tenant` when present (else DB `gen_random_uuid()` default, unchanged); idempotency guard returns the existing tenant (+ its client) if the id already exists so a re-run doesn't error/duplicate. Threaded through `TenantCreationInput` → `createTenantComplete`. `create-tenant.ts` reads `INITIAL_TENANT_ID` (env or `--tenantId`) and passes it down. - (2026-06-05) **Three `tenant-creation.ts` copies** — only `ee/server/src/lib/testing/tenant-creation.ts` is the live path (what `server/scripts/create-tenant.ts` imports). `packages/ee/src/lib/testing/…` is the CE stub (throws). No parallel edit needed. - (2026-06-05) No caller breakage: the change is additive-optional; `tenant-test-factory.ts` (the only other `createTenantComplete` consumer) is unaffected. Other `createTenant(` matches are unrelated functions (temporal activities, server test-utils). - (2026-06-05) **Not committed** — these two files also carry the user's licval WIP (password seam). Left uncommitted in the working tree to avoid committing in-flight work; appliance changes (steps 2+4) commit together when the user is ready. F053 (bootstrap passes the redeemed id) + full typecheck land in step 4. - (2026-06-05) T007/T008 reclassified AUTO→SMOKE: exercising `createTenant`'s id adoption needs the full alga-psa `tenants`/`clients` schema (heavy), so it's validated in the live install loop, not a unit DB. ## Build-step 3 — /register-tenant + /install-codes/reissue + presign (DONE, alga-license branch) - (2026-06-05) **Dependency-free SigV4 presigner** (`src/storage/presign.ts`) — the service had no AWS SDK and is intentionally lean (fastify/knex/pg only), so the S3/MinIO presigned-GET URL is hand-rolled with `node:crypto` (path-style, UNSIGNED-PAYLOAD, query-auth). `now` is injectable for deterministic tests. - (2026-06-05) Object-store config is **env-driven and OPTIONAL**: `OBJECT_STORE_ENDPOINT/REGION/BUCKET/ACCESS_KEY/SECRET_KEY` + `APPLIANCE_ISO_KEY` (default `appliance/current/alga-appliance.iso`). `getPresignConfigFromEnv()` returns null when unset so existing deploys (and /sign /register /check-in) boot without it; only register-tenant/reissue need it, and they emit `download_url:''` if it's missing. TTL = `claimCodeTtlSeconds` (link expiry aligned to the code). - (2026-06-05) New routes: `/register-tenant` (service-authed) creates the registry row, (paid) upserts the entitlement bound to the new tenant, mints the install code carrying tenant_id (+ entitlement), presigns. `/install-codes/reissue` resolves by tenant_id|email, `revokeClaimCodesForTenant`, re-attaches the active entitlement via new `getActiveEntitlementByTenant`, mints a fresh code + link. - (2026-06-05) **Validated:** tsc clean; jest **31/31** (added presign structural test ×2 + getActiveEntitlementByTenant). Route HTTP behavior (T010/T018) is smoke. - (2026-06-05) **DEPLOY FOLLOW-UP (relates to F041):** the alga-license Deployment (service `k8s/deployment.yaml` + nm-kube-config) must get the `OBJECT_STORE_*` env + `APPLIANCE_ISO_KEY`, and the appliance release process must publish the current ISO to that key. Not wired here (ops); register-tenant returns an empty download_url until it is. ## Build-step 4 — appliance setup integration (MAPPED; needs coordination, NOT yet built) - (2026-06-05) The redeem belongs in the **host-service** `setup-engine.mjs` (has the form inputs + network egress; mints the in-cluster secrets), not the in-app `connectAppliance` (which runs post-tenant). Seam: after `normalizeInitialTenant`, if an install code is present, POST it to `ALGA_LICENSE_SERVICE_URL/register` → capture `tenant_id` + `edition` (+ paid `first_jwt`/`appliance_credential`/ `check_in_url`), then: - add `INITIAL_TENANT_ID` to the `appliance-initial-tenant` Secret (`initialTenantSecretYaml`, ~line 429) → create-tenant adopts it (build-step 2); - feed the `appliance-license-seed` Secret (`licenseSeedCmd`, ~line 1014: `EDITION_CHOICE` + optional `LICENSE_TOKEN`). - (2026-06-05) **THREE reconciliation decisions block a clean edit** (all inside the user's ACTIVE licval license-setup rework): 1. **UX:** the setup UI (`status-ui/app/setup/page.tsx`) already has `editionChoice` (ee/ce) + a pasted `licenseKey`. Does the install code become the primary path (overriding those), an added third option, or replace them? 2. **Taxonomy:** registry `edition = essentials|pro|premium` vs appliance `editionChoice = ee|ce` + license tier `pro|premium`. Need a mapping (essentials→ce? pro/premium→ee+token?). 3. **Connected seed:** `appliance_credential`/`check_in_url` are written today only by in-app `connectAppliance`, not the seed Secret — install-time connected licensing needs new seed plumbing in `packages/licensing/src/lib/license-state.ts`. - (2026-06-05) `setup-engine.mjs` + `status-ui/` are NOT in the uncommitted WIP (safe to edit git-wise) but are conceptually the licval subsystem. F053/F060–F068 pend on the three decisions above. ## Build-step 4 — appliance setup integration (BUILT in working tree, alga-psa) - (2026-06-05) Redeem lives in the host-service. New `install-code.mjs` (`redeemInstallCode` / `deriveApplianceId` / `licenseSeedFromRedeem`) is pure + unit-tested (7 tests, mock fetch). `setup-engine.mjs` `validateSetupInputs` takes `installCode`; `applyRuntimeValuesAndReleaseSelection` redeems it (injectable via `options.redeemInstallCode` for tests) BEFORE building the two Secrets, threading `tenant_id` → `initialTenantSecretYaml(initialTenant, tenantId)` (INITIAL_TENANT_ID line) and the license fields → the `appliance-license-seed` literals (`licenseSeedFromRedeem`). Redeem failure → `preflightFailure` blocks the install (F067/F068). 2 new workflow tests; 8/8 pass. - (2026-06-05) `appliance-bootstrap-configmap.yaml`: create-tenant gets `INITIAL_TENANT_ID="${INITIAL_TENANT_ID:-}"` (create-tenant.ts reads it; empty => DB-generated). License-seed SQL extended: a `sql_value()` helper + new columns `appliance_id`/`check_in_url`/`appliance_credential` (connected refresh), and the trial is suppressed when `INSTALL_EDITION=essentials` (essentials resolves to the essentials tier, not an auto premium trial). - (2026-06-05) status-ui `setup/page.tsx`: an **Install code** field (primary, recommended) above the manual Edition/license fallback; included in the POST. Typechecks clean. - (2026-06-05) **DEFAULTS I PICKED — confirm during smoke** (you authorized sensible defaults + flag): 1. **Taxonomy:** the appliance always runs EE (`EDITION_CHOICE=ee`); essentials = EE-unlicensed (no token, no auto-trial), pro/premium = EE + minted token. The `ce` choice stays only for a manual community install (no install code). 2. **Connected seed:** `appliance_id`/`check_in_url`/`appliance_credential` are seeded straight into `license_state` by the bootstrap SQL (vs. the in-app `connectAppliance` plaintext write) so daily check-in works from first boot. - (2026-06-05) **Two caveats:** (a) the install code is single-use and **consumed at apply time** — a failed install past that point needs a re-issued code (the reissue endpoint exists; consider persisting the redeem to skip re-redeem on retry). (b) `ee/appliance/ubuntu-iso/overlay/.../host-service/` is a build-synced COPY — the ISO build must pick up the new `install-code.mjs` + setup-engine edits (overlay sync, not a manual edit). ## Build-step 5 — nm-store (separate repo /home/robert/nm-store; outward-facing) - (2026-05-31/06-06) nm-store is local. Its existing order flow is **hosted-only** (checkout → Temporal `OrderInstallWorkflow` → hosted tenant; no appliance/ deploymentType concept). Appliance registration is a **new outward-facing product surface** there. - (2026-06-06) **BUILT (branch `feat/appliance-registration-client`, commit 6ab0a427):** `src/lib/algaLicenseClient.ts` — typed `registerTenant()` + `reissueInstallCode()` calling `/register-tenant` + `/install-codes/reissue` (Bearer `ALGA_LICENSE_SERVICE_SECRET`, `ALGA_LICENSE_SERVICE_URL`). This is the transport F071/F075 build on. Logic validated via a tsx harness (the repo's vitest can't run here — missing `strip-literal` in node_modules under Node 24; the `.test.ts` is correct and runs once that dep gap is fixed). - (2026-06-06) **BUILT — commerce surface (decisions: SEPARATE route + essentials+paid), branch `feat/appliance-registration-client` commit `ee53d0b0`, tsc clean:** - `/order/appliance` registration form (client): essentials → direct register → show code; paid (pro/premium) → embedded Stripe checkout. `/order/appliance/reissue`. - `lib/appliance/applianceRegistration.ts`: `registerApplianceAndEmail`, `reissueApplianceAndEmail`, `sendInstallCodeEmail` (Resend, reuses the newsletter pattern), and **idempotent** `provisionApplianceFromCheckoutSession` — nm-store has no DB, so it writes the minted `tenant_id`/`install_code` back onto the **Stripe subscription metadata** and reuses them on thank-you refresh (so a refresh never re-mints). - `lib/appliance/applianceCheckout.ts`: embedded subscription checkout stamping `deploymentType=appliance` + edition/contact into metadata, dedicated `return_url=/order/appliance/thank-you`. **Hosted OrderForm/checkout/thank-you untouched** (the separate-route choice). - Checkout-complete is handled by the thank-you page (Stripe `return_url`), not a webhook — so the appliance thank-you provisions on render. - **Email is best-effort** (Resend): logs + returns `emailSent:false` if `RESEND_API_KEY` unset; the code is always shown on-page too. - (2026-06-06) **nm-store env to set for PAID:** `STRIPE_PRICE_ID_APPLIANCE_{PRO,PREMIUM}_{MONTHLY,YEARLY}` (+ optional `..._USER_MONTHLY/YEARLY` per-user), `ALGA_LICENSE_SERVICE_URL`, `ALGA_LICENSE_SERVICE_SECRET`, `RESEND_API_KEY`/`APPLIANCE_FROM_EMAIL`. Essentials needs no Stripe price. Tests can't run here (repo vitest `strip-literal` gap + `server-only` blocks tsx) — validated by tsc + the tsx client harness + smoke. - (2026-06-06) F076 operator doc written: `ee/appliance/docs/registration-install-flow.md`. - (2026-06-06) **F041 remains ops:** publish the current generic ISO to the object-store key the presigner targets (`APPLIANCE_ISO_KEY`), and wire the `OBJECT_STORE_*` env onto the alga-license Deployment (service `k8s/deployment.yaml` + nm-kube-config). ## Commands / Runbooks - (2026-06-05) **alga-license migration/db tests:** run against a throwaway `docker run postgres:16` (snap docker can't reach /tmp; `docker run` is fine — prior runs used port 5433). The least-priv app role can't `TRUNCATE` (by design) — tests `DELETE`. DB-layer tests are gated on `DB_HOST` (skip without a DB). - (2026-06-05) **Build gotcha:** the shell has `NODE_ENV=production`, so `npm install` omits devDeps (breaks tsc/@types) — use `npm install --include=dev`. - (2026-06-05) **alga-license validation one-liner** (throwaway pg): `docker run -d --name algalic-pg -e POSTGRES_PASSWORD=test -e POSTGRES_DB=alga_license -p 5433:5432 postgres:16` then `DB_HOST=localhost DB_PORT=5433 DB_NAME=alga_license DB_USER_ADMIN=postgres DB_PASSWORD_ADMIN=test npm run migrate` and `DB_HOST=localhost DB_PORT=5433 DB_NAME=alga_license DB_USER_APP=postgres DB_PASSWORD_APP=test npm test`. The signLicense tests need the alga-psa fixture key at `packages/licensing/src/lib/__test-fixtures__/v1-test.private.pem` (present). - (2026-06-05) **Appliance VM smoke:** the full register→download→install loop is validated live on the libvirt appliance VM (see the appliance teardown/reinstall + VM ISO-test memories for driving setup via the browser / virsh). ## Links / References - Design spec: `docs/superpowers/specs/2026-06-05-appliance-registration-install-flow-design.md` - Registry foundation (already shipped): alga-license PR #3/#4, nm-kube-config PR #50/#51. - alga-license routes: `src/routes/{register,claimCodes,checkIn,sign,revoke}.ts`; db: `src/db/db.ts`; migrations: `migrations/0{1..4}_*.cjs`; types: `src/api-types.ts`. - Appliance: `ee/server/src/lib/testing/tenant-creation.ts`, `server/scripts/create-tenant.ts`, `server/src/lib/actions/licenseManagementActions.ts`. ## Open Questions - (2026-06-05) **All four §16 design questions resolved** — see the Decisions section. No open design forks remain. Only impl detail left: the object-store key for the current generic ISO + the release-process publish step (F041).