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>
Existing cloud instances get licensed by hand through the admin UI instead of
an automated backfill. That needs POST /api/staff/instances, which nothing else
provided — without it there is no way to attach an existing cloud instance to
an account.
go build inside the Docker image runs outside the workspace, so server/go.sum
needed the hyperboloide/lk entry that GOWORK resolution was supplying locally.
Caught by the image build, not by go build.
Monitor execution and the workflow runner are deliberately unguarded: billing
state must not take away a customer's ability to know their infrastructure is
on fire, and killing a run midway leaves a half-configured server.
The plan called for a server-limit check in gRPC Register. Left out: a server
row only comes from CreateServer, which already checks the cap, so counting in
Register counts the caller itself and would reject a legitimate agent at
exactly the cap.
RequireActiveLicense is mounted on the /api group so new routes are gated by
where they live. GET /api/servers/new is named explicitly: it mints a
pre-registration token, so it mutates despite the method.
hyperboloide/lk signs with ECDSA P-384 and SHA-256, not ed25519, and encodes
keys as base32 rather than hex. Probed in task 1 of the licensing-core plan.
Design is unaffected — only the prose was wrong.
Probed the library before building against it. Two corrections to plan 1:
- PublicKey.ToB32String() returns one value, not (string, error)
- The scheme is ECDSA P-384 with SHA-256, not ed25519 as the spec and plan
claim. Design is unaffected; the prose needs fixing.
- 2026-07-24-licensing-core.md: 7 tasks. lk payload, offline verify, the
trusted key slice, the noSign build tag, and lkctl for issuing by hand.
- 2026-07-24-instance-licensing.md: 10 tasks. Licence on the instance
document, cached runtime state, deny-by-default mutation gate, feature
gates, service-layer limits, settings UI, and migration 0005 to
grandfather existing cloud instances.
Plan 2 opens by finishing the Org to Instance rename: 18 private identifiers
survived plan 0b's sweep. Nothing functional, but the file that gains the
licence cache is one of the two still carrying the old names.
Spec index updated with plan links and shipped status.
Shared Go module extraction (spec 0a) and the Org to Instance rename (spec 0b).
- shared/ module holds the documents and provisioning rules the control plane
and sitesvc both write; sitesvc's hand-copied duplicates are deleted
- Org becomes Instance everywhere, including the org_id field on all 17
tenant-scoped collections, via migration 0004_org_to_instance
- cmd/rename-rollback reverses the migration
- Go images now build from the repo root so the replace directive resolves
Migration verified against a seeded legacy fixture: lossless, tenant-isolated,
idempotent, resumable and reversible. NOT yet rehearsed against a production
snapshot, which plan 0b requires before deploying.
Two defects found by running migration 0004 against a seeded legacy database.
A unique index on org_id treats a missing org_id as null. Renaming the field
strips it, so the second document collided and the whole update failed:
E11000 duplicate key error collection: instance_oidc index: org_id_1
dup key: { org_id: null }
The index cleanup therefore has to run BEFORE the field rename, not after.
rename-rollback needs the symmetric step for instance_id, or reverting hits
the same wall.
The detection also silently matched nothing: the driver decodes an index key
document as bson.D, not bson.M, so the type assertion always failed and no
index was ever dropped. IndexKeyedOn now handles both.
The signup form now posts instance_name, matching sitesvc.
Also restores site/next-env.d.ts. Its /// <reference> directives had been
stripped, which removed the Next.js type environment and failed the build
with "Cannot find name 'Promise'". Same cause as the agent's missing build
constraint.
The signup form's JSON field becomes instance_name, and the pending-signup
document field with it. That collection is sitesvc-private and expires after
24 hours, so no migration is needed, but in-flight signups written before the
deploy will fail verification.
Adds migration 0004_org_to_instance, the ScopedCollections list, the
AssertNoScopedCollectionMissed boot check, and moves EnsureAuthIndexes into
its own file.
Two ordering constraints the rename exposed, both now enforced and commented:
- 0004 must run BEFORE EnsureAuthIndexes. The index builder creates
instances.slug, which would create an empty instances collection and make
0004 refuse to rename orgs onto an existing target.
- Migrations 0001 to 0003 run BEFORE 0004 and still read and write org_id, so
they use a private legacyOrg struct rather than shared/models.
Unrelated to the shared module work; pre-existing on main.
"_other" is not a GOOS suffix, so without the constraint this file compiled
on Linux alongside collect_linux.go and redeclared collect. Linux agent
builds failed, which agent-release.yml depends on.
Also pins the explicit shared require in both go.mod files. `go mod tidy`
in workspace mode drops it, which breaks the Docker build where no
workspace exists.
- 2026-07-24-shared-module.md: 8 tasks, extract the shared Go module
- 2026-07-24-instance-rename.md: 9 tasks, Org -> Instance including the
database field, with migration 0004 and a rollback command
No automated tests per instruction; verification is by compiler, grep, and
manual end-to-end plus production-snapshot rehearsal. Spec testing sections
updated to match.