InitRedis now takes a username and password, read from REDIS_USERNAME and
REDIS_PASSWORD, matching what admin has always done. Both empty keeps an
unauthenticated Redis working; a password with an empty username is what a
legacy requirepass instance needs, since go-redis then sends AUTH with one
argument instead of two.
This is what lets a Kubernetes install point at a managed Redis instead of
the bundled one.
Session.Close now closing its own accepted conn (from the prior fix wave)
made net.ErrClosed on the guacd-side reader indistinguishable from a real
remote failure, so a normal browser-tab close could race the handler's
defer and intermittently log console.proxy_failed on a healthy session.
Add a closing flag, set before Close's sync.Once body actually tears
anything down, that setReason respects -- a deliberate local teardown can
no longer produce or race in a failure reason, while Close's own explicit
reason argument still wins normally.
- Arm the unclaimed-relay watchdog in NewSession rather than Serve, so an
agent that never opens its ProxyStream is bounded to 10s and reports
reason "agent_timeout", per the design spec's failure-mode table.
- Session.Close now also closes the accepted net.Conn (stored via setConn),
so ConsoleProxy.Close() is an unconditional kill of the whole relay chain
instead of only closing an already-idle listener.
- Emit console.proxy_failed and end the console session from a defer in
consoleTunnel guarded on relay.Reason(), since guac's OnDisconnect never
runs when the connect callback errors -- which is the path every relay
failure this feature introduces takes. Update the two docsite
troubleshooting rows to match what the audit event can now actually show.
Every console session now rides the agent's outbound gRPC connection
instead of a direct guacd-to-target dial, so it works for servers
behind NAT and now requires a live agent (409 agent_offline
otherwise). Documents PROXY_ADVERTISE_HOST / PROXY_LISTEN_HOST and
corrects reachability claims across the docsite and CLAUDE.md.
Docusaurus 3 docs-only site at docsite/, served statically by nginx under
/docs on the marketing host. Covers getting started (self-hosted install
through first server and first licence), the control plane, Vantage HQ,
a reference section and operations.
Wired into docker-compose.site.yml as docsite (3005:80) and into the
image build workflow, rebuilding on its own directory only. Never added
to the self-hosted compose file.
Purchase flow: name -> placeholder -> configure via the shipped PlanConfigurator
-> Paddle overlay with custom_data -> paste install UUID to link and issue.
lineItemsFor mirrors the Go catalogue.LineItems/billable exactly (base included
in exactly one place). ManageBillingButton opens the hosted portal. Paddle token
and env are baked into the build, never fetched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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.