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