# 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:<event id>"
    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 | ECDSA P-384 private key, base32 (lk PrivateKey.ToB32String). **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 |
