# Licensing Core Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add `shared/license`, the signed licence payload with offline verification, plus `lkctl` to issue licences by hand.

**Architecture:** A new package inside the existing `shared` module, so the control plane (verifier) and the future admin service (signer) read one struct definition. Signing is excluded from the server binary by a build tag; the trusted public keys are compiled in as a slice. No network calls anywhere — verification checks a signature, an expiry, a deployment mode and an instance ID, and asks nobody's permission.

**Tech Stack:** Go 1.26, `github.com/hyperboloide/lk` (ECDSA P-384 + SHA-256, base32), the existing `shared` module.

**No automated tests.** Verification is by compiler, a probe command, and `lkctl` round trips at the command line. Every task ends with observable output.

## Global Constraints

- Package path: `gitea.hostxtra.co.uk/mrhid6/vantage/shared/license`
- `shared/go.mod` gains **one** dependency: `github.com/hyperboloide/lk`. This is a deliberate exception to plan 0a's three-dependency limit, and the only one. `shared` must still not import gin, redis, guac or the mongo-driver from this package.
- Tier and deployment values are exact strings: `free`, `professional`, `self_hosted`; `cloud`, `self_hosted`.
- Feature keys are exact strings: `console`, `oidc`.
- `-1` means unlimited in every `Limits` field.
- **The verifier never branches on `Tier`.** It reads `Limits` and `Features` only. `Tier` is for display, support and analytics.
- **Every licence is bound to an instance.** There is no unbound licence and no claim protocol.
- The private signing key never appears in the repo, in an image, or in the control plane's environment.
- Do not run the app with `go run` — use the Docker images. `lkctl` is a local CLI, not the app, and may be run directly.

---

## File Structure

**Created:**

| Path                        | Responsibility                                         |
| --------------------------- | ------------------------------------------------------ |
| `shared/license/license.go` | `License`, `Limits`, tier/deployment/feature constants |
| `shared/license/keys.go`    | `trustedPublicKeys`, key lookup                        |
| `shared/license/sign.go`    | `Sign`, build-tagged out of the server                 |
| `shared/license/verify.go`  | `Verify`, `Parse`, `VerifyOpts`, `Result`, `State`     |
| `shared/license/plans.go`   | The tier seed table used by `lkctl`                    |
| `shared/cmd/lkctl/main.go`  | `keypair`, `issue`, `inspect`                          |

**Modified:** `shared/go.mod`, `shared/go.sum`.

---

### Task 1: Add the dependency and confirm the library's API

The rest of the plan assumes specific `lk` function names. Confirm them first rather than discovering a mismatch three tasks later.

**Files:**

- Modify: `shared/go.mod`, `shared/go.sum`
- Create (temporary): `shared/cmd/lkprobe/main.go`

**Interfaces:**

- Consumes: nothing
- Produces: a pinned `lk` version and a confirmed API surface

- [ ] **Step 1: Add the dependency**

```bash
cd c:/Work/Repos/vantage/shared
go get github.com/hyperboloide/lk@latest
```

- [ ] **Step 2: Write a probe that exercises the whole surface this plan needs**

Create `shared/cmd/lkprobe/main.go`:

```go
package main

import (
	"fmt"

	"github.com/hyperboloide/lk"
)

func main() {
	priv, err := lk.NewPrivateKey()
	if err != nil {
		panic(err)
	}
	privStr, err := priv.ToB32String()
	if err != nil {
		panic(err)
	}
	fmt.Println("private b32 len:", len(privStr))

	pub := priv.GetPublicKey()
	pubStr, err := pub.ToB32String()
	if err != nil {
		panic(err)
	}
	fmt.Println("public b32 len:", len(pubStr))

	l, err := lk.NewLicense(priv, []byte(`{"hello":"world"}`))
	if err != nil {
		panic(err)
	}
	blob, err := l.ToB32String()
	if err != nil {
		panic(err)
	}
	fmt.Println("licence b32 len:", len(blob))

	parsed, err := lk.LicenseFromB32String(blob)
	if err != nil {
		panic(err)
	}
	pub2, err := lk.PublicKeyFromB32String(pubStr)
	if err != nil {
		panic(err)
	}
	ok, err := parsed.Verify(pub2)
	fmt.Println("verify:", ok, err)
	fmt.Println("data:", string(parsed.Data))

	// Tamper: a licence signed by a different key must not verify.
	other, _ := lk.NewPrivateKey()
	bad, _ := lk.NewLicense(other, []byte(`{"hello":"world"}`))
	badBlob, _ := bad.ToB32String()
	badParsed, _ := lk.LicenseFromB32String(badBlob)
	ok2, _ := badParsed.Verify(pub2)
	fmt.Println("foreign key verify (must be false):", ok2)
}
```

- [ ] **Step 3: Run the probe**

Run: `cd shared && go run ./cmd/lkprobe`

Expected: non-zero lengths for all three, `verify: true <nil>`, the JSON echoed back, and `foreign key verify (must be false): false`.

**If any function name does not compile, fix the probe against the installed version and carry the corrected names through every later task in this plan.** The names used below are `lk.NewPrivateKey`, `PrivateKey.ToB32String`, `PrivateKey.GetPublicKey`, `PublicKey.ToB32String`, `lk.PrivateKeyFromB32String`, `lk.PublicKeyFromB32String`, `lk.NewLicense`, `License.ToB32String`, `lk.LicenseFromB32String`, `License.Verify`, `License.Data`.

- [ ] **Step 4: Delete the probe and commit**

```bash
cd c:/Work/Repos/vantage
rm -rf shared/cmd/lkprobe
git add shared/go.mod shared/go.sum
git commit -m "chore(shared): add hyperboloide/lk for licence signing"
```

---

### Task 2: The payload and the tier table

**Files:**

- Create: `shared/license/license.go`
- Create: `shared/license/plans.go`

**Interfaces:**

- Consumes: nothing
- Produces:
    - `type License struct` with fields `ID, InstanceID, AccountID, InstanceName, Tier, Deployment string`, `IssuedAt, ExpiresAt time.Time`, `Limits Limits`, `Features []string`
    - `type Limits struct { MaxServers, MaxSecretGroups, MaxChannels int }`
    - `const TierFree = "free"`, `TierProfessional = "professional"`, `TierSelfHosted = "self_hosted"`
    - `const DeploymentCloud = "cloud"`, `DeploymentSelfHosted = "self_hosted"`
    - `const FeatureConsole = "console"`, `FeatureOIDC = "oidc"`
    - `func (l License) HasFeature(name string) bool`
    - `func (l Limits) Allows(current, max int) bool` — no; see below for the exact helper
    - `type Plan struct` and `func PlanFor(tier string) (Plan, bool)`

- [ ] **Step 1: Write the payload**

Create `shared/license/license.go`:

```go
// Package license defines the Vantage licence payload and its offline
// verification.
//
// A licence is a signed blob (ECDSA P-384 with SHA-256). The server checks a signature, an
// expiry, a deployment mode and an instance ID, and asks nobody's permission.
// That buys air-gapped self-hosting and means no instance depends on the
// licensing service being reachable.
//
// It costs revocation: once issued, a licence is valid until it expires
// whatever the billing system later says. Self Hosted is sold annually only so
// that window is bounded.
package license

import "time"

const (
	TierFree         = "free"
	TierProfessional = "professional"
	TierSelfHosted   = "self_hosted"

	DeploymentCloud      = "cloud"
	DeploymentSelfHosted = "self_hosted"

	FeatureConsole = "console" // browser SSH/RDP/VNC
	FeatureOIDC    = "oidc"    // per-instance single sign-on
)

// Unlimited is the sentinel for "no cap" in every Limits field.
const Unlimited = -1

// Limits are the countable caps a licence grants.
type Limits struct {
	MaxServers      int `json:"max_servers"`
	MaxSecretGroups int `json:"max_secret_groups"`
	MaxChannels     int `json:"max_channels"`
}

// License is the signed payload.
//
// InstanceID is always populated: the self-hosted purchase flow links the
// instance UUID before the licence is signed, so there is no unbound licence
// and no claim protocol.
type License struct {
	ID           string    `json:"id"`            // uuid, for support and audit
	InstanceID   string    `json:"instance_id"`   // the instance this licence is bound to
	AccountID    string    `json:"account_id"`    // admin-side customer, informational
	InstanceName string    `json:"instance_name"` // display only
	Tier         string    `json:"tier"`
	Deployment   string    `json:"deployment"`
	IssuedAt     time.Time `json:"issued_at"`
	ExpiresAt    time.Time `json:"expires_at"`
	Limits       Limits    `json:"limits"`
	Features     []string  `json:"features"`
}

// HasFeature reports whether the licence grants a named feature.
//
// Callers must use this rather than switching on Tier. Adding a tier, or
// changing what a tier includes, must never require a server release.
func (l License) HasFeature(name string) bool {
	for _, f := range l.Features {
		if f == name {
			return true
		}
	}
	return false
}

// WithinLimit reports whether one more of something is allowed.
// A max of Unlimited always allows.
func WithinLimit(current, max int) bool {
	if max == Unlimited {
		return true
	}
	return current < max
}
```

- [ ] **Step 2: Write the tier seed table**

Create `shared/license/plans.go`:

```go
package license

// Plan is the contents of a tier at issue time.
//
// This table is the seed. Once the admin service exists (spec 3) it owns the
// authoritative copy in its `plans` collection, and every issued licence
// snapshots the plan it was cut from — so editing a plan never rewrites an
// existing licence, the same rule as workflow_runs.steps_snapshot.
//
// lkctl uses this table to issue by hand until then.
type Plan struct {
	Tier       string
	Name       string
	Deployment string
	Limits     Limits
	Features   []string
}

var plans = map[string]Plan{
	TierFree: {
		Tier:       TierFree,
		Name:       "Free",
		Deployment: DeploymentCloud, // cloud only, by construction
		Limits:     Limits{MaxServers: 3, MaxSecretGroups: 1, MaxChannels: 1},
		Features:   nil,
	},
	TierProfessional: {
		Tier:       TierProfessional,
		Name:       "Professional",
		Deployment: DeploymentCloud,
		Limits:     Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
		Features:   []string{FeatureConsole, FeatureOIDC},
	},
	TierSelfHosted: {
		Tier:       TierSelfHosted,
		Name:       "Self Hosted",
		Deployment: DeploymentSelfHosted,
		Limits:     Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
		Features:   []string{FeatureConsole, FeatureOIDC},
	},
}

// PlanFor returns the seed plan for a tier.
func PlanFor(tier string) (Plan, bool) {
	p, ok := plans[tier]
	return p, ok
}
```

Note Free's `Deployment` is `cloud`. That single value is what makes Free cloud-only: verification rejects a deployment mismatch, so a self-hosted install can never hold a valid Free licence, and there is no server-side flag to edit.

- [ ] **Step 3: Build**

Run: `cd shared && go build ./... && go vet ./...`
Expected: no output.

- [ ] **Step 4: Commit**

```bash
git add shared/license/license.go shared/license/plans.go
git commit -m "feat(license): add the licence payload and tier seed table"
```

---

### Task 3: Signing, and keeping it out of the server

**Files:**

- Create: `shared/license/keys.go`
- Create: `shared/license/sign.go`

**Interfaces:**

- Consumes: `License`
- Produces:
    - `func Sign(l License, privateKeyB32 string) (string, error)` — build tag `!noSign`
    - `var trustedPublicKeys []string`
    - `func publicKeys() ([]*lk.PublicKey, error)`

- [ ] **Step 1: Write the trusted key list**

Create `shared/license/keys.go`:

```go
package license

import (
	"fmt"

	"github.com/hyperboloide/lk"
)

// trustedPublicKeys are the keys a licence may be signed with, newest first.
//
// To rotate: prepend the new key, ship a server release that trusts both, then
// reissue. Remove a retired key only once every licence signed with it has
// expired.
//
// This is 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.
//
// These are compiled in and deliberately not configurable. A configurable trust
// root is a licensing bypass: a self-hosted operator could point it at a keypair
// they generated themselves.
var trustedPublicKeys = []string{
	// Populated in Task 6 with the real production key.
	// Until then this slice is empty and every licence fails to verify,
	// which is the correct default for a build with no trust root.
}

func publicKeys() ([]*lk.PublicKey, error) {
	out := make([]*lk.PublicKey, 0, len(trustedPublicKeys))
	for i, s := range trustedPublicKeys {
		k, err := lk.PublicKeyFromB32String(s)
		if err != nil {
			return nil, fmt.Errorf("trusted public key %d is malformed: %w", i, err)
		}
		out = append(out, k)
	}
	return out, nil
}
```

- [ ] **Step 2: Write the signer, excluded from the server build**

Create `shared/license/sign.go`:

```go
//go:build !noSign

package license

import (
	"encoding/json"
	"fmt"

	"github.com/hyperboloide/lk"
)

// Sign marshals a licence and signs it, returning the base32 blob.
//
// This file carries the !noSign build tag so the signing path can be compiled
// out of the control plane. The server has no reason to hold signing code and
// no reason to ship it into a customer's data centre.
//
// privateKeyB32 comes from LICENSE_SIGNING_KEY on the issuing side only.
func Sign(l License, privateKeyB32 string) (string, error) {
	if privateKeyB32 == "" {
		return "", fmt.Errorf("no signing key provided")
	}
	priv, err := lk.PrivateKeyFromB32String(privateKeyB32)
	if err != nil {
		return "", fmt.Errorf("parse signing key: %w", err)
	}

	data, err := json.Marshal(l)
	if err != nil {
		return "", fmt.Errorf("marshal licence: %w", err)
	}

	signed, err := lk.NewLicense(priv, data)
	if err != nil {
		return "", fmt.Errorf("sign licence: %w", err)
	}

	blob, err := signed.ToB32String()
	if err != nil {
		return "", fmt.Errorf("encode licence: %w", err)
	}
	return blob, nil
}
```

- [ ] **Step 3: Build both ways**

Run:

```bash
cd c:/Work/Repos/vantage/shared
go build ./... && go vet ./...
go build -tags noSign ./...
```

Expected: no output from any of them.

- [ ] **Step 4: Commit**

```bash
git add shared/license/keys.go shared/license/sign.go
git commit -m "feat(license): add signing and the trusted key list"
```

---

### Task 4: Verification

The heart of the system. Check order is part of the contract, because the reason drives the message a customer sees.

**Files:**

- Create: `shared/license/verify.go`

**Interfaces:**

- Consumes: `License`, `publicKeys()`
- Produces:
    - `type State string`, `const StateValid = "valid"`, `StateExpired = "expired"`, `StateInvalid = "invalid"`
    - `type VerifyOpts struct { InstanceID, Deployment string; Now time.Time }`
    - `type Result struct { License License; State State; Reason string }`
    - `func Verify(blob string, opts VerifyOpts) Result`
    - `func Parse(blob string) (License, error)`
    - Reason constants: `ReasonNoLicense`, `ReasonBadSignature`, `ReasonDeploymentMismatch`, `ReasonInstanceMismatch`, `ReasonExpired`

- [ ] **Step 1: Write the verifier**

Create `shared/license/verify.go`:

```go
package license

import (
	"encoding/json"
	"fmt"
	"time"

	"github.com/hyperboloide/lk"
)

type State string

const (
	StateValid   State = "valid"
	StateExpired State = "expired"
	StateInvalid State = "invalid"
)

// Reasons a licence is not valid. These are stable identifiers: the API returns
// them and the UI maps them to messages, so do not reword them casually.
const (
	ReasonNoLicense          = "no_license"
	ReasonBadSignature       = "bad_signature"
	ReasonDeploymentMismatch = "deployment_mismatch"
	ReasonInstanceMismatch   = "instance_mismatch"
	ReasonExpired            = "expired"
)

// VerifyOpts is what the verifier knows about itself.
type VerifyOpts struct {
	InstanceID string    // this instance's own ID; required
	Deployment string    // "cloud" or "self_hosted"; required
	Now        time.Time // zero means time.Now()
}

type Result struct {
	License License
	State   State
	Reason  string
	// ClockSkewed is set when IssuedAt is in the future, which usually means
	// the host clock is wrong. It does not by itself invalidate the licence.
	ClockSkewed bool
}

// Verify checks a licence blob against this instance.
//
// The checks run in a fixed order and stop at the first failure:
//
//  1. signature against a trusted public key  -> bad_signature
//  2. deployment matches this install         -> deployment_mismatch
//  3. instance ID matches this instance       -> instance_mismatch
//  4. not past ExpiresAt                      -> expired
//
// The order matters. A blob that is both expired and bound to another instance
// reports instance_mismatch, not expired, because that is the more useful thing
// to tell the person holding it.
//
// No clock tolerance is applied. Terms are a month or a year; a host whose clock
// is wrong by enough to matter has larger problems, and a tolerance window is a
// thing to get wrong.
func Verify(blob string, opts VerifyOpts) Result {
	if blob == "" {
		return Result{State: StateInvalid, Reason: ReasonNoLicense}
	}

	l, err := Parse(blob)
	if err != nil {
		return Result{State: StateInvalid, Reason: ReasonBadSignature}
	}

	res := Result{License: l}

	if l.Deployment != opts.Deployment {
		res.State, res.Reason = StateInvalid, ReasonDeploymentMismatch
		return res
	}
	if l.InstanceID != opts.InstanceID {
		res.State, res.Reason = StateInvalid, ReasonInstanceMismatch
		return res
	}

	now := opts.Now
	if now.IsZero() {
		now = time.Now()
	}
	res.ClockSkewed = l.IssuedAt.After(now)

	if !now.Before(l.ExpiresAt) {
		res.State, res.Reason = StateExpired, ReasonExpired
		return res
	}

	res.State = StateValid
	return res
}

// Parse verifies the signature only, ignoring binding and expiry.
//
// Used to display a licence and to inspect a blob a customer has emailed in.
// Never use it for enforcement — it does not check who the licence is for.
func Parse(blob string) (License, error) {
	parsed, err := lk.LicenseFromB32String(blob)
	if err != nil {
		return License{}, fmt.Errorf("licence is not readable: %w", err)
	}

	keys, err := publicKeys()
	if err != nil {
		return License{}, err
	}
	if len(keys) == 0 {
		return License{}, fmt.Errorf("this build trusts no licence signing keys")
	}

	verified := false
	for _, k := range keys {
		ok, err := parsed.Verify(k)
		if err == nil && ok {
			verified = true
			break
		}
	}
	if !verified {
		return License{}, fmt.Errorf("licence signature does not match any trusted key")
	}

	var l License
	if err := json.Unmarshal(parsed.Data, &l); err != nil {
		return License{}, fmt.Errorf("licence contents are not readable: %w", err)
	}
	return l, nil
}
```

- [ ] **Step 2: Build both ways**

Run:

```bash
cd c:/Work/Repos/vantage/shared
go build ./... && go vet ./...
go build -tags noSign ./...
```

Expected: no output. The `noSign` build must succeed — `verify.go` must not reference anything in `sign.go`.

- [ ] **Step 3: Commit**

```bash
git add shared/license/verify.go
git commit -m "feat(license): add offline verification"
```

---

### Task 5: lkctl

The production issuance path until the admin service exists, and the disaster-recovery path forever after — if admin is down and a customer's licence expires, a blob can still be cut by hand.

**Files:**

- Create: `shared/cmd/lkctl/main.go`

**Interfaces:**

- Consumes: `Sign`, `Parse`, `PlanFor`, `License`
- Produces: the `lkctl` binary

- [ ] **Step 1: Write it**

Create `shared/cmd/lkctl/main.go`:

```go
// Command lkctl issues and inspects Vantage licences by hand.
//
//	lkctl keypair
//	lkctl issue --instance-id=<uuid> --instance-name="Acme" --tier=professional --term=1y
//	lkctl inspect <blob-or-file>
//
// issue reads the signing key from LICENSE_SIGNING_KEY.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/hyperboloide/lk"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)

func main() {
	if len(os.Args) < 2 {
		usage()
	}
	switch os.Args[1] {
	case "keypair":
		keypair()
	case "issue":
		issue(os.Args[2:])
	case "inspect":
		inspect(os.Args[2:])
	default:
		usage()
	}
}

func usage() {
	fmt.Fprintln(os.Stderr, "usage: lkctl keypair | issue | inspect")
	os.Exit(2)
}

func keypair() {
	priv, err := lk.NewPrivateKey()
	if err != nil {
		fatal("generate key: %v", err)
	}
	privStr, err := priv.ToB32String()
	if err != nil {
		fatal("encode private key: %v", err)
	}
	pubStr, err := priv.GetPublicKey().ToB32String()
	if err != nil {
		fatal("encode public key: %v", err)
	}

	fmt.Println("PRIVATE KEY (store in a password manager and in the admin service's")
	fmt.Println("LICENSE_SIGNING_KEY; back it up in two places, it cannot be recovered):")
	fmt.Println()
	fmt.Println(privStr)
	fmt.Println()
	fmt.Println("PUBLIC KEY (paste into trustedPublicKeys in shared/license/keys.go):")
	fmt.Println()
	fmt.Println(pubStr)
}

func issue(args []string) {
	fs := flag.NewFlagSet("issue", flag.ExitOnError)
	instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)")
	instanceName := fs.String("instance-name", "", "display name")
	accountID := fs.String("account-id", "", "admin-side account id, optional")
	tier := fs.String("tier", "", "free | professional | self_hosted (required)")
	term := fs.String("term", "1y", "1m or 1y")
	expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term")
	out := fs.String("out", "", "write the blob to this file instead of stdout")
	fs.Parse(args)

	if *instanceID == "" || *tier == "" {
		fatal("--instance-id and --tier are required")
	}

	plan, ok := license.PlanFor(*tier)
	if !ok {
		fatal("unknown tier %q", *tier)
	}

	key := os.Getenv("LICENSE_SIGNING_KEY")
	if key == "" {
		fatal("LICENSE_SIGNING_KEY is not set")
	}

	now := time.Now().UTC()
	var exp time.Time
	switch {
	case *expires != "":
		t, err := time.Parse(time.RFC3339, *expires)
		if err != nil {
			fatal("parse --expires: %v", err)
		}
		exp = t.UTC()
	case *term == "1m":
		exp = now.AddDate(0, 1, 0)
	case *term == "1y":
		exp = now.AddDate(1, 0, 0)
	default:
		fatal("--term must be 1m or 1y")
	}

	// Self Hosted is sold annually only, so the window in which a cancelled
	// licence keeps working is bounded at a year.
	if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" {
		fatal("self_hosted is annual only; use --term=1y or an explicit --expires")
	}

	name := *instanceName
	if name == "" {
		name = *instanceID
	}

	l := license.License{
		ID:           uuid.NewString(),
		InstanceID:   *instanceID,
		AccountID:    *accountID,
		InstanceName: name,
		Tier:         plan.Tier,
		Deployment:   plan.Deployment,
		IssuedAt:     now,
		ExpiresAt:    exp,
		Limits:       plan.Limits,
		Features:     plan.Features,
	}

	blob, err := license.Sign(l, key)
	if err != nil {
		fatal("%v", err)
	}

	if *out != "" {
		if err := os.WriteFile(*out, []byte(blob+"\n"), 0o600); err != nil {
			fatal("write %s: %v", *out, err)
		}
		fmt.Fprintf(os.Stderr, "wrote %s (tier=%s deployment=%s expires=%s)\n",
			*out, l.Tier, l.Deployment, l.ExpiresAt.Format(time.RFC3339))
		return
	}
	fmt.Println(blob)
}

func inspect(args []string) {
	if len(args) < 1 {
		fatal("usage: lkctl inspect <blob-or-file>")
	}
	blob := args[0]
	if b, err := os.ReadFile(blob); err == nil {
		blob = strings.TrimSpace(string(b))
	}

	l, err := license.Parse(blob)
	if err != nil {
		fatal("%v", err)
	}

	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(l); err != nil {
		fatal("%v", err)
	}

	if time.Now().After(l.ExpiresAt) {
		fmt.Fprintf(os.Stderr, "\nNOTE: expired %s\n", l.ExpiresAt.Format(time.RFC3339))
	}
}

func fatal(format string, args ...any) {
	fmt.Fprintf(os.Stderr, format+"\n", args...)
	os.Exit(1)
}
```

- [ ] **Step 2: Build**

Run: `cd shared && go build ./... && go vet ./...`
Expected: no output.

- [ ] **Step 3: Commit**

```bash
git add shared/cmd/lkctl
git commit -m "feat(license): add lkctl for issuing licences by hand"
```

---

### Task 6: Generate the production keypair and wire it in

This is the step that makes the system real. Do it once, carefully.

**Files:**

- Modify: `shared/license/keys.go`

**Interfaces:**

- Consumes: `lkctl keypair`
- Produces: a populated `trustedPublicKeys`

- [ ] **Step 1: Generate the keypair**

Run: `cd shared && go run ./cmd/lkctl keypair`

- [ ] **Step 2: Store the private key**

Put the private key in **two** places before continuing:

1. A password manager entry named "Vantage licence signing key (production)".
2. The admin service's secret store, ready for `LICENSE_SIGNING_KEY` in spec 3.

**If this key is lost, no new licence can be issued for any existing customer without shipping a server release.** There is no recovery. Confirm both copies exist and are readable before moving on.

- [ ] **Step 3: Paste the public key in**

In `shared/license/keys.go`, replace the empty slice:

```go
var trustedPublicKeys = []string{
	// Production signing key, generated 2026-07-24. Index 0 is current.
	"<the public key printed by lkctl keypair>",
}
```

- [ ] **Step 4: Confirm a full round trip**

```bash
cd c:/Work/Repos/vantage/shared
export LICENSE_SIGNING_KEY='<the private key>'
go run ./cmd/lkctl issue \
  --instance-id=11111111-2222-3333-4444-555555555555 \
  --instance-name="Round Trip Ltd" \
  --tier=professional --term=1y > /tmp/rt.lic
go run ./cmd/lkctl inspect /tmp/rt.lic
```

Expected: JSON showing `"tier": "professional"`, `"deployment": "cloud"`, the instance ID you passed, `max_servers: -1`, and `features` containing `console` and `oidc`.

- [ ] **Step 5: Confirm the rejections**

Create a temporary `shared/cmd/vercheck/main.go`:

```go
package main

import (
	"fmt"
	"os"
	"strings"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)

func main() {
	b, err := os.ReadFile(os.Args[1])
	if err != nil {
		panic(err)
	}
	blob := strings.TrimSpace(string(b))

	const good = "11111111-2222-3333-4444-555555555555"

	show := func(label string, r license.Result) {
		fmt.Printf("%-22s state=%-8s reason=%s\n", label, r.State, r.Reason)
	}

	show("correct", license.Verify(blob, license.VerifyOpts{
		InstanceID: good, Deployment: license.DeploymentCloud}))

	show("wrong instance", license.Verify(blob, license.VerifyOpts{
		InstanceID: "99999999-9999-9999-9999-999999999999", Deployment: license.DeploymentCloud}))

	show("wrong deployment", license.Verify(blob, license.VerifyOpts{
		InstanceID: good, Deployment: license.DeploymentSelfHosted}))

	show("expired", license.Verify(blob, license.VerifyOpts{
		InstanceID: good, Deployment: license.DeploymentCloud,
		Now: time.Now().AddDate(2, 0, 0)}))

	show("tampered", license.Verify(blob[:len(blob)-2]+"AA", license.VerifyOpts{
		InstanceID: good, Deployment: license.DeploymentCloud}))

	show("empty", license.Verify("", license.VerifyOpts{
		InstanceID: good, Deployment: license.DeploymentCloud}))
}
```

Run: `cd shared && go run ./cmd/vercheck /tmp/rt.lic`

Expected, exactly:

```
correct                state=valid    reason=
wrong instance         state=invalid  reason=instance_mismatch
wrong deployment       state=invalid  reason=deployment_mismatch
expired                state=expired  reason=expired
tampered               state=invalid  reason=bad_signature
empty                  state=invalid  reason=no_license
```

**`wrong deployment` reporting `deployment_mismatch` rather than `instance_mismatch` proves the check order.** That order is what makes Free cloud-only.

- [ ] **Step 6: Confirm a Free licence cannot be issued for self-hosted use**

```bash
cd shared
go run ./cmd/lkctl issue --instance-id=$(uuidgen 2>/dev/null || echo 11111111-2222-3333-4444-555555555555) \
  --tier=free --term=1m > /tmp/free.lic
go run ./cmd/vercheck /tmp/free.lic
```

Expected: the `correct` line reports `valid` only when the instance matches; the `wrong deployment` line — which asks for `self_hosted` — reports `deployment_mismatch`. A Free licence is signed `deployment: cloud` and can never satisfy a self-hosted install.

- [ ] **Step 7: Confirm Self Hosted refuses a monthly term**

Run: `cd shared && go run ./cmd/lkctl issue --instance-id=x --tier=self_hosted --term=1m`
Expected: `self_hosted is annual only; use --term=1y or an explicit --expires`, exit 1.

- [ ] **Step 8: Confirm the signer is absent from a noSign build**

```bash
cd c:/Work/Repos/vantage/shared
go build -tags noSign -o /tmp/nosign-probe ./cmd/lkctl 2>&1 | head -3
```

Expected: a **compile error** naming `license.Sign` as undefined. That failure is the proof the build tag works — `lkctl` needs the signer, so a `noSign` build of it must not link.

- [ ] **Step 9: Clean up and commit**

```bash
cd c:/Work/Repos/vantage
rm -rf shared/cmd/vercheck /tmp/rt.lic /tmp/free.lic
unset LICENSE_SIGNING_KEY
git add shared/license/keys.go
git commit -m "feat(license): trust the production signing key"
```

**Do not commit the private key. Check the diff before committing.**

---

### Task 7: Final verification

**Files:** none

- [ ] **Step 1: Build every module**

```bash
cd c:/Work/Repos/vantage
(cd shared  && go build ./... && go vet ./...)
(cd server  && go build ./... && go vet ./...)
(cd sitesvc && go build ./... && go vet ./...)
(cd agent   && GOWORK=off go build ./... && GOWORK=off go vet ./...)
```

Expected: no output.

- [ ] **Step 2: Confirm the images still build**

```bash
cd c:/Work/Repos/vantage
docker build -q -f server/Dockerfile -t vantage-server:lic .
docker build -q -f sitesvc/Dockerfile -t vantage-sitesvc:lic .
```

Expected: both print an image digest.

- [ ] **Step 3: Confirm the signing key is nowhere in the repo**

```bash
cd c:/Work/Repos/vantage
git log -p --all | grep -c "LICENSE_SIGNING_KEY='" || true
grep -rn "$(echo YOUR_PRIVATE_KEY_PREFIX)" --include=* . 2>/dev/null | head -3
```

Expected: no commit contains the private key. Check the first 12 characters of the real private key against the working tree and the log before considering this done.

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "chore: verify licensing core end to end" --allow-empty
```

## What this unblocks

Plan 2 (`instance-licensing`) can store a blob on the instance document, resolve
it into a runtime state, and gate the API on it — with real licences to test
against, cut by `lkctl`, before any admin service exists.
