Commit Graph
17 Commits
Author SHA1 Message Date
mrhid6andClaude Opus 5 cf318470b8 feat(admin): drop the unscoped control-plane login branch
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>
2026-07-26 12:55:44 +01:00
mrhid6 f8cd909828 doc: Doc updated for cloud instance creation.
Server Deploy / deploy (push) Successful in 2m16s
2026-07-25 22:54:04 +01:00
mrhid6andClaude Opus 5 92ac1eeb62 feat(adminsite): licence delivery, paste instructions and relink
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>
2026-07-25 21:06:21 +01:00
mrhid6andClaude Opus 5 55526263a0 feat(admin): staff instance detail, subscriptions and richer search
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>
2026-07-25 20:50:11 +01:00
mrhid6andClaude Opus 5 4bb7400b8e feat(admin): session probe, self-hosted signup and the relink cap
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>
2026-07-25 20:48:09 +01:00
mrhid6andClaude Opus 5 58c37bf81b feat(admin): support authenticated Redis, and point at the external server
Server Deploy / deploy (push) Successful in 2m14s
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>
2026-07-25 19:50:19 +01:00
mrhid6andClaude Opus 5 946a748038 chore: declare go 1.26 rather than 1.26.4 across the workspace
Server Deploy / deploy (push) Successful in 5m58s
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>
2026-07-25 19:33:58 +01:00
mrhid6andClaude Opus 5 b7221d8111 fix(admin): reconcile once at boot, not only on the ticker
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>
2026-07-25 19:21:16 +01:00
mrhid6andClaude Opus 5 268134d821 feat(admin): staff API and the route table
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>
2026-07-25 19:11:04 +01:00
mrhid6andClaude Opus 5 c829cc41d9 feat(admin): linking, relink and the scoped customer API
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>
2026-07-25 19:09:13 +01:00
mrhid6andClaude Opus 5 07a3756b18 feat(admin): self-hosted customer accounts with email verification
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>
2026-07-25 19:07:47 +01:00
mrhid6andClaude Opus 5 769839a70d feat(admin): cloud owner login against the control plane
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>
2026-07-25 19:06:23 +01:00
mrhid6andClaude Opus 5 480a578deb feat(admin): sessions, staff auth and adminctl
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>
2026-07-25 19:05:28 +01:00
mrhid6andClaude Opus 5 a636a48e07 feat(admin): cloud injection and the 15-minute reconciler
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>
2026-07-25 19:02:45 +01:00
mrhid6andClaude Opus 5 427191b14c feat(admin): licence issuance with plan snapshots and supersession
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>
2026-07-25 19:01:47 +01:00
mrhid6andClaude Opus 5 b8fcf89ee7 feat(admin): documents, indexes and the plan seed
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>
2026-07-25 19:00:37 +01:00
mrhid6andClaude Opus 5 64eac6dbc7 feat(admin): module skeleton, config and two database connections
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>
2026-07-25 18:58:44 +01:00