Two bugs, one symptom: signup created an account and a customer_user but
no verification email ever arrived.
net/smtp.SendMail only speaks STARTTLS. Against a port-465 server, which
expects a TLS handshake immediately, it never delivers. The transport now
wraps the connection before speaking SMTP on 465, exactly as
sitesvc/internal/mail already did — the two are duplicated, so change both
or consolidate into shared/. Also adds Date and Message-ID, whose absence
gets a message scored as spam, and a 15s deadline on the conversation.
The rollbacks ran on the HTTP request's context. A stalled mail server
holds the request until the browser gives up, which cancels that context
and turns both rollbacks into silent no-ops — stranding the exact rows
they exist to remove. They now run detached with their own timeout, and
log when they fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without this the row stays active forever, the reconciler re-logs the
same miss every fifteen minutes, and the lifecycle sweep keeps emailing
about an instance that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hourly sweep marks expired Free instances lapsed and sends at most one
notice per instance per pass, most urgent first, recorded on the document
so a restart cannot re-send.
Deletion warnings are suppressed when the reaper is off. Promising a
deletion that will never happen is a lie, and a scarier one than silence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Admin's second and final write path into the control plane. It creates
instances and users and nothing else; inject still owns exactly three
licence fields and is untouched.
The owner's password hash is copied from the HQ account, not shared. The
two diverge on the next password change, which is accepted: propagating a
hash across two databases is worse than two passwords that started equal.
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>
Adds REDIS_USERNAME and REDIS_PASSWORD. Both are optional, so an
unauthenticated instance still works unchanged. Redis 6+ ACL auth takes
both; a legacy requirepass instance takes the password with an empty
username, which is what go-redis needs to send single-argument AUTH.
Admin now defaults to the external Redis at 10.10.10.2:6379 rather than the
compose-local one, and no longer declares depends_on: redis -- it is not
starting that container any more. The base stack keeps its own Redis for
`server`, which still has no auth support.
Also fixes SMTP_PASSWORD in the admin block: it read `$SMTP_PASSWORD:-}`
rather than `${SMTP_PASSWORD:-}`, which appended a literal `:-}` to the
password and would have failed SMTP auth at the first verification email.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 1.26.4 floor meant a base toolchain of 1.26.2 could not load the
workspace at all: the terminal worked only because GOTOOLCHAIN=auto
silently swapped in a downloaded 1.26.4, and gopls -- which does not get
that switch -- failed every packages.Load with
go: go.work requires go >= 1.26.4 (running go 1.26.2)
Nothing needed the patch-level floor. agent/go.mod already declared plain
go 1.26, so this makes the workspace uniform rather than introducing a new
convention, and CI is unaffected because golang:1.26 is already newer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StartReconciler only fired on its 15-minute ticker, so nothing reconciled
until a full interval had passed and restarting admin repaired nothing.
Injection failures are most likely around a deploy or a crash, which is
exactly when the backstop was asleep -- a paying customer could sit
read-only for 15 minutes with the repair already computable. A restart is
now also a supported way to force reconciliation.
Found by the plan's own Step 8, which assumed this behaviour: verified by
tampering with a control-plane blob, confirming the instance went invalid,
and watching the boot pass restore it (checked 1, repaired 1).
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>
Mirrors the pattern sitesvc already proves: 32 random bytes, only the
SHA-256 hash stored, a 24-hour expiry, and the token cleared on use -- so a
leaked database yields no working links.
Unverified login returns a distinct "verify your email address first" rather
than the generic error. The address is already known to be theirs, so there
is nothing to disclose and that is the only useful thing to say.
Licence blobs are emailed inline. A blob is signed public data, not a
secret: it is useless on any instance other than the one it names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cloud customers sign in with the control-plane credentials they already
hold, so there is no second password to manage. Two accepted consequences,
documented at the handler: their control-plane password now also unlocks
billing, and only role owner may sign in -- admin and member are refused
because billing is an owner concern.
A self-hosted customer_users row wins over a control-plane user with the
same address, so the precedence is chosen rather than emergent.
Adds the reads of control users this needs; the write surface is still one
UpdateOne on instances.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One Redis session store and one cookie for all three identities. Staff
login returns the same error for every failure mode and spends a bcrypt
comparison against a dummy hash when no user exists, so neither the message
nor the timing confirms which addresses have accounts.
Staff users are created only by adminctl. There is no signup endpoint: a
licensing authority that can be joined over the internet is not one.
Pins gin and go-redis to the versions server/ already uses rather than the
latest tidy would pick.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Injection is a single UpdateOne of three licence fields, so it is
idempotent and safe to re-run. The control plane caches licence state for
60 seconds, so an injected licence takes effect within a minute with no
restart.
Deliver never fails its caller. The reconciler, not the issuance path, is
what actually guarantees a cloud instance ends up holding the licence admin
says it holds -- injection at issue time is best-effort and this is the
backstop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue is the only place that signs. It records the licence, supersedes its
predecessor and updates the instance -- but deliberately does not deliver.
The ordering matters: a licence recorded but not delivered is recoverable,
because the customer can download it. A licence delivered but not recorded
is a support mystery with no paper trail.
Free stays cloud-only through one comparison of plan against instance
deployment, not a flag. Renewals reset relink_count because the cap is per
term.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds admin's own documents, its unique indexes and the plan seed from
shared/license.
Licences are append-only -- a renewal writes a new row and supersedes the
old one -- because the history is the support tool. Plans are seeded with
$setOnInsert only, so a redeploy never stamps over staff edits to limits,
features or Paddle IDs.
admin_instances.instance_id unique is a correctness property, not an
optimisation: without it two customers could both claim one self-hosted
UUID and both be issued licences for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the admin module: env config with fail-fast validation, two MongoDB
connections (its own vantage_admin database plus a narrow path into the
control plane), the boot sequence and the image.
Config refuses to start without a signing key, and both Mongo URIs must
name their database inline -- admin talks to two databases, so a bare
MONGO_DB would be ambiguous about which.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>