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:
@@ -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 |
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user