diff --git a/.gitignore b/.gitignore index 5519071..d9c7a05 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ node_modules dist build .env -docs +docs/* +!docs/superpowers/ .superpowers installer/vantage-agent-windows-amd64.exe installer/*.msi diff --git a/docs/superpowers/specs/2026-07-24-admin-backend-design.md b/docs/superpowers/specs/2026-07-24-admin-backend-design.md new file mode 100644 index 0000000..dcd3de2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-admin-backend-design.md @@ -0,0 +1,410 @@ +# Spec 3 — Admin Backend + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`) +Ships: with spec 4 (the site it serves). Blocks specs 4 and 5. + +## Context + +A fourth Go service, `admin/`, owning customers, instances, licences and +subscriptions. It is the only service that holds the signing key. + +Its data model separates two things the control plane deliberately does not know +about: + +- **Account** — a paying customer. Holds a Paddle customer, a billing email, and + one or more instances. +- **Instance** — one deployment. Cloud instances mirror a control-plane + `Instance` row; self-hosted instances exist only here, because the customer's + database is theirs and we cannot see it. + +## Goals + +1. Issue, store and re-issue licences, with full history. +2. Inject licences into cloud instances. +3. Serve both staff and customers, with the right things hidden from each. +4. Never be a runtime dependency of a Vantage instance. If admin is down, + every instance keeps working; only purchasing and renewals stop. + +## Non-goals + +- The UI. Spec 4. +- Paddle. Spec 5. This spec defines the `subscriptions` table and the issuance + functions that spec 5's webhooks call, and nothing more. +- Rebuilding billing management. Card changes, invoices and cancellation go to + Paddle's own customer portal. + +## Design + +### Module + +``` +admin/ +├── go.mod # replace => ../shared +├── cmd/main.go +└── internal/ + ├── api/ # gin handlers + ├── auth/ # staff, cloud-customer and local-customer sessions + ├── db/ # two connections: admin DB and control-plane DB + ├── models/ # admin-owned documents + ├── licensing/ # issuance, renewal, relink + ├── inject/ # control-plane writes + ├── mail/ # licence delivery + └── paddle/ # spec 5 lands here +``` + +Port `8083`. In `deploy/docker-compose.site.yml` only — like sitesvc, admin is +**excluded from the self-hosted deployment**. A self-hosted customer runs +instances, not the licensing authority. + +### Two database connections + +The service holds two: + +- `ADMIN_MONGO_URI` — its own database, `vantage_admin`. Sole owner. +- `CONTROL_MONGO_URI` — the control plane's database, used to write licence + fields onto cloud instance documents and to authenticate cloud customers. + +The control-plane connection uses the `Instance` and `User` structs from +`shared` (spec 0a). This is what makes direct writes safe: there is no admin-side +copy of the document shape to drift, which is the coupling hazard sitesvc used to +carry. + +Admin's control-plane access is **narrow by construction**: it reads +`instances` and `users`, and it writes exactly three fields on `instances`. The +Mongo credential it is given should be scoped to that where the deployment allows +it. It must never write to any other collection. + +### Data model + +```go +type Account struct { + ID bson.ObjectID + AccountID string // uuid + Name string + BillingEmail string + PaddleCustomerID string // empty until first checkout + Status string // active | suspended + CreatedAt time.Time +} + +type Instance struct { + ID bson.ObjectID + InstanceID string // for cloud: equals the control-plane instance_id + // for self-hosted: the UUID the customer pasted + AccountID string + Name string + Slug string // cloud only; the subdomain label + Deployment string // cloud | self_hosted + Tier string + Status string // awaiting_link | active | lapsed | cancelled + CurrentLicense string // licence ID + RelinkCount int // reset each term + CreatedAt time.Time +} + +type License struct { + ID bson.ObjectID + LicenseID string + InstanceID string + AccountID string + Tier string + Deployment string + Limits license.Limits // snapshot + Features []string // snapshot + IssuedAt time.Time + ExpiresAt time.Time + Blob string + SupersededBy string // licence ID, when replaced + IssuedBy string // staff user, "system", or "paddle:" + Reason string // new | renewal | tier_change | relink | manual +} + +type Subscription struct { + ID bson.ObjectID + SubscriptionID string + AccountID string + InstanceID string + PaddleSubscriptionID string + PaddlePriceID string + Tier string + Term string // monthly | annual + Status string // active | past_due | cancelled | awaiting_link + CurrentPeriodEnd time.Time +} + +type Plan struct { + Tier string + Name string + Deployment string + Limits license.Limits + Features []string + PaddleProductID string + PaddlePriceIDs map[string]string // "monthly" | "annual" + Active bool +} +``` + +Collections: `accounts`, `admin_instances`, `licenses`, `subscriptions`, +`plans`, `staff_users`, `customer_users`, `admin_audit`. + +Unique indexes: `accounts.account_id`, `admin_instances.instance_id`, +`licenses.license_id`, `subscriptions.paddle_subscription_id`, `plans.tier`, +`staff_users.email`, `customer_users.email`. + +`admin_instances.instance_id` unique is load-bearing: it is what stops the same +self-hosted UUID being linked to two accounts. + +**Licences are append-only.** A renewal writes a new row and sets +`SupersededBy` on the old one. Nothing is ever edited or deleted. When a support +question arrives about why a customer's instance stopped working on a given +date, the answer is in the table. + +`plans` holds tier contents so they change without a deploy, seeded from the +table in spec 1. Every issued licence snapshots the plan, so editing a plan never +changes an existing licence — the same rule as `workflow_runs.steps_snapshot`. + +### Issuance + +```go +func Issue(ctx, instanceID, tier, term, reason, issuedBy string) (*models.License, error) +``` + +1. Load the instance and its account. +2. Load the plan for `tier`; refuse if `plan.Deployment != instance.Deployment`. + **This is the check that makes Free cloud-only** — Free's plan is + `deployment: cloud`, so it can never be issued to a self-hosted instance. +3. Build the payload with the instance's UUID bound in, `ExpiresAt` from the term + plus a **3-day grace** so a renewal webhook arriving slightly late does not + create a gap. +4. Sign with `LICENSE_SIGNING_KEY`. +5. Insert the licence row; set `SupersededBy` on the previous one; update + `instance.CurrentLicense` and `instance.Tier`. +6. If cloud, inject. If self-hosted, email the blob and make it downloadable. +7. Write an `admin_audit` entry. + +Steps 5 and 6 are not transactional. Order matters: **record first, deliver +second.** A licence recorded but not delivered is recoverable — the customer +downloads it. A licence delivered but not recorded is a support mystery. + +### Free tier rule + +One Free instance per account, enforced in `Issue`: refuse a second Free instance +for an account that already has one that is not `cancelled`. Additional +instances must be paid. + +### Injection + +```go +func InjectCloud(ctx, instanceID string, lic *models.License) error +``` + +Writes `license_blob`, `license_tier`, `license_expiry` onto the control-plane +`instances` document via a single `UpdateOne`. Idempotent, retryable, and safe to +re-run. + +Retries three times with backoff; on final failure the licence stays recorded and +`instance.Status` is set to `active` regardless, with the failure logged and +surfaced as a staff alert. A **reconciliation job runs every 15 minutes**, +comparing each cloud instance's `CurrentLicense` against the blob actually stored +in the control plane, and re-injecting on mismatch. That job, not the webhook, is +what guarantees eventual consistency. + +The control-plane instance caches licence state for 60 seconds (spec 2), so an +injection takes effect within a minute without a restart. + +### Self-hosted linking + +The flow, end to end: + +``` +Customer runs /setup on their own install → instance UUID generated and shown +Customer buys Self Hosted in the admin site → subscription created, + status awaiting_link +Customer pastes the UUID into the admin site → admin_instances row created, + status active +Admin issues the licence with that UUID bound in +Customer downloads the .lic file or copies the blob +Customer pastes it into /settings/license on their install +``` + +Validation on link: the UUID must parse as a UUID, must not already exist in +`admin_instances`, and must not collide with a cloud instance ID. A duplicate +returns "That instance ID is already linked to an account" without revealing +which — it is a small enumeration surface but there is no reason to leave it +open. + +### Relink + +A rebuilt server has a new UUID. `POST /api/instances/:id/relink` with the new +UUID: + +- Allowed **3 times per term**, `RelinkCount` reset on renewal. +- Updates `admin_instances.instance_id`, issues a replacement licence for the + **remaining term** with `reason: relink`, supersedes the old one. +- The old licence is not revoked — it cannot be, offline verification has no + revocation. It simply no longer matches any UUID the customer controls, and its + binding stops it being useful on a different machine anyway. +- Beyond 3, the endpoint returns a message directing the customer to support, and + staff can relink without limit. + +`RelinkCount` is the abuse signal, not the abuse prevention. Its real job is to +put a human in front of the fourth attempt. + +### Authentication + +Three identities, three paths, one session store (Redis, `admin_session` +cookie, 24h). + +**Staff** — `staff_users`, local email plus bcrypt. Full access. Created by CLI +only; there is no staff signup. + +**Cloud customers** — authenticate against the control plane's `users` +collection with the credentials they already use. Admin looks the user up +by email, checks bcrypt, resolves their control-plane instance, then resolves the +account that owns it. + +Two consequences, stated plainly because they are real: + +1. A cloud user's control-plane password now also unlocks billing. Any password + change or compromise has a wider blast radius than before. +2. Only users with control-plane role `owner` may sign in to the admin site. + `admin` and `member` are refused. Billing is an owner concern. + +Mitigations: rate-limit to 5 attempts per email per 15 minutes and 20 per IP per +hour; log every attempt to `admin_audit`; return an identical error for unknown +email and wrong password. + +**Self-hosted customers** — `customer_users`, local email plus bcrypt at cost 12, +created during purchase, scoped to one account. Email verification reuses the +pattern sitesvc already proved: 32 random bytes, only the SHA-256 hash stored, +24-hour expiry, TTL index. + +A single email address could in principle be both a cloud user and a +self-hosted customer user. `customer_users` is checked first; if it matches, that +identity wins. Documented so the behaviour is chosen rather than emergent. + +### API + +Staff: + +``` +GET /api/staff/accounts list, search +POST /api/staff/accounts +GET /api/staff/accounts/:id +GET /api/staff/instances filter by account, deployment, status, expiry +POST /api/staff/instances/:id/issue manual issue or reissue +POST /api/staff/instances/:id/relink no limit +GET /api/staff/licenses full history, filterable +GET /api/staff/plans +PUT /api/staff/plans/:tier +GET /api/staff/audit +GET /api/staff/health/injection reconciliation status and failures +``` + +Customer: + +``` +GET /api/account own account and instances +POST /api/instances/link self-hosted UUID link +POST /api/instances/:id/relink rate-limited +GET /api/instances/:id/license current licence metadata +GET /api/instances/:id/license/download .lic file +GET /api/subscriptions status, next renewal +POST /api/billing/portal Paddle portal redirect (spec 5) +``` + +Every customer handler resolves the account from the session and scopes by it. +The scoping is enforced by a helper every handler calls, not by each handler +remembering — the same deny-by-default reasoning as spec 2's middleware. + +### Configuration + +| Variable | Required | Notes | +|---|---|---| +| `ADMIN_MONGO_URI` | yes | admin's own database; name read from the URI path, refused if absent | +| `CONTROL_MONGO_URI` | yes | control-plane database, for injection and cloud auth | +| `REDIS_ADDR` | yes | sessions | +| `LICENSE_SIGNING_KEY` | yes | ed25519 private key hex. **Boot fails without it** — a licensing service that cannot sign is worse than one that is down, because it looks healthy | +| `PUBLIC_URL` | yes | for verification and licence links | +| `SMTP_*` | yes | licence delivery | +| `ADMIN_ORIGIN` | yes | CORS allow-list | +| `TRUST_PROXY` | no | only behind a proxy that overwrites `X-Forwarded-For` | +| Paddle variables | spec 5 | | + +### Backfill + +Licences issued by `lkctl` during the spec 1–2 period exist only as blobs. +A one-shot `admin backfill --from=blobs.json` parses each with +`license.Parse`, creates the account, instance and licence rows, and marks them +`reason: manual`. Run once when admin goes live. + +## Testing + +**Issuance:** + +1. `Issue` produces a licence that `license.Verify` accepts for that instance. +2. Deployment mismatch (Free plan, self-hosted instance) is refused. +3. A second Free instance for the same account is refused; a third paid one is + allowed. +4. Renewal supersedes the previous licence and leaves it in the table. +5. The issued licence snapshots the plan; editing the plan afterwards does not + change the issued licence. +6. Grace period: `ExpiresAt` is term end plus 3 days. + +**Injection:** + +7. `InjectCloud` writes all three fields; the control plane then reports `valid`. +8. Injection is idempotent across two calls. +9. Injection failure leaves the licence recorded and flags the instance. +10. The reconciliation job detects a control-plane blob that does not match + `CurrentLicense` and re-injects. + +**Linking and relink:** + +11. Linking an unknown UUID succeeds; linking one already linked is refused. +12. Relink issues a licence for the *remaining* term, not a fresh full term. +13. The fourth relink in a term is refused for a customer and allowed for staff. +14. `RelinkCount` resets on renewal. + +**Auth:** + +15. Cloud owner signs in with control-plane credentials; `admin` and `member` + roles are refused. +16. Unknown email and wrong password return identical errors and timing is not a + meaningful oracle. +17. Rate limits trigger at the documented thresholds. +18. Self-hosted customer cannot sign in before verifying their email. +19. A customer requesting another account's instance gets `404`, not `403` — + no existence disclosure. + +**Scoping:** + +20. Every customer endpoint, called with a session for account A against a + resource of account B, returns `404`. Written as a table-driven test over the + route list so a new endpoint that forgets to scope fails the build. + +## Verification before merge + +1. Full suite green, including the scoping table test (test 20). +2. End to end, cloud: create account → create instance → issue Professional → + confirm the control-plane instance reports `valid` within 60 seconds with no + restart. +3. End to end, self-hosted: run `/setup` on a scratch install, copy the UUID, + link it, issue, download, paste, confirm `valid`. +4. Confirm admin's control-plane credential cannot write to `servers`, `keys` or + any collection other than `instances`. +5. Kill the admin service and confirm every Vantage instance keeps working + entirely normally. + +## Risks + +| Risk | Mitigation | +|---|---| +| Admin becomes a runtime dependency | Verification step 5; instances verify offline and never call admin | +| Signing key exposure | Single service, single variable, never in an image; rotation path from spec 1 | +| Cloud password now unlocks billing | Owner-only, rate-limited, audited, and stated in the release notes | +| Injection silently fails | Reconciliation every 15 minutes plus a staff health endpoint | +| Admin writes outside its remit in the control plane | Narrow code path; scoped Mongo credential; reviewed on every change | +| Self-hosted UUID squatted by another account | Unique index plus a non-disclosing error | diff --git a/docs/superpowers/specs/2026-07-24-admin-site-design.md b/docs/superpowers/specs/2026-07-24-admin-site-design.md new file mode 100644 index 0000000..1a11018 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-admin-site-design.md @@ -0,0 +1,203 @@ +# Spec 4 — Admin Site + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 3 (`admin-backend`) +Ships: with spec 3. Can be developed in parallel with spec 5 once spec 3's API +is stable. + +## Context + +A fifth Next.js app, `adminsite/`, serving two audiences from one codebase: + +- **Staff** — internal operators. Accounts, instances, licence history, plan + editing, injection health, audit. +- **Customers** — their own account, instances, licences and subscription state. + +They share auth plumbing and a component library but almost no screens. The +split is by route group, so a customer route can never accidentally render a +staff view. + +## Goals + +1. A customer can buy, link a self-hosted instance, download a licence, and see + when it expires — without contacting anyone. +2. Staff can answer "why did this customer's instance stop working" in one screen. +3. Nothing about the marketing site or the control-plane UI changes. + +## Non-goals + +- Rebuilding billing management. Card details, invoices, payment methods and + cancellation all deep-link into Paddle's customer portal. +- Server management. This is not a second control plane; there is exactly one + link out to the instance and no data about servers, keys or workflows. +- Public signup for cloud. That stays on the marketing site (moving to admin's + backend in spec 5, but the *form* stays where customers already find it). + +## Design + +### App + +Built exactly like `web/` and `site/`: Next.js 16 App Router, React 18, Tailwind +3, TanStack Query, `output: "standalone"`, `node:26-alpine`, listening on `3000`, +published as `3002`. In `docker-compose.site.yml` only. + +`ADMIN_API_URL` is baked in at build time, as `API_URL` is for `web/`. It must be +**browser-reachable** and must appear in the backend's `ADMIN_ORIGIN`. Getting +this wrong is the single most common deployment failure in this repo's history — +`SITE_API_URL` has the same footgun documented in `CLAUDE.md` — so the app +renders an explicit "not connected" state rather than failing silently. + +``` +adminsite/ +├── app/ +│ ├── login/ +│ ├── signup/ # self-hosted customer account creation +│ ├── verify/ +│ ├── (customer)/ +│ │ ├── page.tsx # account overview +│ │ ├── instances/[id]/ +│ │ ├── instances/link/ +│ │ ├── billing/ +│ │ └── layout.tsx # customer nav, account guard +│ └── (staff)/staff/ +│ ├── page.tsx # operations dashboard +│ ├── accounts/[id]/ +│ ├── instances/[id]/ +│ ├── licenses/ +│ ├── plans/ +│ └── layout.tsx # staff nav, staff guard +├── components/ +└── lib/ +``` + +Route-group layouts do the guarding. A customer session hitting `/staff/*` gets +redirected, not a 403 page — there is nothing to tell them about. + +### Customer screens + +**Overview** — the account, its instances as cards. Each card: name, cloud or +self-hosted, tier, licence state, expiry with days remaining, and a link either +to the instance's subdomain (cloud) or to its licence page (self-hosted). + +Licence state is colour-coded and blunt: green valid, amber under 14 days, red +expired. An expired card says what still works — "servers and monitors are still +running; changes are disabled" — because that is the first thing a worried +customer wants to know. + +**Instance detail** — tier, limits, features, subscription status, next renewal +date. For self-hosted: the linked UUID, a **Download licence** button, the blob +in a copy-to-clipboard box, and step-by-step paste instructions with the target +route named (`Settings → Licence` on their own install). A **Relink** action +showing the remaining allowance ("2 of 3 relinks remaining this term"). + +**Link an instance** — the self-hosted activation screen. Explains where to find +the UUID (shown on `/setup`, and permanently on `/settings/license`), takes the +paste, validates the format client-side, and on success issues the licence and +lands the customer directly on the download. + +The whole flow — buy, link, download, paste — should be completable without +reading documentation. That is the bar for this screen. + +**Billing** — subscription list with status and renewal date, plus a button to +Paddle's portal. Deliberately thin. + +### Staff screens + +**Dashboard** — the operational answers, not vanity metrics: licences expiring +in the next 14 days, subscriptions `past_due`, instances `awaiting_link` for more +than 48 hours, and **failed injections** from the reconciliation job. Each row +links straight to the thing that needs doing. + +**Accounts** — searchable by name, email, Paddle customer ID and instance UUID. +Searching by UUID matters: a support email arrives containing a UUID and nothing +else. + +**Account detail** — instances, subscriptions, customer users, audit trail. + +**Instance detail** — everything about one instance, with the **full licence +history as a timeline**: issued, superseded, renewed, relinked, each with a +timestamp, reason and who did it. This is the screen that answers "why did this +stop working on the 14th". Actions: issue, reissue, relink without limit, and a +live view of the control-plane injection state for cloud instances. + +**Licences** — global history, filterable by tier, deployment, expiry window and +issuance reason. + +**Plans** — edit limits and features per tier. Two guard rails, because this +screen changes what every future customer gets: + +- A confirmation step naming exactly what changes and stating that existing + licences are unaffected until reissued. +- The deployment field is not editable. Moving Free to `self_hosted` would break + the cloud-only rule that spec 1 leans on; changing it is a code review, not a + form field. + +**Audit** — every mutating action, filterable. + +### Design language + +Visually distinct from `web/`. Staff regularly have both open, and a moment of +"which app am I in" before clicking Reissue is worth designing out. Different +accent colour and a persistent environment badge in the header (sandbox or +production, from a build-time flag) — clicking Issue against the wrong Paddle +environment should be hard. + +Shared component patterns with `web/` where they exist; this is not a reason to +invent a second design system. + +### Error and empty states + +- Backend unreachable: a page-level "not connected" state naming + `ADMIN_API_URL`, matching the pattern the marketing site already uses. +- No instances yet: a customer-facing explanation of the two paths — buy cloud, + or buy self-hosted and link. +- `awaiting_link`: a prominent prompt on the overview, since a customer who has + paid and not linked is a customer who has paid for nothing yet. +- Licence download failure: show the blob inline as a fallback so the customer is + never blocked by a file download. + +## Testing + +Component and integration tests with mocked API responses. The repo has no +frontend test setup today; this is where one starts, scoped to the flows that +lose money or leak data when broken. + +1. Customer session on `/staff/*` redirects; staff session reaches it. +2. Instance card renders correctly for each licence state, including expired, + and the expired copy names what still works. +3. Link flow: valid UUID succeeds and lands on download; malformed UUID is caught + client-side; already-linked UUID surfaces the backend's message. +4. Relink shows the remaining allowance and disables at zero with the support + message. +5. Licence download failure falls back to the inline blob. +6. Not-connected state renders when the API is unreachable. +7. Staff dashboard renders each alert category and links to the right resource. +8. Plan edit requires confirmation and shows the "existing licences unaffected" + wording. +9. Instance search by UUID returns the instance. +10. Licence history timeline renders every reason type in order. + +## Verification before merge + +1. Test suite green. +2. Full manual pass, self-hosted purchase to working licence, using only the UI + and no documentation — timed, and if it takes more than five minutes the flow + needs work. +3. Full manual pass, cloud: buy, confirm the licence appears in the control plane + within a minute, confirm the instance's own settings page agrees. +4. Staff pass: find an account by instance UUID, read its licence history, + reissue, confirm the control plane picks it up. +5. Responsive check at mobile width — a customer hit by an expiry email will open + this on a phone. +6. `docker build` from the repo root succeeds and the image runs with + `ADMIN_API_URL` baked in. + +## Risks + +| Risk | Mitigation | +|---|---| +| `ADMIN_API_URL` misconfigured at build | Explicit not-connected state; documented alongside the existing `SITE_API_URL` footgun | +| Staff action taken against the wrong environment | Persistent environment badge; confirmation on destructive actions | +| Customer confused by the self-hosted flow | Step-by-step link screen; five-minute bar in verification | +| Customer session reaching staff data | Route-group guards plus backend scoping (spec 3, test 20). Two layers, because one is not enough for this | diff --git a/docs/superpowers/specs/2026-07-24-instance-licensing-design.md b/docs/superpowers/specs/2026-07-24-instance-licensing-design.md new file mode 100644 index 0000000..6a8e56b --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-instance-licensing-design.md @@ -0,0 +1,353 @@ +# Spec 2 — Instance Licensing and Enforcement + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`) +Ships: independently, with licenses issued by hand via `lkctl`. No admin site +needed. + +## Context + +Spec 1 defines what a license is. This spec makes the control plane hold one, +act on it, and let a self-hosted operator paste one in. + +The guiding rule: **an expired license must never break a running fleet.** Agents +keep their keys, monitors keep watching, alerts keep firing. What stops is +growth and change. A customer whose card fails should be inconvenienced, not +paged at 3am because their monitoring went dark when Vantage decided to sulk. + +## Goals + +1. A license lives on the instance document and is verified on read. +2. Enforcement is deny-by-default: a new mutating route is gated because of where + it is mounted, not because someone remembered. +3. Degraded mode is obvious in the UI and reversible by pasting a valid license. +4. Self-hosted operators get an instance UUID they can hand to the admin site. + +## Non-goals + +- Issuing licenses. `lkctl` (spec 1) or the admin backend (spec 3). +- Any outbound network call. Verification is offline, permanently. +- Per-user or per-role licensing. The unit is the instance. + +## Design + +### Instance identity + +Every install already has an `Instance` document with an `InstanceID` UUID. For +cloud instances this is created by signup; for self-hosted it is created by +`/setup`. + +Change to `/setup`: after bootstrapping the first instance and its owner, the +setup page **displays the instance UUID** with a copy button and the text that +it is needed to activate a license. It is also shown permanently on +`/settings/license`. + +No new identifier is invented. The instance UUID is the licensing identity. + +### Storage + +`shared/models.Instance` gains: + +```go +LicenseBlob string `bson:"license_blob,omitempty" json:"-"` +LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"` +LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"` +``` + +The blob is authoritative. `LicenseTier` and `LicenseExpiry` are a denormalised +cache for listing and for the admin site's queries, rewritten from the verified +payload every time a blob is accepted. Nothing reads them for enforcement. + +`LicenseBlob` is `json:"-"`. It is not a secret in the confidentiality sense — +it is signed public data — but there is no reason to spray it through API +responses. + +### Runtime state + +```go +type State struct { + Status license.State // valid | expired | invalid + Reason string + Tier string + ExpiresAt *time.Time + Limits license.Limits + Features map[string]bool +} +``` + +Resolved by `services.LicenseState(instanceID) State`, cached for 60 seconds +alongside the existing instance cache and invalidated immediately when a blob is +stored. + +Three inputs, in precedence order: + +1. `Instance.LicenseBlob`. +2. `VANTAGE_LICENSE` environment variable, used **only when the instance has no + stored blob**. This lets an automated self-hosted deployment ship a license + without a human pasting one. A blob stored through the UI always wins + afterwards, so an operator is never locked out by a stale environment value. +3. Neither → `Status: invalid`, `Reason: no_license`. + +The verifier is called with `InstanceID` from the instance document and +`Deployment` from `VANTAGE_DEPLOYMENT` (`cloud` on our infrastructure, +`self_hosted` everywhere else, defaulting to `self_hosted`). The default matters: +an operator who removes the variable gets the stricter mode, not the looser one. + +`invalid` and `expired` degrade identically. They differ only in the message. + +### Enforcement + +Three layers, deliberately separate because they answer different questions. + +**Layer 1 — mutation gate.** A gin middleware `RequireActiveLicense` mounted on +the `/api` group, applying to every request whose method is not `GET` or `HEAD`. + +```go +api := r.Group("/api", auth.RequireSession(), services.RequireActiveLicense()) +``` + +Non-`valid` → `403 {"error":"license_required","state":"expired","reason":"..."}`. + +Mounting at the group means **a route added tomorrow is gated by default**. That +is the whole point of putting it here rather than on individual handlers. + +Explicit exemptions, allow-listed by path because they must work in degraded +mode: + +| Route | Why | +|---|---| +| `POST /api/license` | Pasting a valid license is how you recover | +| `POST /auth/*` | Login and logout are outside `/api` already; listed for clarity | +| `DELETE` on any resource | Deleting is how you get back under a limit | +| `POST /api/servers/:id/apply-updates` | Security patching must never be paywalled | + +The `DELETE` exemption deserves emphasis: a customer downgraded to Free with 10 +servers must be able to remove 7 of them. Blocking deletes would trap them. + +**Layer 2 — feature gate.** `RequireFeature(name)` on the route groups that need +it: + +- `console` → `POST /api/console/connect`, `GET /api/console/tunnel` +- `oidc` → `GET,PUT /api/instance/oidc` + +Missing feature → `403 {"error":"feature_unavailable","feature":"console"}`. + +OIDC needs care: `/auth/oidc/start` and `/auth/oidc/callback` are unauthenticated +and outside `/api`. They check the feature directly and, if unavailable, redirect +to `/login?error=oidc_unavailable` rather than returning JSON. **Existing OIDC +sessions are not terminated** — losing the feature stops new SSO logins, it does +not evict people mid-session. + +**Layer 3 — limits.** Enforced in the service layer, because a limit needs a +count that middleware does not have: + +| Limit | Checked in | +|---|---| +| `max_servers` | `services.CreateServer` / `POST /api/servers/new` | +| `max_secret_groups` | `services.CreateSecretGroup` | +| `max_channels` | `services.CreateChannel` | + +`-1` means unlimited. Over limit → `403 {"error":"limit_exceeded","limit":"max_servers","current":3,"max":3}`. + +Counts are of live rows: revoked assignments and deleted servers do not count. + +**Over-limit instances are never truncated.** A Professional instance with 20 +servers that lapses to Free keeps all 20 running; it simply cannot add a 21st. +Deleting resources is always permitted. Silently disabling a customer's servers +because their card expired is not a behaviour this system will have. + +### Background work in degraded mode + +This is where "read-only" needs to be specific, because these paths do not go +through gin at all. + +| Subsystem | Degraded behaviour | +|---|---| +| **Monitor scheduler** | **Keeps running.** Checks execute, incidents open, notifications fire. | +| Monitor create/edit/delete | Blocked by layer 1 (delete exempted). | +| Workflow runner | New runs blocked by layer 1. **In-flight runs finish** rather than being killed mid-step — a half-run workflow is worse than a completed one. | +| Agent `SyncKeys` | Returns the existing desired key set unchanged. Nothing is torn off disk. New assignments cannot be created, so nothing changes anyway. | +| Agent registration | A **new** agent registering against an over-limit instance is refused with a clear message; existing agents re-register freely. | +| Inventory, heartbeat, update reporting | Unaffected. | +| `ApplyUpdatesCmd` | Allowed. Security patching is not gated. | +| ESO secrets read (`GET /api/secrets/:group/values`) | **Allowed.** It is a `GET`, and breaking a Kubernetes cluster's secret sync over a billing state is disproportionate. | +| Log retention sweep, offline sweep | Unaffected. | + +Keeping monitors alive is a deliberate reversal of a stricter earlier draft. It +is the single most important line in this spec: **billing state must not take +away a customer's ability to know their infrastructure is on fire.** + +### API + +``` +GET /api/license any authenticated user +POST /api/license owner only +``` + +`GET` returns: + +```json +{ + "instance_id": "…", + "state": "valid", + "reason": "", + "tier": "professional", + "expires_at": "2027-07-24T00:00:00Z", + "days_remaining": 365, + "limits": { "max_servers": -1, "max_secret_groups": -1, "max_channels": -1 }, + "features": { "console": true, "oidc": true }, + "usage": { "servers": 12, "secret_groups": 4, "channels": 2 }, + "source": "stored" +} +``` + +`usage` is included so the UI can render "12 of 3 servers" honestly when an +instance is over its limit, rather than pretending. + +`POST` takes `{"blob": "..."}`, verifies with the instance's own ID and +deployment mode, and on success stores the blob, refreshes the cache, and writes +an audit event. On failure it returns `400` with the specific reason: + +| Reason | Message | +|---|---| +| `bad_signature` | This licence key is not valid. Check it was copied in full. | +| `deployment_mismatch` | This licence is for Vantage Cloud and cannot be used on a self-hosted install. | +| `instance_mismatch` | This licence was issued for a different instance. Your instance ID is ``. | +| `expired` | This licence expired on ``. | + +An **expired** blob is still stored if it is otherwise valid, so the UI can show +what expired and when. An **invalid** blob is rejected and the previous one kept. + +Rate-limited to 10 attempts per instance per hour. There is no oracle here worth +protecting, but an unbounded verify endpoint is an unbounded CPU endpoint. + +### Frontend + +`useLicense()` hook over `GET /api/license`, cached by TanStack Query and +invalidated after a successful paste. + +- **Banner, persistent, top of every page** when `state != valid`: + - `expired` — "Your Vantage licence expired on ``. Your servers and + monitors are still running, but changes are disabled until it is renewed." + with a link to the admin site. + - `invalid` / `no_license` — "This instance has no valid licence. Add one in + Settings → Licence." +- **Warning banner** in the final 14 days of a valid term, dismissible per + session. +- **Gated features render disabled with an upgrade tooltip, not hidden.** A + customer cannot buy what they cannot see, and a feature that vanishes reads as + a bug. +- **Limit indicators** on the servers, secrets and channels list pages: "3 of 3 + servers used" with the create button disabled at the cap. +- `/settings/license`: current state, tier, expiry, limits with live usage, the + instance UUID with a copy button, and a textarea plus file upload for a new + blob. Owner-only; other roles see the state read-only. + +### Grandfathering existing tenants + +Migration `0005_grandfather_licenses`, cloud only, guarded on +`VANTAGE_DEPLOYMENT == "cloud"`: + +For every instance with no `license_blob`, issue a Professional license expiring +**one year** from the migration date and store it. + +The migration cannot sign — the server has no private key and, per spec 1, no +signing code. So the blobs are **generated ahead of time with `lkctl`** and +supplied to the migration through `VANTAGE_GRANDFATHER_BLOBS`, a JSON map of +instance ID to blob. The migration stores what it is given, verifies each blob +against its instance before storing, and logs any instance it had no blob for. + +Clumsy, and correct. The alternative is putting a signing key in the control +plane, which is the thing this design most wants to avoid. + +Self-hosted installs are not grandfathered. On upgrade they land in `no_license` +and read-only until an operator pastes a key — which is the intended behaviour +for a paid product, and is why the release notes must lead with it. + +## Testing + +**Unit, no database:** + +1. `State` resolution precedence: stored blob wins over `VANTAGE_LICENSE`; + environment used when no blob; neither → `no_license`. +2. Feature map construction from the payload's `Features` slice. +3. Limit comparison with `-1`, with zero, and with a count exactly at the cap. + +**Middleware, with a stub state:** + +4. `GET` passes in every state. +5. `POST`/`PUT`/`DELETE` pass when `valid`, fail `403` when `expired` and when + `invalid` — except `DELETE`, which passes in all states. +6. `POST /api/license` passes when `expired` (the recovery path). +7. `POST /api/servers/:id/apply-updates` passes when `expired`. +8. `RequireFeature("console")` passes with the feature, `403`s without it. +9. **Coverage test:** enumerate every registered route and assert that every + non-`GET` route is either behind `RequireActiveLicense` or on the exemption + allow-list. This test is what stops layer 1 rotting as routes are added. + +**Service layer, against MongoDB:** + +10. `CreateServer` at the cap → `limit_exceeded`; one below → succeeds. +11. Over-limit instance can still `DELETE` a server, and can create again once + back under the cap. +12. Deleted and revoked rows do not count toward limits. + +**Degraded background behaviour:** + +13. Monitor scheduler executes checks for an instance with an expired license. +14. An incident opened during degraded mode still dispatches notifications. +15. `SyncKeys` for an expired instance returns the same key set as before expiry. +16. A new agent registering against an over-limit instance is refused; an + existing agent re-registers successfully. +17. A workflow run in flight when the license expires completes its remaining + steps. + +**API:** + +18. `POST /api/license` with a valid blob stores it and flips state to `valid`. +19. Each rejection reason returns its own message and leaves the stored blob + untouched. +20. An expired-but-well-formed blob is stored and reported as `expired`. +21. Non-owner `POST` → `403`. + +**Migration:** + +22. `0005` stores and verifies supplied blobs, skips instances that already have + one, logs instances with no blob supplied, and is a no-op when + `VANTAGE_DEPLOYMENT != "cloud"`. + +## Verification before merge + +1. Full test suite green, including the route-coverage test (test 9). +2. Manual pass on a scratch instance: issue a Professional license with `lkctl`, + paste it, confirm everything works. Issue one expiring in 60 seconds, wait, + confirm the banner appears, mutations `403`, **monitors keep firing**, and + pasting a fresh license restores normal operation without a restart. +3. Manual pass on the Free tier: confirm the 3-server cap, that console and OIDC + are visibly disabled with upgrade tooltips, and that a 4th server is refused + with a clear message. +4. Confirm a cloud-issued Free license is rejected on a `self_hosted` install + with `deployment_mismatch`. +5. Confirm a license issued for another instance is rejected with + `instance_mismatch` and the message shows the correct local UUID. + +## Rollout + +1. Generate grandfather blobs with `lkctl` for every existing cloud instance. +2. Deploy with `VANTAGE_GRANDFATHER_BLOBS` set; migration `0005` runs. +3. Verify every cloud instance reports `valid`, Professional, one year out. +4. Unset the variable on the next deploy — it is single-use. +5. Release notes for self-hosted must state plainly that upgrading requires a + licence key, and how to get one. + +## Risks + +| Risk | Mitigation | +|---|---| +| A mutating route added later without a gate | Route-coverage test (test 9) fails the build | +| Customer locked out and unable to recover | `POST /api/license` and all `DELETE`s exempt from the gate | +| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch | +| Over-limit customer trapped | Deletes always allowed; existing resources never truncated | +| Clock wrong on a self-hosted host | `Verify` warns on a future `IssuedAt`; documented in the licence settings page | +| Monitoring lost on billing failure | Explicitly designed out — the scheduler ignores licence state | diff --git a/docs/superpowers/specs/2026-07-24-instance-rename-design.md b/docs/superpowers/specs/2026-07-24-instance-rename-design.md new file mode 100644 index 0000000..40b3baa --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-instance-rename-design.md @@ -0,0 +1,225 @@ +# Spec 0b — Org to Instance Rename + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 0a (`shared-module`) +Ships: independently, before any licensing code + +## Context + +The licensing model separates two concepts that the codebase currently conflates +under one word: + +- **Account** — a paying customer. Lives only in the admin control plane + (spec 3). The control plane never learns about it. +- **Instance** — one deployment of Vantage: its own subdomain, its own users, + its own servers, keys, workflows, monitors and secrets. One license attaches + to one instance. + +Today's control-plane `Org` **is** an Instance. An Account may hold several, +some cloud and some self-hosted, and the self-hosted ones have no row in the +cloud database at all. + +Keeping the name `Org` would leave the control plane using a word that means +something different in the admin site, in Paddle, and in every support +conversation. This spec renames it everywhere, including on disk. + +This is the highest-risk change in the programme: `org_id` is the tenant +isolation key on every document in every collection. It is done alone, before +anything else, so that nothing else is in flight when it deploys. + +## Goals + +1. `Instance` is the only word for a tenant, in code, API, UI and database. +2. No document is lost and no tenant scoping is weakened. +3. The migration is reversible. + +## Non-goals + +- Any behaviour change. Same routes' semantics, same permissions, same data. +- Introducing Accounts. The control plane never gets them. +- Touching the agent. It talks gRPC and has no concept of a tenant. + +## Design + +### Naming map + +| Today | After | +|---|---| +| collection `orgs` | `instances` | +| collection `org_oidc` | `instance_oidc` | +| field `org_id` (all collections) | `instance_id` | +| `models.Org` | `models.Instance` | +| `Org.OrgID` | `Instance.InstanceID` | +| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` | +| `services/orgs.go`, `GetOrg`, `CreateOrg`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg`, `GetOrgBySlug` | `services/instances.go`, `GetInstance`, `CreateInstance`, … | +| `services/org_oidc.go` | `services/instance_oidc.go` | +| `auth/orghost.go` | `auth/instancehost.go` | +| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` | +| `shared/provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` | +| session field `org_id` | `instance_id` | +| `GET /auth/me` response `org_id` / `org` | `instance_id` / `instance` | +| UI copy "Organisation" | "Instance" | + +Reserved slugs gain no new entries here, but note `admin` is already reserved, +which the admin site relies on later. + +### Collections carrying `org_id` + +All of: `servers`, `keys`, `assignments`, `users`, `org_oidc`, `settings`, +`secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `monitors`, +`incidents`, `monitor_rollups`, `notification_channels`, `console_sessions`, +`audit_logs`, plus `orgs` itself. `migrations` does not carry one. + +`site_pending_signups` does not carry `org_id`, but its `org_name` field becomes +`instance_name` for consistency; it is sitesvc-private so this is free. + +The migration must derive this list from a constant in code, not from a +hand-written list in a runbook, so that a collection added between design and +deploy is not silently missed: + +```go +var scopedCollections = []string{ /* the list above */ } +``` + +A boot-time assertion (spec 2 onwards) checks that no collection outside this +list contains an `org_id` field. Cheap insurance against a future collection +being added without being renamed. + +### Migration `0004_org_to_instance` + +Recorded in `migrations` like the existing three. Runs after +`0003_missed_org_scopes`. + +**The migration only renames. It never deletes and never drops.** A bad deploy +is recovered by running the inverse rename, not by restoring a backup. + +Steps, in order: + +1. **Guard.** If collection `instances` already exists and `orgs` does not, the + migration has already run against this database by an earlier binary; record + the marker and return. Idempotency matters because the marker write and the + data work are not in one transaction. +2. **Rename collections.** `orgs` → `instances`, `org_oidc` → `instance_oidc`, + via `adminCommand{renameCollection}`. Fails loudly if the target exists. +3. **Rename the field.** For each collection in `scopedCollections`: + `UpdateMany({org_id: {$exists: true}}, {$rename: {"org_id": "instance_id"}})`. + Record `matched` and `modified` per collection in the log. +4. **Verify.** For each collection, assert + `CountDocuments({org_id: {$exists: true}}) == 0` and + `CountDocuments({instance_id: {$exists: true}}) == totalCount`. Any mismatch + aborts before the marker is written, leaving the migration to retry. +5. **Indexes.** Drop and recreate indexes that name `org_id` in their key spec: + unique `settings.instance_id`, the ESO token-hash index, and any compound + scoping indexes. Unique `instances.slug` and `users.email` are unaffected by + the field rename but are re-declared idempotently. +6. **Write the marker.** + +Steps 2–4 are not atomic across collections. Mongo multi-document transactions +would require a replica set, which is not guaranteed for self-hosted installs. +Instead the migration is written to be **safely re-runnable**: `$rename` on a +document that has already been renamed matches nothing, and the collection +rename is guarded in step 1. + +Rollback, if ever needed, is the same code with the rename reversed, shipped as +a one-shot command rather than a migration — deliberately manual, because the +only reason to run it is a decision to revert the release. + +### Version skew + +`sitesvc` and `server` write the same documents. A skew where one writes +`org_id` and the other reads `instance_id` creates tenants that are invisible to +the application — the exact failure `CLAUDE.md` warns about. + +After spec 0a both read the shape from `shared`, so the skew window is a +deployment-ordering problem rather than a code-drift problem: + +- Both images are built from the same commit and deployed together. +- The migration runs from the `server` container at boot, as the existing three + do. +- `sitesvc` at boot asserts that collection `instances` exists and refuses to + start otherwise, with the message + `instances collection not found; deploy the control plane first`. Failing to + start is strictly better than provisioning into a collection nobody reads. + +The self-hosted deployment runs no sitesvc, so it sees only the server change. + +### API and frontend + +REST route renames are **breaking**, but every consumer is first-party (`web/`) +and ships in the same release. No compatibility aliases — a permanent dual path +in the tenant-scoping layer is worse than a coordinated release. + +`web/` changes: the API client's paths, the `useMe` shape, all UI copy from +"Organisation" to "Instance", and the settings route `/settings/org` → +`/settings/instance`. + +`site/` marketing copy changes where it says "organisation" about a tenant. Where +it means the customer, it becomes "account" — that word now has a specific +meaning and the marketing site is the first place a customer meets it. + +## Testing + +Extends the suite started in spec 0a. + +1. **Migration test, empty database.** Runs clean, writes the marker. +2. **Migration test, seeded database.** Seed one document in every collection in + `scopedCollections` with a known `org_id`. Run. Assert every document has + `instance_id` with the same value, no document has `org_id`, and per-collection + counts are unchanged. +3. **Idempotency.** Run the migration twice. Second run is a no-op and does not + error. +4. **Interrupted run.** Rename half the collections, then run the full migration. + It completes the rest without error and passes verification. +5. **Guard.** With `instances` present and `orgs` absent, the migration + short-circuits and records the marker. +6. **Scoped-collection completeness.** A test that reads the collection list from + the live database and fails if any collection outside `scopedCollections` + contains an `org_id` field. This test protects the list from going stale. +7. **`shared/provision` tests from 0a** pass unchanged after the rename. + +## Verification before merge + +Run against a **restored production snapshot**, not a synthetic database: + +1. Record `db.getCollectionNames()` and per-collection `countDocuments()` before. +2. Run the migration. +3. Assert every count is identical afterwards. +4. Assert `instances.countDocuments()` equals the old `orgs.countDocuments()`. +5. Pick three real tenants; run the same scoped query before (by `org_id`) and + after (by `instance_id`) and confirm identical result sets. This is the test + that proves tenant isolation survived. +6. Boot the server against the migrated snapshot; log in as a real user; confirm + servers, keys, workflows, monitors and secrets all list correctly. +7. Boot sitesvc against the migrated snapshot; complete a signup end to end. +8. Boot sitesvc against an **un**migrated snapshot; confirm it refuses to start + with the expected message. + +## Rollout + +1. Take a database backup. Not optional — this is the one change where the + inverse rename is the recovery path and the backup is the second. +2. Deploy `server`, `web`, `site` and `sitesvc` from one commit, together. +3. Server boots, migration runs, marker recorded. +4. Watch for the sitesvc guard message; if it appears, sitesvc started first and + will restart cleanly. + +Expect a short window during the server restart where the API is unavailable. +Agents are unaffected: they reconnect, and no gRPC message carries a tenant ID. + +## Risks + +| Risk | Mitigation | +|---|---| +| Partial migration leaves mixed field names | Step 4 verification aborts before the marker; migration is re-runnable | +| A collection missed from the list | List is a code constant plus a completeness test plus a boot-time assertion | +| sitesvc deployed before server | Boot guard refuses to start | +| An index still keyed on `org_id` | Step 5 drops and recreates; verification includes an index listing diff | +| A hard-coded `org_id` string outside the model layer | `grep -rn '"org_id"' server/ sitesvc/ shared/` must return only the migration file after the change | +| Frontend missed a renamed route | Full manual pass over every route in the UI before release | + +## Follow-on + +With `Instance` established, spec 1 (`licensing-core`) can define a license +payload that binds to `instance_id` without inventing a word the codebase does +not use. diff --git a/docs/superpowers/specs/2026-07-24-licensing-core-design.md b/docs/superpowers/specs/2026-07-24-licensing-core-design.md new file mode 100644 index 0000000..b8ec673 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-licensing-core-design.md @@ -0,0 +1,294 @@ +# Spec 1 — Licensing Core + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 0a (`shared-module`), spec 0b (`instance-rename`) +Ships: independently. Adds a package and a CLI; changes no running behaviour. + +## Context + +Licenses are **offline-verified signed blobs**. A Vantage server checks a +signature and an expiry date and asks nobody's permission. That choice buys +self-hosted installs that work in air-gapped networks and a control plane with no +licensing availability dependency. + +It costs revocation. Once issued, a license is valid until it expires, whatever +Paddle later says. Every other decision in the programme follows from accepting +that: Self Hosted is annual-only so the unenforceable window is bounded, and +cancellation takes effect at term end rather than immediately (spec 5). + +This spec defines the payload, the signing and verification, and a CLI to issue +licenses by hand. It deliberately lands before the admin site so that specs 1+2 +together give working licensing with no new service to operate. + +## Goals + +1. One struct, in `shared`, read identically by the verifier and the issuer. +2. Verification that needs no network, no clock sync beyond a rough one, and no + configuration. +3. A hand-issuance path good enough to run production on until spec 3 lands. + +## Non-goals + +- Storing licenses. Spec 2 owns the instance document; spec 3 owns issuance + history. +- Deciding tier contents. Tiers are data; the values in this spec are the + initial seed, and spec 3's `plans` table becomes their home. +- Any phone-home, revocation list or online check. There is none, anywhere, by + design. + +## Design + +### Package + +`shared/license/`, inside the module created by spec 0a: + +``` +shared/license/ +├── license.go # License, Limits, feature constants +├── sign.go # Sign, build-tagged out of the server binary +├── verify.go # Verify, Parse +├── keys.go # trustedPublicKeys +└── license_test.go +``` + +Uses `github.com/hyperboloide/lk` (ed25519, base32 encoding). + +### Payload + +```go +package license + +type License struct { + ID string `json:"id"` // uuid, for support and audit + InstanceID string `json:"instance_id"` // the instance this license is bound to + AccountID string `json:"account_id"` // admin-side customer, informational + InstanceName string `json:"instance_name"` // display only + Tier string `json:"tier"` // "free" | "professional" | "self_hosted" + Deployment string `json:"deployment"` // "cloud" | "self_hosted" + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Limits Limits `json:"limits"` + Features []string `json:"features"` +} + +type Limits struct { + MaxServers int `json:"max_servers"` // -1 means unlimited + MaxSecretGroups int `json:"max_secret_groups"` + MaxChannels int `json:"max_channels"` +} + +const ( + FeatureConsole = "console" // browser SSH/RDP/VNC + FeatureOIDC = "oidc" // per-instance single sign-on +) + +const ( + TierFree = "free" + TierProfessional = "professional" + TierSelfHosted = "self_hosted" + + DeploymentCloud = "cloud" + DeploymentSelfHosted = "self_hosted" +) +``` + +`InstanceID` is **always populated**. There is no unbound license: the +self-hosted purchase flow (spec 4) links the instance UUID before the license is +issued, so binding happens at signing time. This removes the claim endpoint, the +best-effort phone-home and the multi-claim reconciliation that an unbound design +would have needed. + +**The server never branches on `Tier`.** It reads `Limits` and `Features` only. +`Tier` exists for display, support and analytics. Adding a tier, or changing what +a tier includes, must never require a server release. + +### Tier seed values + +Recorded here as the initial contents of spec 3's `plans` table. Snapshotted into +each license at issue, so changing the table never rewrites an issued license — +the same principle as `workflow_runs.steps_snapshot`. + +| | Free | Professional | Self Hosted | +|---|---|---|---| +| `deployment` | `cloud` | `cloud` | `self_hosted` | +| `max_servers` | 3 | -1 | -1 | +| `max_secret_groups` | 1 | -1 | -1 | +| `max_channels` | 1 | -1 | -1 | +| `console` | no | yes | yes | +| `oidc` | no | yes | yes | +| billing term | monthly, £0 | monthly or annual | **annual only** | + +Free is cloud-only. A self-hosted install can never hold a valid Free license +because Free is only ever signed with `deployment: "cloud"`, and verification +rejects a deployment mismatch. There is no server-side flag to edit. + +### Signing + +```go +//go:build !noSign + +func Sign(l License, privateKeyHex string) (string, error) +``` + +Marshals to canonical JSON, signs with lk, returns the base32 blob. + +`Sign` is excluded from the server binary with a build tag. The server has no +reason to hold signing code and there is no reason to ship it into a customer's +data centre. + +The private key lives in `LICENSE_SIGNING_KEY` on the issuing side only — the +CLI now, the admin backend from spec 3. It is never in the repo, never in an +image, never in the control plane's environment. + +### Verification + +```go +type VerifyOpts struct { + InstanceID string // required: the verifier's own instance + Deployment string // required: "cloud" or "self_hosted" + Now time.Time // injectable for tests +} + +type Result struct { + License License + State State // Valid, Expired, Invalid + Reason string +} + +const ( + StateValid State = "valid" + StateExpired State = "expired" + StateInvalid State = "invalid" +) + +func Verify(blob string, opts VerifyOpts) Result +``` + +Checks, in order, stopping at the first failure: + +1. Blob decodes and the signature verifies against one of `trustedPublicKeys`. + Failure → `Invalid`, reason `bad_signature`. +2. `l.Deployment == opts.Deployment`. Failure → `Invalid`, reason + `deployment_mismatch`. This is the check that makes Free cloud-only. +3. `l.InstanceID == opts.InstanceID`. Failure → `Invalid`, reason + `instance_mismatch`. +4. `opts.Now.Before(l.ExpiresAt)`. Failure → `Expired`. +5. Otherwise `Valid`. + +**`Expired` and `Invalid` are distinct states and the caller treats them +differently in messaging** (spec 2), even though both degrade the instance the +same way. A customer whose card failed and a customer who pasted the wrong blob +need different words. + +`Parse(blob) (License, error)` verifies the signature only, ignoring binding and +expiry. Used by the admin site to display a license and by support to inspect a +blob a customer has emailed in. Never used for enforcement. + +Clock skew: no tolerance is applied. Terms are a month or a year; a server whose +clock is wrong by enough to matter has bigger problems, and a tolerance window is +a thing to get wrong. `Verify` logs at warn level if `IssuedAt` is in the future, +which is the signal that a clock is badly off. + +### Key management + +```go +// trustedPublicKeys is ordered. Index 0 is the current signing key. +// To rotate: prepend the new key, ship a server release, then reissue. +// Remove a retired key only after every license signed with it has expired. +var trustedPublicKeys = []string{ + "", +} +``` + +A slice from day one even though it holds one entry, because retrofitting a +single-key verifier into a multi-key one during an incident is not a thing to +plan for. + +Public keys are compiled in. They are not configurable, because a configurable +trust root is a licensing bypass: a self-hosted operator could point it at a +keypair they generated. + +Key generation is a documented one-off: + +``` +go run ./shared/license/cmd/lkgen keypair +``` + +prints a private key hex for the vault and a public key hex to paste into +`keys.go`. The private key is stored in a password manager and in the admin +service's environment. **If it is lost, no new licenses can be issued for any +existing customer without a server release.** Back it up in two places. + +### CLI issuer + +`shared/license/cmd/lkctl`, built only for internal use: + +``` +lkctl keypair +lkctl issue --instance-id= --instance-name="Acme" \ + --tier=professional --deployment=cloud \ + --term=1y [--account-id=] [--out=acme.lic] +lkctl inspect +``` + +`issue` reads `LICENSE_SIGNING_KEY`, applies the tier seed values from a table +compiled into the CLI, and prints the blob. `--term` accepts `1m`, `1y` or an +explicit `--expires=RFC3339`. + +This is the production issuance path until spec 3 ships. It is kept afterwards +for support and disaster recovery — if the admin service is down and a customer's +license expires, a blob can still be cut by hand. + +Issued blobs from `lkctl` are not recorded anywhere. Spec 3 backfills its +`licenses` table from `inspect` output when it takes over. + +## Testing + +`shared/license` is pure and needs no database, so this suite is fast and +thorough. Written test-first. + +1. Round trip: `Sign` then `Verify` returns `Valid` with an identical payload. +2. Tampering: flip one character of the blob → `Invalid`, `bad_signature`. +3. Tampering with intent: re-sign a payload with a *different* keypair → + `Invalid`. This is the test that proves an attacker cannot mint licenses. +4. Expiry: `ExpiresAt` one second in the past → `Expired`. One second in the + future → `Valid`. +5. Deployment mismatch: a Free (`cloud`) license verified with + `Deployment: "self_hosted"` → `Invalid`, `deployment_mismatch`. +6. Instance mismatch: correct signature, different `InstanceID` → `Invalid`, + `instance_mismatch`. +7. Check order: a blob that is both expired *and* instance-mismatched reports + `instance_mismatch`, not `Expired`. Order is part of the contract because the + reason drives the message. +8. Multi-key: a license signed with `trustedPublicKeys[1]` verifies. One signed + with a key not in the slice does not. +9. `Parse` returns the payload for an expired and for a mismatched license, and + errors for a bad signature. +10. Unicode and long instance names survive the round trip. +11. Golden blob: a fixture blob checked into the repo, signed with a **test-only** + keypair, must keep verifying. This catches an accidental change to the + canonical JSON encoding, which would silently invalidate every issued + license in the field. + +Test 11 matters more than it looks. The encoding is part of the wire format. + +## Verification before merge + +1. `go test ./shared/license/...` passes, including the golden fixture. +2. `lkctl keypair` → `lkctl issue` → `lkctl inspect` round trips at the command + line. +3. `go build -tags noSign ./server/...` succeeds and + `go tool nm` on the resulting binary shows no `license.Sign` symbol. +4. The production keypair is generated, the private half stored in two places, + and the public half committed in `keys.go`. + +## Risks + +| Risk | Mitigation | +|---|---| +| Signing key lost | Documented two-location backup; generation is a one-off with an explicit checklist | +| Signing key leaked | Rotation path exists from day one: prepend key, release, reissue. Retire the old key once its licenses expire | +| Canonical encoding changes | Golden fixture test | +| Signing code shipped to customers | Build tag plus a symbol check in verification | +| No revocation | Accepted and documented. Bounded by term length; Self Hosted is annual-only | diff --git a/docs/superpowers/specs/2026-07-24-paddle-billing-design.md b/docs/superpowers/specs/2026-07-24-paddle-billing-design.md new file mode 100644 index 0000000..dcd3570 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-paddle-billing-design.md @@ -0,0 +1,271 @@ +# Spec 5 — Paddle Billing + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 3 (`admin-backend`) +Ships: after spec 3. Can be developed in parallel with spec 4. + +## Context + +Paddle is merchant of record: it owns checkout, tax, invoices, dunning and the +customer billing portal. This spec connects Paddle's subscription lifecycle to +the licence issuance functions spec 3 defines, and moves cloud signup off +sitesvc. + +The central constraint, restated because every table below follows from it: +**licences are offline-verified, so nothing Paddle says can revoke one early.** +Cancellation takes effect when the licence expires. Self Hosted is annual-only to +bound that window; the alternative — a customer holding a valid key for eleven +months after cancelling a monthly plan — is not acceptable. + +## Goals + +1. A catalog in Paddle sandbox, promotable to production by configuration alone. +2. Webhooks that issue and renew licences reliably, including under retries and + out-of-order delivery. +3. Cloud signup owned by one service instead of two. + +## Non-goals + +- Building any part of billing Paddle already provides. +- Usage-based or metered pricing. Tiers are flat. +- Proration logic. Paddle handles money; we react to the resulting subscription + state. + +## Design + +### Catalog + +Three products, created in **sandbox** first. Production is a configuration +change: the same `plans` rows carry different `paddle_product_id` and +`paddle_price_ids`, selected by `PADDLE_ENV`. + +| Product | Prices | Notes | +|---|---|---| +| Vantage Free | monthly, £0 | Yes, a real £0 subscription. It gives every account a Paddle customer, a lifecycle, and an upgrade path with no special-case code. | +| Vantage Professional | monthly, annual | Cloud | +| Vantage Self Hosted | **annual only** | No monthly price exists, so the offline-revocation window is at most a year | + +**No price ID is ever hard-coded.** They live in `plans.paddle_price_ids` and are +edited through the staff UI. A price change in Paddle is a data edit, not a +deploy. + +`custom_data` on every checkout carries `{ account_id, instance_id, tier }`. This +is what lets a webhook route without a lookup table, and it is why the +self-hosted flow creates the instance record *before* checkout completes. + +### Checkout + +Paddle Checkout, overlay mode, in the admin site. + +**Cloud upgrade** — instance exists, `instance_id` in `custom_data`, existing +Paddle customer reused. + +**Self-hosted purchase** — the instance does not exist yet. Order: + +``` +Customer creates an admin-site account (verified email) +Account row created, then an admin_instances row with status awaiting_link + and a generated placeholder instance record +Checkout opened with account_id and that instance row's id in custom_data +subscription.created fires → subscription recorded, status awaiting_link, + NO licence issued +Customer pastes their install's UUID → instance_id set, status active + → licence issued and delivered +``` + +The instance row exists before payment so the webhook has something to attach to. +The licence is not issued until the UUID is known, because a licence with no +instance to bind to cannot be signed — spec 1 has no unbound licence. + +A customer who pays and never links has a subscription and no licence. Spec 4's +staff dashboard flags `awaiting_link` older than 48 hours, and a reminder email +goes out at 24 hours and 72 hours. This is the most likely place for a paying +customer to get stuck, so it gets active chasing rather than a support queue. + +### Webhooks + +`POST /api/paddle/webhook`, signature-verified with `PADDLE_WEBHOOK_SECRET`. +An unsigned or badly signed request is rejected `401` and logged — never +processed. + +**Idempotency is mandatory.** Paddle retries. Every event ID is recorded in +`paddle_events` with a unique index before processing; a duplicate returns `200` +without acting. `200` on duplicates matters — returning an error would make +Paddle retry a message we have already handled, forever. + +| Event | Action | +|---|---| +| `subscription.created` | Record the subscription. Cloud: issue and inject. Self-hosted: leave `awaiting_link`, issue nothing. | +| `subscription.updated` | Tier or term changed: issue a replacement licence at the new tier, supersede the old. Cloud injects; self-hosted emails a new blob and flags the site. Reflects Paddle's resulting state; no proration maths here. | +| `subscription.canceled` | Mark `cancelled`. **No licence action.** The current licence runs to expiry, then the instance degrades per spec 2. | +| `subscription.past_due` | Mark `past_due`, notify the customer, flag for staff. Licence untouched. Dunning is Paddle's job; ours is not to punish a retryable card failure. | +| `transaction.completed` where the transaction is a subscription renewal | Issue the next term's licence, supersede, inject or email. Reset `RelinkCount`. | +| `transaction.payment_failed` | Record for staff visibility. No licence action. | +| `customer.updated` | Sync `billing_email` onto the account. | + +Out-of-order delivery is handled by making every handler a function of the +subscription's *current* state as reported in the event payload, rather than of +the transition. An `updated` arriving before its `created` creates the +subscription row and proceeds. + +Renewal licences are issued with a **3-day grace** past the period end (spec 3), +so a webhook delayed by hours never produces a gap in coverage. + +**Webhook failures must be visible.** Every failed handler writes to +`admin_audit` and appears on the staff dashboard. A licence that silently failed +to issue is a customer who paid and got nothing. + +### Cancellation, stated plainly + +When a customer cancels: + +- Paddle stops billing at period end. +- We issue no further licences. +- Their current licence keeps working until it expires — up to a month for + Professional monthly, up to a year for Self Hosted. +- On expiry the instance degrades per spec 2: monitors keep running, changes stop. + +This is documented in the terms and shown on the cancellation confirmation +screen, because a customer who cancels and sees their instance keep working +should understand why rather than assume the cancellation failed. + +### Signup migration off sitesvc + +Cloud signup currently lives in sitesvc: `site_pending_signups`, a verification +email, and provisioning on link click. It now needs to also create an Account, a +Paddle customer, a Free subscription and a licence. + +**Signup moves to the admin backend.** The form stays on the marketing site where +customers find it, but it posts to admin instead of sitesvc. sitesvc keeps the +contact form only. + +The reason is the one `CLAUDE.md` already names: provisioning logic duplicated +across services drifts. Spec 0a removed the second copy; adding signup to admin +while leaving it in sitesvc would create a third. + +New flow, preserving every property of the current one: + +``` +Marketing site form → POST /api/signup on admin + → pending record, password bcrypt cost 12, token 32 random bytes, + only the SHA-256 hash stored, 24h expiry, TTL index + → verification email +Link opened → FindOneAndDelete the pending record (atomic, before provisioning) + → shared.CreateInstance + shared.CreateUser in the control plane + → Account created + → Paddle customer created, Free subscription created + → Free licence issued and injected + → redirect to APP_LOGIN_URL with {slug} filled in +``` + +Properties that must survive, verified by test: + +- Nothing written to `instances` or `users` until the link is opened. +- `FindOneAndDelete` before provisioning, so a double-clicked link cannot create + two instances. +- Instance rollback if the owner insert fails, refusing to delete an instance + that has users. +- Re-submitting for the same address replaces the pending record. +- Rate limited to 3 signups per IP per hour, plus the honeypot field. + +Two failure modes are new, because provisioning now spans two systems: + +- **Paddle customer creation fails** — the instance and user are already created. + Complete the signup, record the account with an empty `PaddleCustomerID`, issue + the Free licence anyway, and flag for staff. A new customer must never be + blocked from signing in by a billing-system hiccup. +- **Licence issuance fails** — the instance exists with no licence and is + read-only. Flagged for staff, and the 15-minute reconciliation job (spec 3) + retries. The customer can log in and sees the licence banner. + +Both resolve toward "the customer gets in", because a signup that half-fails +silently is worse than either outcome. + +sitesvc changes: signup, verify, `site_pending_signups` and the provisioning +calls are deleted. `SITE_API_URL` gains a sibling for the admin endpoint, or the +marketing site posts signup to `ADMIN_API_URL` directly — the latter, so the two +form targets are explicit rather than implied. + +### Configuration + +| Variable | Required | Notes | +|---|---|---| +| `PADDLE_ENV` | yes | `sandbox` or `production`; selects which price IDs the plans table serves | +| `PADDLE_API_KEY` | yes | server-side API | +| `PADDLE_CLIENT_TOKEN` | yes | browser checkout; baked into the admin site build | +| `PADDLE_WEBHOOK_SECRET` | yes | signature verification. Boot fails without it — an unverified webhook endpoint is an endpoint anyone can issue licences through | +| `APP_LOGIN_URL` | yes | moved from sitesvc; `{slug}` template | + +### Cutover + +Signup migration is the only user-visible switch: + +1. Deploy admin with signup enabled; sitesvc still serving its own. +2. Point the marketing site's form at admin. Deploy. +3. Let sitesvc's outstanding pending signups expire naturally — 24 hours — while + its verify endpoint stays live. **Do not delete the collection until it is + empty**, or someone's verification link breaks. +4. Deploy sitesvc with signup removed. + +## Testing + +**Webhooks:** + +1. Each event type produces its documented action against a mock Paddle payload. +2. Replaying an event ID is a no-op returning `200`. +3. A bad signature is rejected `401` and processes nothing. +4. `subscription.updated` before `subscription.created` creates the subscription + and applies the update. +5. `subscription.canceled` issues nothing and leaves the current licence intact. +6. `past_due` leaves the licence intact and flags the account. +7. Renewal issues the next term, supersedes, resets `RelinkCount`, and the new + `ExpiresAt` is period end plus 3 days. +8. A handler failure writes to `admin_audit` and surfaces on the dashboard. + +**Checkout:** + +9. `custom_data` round-trips account, instance and tier through to the webhook. +10. Self-hosted checkout leaves the instance `awaiting_link` with no licence. +11. Linking after checkout issues the licence. + +**Signup:** + +12. Nothing is written to `instances` or `users` before the link is opened. +13. A double-clicked verification link creates exactly one instance. +14. Owner-insert failure rolls the instance back; rollback refuses an instance + with users. +15. Re-submitting replaces the pending record and invalidates the earlier link. +16. Rate limit and honeypot both reject. +17. Paddle customer creation failure still completes signup and issues the Free + licence. +18. Licence issuance failure still lets the user log in, showing the banner. +19. Expired pending records are dropped by the TTL index. + +## Verification before merge + +1. Full suite green. +2. Against Paddle **sandbox**, end to end for each tier: checkout with a test + card, confirm the licence is issued, confirm the instance reports `valid`. +3. Trigger a sandbox renewal and confirm the next term's licence arrives and is + injected. +4. Cancel in sandbox and confirm the licence keeps working to expiry, then the + instance degrades correctly — monitors still running. +5. Replay every webhook from Paddle's dashboard and confirm no duplicate licences + are created. +6. Full signup end to end through admin, then confirm the new user can log into + their control-plane instance and sees a valid Free licence. +7. Confirm sitesvc's pending-signup collection is empty before its signup code is + removed. + +## Risks + +| Risk | Mitigation | +|---|---| +| Duplicate licences from webhook retries | Unique index on event ID, checked before processing | +| Webhook missed entirely | 15-minute reconciliation job (spec 3) compares subscription state against issued licences | +| Cancellation not enforceable until expiry | Accepted, bounded by term; Self Hosted annual-only; stated in terms and on the cancellation screen | +| Signup cutover breaks in-flight verification links | Staged cutover; sitesvc's verify stays live until its collection is empty | +| Sandbox price IDs reaching production | `PADDLE_ENV` selects them from the plans table; environment badge in the admin site | +| Webhook endpoint unauthenticated | Signature verification mandatory; boot fails without the secret | +| Customer pays and never links | Reminder emails at 24h and 72h, staff dashboard alert at 48h | diff --git a/docs/superpowers/specs/2026-07-24-shared-module-design.md b/docs/superpowers/specs/2026-07-24-shared-module-design.md new file mode 100644 index 0000000..3213599 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-shared-module-design.md @@ -0,0 +1,282 @@ +# Spec 0a — Shared Module Extraction + +Date: 2026-07-24 +Status: Design approved, not implemented +Ships: independently. No dependency on any other licensing spec. + +## Context + +Vantage is three independent Go modules: `server`, `sitesvc`, `agent`. There is no +root `go.mod` and no `go.work`. + +`sitesvc` writes into the same MongoDB collections the control plane reads, but +cannot import the control plane, so it carries hand-copied duplicates: + +- `sitesvc/internal/models/models.go` — `Org` and `User` mirrored field for field +- `sitesvc/internal/provision/provision.go` — `Slugify`, `ReservedSlugs`, + `MinSlugLength`, `MaxSlugLength`, `BcryptCost`, slug-collision rules + +Both files carry comments saying they must be changed in lockstep with the +control plane, and `CLAUDE.md` names the hazard explicitly: nothing enforces the +match. **The duplication has already drifted.** The control plane's `CreateOrg` +resolves slug collisions with an inline `fmt.Sprintf("%s-%d", base, i)` loop, +while sitesvc exposes the same rule as a separate `NextSlug(base, attempt)` +helper. They currently agree by luck, not by construction. + +The licensing programme adds a fourth service (`admin`) that writes the license +blob onto the same tenant document. Adding a third copy of these rules is not +acceptable. This spec removes the duplication before any licensing code is +written. + +This spec is a **pure refactor**. No database document changes. No behaviour +changes. Names stay as they are today (`Org`, `org_id`) — renaming happens in +spec 0b, deliberately kept separate so that a failed deploy has one suspect +rather than two. + +## Goals + +1. One authoritative definition of every document shape written by more than one + service. +2. One authoritative definition of provisioning rules (slug, bcrypt cost, + creation, rollback). +3. `sitesvc` keeps its independence from `server` — it depends on `shared`, not + on the control plane. The original design intent survives; only the copying + dies. +4. The agent is untouched. + +## Non-goals + +- Renaming anything. That is spec 0b. +- Moving control-plane-only models. `workflow.go`, `monitor.go`, `key.go`, + `server.go`, `secret.go`, `assignment.go`, `channel.go`, `console_session.go`, + `audit.go`, `org_oidc.go` stay in `server/internal/models`. Only the control + plane touches them, and hoisting them would make `shared` a dumping ground. +- Merging the repo into a single module. + +## Design + +### Module layout + +``` +vantage/ +├── go.work # NEW: server, sitesvc, shared (NOT agent) +├── shared/ # NEW module: github.com/mrhid6/vantage/shared +│ ├── go.mod +│ ├── models/ +│ │ ├── org.go # Org +│ │ ├── user.go # User, RoleOwner/RoleAdmin/RoleMember, ValidRole +│ │ └── settings.go # Settings, AlertSettings, EmailSettings, SecretsSettings +│ ├── provision/ +│ │ ├── slug.go # Slugify, BaseSlug, NextSlug, ReservedSlugs, limits +│ │ ├── org.go # CreateOrg +│ │ ├── user.go # CreateUser, BcryptCost +│ │ └── rollback.go # RollbackOrg +│ └── indexes/ +│ └── indexes.go # EnsureCoreIndexes +├── server/ # replace => ../shared +├── sitesvc/ # replace => ../shared +└── agent/ # untouched +``` + +`go.work`: + +``` +go 1.26 + +use ( + ./shared + ./server + ./sitesvc +) +``` + +Each consumer's `go.mod` also carries an explicit replace: + +``` +require github.com/mrhid6/vantage/shared v0.0.0 +replace github.com/mrhid6/vantage/shared => ../shared +``` + +Both are needed. `go.work` makes editors, `go test ./...` and local tooling work +across modules. The `replace` directives make Docker builds work whether or not +`go.work` is present, and stop `go build` outside the workspace from silently +trying to resolve `shared` from the network. + +`shared` depends only on `go.mongodb.org/mongo-driver/v2`, +`golang.org/x/crypto/bcrypt` and `github.com/google/uuid`. It must not import +gin, redis, guac or anything else from the control plane's tree — that is what +keeps sitesvc small. + +### What moves + +**`shared/models`** — the three documents written by more than one service: + +| Type | From | Written by | +|---|---|---| +| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin | +| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc | +| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later | + +`Settings` moves now rather than later because spec 3's admin service reads it, +and moving it later would mean a second round of import churn across both +services. + +`PendingSignup` does **not** move. Only sitesvc writes `site_pending_signups`, +and the control plane does not know the collection exists. + +**`shared/provision`** — the rules, promoted from private helpers to a real API: + +```go +const ( + MinSlugLength = 3 + MaxSlugLength = 40 + BcryptCost = 12 +) + +var ReservedSlugs = map[string]bool{ /* www, api, app, admin, auth, install, static, _next, default */ } + +func Slugify(name string) string +func BaseSlug(name string) (string, error) // validates length + reserved +func NextSlug(base string, attempt int) string + +// CreateOrg resolves a free slug and inserts. The caller supplies the +// collection handle so shared does not own a Mongo connection. +func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error) + +func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role string) (*models.User, error) + +// RollbackOrg deletes an org only if it has no users. Refuses otherwise. +func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error +``` + +`shared.CreateOrg` becomes the single implementation. The control plane's +`services.CreateOrg` shrinks to a wrapper that calls it and then runs +`SeedDefaultSteps` — seeding stays in the server, because `shared` must not know +about workflow steps. sitesvc calls `shared.CreateOrg` directly and does not +seed, which is the behaviour it has today. + +Note on the slug loop: it is count-then-insert and therefore racy. It is safe +only because of the unique index on `orgs.slug`. `CreateOrg` must keep handling +`mongo.IsDuplicateKeyError` and returning a clean error — moving the code must +not lose that. Document the reliance in a comment at the loop. + +**`shared/indexes`** — `EnsureCoreIndexes(ctx, db)` declares the unique indexes +on `users.email` and `orgs.slug`. Both services call it at boot; creating an +existing index is a no-op. These indexes are a security property, not an +optimisation (see `CLAUDE.md`: `GetUserByEmail` does an unscoped `FindOne`, so +duplicates would break the OIDC cross-org guard), so the shared version is +**fatal on failure** for both callers. + +Server-only index builders (`EnsureSettingsIndexes`, `EnsureSecretIndexes`, +`EnsureWorkflowIndexes`) stay in the server and keep their current +fatal/warn behaviour. + +### What is deleted + +- `sitesvc/internal/models/models.go` — reduced to `PendingSignup` only +- `sitesvc/internal/provision/` — deleted entirely +- `sitesvc/internal/store/store.go` — org/user creation replaced by calls into + `shared/provision`; pending-signup storage stays + +### Docker and CI + +Both Go Dockerfiles currently build with the module directory as context: + +```dockerfile +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build ... ./cmd +``` + +A `replace => ../shared` cannot resolve from that context. Build contexts move +to the repo root: + +```dockerfile +WORKDIR /src +COPY shared/go.mod shared/go.sum ./shared/ +COPY server/go.mod server/go.sum ./server/ +RUN cd server && go mod download +COPY shared/ ./shared/ +COPY server/ ./server/ +ARG VERSION=dev +RUN cd server && CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd +``` + +The two-stage copy keeps the dependency-download layer cached, which is the +reason the current Dockerfiles are written the way they are. + +`.gitea/workflows/server-deploy.yml` must set `context: .` and +`file: server/Dockerfile` (and likewise for sitesvc) for the two Go images. The +`web` and `site` image builds are unaffected. + +`agent-release.yml` is untouched. The agent is not in the workspace, has no +`replace`, and cross-compiles exactly as it does today. + +### Error handling + +No new error paths. `shared/provision` returns the same error strings the two +callers produce today so that API responses do not change. The one place to be +careful is wording: sitesvc says "organisation" and the control plane says +"organization". `shared` standardises on **"organisation"**; the control plane's +two error strings change spelling. This is user-visible in API error text and is +called out here so it is a decision rather than an accident. + +## Testing + +The repo has no Go test suite today. This refactor is where one starts, because +the moved code is exactly the code whose correctness is load-bearing. + +`shared/provision` unit tests, against a real MongoDB (testcontainers or a +`MONGO_TEST_URI` env guard — skip when unset rather than fail): + +1. `Slugify` — table test covering the existing regex behaviour: casing, + punctuation runs collapsing to a single `-`, leading/trailing trim. +2. `BaseSlug` — rejects under `MinSlugLength`, truncates over `MaxSlugLength`, + rejects every entry in `ReservedSlugs`. +3. `CreateOrg` — a second org with a colliding name gets `-2`; a third gets `-3`. +4. `CreateOrg` under a concurrent duplicate insert returns the clean + "slug already taken" error rather than a raw Mongo error. +5. `CreateUser` — hash verifies with bcrypt at cost 12; duplicate email is + rejected by the unique index. +6. `RollbackOrg` — deletes an org with no users; refuses one that has users. +7. `EnsureCoreIndexes` — idempotent across two calls. + +## Verification before merge + +Evidence required, not assertions: + +1. `go build ./...` succeeds in `shared`, `server` and `sitesvc`. +2. `go vet ./...` clean in all three. +3. `docker build -f server/Dockerfile .` and `docker build -f sitesvc/Dockerfile .` + both succeed from the repo root. +4. `grep -r "org_id" sitesvc/` returns hits only in `PendingSignup` context and + `shared` imports — no local struct redefinitions. +5. End-to-end against a scratch database: sitesvc signup form → verification link + → org and owner created → that owner logs into the control plane + successfully. This is the test that proves the two services still agree. +6. The agent still builds for `linux/amd64`, `linux/arm64` and `windows/amd64`. + +## Rollout + +Single release. `server` and `sitesvc` images must be deployed together — a skew +is harmless here (documents are unchanged) but there is no reason to split it. + +No database migration. No downtime. + +## Risks + +| Risk | Mitigation | +|---|---| +| Docker context change breaks CI | Verified locally by building both images from root before pushing | +| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move | +| `shared` accumulating control-plane concerns | Explicit non-goals above; keep its `go.mod` dependency list to three entries and review any addition | +| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings | + +## Follow-on + +Spec 0b (`instance-rename`) becomes a rename inside one module plus its +consumers, rather than a rename across three independent copies. That is the +whole reason this spec goes first. diff --git a/docs/superpowers/specs/README.md b/docs/superpowers/specs/README.md new file mode 100644 index 0000000..a69e8ec --- /dev/null +++ b/docs/superpowers/specs/README.md @@ -0,0 +1,67 @@ +# Vantage Licensing Programme — Spec Index + +Seven specs, designed 2026-07-24. Build in this order. + +| # | Spec | Ships alone | Blocks | +|---|---|---|---| +| 0a | [shared-module](2026-07-24-shared-module-design.md) | yes | everything | +| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | yes | 1, 2, 3 | +| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | yes | 2, 3 | +| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | yes, with `lkctl`-issued licences | — | +| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | no | 4, 5 | +| 4 | [admin-site](2026-07-24-admin-site-design.md) | no | — | +| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | no | — | + +4 and 5 can run in parallel once 3 lands. + +## The shape + +``` +Account (admin only) + ├── Instance 1 cloud vantage.hostxtra.co.uk/ licence auto-injected + ├── Instance 2 cloud licence auto-injected + └── Instance 3 self-hosted customer's own deployment licence pasted by hand +``` + +The control plane knows only **Instance**. Accounts exist solely in the admin +service, because a self-hosted instance has no row in the cloud database at all. + +## Decisions that everything else follows from + +**Licences are offline-verified signed blobs.** ed25519 via +`github.com/hyperboloide/lk`, public key compiled into the server, no phone-home +anywhere. This buys air-gapped self-hosting and means no Vantage instance ever +depends on the licensing service being up. It costs revocation: a licence is +valid until it expires whatever Paddle later says. Self Hosted is annual-only to +bound that window. + +**Every licence is bound to one instance UUID.** Self-hosted customers link their +UUID before the licence is signed, so there is no unbound licence and no claim +protocol. + +**Expiry degrades, it does not break.** Monitors keep executing, alerts keep +firing, agents keep their keys, in-flight workflow runs finish. Mutations stop. +Deletes and OS-update application stay open so a customer is never trapped +over-limit or unpatched. + +**Tiers are data, not code.** The server reads `Limits` and `Features` and never +branches on tier name. Tier contents live in the admin `plans` table and are +snapshotted into each issued licence, so editing a plan never rewrites history — +the same rule as `workflow_runs.steps_snapshot`. + +| | Free | Professional | Self Hosted | +|---|---|---|---| +| deployment | cloud only | cloud | self-hosted | +| max servers | 3 | unlimited | unlimited | +| max secret groups | 1 | unlimited | unlimited | +| max channels | 1 | unlimited | unlimited | +| console | no | yes | yes | +| OIDC | no | yes | yes | +| term | monthly, £0 | monthly or annual | annual only | + +Free is cloud-only by construction: it is only ever signed with +`deployment: "cloud"`, and verification rejects a deployment mismatch. There is +no server-side flag to edit. One Free instance per account. + +**Existing cloud tenants** are grandfathered to Professional, one year out, by +migration `0005`.