@@ -327,6 +327,7 @@ POST /auth/staff/login /auth/login /auth/logout
|
||||
POST /auth/signup # self-hosted only; honeypot + rate limited
|
||||
GET /auth/verify?token=…
|
||||
POST /auth/accept-invite # an invitee sets their own password
|
||||
POST /api/paddle/webhook # Paddle events; signature-verified, idempotent, no session
|
||||
```
|
||||
|
||||
Customer-session (`/api`), every instance resolved through `ownedInstance`:
|
||||
@@ -338,6 +339,11 @@ POST /instances/:id/renew # Free renewal; refuses outside t
|
||||
POST /instances/:id/claim-free # issue Free on a linked self-hosted instance
|
||||
POST /instances/link · /instances/:id/relink
|
||||
GET /instances/:id/entitlement
|
||||
GET /checkout/options # active plans + catalogue prices for the running PADDLE_ENV
|
||||
POST /instances/self-hosted # create a paid-checkout placeholder (awaiting_link, no licence)
|
||||
POST /instances/:id/claim-link # bind a paid placeholder to the real UUID and issue
|
||||
PUT /instances/:id/entitlement # set desired config; pushes line items to Paddle (owner|admin)
|
||||
POST /billing/portal # mint a Paddle customer-portal URL
|
||||
GET /instances/:id/license · /instances/:id/license/download
|
||||
GET /subscriptions
|
||||
GET,POST /account/users · PUT /account/users/:id/role · DELETE /account/users/:id
|
||||
@@ -360,11 +366,17 @@ POST /instances/:id/issue · /instances/:id/relink
|
||||
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier
|
||||
GET,PUT /catalogue
|
||||
GET,PUT /instances/:id/entitlement
|
||||
GET /health/injection
|
||||
GET /health/injection · /health/billing
|
||||
```
|
||||
|
||||
**Customer endpoints answer 404, never 403, for another account's resource** — a 403 confirms the resource exists. Route-group guards in `adminsite/` mirror this, but the backend is the layer that matters.
|
||||
|
||||
### Billing (Paddle)
|
||||
|
||||
Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session.
|
||||
|
||||
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's *current* line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
|
||||
@@ -381,7 +393,7 @@ Notes that are not obvious from the structs:
|
||||
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
|
||||
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
|
||||
|
||||
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
|
||||
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
|
||||
|
||||
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
|
||||
|
||||
@@ -613,6 +625,10 @@ git push origin main # server + web deploy
|
||||
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
|
||||
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
|
||||
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
|
||||
| `PADDLE_CLIENT_TOKEN`| Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. |
|
||||
| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. |
|
||||
| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. |
|
||||
| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user