Available from seven days before expiry and, deliberately, at any point
after it up to deletion, so the same button rescues a lapsed instance.
Renewal is manual because it is the entire reclaim signal: an instance
nobody renews is one nobody is using, which is what makes reaping safe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Provisions the control-plane instance and its owner, records the
admin_instances row, issues and injects a Free licence, and emails the
customer where it is and when it expires.
Licence issuance and email cannot fail the request. The instance exists
and the customer can sign in; rolling back something they can already see
would be worse than shipping it unlicensed for staff to fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HQ sign-in names no instance, so a lookup of control-plane users by email
alone cannot be scoped — and users.email is no longer globally unique, so
it would return an arbitrary match. Every customer authenticates against
customer_users instead.
Legacy cloud customers get an HQ login from staff via the new
POST /api/staff/accounts/:id/users, alongside the manual instance attach
the spec README already describes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blob is shown inline as well as offered as a file, because a licence is
signed public data bound to one instance -- useless anywhere else -- and a
blocked download must never leave a paying customer stuck. Admin now returns
it to its owner for the same reason.
Relink shows the remaining allowance from the backend's max_relinks rather
than a hardcoded 3, and at zero it disables and says to contact support
instead of failing at the API.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the rest of what spec 4's screens need. Account search now also
matches a Paddle customer ID and resolves an instance UUID to its owning
account -- a support email often contains a UUID and nothing else, and the
old search returned nothing for it.
GET /api/staff/instances/:id is the "why did this stop working" screen's
data: the instance, its account, its whole licence history newest first, and
whether the control plane currently holds the blob we think it holds.
Injection state is reported only for cloud, because for self-hosted the
customer holds the blob and there is nothing for us to have written.
Account detail gains subscriptions, customer users and its own audit trail.
No secret leaves: the password hash and both verify-token fields are json:"-".
The control-plane write surface is unchanged -- still exactly one UpdateOne
of three licence fields in inject.go, with reads everywhere else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds GET /auth/me so the admin site's route guards can know who is signed
in, POST /auth/signup for self-hosted customers, and max_relinks on the
account payload so the UI never hardcodes a rule the backend enforces.
Signup follows sitesvc's proven shape: honeypot answered as success, a
generic 201 when the address already exists, and nothing usable until the
emailed link is opened.
Also fixes a lockout found while verifying it. When the verification email
failed, the account was rolled back but the customer_users row survived --
an orphan that can never be signed in to and that holds the unique index on
email, so every later signup with that address got a cheerful 201 and the
customer was locked out of their own address with no visible error.
CreateCustomerUser now undoes its own insert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the service -- this is the first commit where the whole thing
compiles and serves.
staffCreateInstance adopts a cloud instance that already exists in the
control plane, taking its name and slug from there and refusing when no
such instance exists: an admin row pointing at nothing would issue licences
nobody can use. Adopt then issue is how the existing read-only cloud
instances get licensed.
staffRelink has no attempt cap. The customer-facing limit exists to put a
human in front of the fourth attempt, and this is that human.
Editing a plan changes what a tier grants from now on only; existing
licences snapshotted theirs at issue time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every customer handler that names an instance resolves it through
ownedInstance, which returns 404 rather than 403 for another account's
instance -- a 403 confirms the instance exists, which is an existence
oracle over customer data.
The unique index on admin_instances.instance_id, not the pre-check, is what
actually prevents two accounts owning one instance. Relink issues a
replacement covering the REMAINING term, so it cannot be used to extend a
subscription, and the old licence is not revoked because offline
verification has no revocation -- its instance binding is what stops it.
The route table lands with the staff handlers in the next commit so every
commit builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>