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 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 12:03:14 +01:00
co-authored by Claude Opus 5
parent f8cd909828
commit 8eb14c1502
2 changed files with 310 additions and 2 deletions
@@ -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
`<slug>.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 |
+3 -2
View File
@@ -1,6 +1,6 @@
# Vantage Licensing Programme — Spec Index
Seven specs, designed 2026-07-24. Build in this order.
Build in this order. Specs 0a5 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.