docs: spec 6 revised for account membership

An account has people, and those people belong to several cloud
instances. That requires dropping the global unique index on users.email
for a compound (instance_id, email), scoping the two unscoped lookups
that made the global index load-bearing, and projecting HQ users into
instances as real control-plane users.

Self-hosted instances are never projected into; their users stay local.

Restructured into three phases: identity, creation and lifecycle,
membership.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 12:16:38 +01:00
co-authored by Claude Opus 5
parent 8eb14c1502
commit d15ab78bd5
@@ -1,4 +1,4 @@
# Cloud instance creation — design
# Cloud instance creation and account membership — design
Spec 6. Designed 2026-07-26. Depends on specs 0a, 0b, 1, 2 and 3, all shipped.
@@ -15,57 +15,161 @@ 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.
Once accounts are real, a second thing follows: an account has *people* in it,
and those people need access to the account's instances. That is what forces the
`users.email` change below, and it is the largest single item in this spec.
## 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
→ accounts row + unverified customer_users row (account_role owner)
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"
→ control-plane instances + users (creator becomes owner)
→ admin_instances row + instance_members row
→ Free licence issued, injected, emailed as "your instance is ready"
HQ portal, "Invite" and "Add to instance"
→ more customer_users on the account
→ each grant projects a control-plane users row into that cloud instance
```
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.
Signup itself needs almost 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.
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.
signup-provisions-an-instance flow to admin unchanged; this changes its shape.
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
## Phasing
`POST /api/instances`, customer session, body `{ "name": "..." }`.
Three phases, each shippable, in this order. The plan should not interleave them
— phase 1 changes an index that everything else then depends on.
1. **Identity** — drop the global email index, scope the lookups, project the
creator as owner. No new UI.
2. **Instance creation and Free lifecycle**`POST /api/instances`, renewal,
notices, the reaper, the sitesvc cutover.
3. **Membership** — account roles, invitations, per-instance grants, password
propagation.
## Phase 1 — identity
### Dropping the global email index
`users.email` currently carries a unique index **across the whole control
plane**. `CLAUDE.md` names it a security property, and it is one today. It is
also what makes "an account's people belong to several instances" impossible:
one address can own exactly one user document anywhere.
It is replaced by a unique compound index on `(instance_id, email)`, which is the
constraint that was actually wanted: one address is one user *within an
instance*.
The global index is only load-bearing because two lookups are unscoped. Both are
scoped instead, and the scoped lookups are a strictly stronger guarantee than the
index was — an index prevents the ambiguity, whereas a scoped query cannot be
ambiguous in the first place.
| Caller | Today | After |
|---|---|---|
| `auth.HandleLocalLogin` | `GetUserByEmail(email)` | resolve the instance, then `GetUserInInstanceByEmail` |
| `auth.HandleOIDCCallback` | `GetUserByEmail(email)`, then a cross-instance guard | `GetUserInInstanceByEmail(instanceID, …)`; the guard is deleted as unreachable |
`services.GetUserByEmail` is **deleted**, not merely left unused. Leaving an
unscoped helper in place is how this bug comes back.
Resolving the instance for local login:
1. `InstanceFromHost` — always succeeds on cloud, where every instance has its
own subdomain.
2. Otherwise, if exactly one instance exists, use it. This is the self-hosted
case, which is single-instance by construction because a licence binds one
instance UUID.
3. Otherwise refuse with a message naming the cause, rather than guessing.
### The index migration
In `shared/indexes.EnsureCoreIndexes`, in this order:
1. Create the unique compound index on `(instance_id, email)`. Fatal on failure.
2. Drop `email_1` if present, ignoring `IndexNotFound` so it is idempotent.
Creating before dropping means a failure at step 2 leaves both indexes in place,
which is safe. A failure at step 1 leaves the old index alone, which is also
safe.
`EnsureCoreIndexes` is called at boot by server, sitesvc and admin, so **all
three images must ship together**. `server-deploy.yml` rebuilds every image on
every push to `main`, so this happens by default; the risk is only a partial
manual rollout on the host.
**This migration is one-way.** Once two users share an address across instances,
`email_1` cannot be recreated. Rolling the server back past this change would
leave unscoped lookups running against data that can now be ambiguous. The
rollback plan is forward-only: fix and redeploy.
`sitesvc.EmailTaken` also does an unscoped count over `users`. It disappears with
sitesvc's signup in phase 2.
`admin.HandleCloudLogin`'s control-plane branch does an unscoped
`FindOne({email})` too, and unlike the other two there is no instance in context
to scope it by — HQ login is not per-instance. **That branch is deleted.** Every
customer created by this spec has a `customer_users` row, which already wins in
the existing precedence. Legacy cloud customers are handled by staff, who already
attach their instances by hand per the spec README, and who gain
`POST /api/staff/accounts/:id/users` to create their HQ login.
### The control-plane user document
`shared/models.User` gains:
- `hq_user_id` — the `customer_users.user_id` this row was projected from, absent
on locally-created users.
- `auth_source: "hq"` as a third value alongside `local` and `oidc`.
An `hq`-sourced user is **managed in HQ, not in the instance**. The control plane
refuses to change its role, delete it, or change its password through
`/api/instance/users`, answering with "managed in Vantage HQ". `web/` renders
those rows read-only with the same label. Locally-created users are unaffected
and stay fully editable in the instance — a cloud instance can hold both kinds.
This gives one owner per fact. A role that is editable in two places is a role
with two answers.
## Phase 2 — instance creation
`POST /api/instances`, customer session, `account_role` owner or admin,
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.
2. Read the caller'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.
4. `provision.CreateUserWithHash(…, RoleOwner, "hq")` with that hash and
`hq_user_id`. On failure, `provision.RollbackInstance`.
5. Insert `admin_instances`, then `instance_members` for the creator. 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.
7. Email the creator: 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
@@ -74,78 +178,51 @@ 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.
Provisioning and membership projection live 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.
`cloudprov` writes exactly three things: instance documents (create and roll
back), user documents (create, delete, update role and password hash), and
nothing else. `CLAUDE.md`'s description of the boundary is updated in the same
commit, because it currently claims admin's control-plane access is read-only
apart from three licence fields, and that stops being true here.
## Free lifecycle
## Phase 2 — 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.
value. One Free instance per account, unchanged.
### Renewal
`POST /api/instances/:id/renew`, customer session, through `ownedInstance`.
`POST /api/instances/:id/renew`, through `ownedInstance`, account owner or admin.
- 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.
- Reissues Free with `Reason: ReasonRenewal`, injects, 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
### Status and notices
`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.
those to `deleted` and clears their `instance_members` rows instead of only
logging.
### Notices
Four emails, driven by the licence's `expires_at`:
Four emails to the account's owners and admins, driven by `expires_at`:
| When | Says |
|---|---|
@@ -154,11 +231,11 @@ Four emails, driven by the licence's `expires_at`:
| 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.
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
## Phase 2 — 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.
@@ -184,6 +261,8 @@ The sweep, in `server/internal/services`:
admin's reconciler keeps that field current.
- Every purge writes an audit entry before deleting, and logs the instance ID,
slug and document counts.
- Admin's reconciler notices the instance has gone and cleans up its own
`admin_instances` status and `instance_members` rows.
### The kill switch
@@ -195,6 +274,78 @@ It is unset in `deploy/docker-compose.yml` and set to `336h` only in
anything — the same containment rule that keeps `LICENSE_SIGNING_KEY` in exactly
one service in exactly one compose file.
## Phase 3 — accounts, people and membership
### The model
```
Account
├── customer_users the people. account_role: owner | admin | member
└── admin_instances the deployments
└── instance_members which people are on which cloud instance
```
`customer_users` gains `account_role`. Existing rows backfill to `owner` — they
are all account creators today. Owners and admins may invite users, create
instances, and grant instance access; billing stays owner-only. The vocabulary
deliberately matches the control plane's own three roles rather than inventing a
second one.
`instance_members` is new: `{member_id, account_id, instance_id,
customer_user_id, role, control_user_id, created_at}`, unique on
`(instance_id, customer_user_id)`. `role` is the role the projected
control-plane user holds inside the instance.
### Grants project, they do not federate
Granting a user access to a cloud instance creates a real control-plane `users`
row through `cloudprov`, with `auth_source: "hq"` and `hq_user_id` set. The
instance authenticates it exactly as it authenticates any other user, with no
runtime dependency on admin. Revoking deletes that row.
**Self-hosted instances are never projected into.** `POST /api/instances/:id/
members` refuses when `deployment != cloud`, with that as the message. For a
self-hosted instance the account's users exist to manage the licence, and the
instance's own users are managed locally in the customer's own deployment, which
we cannot see and have no business writing to.
Endpoints, all customer-session and all through `ownedInstance` where an instance
is named:
```
GET,POST /api/account/users invite; owner|admin
PUT /api/account/users/:id/role owner|admin; cannot demote the last owner
DELETE /api/account/users/:id owner|admin; revokes every grant first
PUT /api/account/password any user; propagates
GET,POST /api/instances/:id/members owner|admin
PUT /api/instances/:id/members/:uid/role
DELETE /api/instances/:id/members/:uid
```
Invitations reuse `auth.CreateCustomerUser`, which already does the
unverified-row-plus-verification-email dance and already deletes the row if the
email fails to send. A user cannot be granted an instance until verified.
Revoking the last **owner** of an instance is refused, mirroring the control
plane's own `ErrLastOwner`. The check counts control-plane owners for that
instance, so it also sees owners created locally inside the instance.
### Password propagation
The HQ password is the single source of truth for every `hq`-sourced row.
`PUT /api/account/password` rehashes at cost 12, updates `customer_users`, then
has `cloudprov` write the same hash to every control-plane user carrying that
`hq_user_id`. The instance refuses to change an `hq`-sourced user's password
locally, so there is no competing writer.
Propagation is best-effort and retried, on exactly the pattern `inject` already
proves: a failure is logged and flagged, and admin's 15-minute reconciler gains a
pass that compares each `hq`-sourced row's hash against its `customer_users`
source and repairs mismatches. The worst case is a stale password on one instance
for up to fifteen minutes, which is recoverable; failing the password change
because one of three instances was unreachable is not.
## Frontend
### `site/`
@@ -208,33 +359,41 @@ goes — there is no instance yet at this point, and showing one would be a lie.
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`.
`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`.
`SITE_API_URL` still serves the contact form.
### `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.
- `(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 and a live slug preview of the
resulting `<slug>.vantage.hostxtra.co.uk`.
- `(customer)/instances/[id]/` — a members panel: who is on this instance, their
role, add and remove. Absent for self-hosted instances, replaced by a line
saying users are managed inside the install.
- `(customer)/users/` — the account's people, invitations, account roles.
- `(customer)/settings/` — change password, with a note that it applies to every
instance you belong to.
- `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`.
- `lib/api.ts` — the new calls, `"deleted"` on `InstanceStatus`, and an
`AccountRole` type.
### `web/`
No changes. Spec 2's licence banner already covers a lapsed instance.
`settings/instance` gains the read-only treatment for `hq`-sourced users: role
shown, controls disabled, labelled "managed in Vantage HQ" with a link to the
portal. Everything else is unchanged; 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`.
Signup, verify, `site_pending_signups`, `EmailTaken` 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.
@@ -260,48 +419,79 @@ same job the README already describes for existing cloud tenants.
## Testing
Instance creation:
Phase 1, identity:
1. Signup writes nothing to `instances` or `users`; only the emailed link makes
1. The compound index exists and `email_1` is gone after one boot; a second boot
is a no-op.
2. Two users with the same address in different instances can both be created and
both sign in, each landing in their own instance.
3. Two users with the same address in one instance are refused by the index.
4. Local login on a cloud subdomain finds only that instance's user; the same
address on another instance is not reachable from this host.
5. Local login on a bare host with one instance works; with two it refuses with a
named cause rather than picking one.
6. OIDC provisions into the instance from the callback state, and an address
belonging to another instance no longer produces a cross-org error because it
is simply not found — it provisions a new member instead, which is correct.
7. `GetUserByEmail` no longer exists.
Phase 2, creation and lifecycle:
8. 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.
9. Creating an instance produces an instance, an `hq`-sourced owner user, an
`admin_instances` row, an `instance_members` row, a Free licence, and an
injected `license_blob`.
10. The creator can sign in to the new instance with their HQ password.
11. A second Free instance on the same account is refused `409` and writes
nothing.
12. Owner-insert failure rolls the instance back, and rollback refuses an
instance that has users.
13. Licence issuance failure still leaves a signed-in-able instance and flags for
staff.
14. Renew outside the window is refused; inside it, it supersedes, injects and
moves `expires_at` forward by a month plus grace.
15. Renewing a lapsed instance restores it before the reaper takes it.
16. Each notice sends once across a restart.
Lifecycle:
Phase 2, the reaper — the part that must be got right:
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
17. With `FREE_INSTANCE_REAP_AFTER` empty, nothing is ever deleted.
18. An instance with no `license_tier` is never eligible, whatever its age.
19. A Professional instance past expiry is never eligible.
20. 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
21. 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
22. Purging is idempotent — a second run over a half-deleted instance completes
it rather than erroring.
Phase 3, membership:
23. An invited user cannot be granted an instance until verified.
24. A grant creates a control-plane user that can sign in to that instance with
the invitee's HQ password.
25. The same user can hold rows in two instances at once, with different roles.
26. Revoking deletes the control-plane row, and that user can no longer sign in
to that instance while keeping access to the others.
27. Revoking or demoting an instance's last owner is refused, including when that
owner was created locally inside the instance.
28. Granting against a self-hosted instance is refused and writes nothing to the
customer's deployment.
29. A `member` cannot invite, create instances, or grant access.
30. A password change propagates to every linked instance; with one instance's
write forced to fail, the reconciler repairs it within one pass.
31. An `hq`-sourced user's role, deletion and password are refused inside the
instance API, not merely hidden in `web/`.
## Risks
| Risk | Mitigation |
|---|---|
| Dropping `email_1` is one-way and weakens a documented security property | Scoped lookups ship in the same binary that drops the index; the unscoped helper is deleted so it cannot be reintroduced; the compound index restores the equivalent guarantee; rollback plan is forward-only and stated |
| A partial rollout leaves an old service recreating `email_1` | All three services call `EnsureCoreIndexes`; `server-deploy.yml` rebuilds every image per push; the host rollout command already updates all services together |
| 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 |
| Admin's widened control-plane write access grows further | Confined to `cloudprov`, which writes instances and users and nothing else; `inject` untouched; `CLAUDE.md` updated to say so |
| Password propagation leaves an instance stale | Reconciler pass compares and repairs; worst case is fifteen minutes; the instance refuses local changes so there is no competing writer |
| A projected user is edited in both places | `hq`-sourced rows are refused by the instance API, not merely hidden in the UI |
| 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 |