From 8eb14c15024b7ce567ec80ca910e14810a36995b Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 12:03:14 +0100 Subject: [PATCH] docs: spec 6, cloud instance creation Separates having an account from having an instance. Signup creates only the HQ account; the customer then creates a Free cloud instance from the portal, which provisions the control-plane instance, the admin_instances row and a Free licence in one path. Supersedes spec 5's signup-migration section. Co-Authored-By: Claude Opus 5 --- ...26-07-26-cloud-instance-creation-design.md | 307 ++++++++++++++++++ docs/superpowers/specs/README.md | 5 +- 2 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md diff --git a/docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md b/docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md new file mode 100644 index 0000000..462b705 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md @@ -0,0 +1,307 @@ +# Cloud instance creation — design + +Spec 6. Designed 2026-07-26. Depends on specs 0a, 0b, 1, 2 and 3, all shipped. + +## The problem + +`https://vantage.hostxtra.co.uk/start` provisions a control-plane instance the +moment a customer opens the verification email. It creates nothing on the admin +side: no `accounts` row, no `admin_instances` row, no licence. Every cloud +customer who signs up today therefore lands on an unlicensed instance that spec 2 +degrades to read-only, and staff must attach it by hand afterwards. + +The fix is not to bolt licence issuance onto the existing verification handler. +It is to separate the two things that flow has conflated — **having an account** +and **having an instance** — so that the account exists first and the instance is +something the customer asks for. + +## The new flow + +``` +site/start form ──POST──▶ admin /auth/signup + account name, email, password + → accounts row + unverified customer_users row + verification email + → nothing written to the control plane + +verification link ──▶ admin /auth/verify + → customer_users.verified_at set + → customer signs in at vantage-hq.hostxtra.co.uk + +HQ portal, "Create instance" ──POST──▶ admin /api/instances + → control-plane instances + users (owner; HQ password hash copied) + → admin_instances row, deployment cloud, status active + → Free licence issued, injected, and emailed as "your instance is ready" +``` + +Signup itself needs no new code: `auth.HandleSignup` already creates an account, +an unverified `customer_users` row and a verification email, and was written for +self-hosted customers. It turns out to be exactly the account-first signup cloud +needs. The new work is instance creation, the Free lifecycle, and reclaim. + +This supersedes the "Signup migration off sitesvc" section of spec 5 +(`2026-07-24-paddle-billing-design.md`). That section moved the *existing* +signup-provisions-an-instance flow to admin unchanged. This spec changes its +shape instead. Spec 5's Paddle work is unaffected and layers on top: the £0 Free +subscription and the Paddle customer are created where this spec issues the Free +licence. + +Paddle is explicitly **out of scope here**. Accounts created by this spec have an +empty `PaddleCustomerID`, which spec 5's account model already permits. + +## Instance creation + +`POST /api/instances`, customer session, body `{ "name": "..." }`. + +In order, each step undoing the previous on failure: + +1. Refuse if the account already holds a non-cancelled Free instance — a + pre-check of the same rule `licensing.checkFreeLimit` enforces, so we never + create an instance we then cannot licence. `409`. +2. Read the session's `customer_users` row for its bcrypt hash. +3. `provision.CreateInstance` — control-plane instance and slug. +4. `provision.CreateUserWithHash(…, RoleOwner, "local")` with that hash. On + failure, `provision.RollbackInstance`. +5. Insert `admin_instances`. On failure, delete the control-plane user, then roll + back the instance. +6. `licensing.Issue{Tier: free, Term: "monthly", Reason: ReasonNew, IssuedBy: + "self-serve"}`, then `inject.Deliver`. +7. Email the customer: instance URL, sign-in address, licence expiry date. + +Steps 6 and 7 do **not** fail the request. A licence that was not issued is +recoverable — the instance exists, the customer can sign in, they see spec 2's +licence banner, and staff can issue by hand. Failing the whole creation and +rolling back an instance the customer can already see would be worse. This +matches the rule spec 5 states for the same pair of failures: both outcomes +resolve toward "the customer gets in". + +### One credential, copied once + +The control-plane owner is created with the bcrypt hash already stored on +`customer_users`, so one password unlocks both HQ and the instance. + +The hash is **copied, not shared**. Changing the password on either side does not +propagate to the other, and they diverge from that moment. This is accepted: +propagating a password across two services' databases is a worse problem than two +passwords that started the same. It is worth saying in the UI at creation time. + +`HandleCloudLogin`'s control-plane fallback stays, and stays second — the +existing `customer_users`-wins branch means every customer created by this spec +authenticates against admin's own row. The fallback now serves only legacy cloud +customers who have an instance but no HQ account. + +### The global email ceiling + +`users.email` carries a **unique index across the whole control plane**, not per +instance. Two consequences, both real: + +- An address that already owns a legacy control-plane user cannot create an + instance here. `provision.CreateUserWithHash` returns `ErrEmailTaken`; the + endpoint answers `409` with a message pointing at support, rather than a + generic failure. +- One email owns at most one instance. Free is capped at one per account, so + nothing in this spec is blocked. It is a genuine ceiling on paid multi-instance + accounts later, and that is spec 5's problem to solve, not this one's. + +### Admin's control-plane write boundary + +`inject`'s package doc says plainly that a second write target into the control +plane "is a design change and not a refactor". This is that design change, and it +is made explicitly rather than by widening `inject`. + +Instance provisioning lives in a **new package, `admin/internal/cloudprov`**. +`inject` is left untouched, still writing exactly three licence fields on +`instances`. `db` gains a `ControlDB() *mongo.Database` accessor, because +`shared/provision` takes a database rather than a collection. + +`CLAUDE.md`'s description of that boundary is updated in the same commit. It +currently claims admin's control-plane access is read-only apart from three +licence fields, and that stops being true here. + +## Free lifecycle + +A Free licence runs for one month plus the existing three-day `GracePeriod`, +using the `"monthly"` term `licensing.Issue` already implements. No new term +value. + +### Renewal + +`POST /api/instances/:id/renew`, customer session, through `ownedInstance`. + +- Tier must be Free. Paid tiers renew through billing, not here. +- Allowed once `now > expires_at - 7d`, and at any point after that up to + deletion — so the same button rescues a lapsed instance rather than needing a + second mechanism. +- Reissues Free with `Reason: ReasonRenewal`, injects, and emails the new date. + +Renewal is deliberately manual. It is the entire reclaim signal: an instance +nobody renews is an instance nobody is using. + +### Status + +`admin_instances.status` gains `deleted`. A sweep in admin flips `active` to +`lapsed` when the current licence's `expires_at` passes, and the existing +15-minute reconciler — which already logs "no control-plane instance X" — flips +those to `deleted` instead of only logging. + +### Notices + +Four emails, driven by the licence's `expires_at`: + +| When | Says | +|---|---| +| 7 days before expiry | Renew, one click, here is the link | +| on expiry | Read-only now; deleted in 14 days unless renewed | +| 7 days before deletion | Deleted in 7 days | +| 1 day before deletion | Deleted tomorrow | + +Each send is recorded on the `admin_instances` document, so a restart or a +double tick cannot re-send one. Renewal clears the record, so the next term +starts the sequence again. + +## Deletion + +Deletion is the only irreversible path in the system, so it is owned by the +service that knows what an instance is made of. + +**The reaper runs in the control plane, not in admin.** Admin already injects +`license_tier` and `license_expiry` onto the instance document, so the server +drives off data it holds locally, and the list of collections carrying +`instance_id` stays in the codebase that defines them. Mirroring that list into +admin would be exactly the class of duplication `CLAUDE.md` already warns about +for slug rules and design tokens — except a divergence here deletes the wrong +rows or leaves orphans behind. + +The sweep, in `server/internal/services`: + +- Eligible when `license_tier == "free"` **and** `license_expiry` is present + **and** `license_expiry` is more than the configured window in the past. +- Purges the instance document, its users, and every `instance_id`-scoped + document across the collections listed in `CLAUDE.md`. Workflow run logs on + disk go with them. +- Fail-safe by construction. An instance whose licence issuance failed has no + `license_tier` and is never eligible. A paid instance is never eligible. An + instance admin has not reached yet keeps whatever expiry was last injected, and + admin's reconciler keeps that field current. +- Every purge writes an audit entry before deleting, and logs the instance ID, + slug and document counts. + +### The kill switch + +Gated on `FREE_INSTANCE_REAP_AFTER`, a duration. **Empty disables the sweep +entirely**, and empty is the default. + +It is unset in `deploy/docker-compose.yml` and set to `336h` only in +`deploy/docker-compose.site.yml`, so a self-hosted deployment can never reap +anything — the same containment rule that keeps `LICENSE_SIGNING_KEY` in exactly +one service in exactly one compose file. + +## Frontend + +### `site/` + +`components/InstanceForm.tsx` becomes `AccountForm.tsx`: account name, email, +password, honeypot. It posts to `NEXT_PUBLIC_ADMIN_API_URL/auth/signup` rather +than to sitesvc. The live `your-instance.vantage.hostxtra.co.uk` slug preview +goes — there is no instance yet at this point, and showing one would be a lie. + +`app/start/page.tsx` copy changes from "Set up your instance" to creating an +account, and its "What happens next" panel gains the create-an-instance step +between confirming the email and adding a key. + +`SITE_API_URL` still serves the contact form. `ADMIN_API_URL` gains a +browser-reachable presence in the `site` image build, and `site`'s origin must be +listed in admin's `ADMIN_ORIGIN`. Both are new failure modes with the same +footgun `CLAUDE.md` already documents for `SITE_API_URL`. + +### `adminsite/` + +- `(customer)/page.tsx` — the "No instances yet" panel gains a primary + **Create a free instance** action. Hidden once the account holds a Free + instance, with the reason stated rather than the button silently absent. +- `(customer)/instances/new/` — name field, live slug preview of the resulting + `.vantage.hostxtra.co.uk`, and a note that the instance password starts + as the HQ password and is changed separately afterwards. +- `components/InstanceCard.tsx` — expiry date, a **Renew** action inside the + window, and a deletion countdown when lapsed. Per `CLAUDE.md`'s rule, licence + state never reads by colour alone; the countdown is a text label. +- `lib/api.ts` — `createInstance`, `renewInstance`, and `"deleted"` added to + `InstanceStatus`. + +### `web/` + +No changes. Spec 2's licence banner already covers a lapsed instance. + +## sitesvc + +Signup, verify, `site_pending_signups` and the provisioning calls are deleted. +sitesvc keeps the contact form only, and drops `APP_LOGIN_URL`. + +The staged cutover from spec 5 applies unchanged, and matters for the same +reason: an in-flight verification link must not break. + +1. Deploy admin. Its signup already exists; nothing to enable. +2. Point `site/start` at admin. Deploy `site`. +3. Wait for sitesvc's outstanding pending signups to expire — 24 hours — with its + verify endpoint still live. **Do not delete the collection until it is empty.** +4. Deploy sitesvc with signup and verify removed. + +A signup that completes through the old path during step 3 produces an instance +with no account and no licence, exactly as today. Staff attach those by hand, the +same job the README already describes for existing cloud tenants. + +## Configuration + +| Service | Variable | Required | Notes | +|---|---|---|---| +| admin | `APP_LOGIN_URL` | yes | moved from sitesvc; `{slug}` template, used in the instance-ready email | +| server | `FREE_INSTANCE_REAP_AFTER` | no | duration past expiry before a Free instance is purged. **Empty disables the reaper**, and empty is the default. `336h` in `docker-compose.site.yml` only | +| site build | `ADMIN_API_URL` | yes | browser-reachable; must be in admin's `ADMIN_ORIGIN` | +| sitesvc | `APP_LOGIN_URL` | — | removed | + +## Testing + +Instance creation: + +1. Signup writes nothing to `instances` or `users`; only the emailed link makes + the account usable. +2. Creating an instance produces an instance, an owner user, an + `admin_instances` row, a Free licence, and an injected `license_blob` on the + control-plane document. +3. The owner can sign in to the new instance with the HQ password. +4. A second Free instance on the same account is refused `409` and writes + nothing. +5. An email that already exists in control-plane `users` is refused `409` and + leaves no instance behind. +6. Owner-insert failure rolls the instance back, and rollback refuses an instance + that has users. +7. Licence issuance failure still leaves a signed-in-able instance and flags for + staff. + +Lifecycle: + +8. Renew outside the window is refused; inside it, it supersedes, injects and + moves `expires_at` forward by a month plus grace. +9. Renewing a lapsed instance restores it before the reaper takes it. +10. Each notice sends once across a restart. + +Reaper — the part that must be got right: + +11. With `FREE_INSTANCE_REAP_AFTER` empty, nothing is ever deleted. +12. An instance with no `license_tier` is never eligible, whatever its age. +13. A Professional instance past expiry is never eligible. +14. A Free instance one hour short of the window is not deleted; one hour past it + is. +15. A purge leaves no document carrying that `instance_id` in any collection, and + writes an audit entry first. +16. Purging is idempotent — a second run over a half-deleted instance completes + it rather than erroring. + +## Risks + +| Risk | Mitigation | +|---|---| +| Reaper deletes a live instance | Kill switch defaults off; eligibility needs an explicitly-Free tier and a present expiry; unset fields are never eligible; four warning emails precede it | +| Admin's widened control-plane write access grows further | Confined to `cloudprov`; `inject` untouched; `CLAUDE.md` updated to say so | +| HQ and instance passwords diverge silently | Stated in the UI at creation time and in the instance-ready email | +| Cutover breaks an in-flight verification link | sitesvc's verify stays live until its collection is empty | +| Global `users.email` uniqueness blocks a legitimate signup | Explicit `409` naming the cause, rather than a generic failure | diff --git a/docs/superpowers/specs/README.md b/docs/superpowers/specs/README.md index 3696f1f..05a115f 100644 --- a/docs/superpowers/specs/README.md +++ b/docs/superpowers/specs/README.md @@ -1,6 +1,6 @@ # Vantage Licensing Programme — Spec Index -Seven specs, designed 2026-07-24. Build in this order. +Build in this order. Specs 0a–5 were designed 2026-07-24; spec 6 on 2026-07-26. | # | Spec | Plan | Status | |---|---|---|---| @@ -10,7 +10,8 @@ Seven specs, designed 2026-07-24. Build in this order. | 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills | | 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | **shipped**, verified end to end against scratch databases | | 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start | -| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | ready to start | +| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | ready to start; its "signup migration off sitesvc" section is superseded by 6 | +| 6 | [cloud-instance-creation](2026-07-26-cloud-instance-creation-design.md) | — | ready to start | Specs 1 and 2 together give working licensing with licences cut by hand with `lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.