The catalogue row alone was not enough; the feature was invisible in
three places and mislabelled in a fourth.
PlanConfigurator rendered every key that was not "console" as "Single
sign-on", so the staff checkbox granting vulnerability scanning was
labelled single sign-on. Feature wording was duplicated between the staff
configurator and the purchase form and the copies had drifted, so it now
lives in adminsite/lib/features.ts and both read from it.
The customer licence panel showed raw keys; it now labels them.
Pricing gains a comparison row. The add-on block with a monthly price is
deliberately NOT added: that is a pricing decision, and the Paddle price
IDs for the new catalogue rows have to be pasted in before it can be sold
anyway.
Adds license.FeatureVulnScanning as the one name for the feature and a
catalogue row per deployment/tier, following console and oidc: features
are opt-in per customer, so no plan bundles it.
Documents the subsystem in CLAUDE.md, including that ScopedCollections is
the canonical registry instance deletion derives from — there is no
separate deletion list, which the plan had wrong.
sweepAwaitingLink chases paid-but-unlinked self-hosted placeholders at 24h/72h.
claimPlaceholderLink rewrites a placeholder's identity to the install's real
UUID, re-points the subscription, and issues from it — linking and claiming are
one call because the payment already happened. staffBillingHealth surfaces
failed webhook handlers and unlinked paid placeholders.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
created/updated/activated fold into 'make the world match current state', so
out-of-order delivery is correct by construction. A confirmed subscription
promotes desired->granted and signs from granted only. Cancel and past-due touch
only status; the licence runs to expiry. IssueForInstance backstops a
self-hosted placeholder that is linked after payment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Client is a thin REST client (net/http) rather than the vendor SDK: the surface
we need is two calls, and a hand-rolled client has no version-drift risk and no
dependency in go.sum. All Paddle wire shapes live only in http.go.
PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET are boot-required — an unverified
webhook endpoint is one anyone can issue licences through. paddle_events carries
a unique index on event_id for webhook idempotency.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Boot ran SeedPlans before Backfill, so the fresh (self_hosted, professional)
seed row was inserted before pass 3 tried to rename the legacy self_hosted TIER
row into it — colliding on deployment_tier_unique and failing boot.
The plan re-key moves to MigrateLegacyPlans, called before SeedPlans so the
rename lands first and the seed no-ops on it. It is also self-healing: on a
database a crashed boot already seeded (self_hosted, professional) into, the
legacy row can no longer be renamed onto it, so it is dropped instead — its
instances are re-tiered and re-entitled from the surviving professional row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1.
Admin: plans re-keyed on (deployment, tier); new catalogue collection holds
every Paddle price ID (one row per priceable component); new entitlements
collection holds desired beside granted. admin/internal/catalogue owns both
folds — entitlement to licence limits, and entitlement to Paddle line items —
so the base allowance is subtracted in exactly one place. licensing.Issue now
snapshots the instance's granted entitlement, never desired. Free is enforced
per account AND deployment. Staff endpoints for plans, catalogue and
entitlements; Free self-hosted can be claimed and renewed on its annual term;
the reaper stays cloud-only.
Server: enforces the monitor cap, audit-log retention (daily sweep, skips
Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are
filled from the seed plan at the single decode site so old blobs never read as
zero.
Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance
screen, and a catalogue-driven PlanConfigurator mounted on the staff instance
page. web shows monitors, audit retention and support level on the licence page.
Docs: CLAUDE.md, spec index and plan 5 preamble updated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Free has no features, so a nil Go slice served "features": null. The
portal's type said string[], called .length on it, and took the page down.
The guarantee lives on a named Features type with its own MarshalJSON
rather than at each of the six places a licence or plan is serialised,
because the seventh is the one that would have been forgotten. Applying it
at marshal time also fixes rows already holding null in Mongo, which a
write-side fix alone would not.
Same class, found while checking the rest: staffListInstances built its
expiring filter from a nil []string, marshalling to $in: null, which Mongo
rejects — so the quiet week when nothing is expiring is precisely when the
staff Operations page's own query would have failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The members panel is absent for self-hosted instances rather than disabled:
the backend refuses those, and a panel rendering controls the server will
reject is a panel that lies.
/auth/me now reports the caller's account role, so the UI hides what the
backend would refuse rather than discovering it in an error toast.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its own package rather than a pass inside inject: inject writes three
licence fields and nothing else, and that narrowness is what makes admin's
reach into the control plane reviewable.
Repairs by copying HQ's hash, not by re-hashing — two bcrypt hashes of one
password differ by salt, so a re-hash would never converge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Best-effort by design: refusing the change because one instance was
unreachable would leave the customer holding the password they were trying
to replace. A failure is flagged and hqsync repairs it.
Also corrects two pieces of copy this makes false — CreateInstance's doc
comment and the instance-ready email both claimed the two passwords
diverge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A grant writes a real control-plane user; the instance_members row is only
admin's index of it, which is why a failed insert unwinds the projection.
Self-hosted instances refuse all three mutations: their users live in a
deployment we cannot see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An invitation carries no password. The HQ password is what signs someone
into every instance they are granted, so a password the inviter chose would
be a shared credential to all of them — the invited row has an empty hash,
which cannot authenticate, until /accept-invite sets one.
Removing a person revokes every projected instance user first, and refuses
outright if any of those is an instance's last owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A grant is a real control-plane users row with auth_source hq, not a
federation shim: the instance authenticates it with no runtime dependency
on admin. CountOtherOwners counts control-plane owners so a locally-created
owner satisfies the last-owner rule too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2 created cloud instances without recording who owns them on this
side, because the collection did not exist. The boot backfill reconstructs
one member row per instance from the hq-sourced control-plane owner, and
marks every existing customer_user an owner — they all created their own
account.
Backfill lives in models rather than db: db is the connection layer and
models already imports it for SeedPlans, so db -> models would cycle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>