diff --git a/docs/superpowers/plans/2026-08-12-api-tokens-openapi.md b/docs/superpowers/plans/2026-08-12-api-tokens-openapi.md new file mode 100644 index 0000000..349e0ff --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-api-tokens-openapi.md @@ -0,0 +1,2049 @@ +# API Tokens and OpenAPI Reference 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 scoped, optionally expiring API tokens to the Vantage control plane REST API, and publish a generated OpenAPI 3.1 document rendered by a self-hosted Scalar reference page. + +**Architecture:** Tokens fall back into the existing `auth.Middleware()` rather than getting their own route group, so every handler, `RequireRole`, `RequireActiveLicense`, `RequireFeature` and `actorFromCtx` keep working untouched. A second middleware enforces coarse resource scopes derived from the matched gin route pattern, failing closed on any unmapped route and refusing to boot if an `/api` route is missing from the map. The OpenAPI document is generated from swaggo v2 annotations, committed, embedded, and verified in CI by regenerate-and-diff. + +**Tech Stack:** Go 1.24 (`server` module), gin, MongoDB (mongo-driver v2), Redis (go-redis v9), Next.js 16 + TanStack Query (`web`), swaggo/swag v2, Scalar standalone JS (vendored). + +**Spec:** `docs/superpowers/specs/2026-08-12-api-tokens-openapi-design.md` + +## Global Constraints + +- Module path prefix is `gitea.hostxtra.co.uk/mrhid6/vantage/`. +- Every new document carries `instance_id`, and every query is scoped by it. +- Any new tenant-scoped collection MUST be added to `services.ScopedCollections` in `server/internal/services/migrate_instance.go`, or `AssertNoScopedCollectionMissed` fails at boot and instance purge leaks rows. +- Token plaintext is stored nowhere. Only `sha256` hex, via the existing `services.HashToken`. +- Roles are `owner` > `admin` > `member`, constants `models.RoleOwner`, `models.RoleAdmin`, `models.RoleMember`. +- `web/` components must carry no hex colour values — Tailwind token classes only (`bg-surface-2`, `border-border`, `text-text-primary`, `bg-well`, `text-danger`, `text-warning`, `text-success`, `accent`). +- No test files are added by this plan. Every task is verified with `go build ./...`, `go vet ./...`, and a concrete curl or UI check. +- The repository has no running dev stack assumed; verification commands assume the server is started locally per the existing compose file with MongoDB and Redis reachable. +- Commit style follows the existing log: `feat:`, `fix:`, `docs:` with a capitalised imperative subject. +- Work happens on branch `feat/api-tokens`, which already exists and holds the spec commit. + +--- + +### Task 1: Token model, indexes and scoped-collection registration + +**Files:** +- Create: `server/internal/models/api_token.go` +- Modify: `server/internal/services/migrate_instance.go` (the `ScopedCollections` slice, after `"vuln_alert_rules"`) +- Create: `server/internal/services/tokenindexes.go` +- Modify: `server/cmd/main.go` (schema setup section, beside the other `Ensure*Indexes` calls) + +**Interfaces:** +- Consumes: nothing. +- Produces: `models.APIToken` struct; `models.TokenScopes` type alias `[]string`; `services.EnsureAPITokenIndexes() error`. + +- [ ] **Step 1: Create the model** + +Create `server/internal/models/api_token.go`: + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// APIToken is a personal access token for the REST API. +// +// The plaintext is shown once at creation and never stored: only TokenHash, +// which is sha256 hex of the value, exactly as servers.agent_token_hash and the +// ESO read token already are. bcrypt is deliberately not used — the value is +// full-entropy random rather than a chosen password, and a per-token salt would +// force a collection scan where an indexed lookup is wanted. +// +// Role and Scopes are immutable after creation. There is no update endpoint: +// editing what a credential already deployed in CI can do, with no record of +// what it could do before, is worse than requiring a rotation. +type APIToken struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + TokenID string `bson:"token_id" json:"token_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` + UserID string `bson:"user_id" json:"user_id"` + + Name string `bson:"name" json:"name"` + // Hint is the first 8 characters of the plaintext, stored in clear so the + // list can identify a token without revealing it. + Hint string `bson:"hint" json:"hint"` + // TokenHash is never serialised to JSON. + TokenHash string `bson:"token_hash" json:"-"` + + Role string `bson:"role" json:"role"` + Scopes []string `bson:"scopes" json:"scopes"` + + // ExpiresAt nil means the token never expires. Whether that is allowed is + // a per-instance policy, settings.api_token_max_days. + ExpiresAt *time.Time `bson:"expires_at,omitempty" json:"expires_at,omitempty"` + + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"` + CreatedByIP string `bson:"created_by_ip,omitempty" json:"created_by_ip,omitempty"` + + // Email of the owning user, joined at read time for the list. Never stored. + UserEmail string `bson:"-" json:"user_email,omitempty"` +} + +// Expired reports whether the token's expiry has passed. A nil ExpiresAt never +// expires. +func (t *APIToken) Expired(now time.Time) bool { + return t.ExpiresAt != nil && now.After(*t.ExpiresAt) +} +``` + +- [ ] **Step 2: Register the collection as tenant-scoped** + +In `server/internal/services/migrate_instance.go`, add `"api_tokens"` to the `ScopedCollections` slice, after `"vuln_alert_rules"`: + +```go + "vuln_alert_rules", + "api_tokens", +``` + +- [ ] **Step 3: Declare the indexes** + +Create `server/internal/services/tokenindexes.go`: + +```go +package services + +import ( + "context" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// EnsureAPITokenIndexes declares the indexes the token path depends on. +// +// The unique index on token_hash is a security property, not an optimisation: +// it is what makes authentication a single indexed lookup rather than a scan, +// and what makes two tokens hashing to one value impossible to store. +// +// Fatal on failure, like EnsureAuthIndexes and unlike the secrets and workflow +// builders: without the unique index the auth path would still answer, which is +// exactly the wrong kind of degradation. +func EnsureAPITokenIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := db.Col("api_tokens").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "token_hash", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + + if _, err := db.Col("api_tokens").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}}, + }); err != nil { + return err + } + return nil +} +``` + +- [ ] **Step 4: Call it at boot** + +In `server/cmd/main.go`, find the block calling `services.EnsureAuthIndexes()` and the other index builders during schema setup. Add immediately after the `EnsureAuthIndexes` call, matching its fatal-on-error handling: + +```go + if err := services.EnsureAPITokenIndexes(); err != nil { + log.Fatalf("api token indexes: %v", err) + } +``` + +If the surrounding code collects errors rather than calling `log.Fatalf` directly, follow the surrounding pattern instead of introducing a second one. + +- [ ] **Step 5: Verify it builds and the boot check still passes** + +```bash +cd /go-projects/vantage && go build ./... && go vet ./server/... +``` + +Expected: no output from either command. + +Then start the server against your local MongoDB and Redis and confirm the log shows no `ScopedCollections` complaint: + +```bash +cd /go-projects/vantage/server && VANTAGE_MIGRATE_ONLY=1 go run ./cmd 2>&1 | tail -20 +``` + +Expected: schema setup runs and the process exits 0. Specifically, no line containing `holds ... document(s) with org_id but is not in ScopedCollections`. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/models/api_token.go server/internal/services/tokenindexes.go server/internal/services/migrate_instance.go server/cmd/main.go +git commit -m "feat: Add the api_tokens collection and its indexes + +The unique index on token_hash is what makes authentication an indexed +lookup rather than a scan, so this builder is fatal on failure like +EnsureAuthIndexes rather than warning like the secrets one. + +Registered in ScopedCollections so instance purge reaches it." +``` + +--- + +### Task 2: Scope vocabulary and validation + +**Files:** +- Create: `server/internal/services/scopes.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `services.AllScopes() []string`; `services.ValidScopes(scopes []string) error`; `services.ScopeSatisfied(held []string, required string) bool`; `services.ErrInvalidScope`. + +- [ ] **Step 1: Write the scope vocabulary** + +Create `server/internal/services/scopes.go`: + +```go +package services + +import ( + "errors" + "fmt" + "sort" + "strings" +) + +// ErrInvalidScope is returned when a token is requested with a scope outside +// the vocabulary below. +var ErrInvalidScope = errors.New("invalid scope") + +// ScopeResources is the whole vocabulary. Eight resources, each with :read and +// :write, and write implies read on the same resource. +// +// It is deliberately coarse. A scope per endpoint is a table nobody maintains, +// and a route added without an entry either fails closed and breaks, or +// defaults open and is pointless. +var ScopeResources = []string{ + "servers", + "keys", + "secrets", + "workflows", + "monitors", + "vulns", + "workloads", + "settings", +} + +const ( + ScopeRead = "read" + ScopeWrite = "write" +) + +// AllScopes returns every valid scope string, sorted, for the API to advertise +// to the token-creation UI. +func AllScopes() []string { + out := make([]string, 0, len(ScopeResources)*2) + for _, r := range ScopeResources { + out = append(out, r+":"+ScopeRead, r+":"+ScopeWrite) + } + sort.Strings(out) + return out +} + +func validScope(s string) bool { + resource, action, ok := strings.Cut(s, ":") + if !ok || (action != ScopeRead && action != ScopeWrite) { + return false + } + for _, r := range ScopeResources { + if r == resource { + return true + } + } + return false +} + +// ValidScopes rejects an unknown scope and an empty list. A token with no +// scopes can reach nothing, so creating one is a mistake worth naming rather +// than a credential worth issuing. +func ValidScopes(scopes []string) error { + if len(scopes) == 0 { + return fmt.Errorf("%w: at least one scope is required", ErrInvalidScope) + } + for _, s := range scopes { + if !validScope(s) { + return fmt.Errorf("%w: %q", ErrInvalidScope, s) + } + } + return nil +} + +// ScopeSatisfied reports whether the held scopes cover the required one. +// Holding "servers:write" satisfies a requirement of "servers:read"; the +// converse is false. +func ScopeSatisfied(held []string, required string) bool { + resource, action, ok := strings.Cut(required, ":") + if !ok { + return false + } + for _, h := range held { + if h == required { + return true + } + if action == ScopeRead && h == resource+":"+ScopeWrite { + return true + } + } + return false +} +``` + +- [ ] **Step 2: Verify it builds** + +```bash +cd /go-projects/vantage && go build ./... && go vet ./server/... +``` + +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/services/scopes.go +git commit -m "feat: Define the API token scope vocabulary + +Eight resources with read and write, write implying read. Coarse on +purpose: a scope per endpoint is a table nobody maintains, and a route +added without an entry either breaks or is unguarded." +``` + +--- + +### Task 3: Token lifetime policy in settings + +**Files:** +- Modify: `shared/models/settings.go` +- Modify: `server/internal/services/settings.go` (`SaveSettings`) +- Modify: `server/internal/api/handlers.go` (`saveSettings` handler, lines 564-584) + +**Interfaces:** +- Consumes: nothing. +- Produces: `Settings.APITokenMaxDays *int` field; `models.APITokenMaxDays(s *Settings) int` reader returning 0 for "no cap"; `services.SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, apiTokenMaxDays *int) error` — note the added final parameter. + +- [ ] **Step 1: Add the field to the shared settings model** + +In `shared/models/settings.go`, add to the `Settings` struct after `VulnFindingRetentionDays`: + +```go + // APITokenMaxDays caps how long a newly created API token may live. + // + // A pointer for the same reason the retention fields are: absent must mean + // the default, and the default here is no cap at all — never-expire tokens + // are allowed until an instance decides otherwise, so an upgrade changes + // nothing. Nil or 0 is no cap. A positive value refuses both a longer + // expiry and a token with no expiry. + // + // It is a policy on issuance, not on use: raising or lowering it never + // invalidates a token that already exists. + APITokenMaxDays *int `bson:"api_token_max_days,omitempty" json:"api_token_max_days,omitempty"` +``` + +And add the reader beside `LocalLoginEnabled`: + +```go +// APITokenMaxDays reads the token lifetime cap with its absent-means-uncapped +// default. 0 means no cap. Every caller must go through this rather than +// dereferencing the field. +func APITokenMaxDays(s *Settings) int { + if s == nil || s.APITokenMaxDays == nil || *s.APITokenMaxDays < 0 { + return 0 + } + return *s.APITokenMaxDays +} +``` + +- [ ] **Step 2: Persist it in SaveSettings** + +In `server/internal/services/settings.go`, change the signature and the `set` document: + +```go +func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, apiTokenMaxDays *int) error { +``` + +and after the `local_login_enabled` block, before the `UpdateOne`: + +```go + if apiTokenMaxDays != nil { + set["api_token_max_days"] = *apiTokenMaxDays + } +``` + +- [ ] **Step 3: Accept it in the handler** + +In `server/internal/api/handlers.go`, extend the `saveSettings` body struct and the call: + +```go + var body struct { + Alerts models.AlertSettings `json:"alerts"` + WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"` + LocalLoginEnabled *bool `json:"local_login_enabled"` + APITokenMaxDays *int `json:"api_token_max_days"` + } +``` + +```go + if body.APITokenMaxDays != nil && *body.APITokenMaxDays < 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "api_token_max_days cannot be negative"}) + return + } + if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.APITokenMaxDays); err != nil { +``` + +Then add an audit line immediately after the existing `settings.updated` event, so the policy change is distinguishable from an alert change: + +```go + if body.APITokenMaxDays != nil { + services.LogEvent(auth.InstanceID(c), "settings.token_policy_updated", actorFromCtx(c), "", "", + fmt.Sprintf("API token maximum lifetime set to %d day(s); 0 means no cap", *body.APITokenMaxDays)) + } +``` + +- [ ] **Step 4: Fix every other caller of SaveSettings** + +```bash +cd /go-projects/vantage && grep -rn "SaveSettings(" --include=*.go . +``` + +Pass `nil` as the new final argument at every call site other than the handler above. Do not change their behaviour. + +- [ ] **Step 5: Verify** + +```bash +cd /go-projects/vantage && go build ./... && go vet ./server/... +``` + +Expected: no output. + +With the server running and a browser session cookie in `cookies.txt`: + +```bash +curl -s -b cookies.txt -X PUT localhost:8080/api/settings \ + -H 'Content-Type: application/json' \ + -d '{"alerts":{"offline_threshold_minutes":5,"offline_channel_ids":[]},"api_token_max_days":90}' +curl -s -b cookies.txt localhost:8080/api/settings | grep api_token_max_days +``` + +Expected: `{"saved":true}` then a line containing `"api_token_max_days":90`. + +- [ ] **Step 6: Commit** + +```bash +git add shared/models/settings.go server/internal/services/settings.go server/internal/api/handlers.go +git commit -m "feat: Add a per-instance API token lifetime cap + +A pointer with absent meaning no cap, so an upgrade allows never-expire +tokens exactly as before and an instance opts into the policy. It governs +issuance only: changing it never invalidates a token that already exists." +``` + +--- + +### Task 4: Token service — mint, resolve, list, revoke + +**Files:** +- Create: `server/internal/services/tokens.go` +- Modify: `server/internal/services/users.go` (`DeleteUser`, around line 157-180) + +**Interfaces:** +- Consumes: `models.APIToken`, `services.ValidScopes`, `services.HashToken` (already in `servers.go`), `models.APITokenMaxDays`, `services.GetUserInInstance` (already in `users.go`), `services.GetSettings`. +- Produces: + - `services.CreateAPIToken(instanceID, userID, name, role string, scopes []string, expiresInDays *int, ip string) (*models.APIToken, string, error)` + - `services.ResolveAPIToken(plaintext string) (*models.APIToken, error)` + - `services.ListAPITokens(instanceID string, userID string, all bool) ([]models.APIToken, error)` + - `services.RevokeAPIToken(instanceID, tokenID string, requester *models.User) (*models.APIToken, error)` + - `services.DeleteTokensForUser(instanceID, userID string) error` + - Errors: `ErrTokenExpired`, `ErrTokenNotFound`, `ErrTokenNameTaken`, `ErrTokenRoleTooHigh`, `ErrTokenExpiryPolicy`. + +- [ ] **Step 1: Write the service** + +Create `server/internal/services/tokens.go`: + +```go +package services + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +var ( + ErrTokenNotFound = errors.New("token not found") + ErrTokenExpired = errors.New("token expired") + ErrTokenNameTaken = errors.New("a token with that name already exists") + ErrTokenRoleTooHigh = errors.New("cannot create a token above your own role") + ErrTokenExpiryPolicy = errors.New("expiry exceeds this instance's maximum token lifetime") +) + +// TokenPrefix is on every plaintext so a leaked value is recognisable in a log +// or a paste, and so a wrong credential fails at the prefix check rather than +// as an anonymous 401. +const TokenPrefix = "vt_" + +const tokenNameMax = 64 + +// roleRank orders the three roles so a token can be capped at its owner's. +func roleRank(role string) int { + switch role { + case models.RoleOwner: + return 3 + case models.RoleAdmin: + return 2 + case models.RoleMember: + return 1 + } + return 0 +} + +// LowerRole returns whichever of the two roles grants less. It is what makes a +// token's authority follow its owner: demote the person and the token demotes +// with them, because this is recomputed on every request rather than frozen at +// creation. +func LowerRole(a, b string) string { + if roleRank(a) <= roleRank(b) { + return a + } + return b +} + +// CreateAPIToken mints a token and returns the document plus the plaintext. +// The plaintext is the only copy: it is returned once and never stored. +func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expiresInDays *int, ip string) (*models.APIToken, string, error) { + name = strings.TrimSpace(name) + if name == "" || len(name) > tokenNameMax { + return nil, "", fmt.Errorf("token name must be 1 to %d characters", tokenNameMax) + } + if !models.ValidRole(role) { + return nil, "", fmt.Errorf("invalid role %q", role) + } + if err := ValidScopes(scopes); err != nil { + return nil, "", err + } + + owner, err := GetUserInInstance(instanceID, userID) + if err != nil { + return nil, "", fmt.Errorf("user not found") + } + if roleRank(role) > roleRank(owner.Role) { + return nil, "", ErrTokenRoleTooHigh + } + + settings, err := GetSettings(instanceID) + if err != nil { + return nil, "", err + } + maxDays := models.APITokenMaxDays(settings) + + var expiresAt *time.Time + switch { + case expiresInDays != nil: + if *expiresInDays <= 0 { + return nil, "", fmt.Errorf("expires_in_days must be positive") + } + if maxDays > 0 && *expiresInDays > maxDays { + return nil, "", fmt.Errorf("%w: maximum is %d day(s)", ErrTokenExpiryPolicy, maxDays) + } + t := time.Now().UTC().AddDate(0, 0, *expiresInDays) + expiresAt = &t + case maxDays > 0: + // A policy is set, so a token with no expiry is refused rather than + // silently capped: the caller asked for something the instance does not + // allow, and quietly giving them something else is worse than a 422. + return nil, "", fmt.Errorf("%w: an expiry of at most %d day(s) is required", ErrTokenExpiryPolicy, maxDays) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + existing := db.Col("api_tokens").FindOne(ctx, bson.M{"instance_id": instanceID, "user_id": userID, "name": name}) + if existing.Err() == nil { + return nil, "", ErrTokenNameTaken + } else if !errors.Is(existing.Err(), mongo.ErrNoDocuments) { + return nil, "", existing.Err() + } + + secret, err := generateToken(32) + if err != nil { + return nil, "", err + } + plaintext := TokenPrefix + secret + + tok := &models.APIToken{ + TokenID: uuid.NewString(), + InstanceID: instanceID, + UserID: userID, + Name: name, + Hint: plaintext[:8], + TokenHash: HashToken(plaintext), + Role: role, + Scopes: scopes, + ExpiresAt: expiresAt, + CreatedAt: time.Now().UTC(), + CreatedByIP: ip, + } + + if _, err := db.Col("api_tokens").InsertOne(ctx, tok); err != nil { + return nil, "", err + } + return tok, plaintext, nil +} + +// ResolveAPIToken looks a plaintext up by hash. +// +// It returns ErrTokenExpired distinctly from ErrTokenNotFound so the auth layer +// can say which happened: a forgotten CI job hitting an expired token is worth +// seeing in the audit log, and an anonymous 401 hides it. +func ResolveAPIToken(plaintext string) (*models.APIToken, error) { + if !strings.HasPrefix(plaintext, TokenPrefix) { + return nil, ErrTokenNotFound + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var tok models.APIToken + err := db.Col("api_tokens").FindOne(ctx, bson.M{"token_hash": HashToken(plaintext)}).Decode(&tok) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrTokenNotFound + } + if err != nil { + return nil, err + } + if tok.Expired(time.Now().UTC()) { + return &tok, ErrTokenExpired + } + return &tok, nil +} + +// TouchAPIToken records use, but only when the stored value is more than a +// minute stale. Without the check this is a Mongo write on every API call. +func TouchAPIToken(tok *models.APIToken) { + now := time.Now().UTC() + if tok.LastUsedAt != nil && now.Sub(*tok.LastUsedAt) < time.Minute { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, _ = db.Col("api_tokens").UpdateOne(ctx, + bson.M{"token_id": tok.TokenID, "instance_id": tok.InstanceID}, + bson.M{"$set": bson.M{"last_used_at": now}}, + ) + tok.LastUsedAt = &now +} + +// ListAPITokens returns a user's own tokens, or every token in the instance +// when all is true. The caller decides whether all is permitted. +func ListAPITokens(instanceID string, userID string, all bool) ([]models.APIToken, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{"instance_id": instanceID} + if !all { + filter["user_id"] = userID + } + cursor, err := db.Col("api_tokens").Find(ctx, filter) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var tokens []models.APIToken + if err := cursor.All(ctx, &tokens); err != nil { + return nil, err + } + if tokens == nil { + tokens = []models.APIToken{} + } + + // Join the owning email so an admin's list names people rather than UUIDs. + users, err := ListUsers(instanceID) + if err == nil { + byID := make(map[string]string, len(users)) + for _, u := range users { + byID[u.UserID] = u.Email + } + for i := range tokens { + tokens[i].UserEmail = byID[tokens[i].UserID] + } + } + return tokens, nil +} + +// RevokeAPIToken deletes a token. A member may revoke only their own; owner and +// admin may revoke any token in the instance. +func RevokeAPIToken(instanceID, tokenID string, requester *models.User) (*models.APIToken, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var tok models.APIToken + err := db.Col("api_tokens").FindOne(ctx, bson.M{"instance_id": instanceID, "token_id": tokenID}).Decode(&tok) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrTokenNotFound + } + if err != nil { + return nil, err + } + + elevated := requester.Role == models.RoleOwner || requester.Role == models.RoleAdmin + if tok.UserID != requester.UserID && !elevated { + // Not 403: confirming the token exists tells a member about somebody + // else's credential. Same argument as admin's customer endpoints. + return nil, ErrTokenNotFound + } + + if _, err := db.Col("api_tokens").DeleteOne(ctx, bson.M{"instance_id": instanceID, "token_id": tokenID}); err != nil { + return nil, err + } + return &tok, nil +} + +// DeleteTokensForUser removes every token belonging to a user. Offboarding is +// one action, not two: a token that outlives its owner is an access path with +// nobody attached to it. +func DeleteTokensForUser(instanceID, userID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := db.Col("api_tokens").DeleteMany(ctx, bson.M{"instance_id": instanceID, "user_id": userID}) + return err +} +``` + +If `github.com/google/uuid` is not already a dependency of the `server` module, check how other services generate IDs first: + +```bash +cd /go-projects/vantage && grep -rn "uuid\." server/internal/services/servers.go | head -3 +``` + +Use whatever that file uses rather than adding a dependency. + +- [ ] **Step 2: Cascade the delete from user removal** + +In `server/internal/services/users.go`, inside `DeleteUser`, immediately after the successful `db.Col("users").DeleteOne(...)` and before the function returns, add: + +```go + // Offboarding is one action. A token outliving its owner is an access path + // with nobody attached to it. + if err := DeleteTokensForUser(instanceID, userID); err != nil { + log.Printf("delete tokens for user %s: %v", userID, err) + } +``` + +Add `"log"` to the file's imports if it is not already there. + +- [ ] **Step 3: Verify** + +```bash +cd /go-projects/vantage && go build ./... && go vet ./server/... +``` + +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/services/tokens.go server/internal/services/users.go +git commit -m "feat: Add the API token service + +Mint, resolve, list and revoke, with the effective role capped at the +owner's and recomputed per request rather than frozen at creation. + +Deleting a user deletes their tokens in the same call, so offboarding is +one action. Revoking somebody else's token answers not-found rather than +forbidden, since a 403 confirms the credential exists." +``` + +--- + +### Task 5: Bearer fallback in the session middleware + +**Files:** +- Modify: `server/internal/auth/session.go` (the `Session` struct, lines 19-25) +- Modify: `server/internal/auth/middleware.go` (`Middleware`, lines 51-79; add accessors) + +**Interfaces:** +- Consumes: `services.ResolveAPIToken`, `services.TouchAPIToken`, `services.LowerRole`, `services.GetUserInInstance`, `services.LogEvent`, `services.ErrTokenExpired`. +- Produces: `Session.TokenID string`, `Session.TokenName string`, `Session.Scopes []string`; `auth.TokenID(c *gin.Context) string`; `auth.Scopes(c *gin.Context) []string`; `auth.IsToken(c *gin.Context) bool`. + +- [ ] **Step 1: Extend the session struct** + +In `server/internal/auth/session.go`: + +```go +type Session struct { + UserID string `json:"user_id"` + InstanceID string `json:"instance_id"` + Role string `json:"role"` + Email string `json:"email"` + Name string `json:"name"` + + // The three fields below are set only when the request authenticated with + // an API token. They are never persisted to Redis — a token authenticates + // per request and mints no session, so a revoked token stops working + // immediately rather than at the end of a session TTL. + TokenID string `json:"-"` + TokenName string `json:"-"` + Scopes []string `json:"-"` +} +``` + +- [ ] **Step 2: Add the bearer fallback** + +Replace `Middleware()` in `server/internal/auth/middleware.go` with: + +```go +// Middleware authenticates a request by session cookie or by API token. +// +// Both paths end by putting a *Session in the context, which is why no handler, +// role guard, licence gate or audit call needed changing: the token path is a +// second way to arrive at the same value, not a second way through the API. +func Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + sess, ok := sessionFromCookie(c) + if !ok { + sess, ok = sessionFromToken(c) + } + if !ok { + // sessionFromCookie and sessionFromToken have already written the + // response describing which credential failed and why. + return + } + + if sess.InstanceID == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has no organization"}) + return + } + + c.Set(ctxSessionKey, sess) + + // The host guard applies to both credential kinds. A token carries an + // instance, and the tenant boundary must not have a token-shaped hole. + if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"}) + return + } + + c.Next() + } +} + +// sessionFromCookie returns false without writing a response when there is no +// cookie at all, so the token path gets its turn. It writes and aborts only +// when a cookie was presented and was not usable. +func sessionFromCookie(c *gin.Context) (*Session, bool) { + cookie, err := c.Request.Cookie(sessionCookieName) + if err != nil { + return nil, false + } + sess, err := GetSession(c.Request.Context(), cookie.Value) + if err != nil { + // A stale cookie plus a valid bearer token is a real combination — + // a browser tab left open beside a curl. Fall through rather than + // refusing a credential that would have worked. + if bearerToken(c) != "" { + return nil, false + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) + return nil, false + } + return sess, true +} + +func bearerToken(c *gin.Context) string { + const prefix = "Bearer " + h := c.GetHeader("Authorization") + if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) { + return "" + } + return h[len(prefix):] +} + +func sessionFromToken(c *gin.Context) (*Session, bool) { + raw := bearerToken(c) + if raw == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return nil, false + } + + tok, err := services.ResolveAPIToken(raw) + if errors.Is(err, services.ErrTokenExpired) { + // Recorded rather than only refused: an expired token still being + // presented is how a forgotten CI job becomes visible. + services.LogEvent(tok.InstanceID, "token.expired_use", tok.Name, "", "", + fmt.Sprintf("expired token '%s' was used", tok.Name)) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token expired", "code": "token_expired"}) + return nil, false + } + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return nil, false + } + + user, err := services.GetUserInInstance(tok.InstanceID, tok.UserID) + if err != nil { + // The owner is gone. DeleteUser removes tokens, so this is the + // belt-and-braces path for a row deleted some other way. + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return nil, false + } + + services.TouchAPIToken(tok) + + return &Session{ + UserID: tok.UserID, + InstanceID: tok.InstanceID, + // Recomputed per request, so demoting the person demotes the token. + Role: services.LowerRole(user.Role, tok.Role), + Email: user.Email, + Name: user.Email, + TokenID: tok.TokenID, + TokenName: tok.Name, + Scopes: tok.Scopes, + }, true +} +``` + +Update the file's import block to: + +```go +import ( + "errors" + "fmt" + "net/http" + "strings" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) +``` + +- [ ] **Step 3: Add the accessors** + +Append to `server/internal/auth/middleware.go`: + +```go +// TokenID is empty for a cookie session and the token's ID for a token +// request. It is what lets audit detail record which credential acted. +func TokenID(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.TokenID + } + return "" +} + +func TokenName(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.TokenName + } + return "" +} + +func Scopes(c *gin.Context) []string { + if s := GetSessionFromContext(c); s != nil { + return s.Scopes + } + return nil +} + +// IsToken reports whether this request authenticated with an API token rather +// than a browser session. +func IsToken(c *gin.Context) bool { return TokenID(c) != "" } +``` + +- [ ] **Step 4: Verify the build and that both credentials work** + +```bash +cd /go-projects/vantage && go build ./... && go vet ./server/... +``` + +Expected: no output. In particular, no import cycle — `auth` already imports `services` (`local.go`, `oidc.go`, `instancehost.go`) and `services` imports `auth` nowhere. + +Confirm the cookie path is unchanged with the server running: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' -b cookies.txt localhost:8080/api/servers +curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/servers +curl -s -w '\n%{http_code}\n' -H 'Authorization: Bearer vt_deadbeef' localhost:8080/api/servers +``` + +Expected: `200`, then `401`, then `{"error":"invalid token"}` with `401`. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/auth/session.go server/internal/auth/middleware.go +git commit -m "feat: Authenticate the API with a bearer token as well as a cookie + +One middleware, two ways to arrive at the same *Session, so every handler, +role guard, licence gate and audit call is untouched. The host guard +applies to both: a token carries an instance, and the tenant boundary must +not have a token-shaped hole in it. + +The effective role is min(user, token) recomputed per request, so demoting +somebody demotes their tokens with them. A stale cookie beside a valid +bearer falls through rather than refusing a credential that would work." +``` + +--- + +### Task 6: Scope enforcement with a boot-time completeness check + +**Files:** +- Create: `server/internal/api/scopes.go` +- Modify: `server/internal/api/handlers.go` (`RegisterRoutes`, line 43-48; end of function) +- Modify: `server/cmd/main.go` (after routes are registered, before serving) + +**Interfaces:** +- Consumes: `auth.IsToken`, `auth.Scopes`, `services.ScopeSatisfied`. +- Produces: `api.RequireScopes() gin.HandlerFunc`; `api.AssertScopeMapComplete(r *gin.Engine) error`. + +- [ ] **Step 1: Write the scope map and middleware** + +Create `server/internal/api/scopes.go`: + +```go +package api + +import ( + "fmt" + "net/http" + "sort" + "strings" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +// routeScopes maps a registered gin route — " " — to +// the scope an API token must hold to reach it. +// +// It is keyed on the route pattern rather than declared per route with a +// decorator, because a route registered without a decorator would be +// unguarded. AssertScopeMapComplete refuses to boot if any /api route is +// missing here, so the failure lands at deploy rather than as a surprise 403 +// in production. +// +// GET is read, everything else is write. The exceptions are written out rather +// than derived, because two of them are not obvious: reading a private key is +// still reading a key, and reading a container's logs is a write-level action +// because container output is arbitrary and cannot be masked. +var routeScopes = map[string]string{ + "GET /api/license": "settings:read", + "POST /api/license": "settings:write", + + "GET /api/servers": "servers:read", + "GET /api/servers/tags": "servers:read", + "POST /api/servers": "servers:write", + "GET /api/servers/new": "servers:write", + "POST /api/servers/new": "servers:write", + "GET /api/servers/:id": "servers:read", + "DELETE /api/servers/:id": "servers:write", + "POST /api/servers/:id/generate-key": "keys:write", + "POST /api/servers/:id/update-agent": "servers:write", + "POST /api/servers/:id/apply-updates": "servers:write", + "PUT /api/servers/:id/tags": "servers:write", + + "GET /api/agent/latest-version": "servers:read", + "GET /api/audit": "settings:read", + + "GET /api/settings": "settings:read", + "PUT /api/settings": "settings:write", + "POST /api/settings/secrets-token": "settings:write", + + "GET /api/secrets": "secrets:read", + "POST /api/secrets": "secrets:write", + "GET /api/secrets/:group": "secrets:read", + "PUT /api/secrets/:group": "secrets:write", + "POST /api/secrets/:group/reveal": "secrets:read", + "DELETE /api/secrets/:group": "secrets:write", + "DELETE /api/secrets/:group/:key": "secrets:write", + + "GET /api/keys": "keys:read", + "POST /api/keys": "keys:write", + "GET /api/keys/:id": "keys:read", + "GET /api/keys/:id/private-key": "keys:read", + "DELETE /api/keys/:id": "keys:write", + "POST /api/keys/:id/assign": "keys:write", + "DELETE /api/keys/:id/assign/:serverId": "keys:write", + + "POST /api/console/connect": "servers:write", + "GET /api/console/tunnel": "servers:write", + + "GET /api/vulnerabilities": "vulns:read", + "GET /api/vulnerabilities/summary": "vulns:read", + "POST /api/vulnerabilities/rescan": "vulns:write", + "POST /api/vulnerabilities/:id/accept": "vulns:write", + "DELETE /api/vulnerabilities/:id/accept": "vulns:write", + "GET /api/servers/:id/vulnerabilities": "vulns:read", + "GET /api/servers/:id/packages": "vulns:read", + "GET /api/packages/search": "vulns:read", + "GET /api/vuln-rules": "vulns:read", + "POST /api/vuln-rules": "vulns:write", + "PUT /api/vuln-rules/:id": "vulns:write", + "DELETE /api/vuln-rules/:id": "vulns:write", + + "GET /api/workloads": "workloads:read", + "GET /api/servers/:id/workloads": "workloads:read", + "POST /api/servers/:id/workloads/refresh": "workloads:read", + "POST /api/servers/:id/workloads/:wid/action": "workloads:write", + "GET /api/servers/:id/workloads/:wid/logs": "workloads:write", + + "GET /api/tokens": "settings:read", + "POST /api/tokens": "settings:write", + "DELETE /api/tokens/:id": "settings:write", +} + +// RequireScopes enforces routeScopes for token-authenticated requests and does +// nothing at all for cookie sessions, whose authority is their role. +func RequireScopes() gin.HandlerFunc { + return func(c *gin.Context) { + if !auth.IsToken(c) { + c.Next() + return + } + + key := c.Request.Method + " " + c.FullPath() + required, ok := routeScopes[key] + if !ok { + // Fail closed. An unmapped route reached by a token is a route + // nobody decided the authority for. + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "this endpoint is not available to API tokens", + "code": "scope_unmapped", + }) + return + } + + if !services.ScopeSatisfied(auth.Scopes(c), required) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": fmt.Sprintf("token is missing the %q scope", required), + "code": "scope_missing", + "required_scope": required, + }) + return + } + c.Next() + } +} + +// AssertScopeMapComplete fails boot when a registered /api route has no scope. +// +// Without it, adding a route silently makes it unreachable by every token, and +// the report arrives as a customer asking why their script gets 403. +func AssertScopeMapComplete(r *gin.Engine) error { + var missing []string + for _, route := range r.Routes() { + if !strings.HasPrefix(route.Path, "/api/") { + continue + } + // The ESO endpoint keeps its own bearer scheme and is deliberately + // outside the token vocabulary. + if route.Path == "/api/secrets/:group/values" { + continue + } + if _, ok := routeScopes[route.Method+" "+route.Path]; !ok { + missing = append(missing, route.Method+" "+route.Path) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return fmt.Errorf("routes missing from the API token scope map: %s", strings.Join(missing, ", ")) + } + return nil +} +``` + +- [ ] **Step 2: Mount the middleware** + +In `server/internal/api/handlers.go`, extend the `/api` group's middleware stack: + +```go + apiGroup := r.Group("/api") + apiGroup.Use(auth.Middleware()) + // Scope enforcement sits between authentication and the licence gate, and + // no-ops for cookie sessions. It is mounted here rather than per route so + // a route added later is covered by where it lives, not by memory. + apiGroup.Use(RequireScopes()) + apiGroup.Use(RequireActiveLicense()) +``` + +- [ ] **Step 3: Call the completeness check at boot** + +In `server/cmd/main.go`, after `api.RegisterRoutes(r)` and before the HTTP server starts: + +```go + if err := api.AssertScopeMapComplete(r); err != nil { + log.Fatalf("api scope map: %v", err) + } +``` + +- [ ] **Step 4: Verify** + +```bash +cd /go-projects/vantage && go build ./... && go vet ./server/... +``` + +Expected: no output. + +Start the server. If it exits with `api scope map: routes missing from the API token scope map: …`, add each listed route to `routeScopes` with the scope its resource implies, then start again. Boot must reach the normal listening log line. + +The workflow, monitor and channel routes are registered by `registerWorkflowRoutes`, `registerMonitorRoutes` and `registerChannelRoutes`, so their exact patterns come from this check rather than from guessing. Map every workflow, step and run route to `workflows:read` for GET and `workflows:write` otherwise; every monitor and incident route to `monitors:read`/`monitors:write`; every channel route to `monitors:read`/`monitors:write`, since channels exist to serve alerts. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/api/scopes.go server/internal/api/handlers.go server/cmd/main.go +git commit -m "feat: Enforce API token scopes from the route map + +Keyed on the registered gin route pattern rather than a per-route +decorator, because a route registered without a decorator would be +unguarded. An unmapped route reached by a token is a 403, and a boot-time +check refuses to start when any /api route is missing, so the failure +lands at deploy rather than as a customer's surprise 403." +``` + +--- + +### Task 7: Token endpoints and audit + +**Files:** +- Create: `server/internal/api/tokens.go` +- Modify: `server/internal/api/handlers.go` (register the three routes inside `apiGroup`) +- Modify: `server/internal/services/audit.go` only if it holds a category or label table that new event names must join — check first with `grep -n "token\|category" server/internal/services/audit.go` +- Modify: `web/lib/auditEvents.ts` (label the new events) + +**Interfaces:** +- Consumes: `services.CreateAPIToken`, `services.ListAPITokens`, `services.RevokeAPIToken`, `services.AllScopes`, `services.ErrTokenNameTaken`, `services.ErrTokenRoleTooHigh`, `services.ErrTokenExpiryPolicy`, `services.ErrInvalidScope`, `services.ErrTokenNotFound`, `services.GetUserInInstance`. +- Produces: `GET /api/tokens`, `POST /api/tokens`, `DELETE /api/tokens/:id`, `GET /api/tokens/scopes`. + +- [ ] **Step 1: Write the handlers** + +Create `server/internal/api/tokens.go`: + +```go +package api + +import ( + "errors" + "fmt" + "net/http" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +func elevated(c *gin.Context) bool { + r := auth.Role(c) + return r == models.RoleOwner || r == models.RoleAdmin +} + +// listTokens returns the caller's own tokens. Owner and admin may ask for every +// token in the instance with ?all=true. +func listTokens(c *gin.Context) { + all := c.Query("all") == "true" && elevated(c) + tokens, err := services.ListAPITokens(auth.InstanceID(c), auth.UserID(c), all) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"tokens": tokens, "all": all}) +} + +// listTokenScopes advertises the vocabulary so the UI never hardcodes it. +func listTokenScopes(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"scopes": services.AllScopes()}) +} + +func createToken(c *gin.Context) { + var body struct { + Name string `json:"name" binding:"required"` + Role string `json:"role" binding:"required"` + Scopes []string `json:"scopes" binding:"required"` + ExpiresInDays *int `json:"expires_in_days"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tok, plaintext, err := services.CreateAPIToken( + auth.InstanceID(c), auth.UserID(c), + body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(), + ) + switch { + case errors.Is(err, services.ErrTokenNameTaken): + c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "name_taken"}) + return + case errors.Is(err, services.ErrTokenRoleTooHigh): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "role_too_high"}) + return + case errors.Is(err, services.ErrTokenExpiryPolicy): + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error(), "code": "expiry_policy"}) + return + case errors.Is(err, services.ErrInvalidScope): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "invalid_scope"}) + return + case err != nil: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + expiry := "no expiry" + if tok.ExpiresAt != nil { + expiry = "expires " + tok.ExpiresAt.Format("2006-01-02") + } + services.LogEvent(auth.InstanceID(c), "token.created", actorFromCtx(c), "", "", + fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s", tok.Name, tok.Role, tok.Scopes, expiry)) + + // The plaintext is returned exactly once and is not stored anywhere. + c.JSON(http.StatusCreated, gin.H{"token": plaintext, "record": tok}) +} + +func revokeToken(c *gin.Context) { + requester, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c)) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + + tok, err := services.RevokeAPIToken(auth.InstanceID(c), c.Param("id"), requester) + if errors.Is(err, services.ErrTokenNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "token not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + services.LogEvent(auth.InstanceID(c), "token.revoked", actorFromCtx(c), "", "", + fmt.Sprintf("API token '%s' revoked", tok.Name)) + c.JSON(http.StatusOK, gin.H{"revoked": true}) +} +``` + +- [ ] **Step 2: Register the routes** + +In `server/internal/api/handlers.go`, inside the `apiGroup` block, after the `/audit` line: + +```go + apiGroup.GET("/tokens", listTokens) + apiGroup.GET("/tokens/scopes", listTokenScopes) + apiGroup.POST("/tokens", createToken) + apiGroup.DELETE("/tokens/:id", revokeToken) +``` + +Then add the matching entry for `/api/tokens/scopes` to `routeScopes` in `server/internal/api/scopes.go`: + +```go + "GET /api/tokens/scopes": "settings:read", +``` + +- [ ] **Step 3: Record the acting credential on every audit event** + +The spec asks for `via: "token:"` on every event written during a token request. It is implemented on the **actor** field rather than appended to each event's detail, because detail is composed at ~60 call sites and the actor is composed in one function. Same information, one edit instead of sixty. In `server/internal/api/handlers.go`, change `actorFromCtx` so a token-authenticated request is distinguishable from a person clicking: + +```go +func actorFromCtx(c *gin.Context) string { + sess := auth.GetSessionFromContext(c) + if sess == nil || sess.Email == "" { + return "admin" + } + // The actor stays the human, because a token acts on their behalf and the + // log has to name somebody. The credential is appended so a person clicking + // and their CI job are told apart. + if sess.TokenID != "" { + return fmt.Sprintf("%s (via token:%s)", sess.Email, sess.TokenName) + } + return sess.Email +} +``` + +- [ ] **Step 4: Label the new events in the web audit page** + +In `web/lib/auditEvents.ts`, follow the existing shape of the file and add entries for `token.created`, `token.revoked`, `token.expired_use` and `settings.token_policy_updated`. Read the file first and match whatever structure it uses — a map of event key to label and category. Suggested labels: "API token created", "API token revoked", "Expired API token used", "API token policy updated". Category: whatever the file uses for `settings.updated`. + +- [ ] **Step 5: Verify the whole loop with curl** + +Restart the server, then with a browser session cookie in `cookies.txt`: + +```bash +# create +curl -s -b cookies.txt -X POST localhost:8080/api/tokens \ + -H 'Content-Type: application/json' \ + -d '{"name":"ci","role":"member","scopes":["servers:read"],"expires_in_days":30}' +``` + +Expected: 201 with `{"token":"vt_…","record":{…}}`. Copy the plaintext into `$VT`. + +```bash +# it authenticates +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $VT" localhost:8080/api/servers +# scope enforcement +curl -s -w '\n%{http_code}\n' -H "Authorization: Bearer $VT" localhost:8080/api/keys +# role cap: member cannot reach an owner|admin route even with the scope +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $VT" localhost:8080/api/settings +# list, then revoke +curl -s -b cookies.txt localhost:8080/api/tokens | head -c 400 +curl -s -b cookies.txt -X DELETE localhost:8080/api/tokens/ +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $VT" localhost:8080/api/servers +``` + +Expected in order: `200`; `403` with `"required_scope":"keys:read"`; `403` from the role guard; the JSON list; `{"revoked":true}`; `401`. + +Check the audit page shows `token.created` and `token.revoked` with the actor as your email. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/api/tokens.go server/internal/api/handlers.go server/internal/api/scopes.go web/lib/auditEvents.ts +git commit -m "feat: Add the API token endpoints + +Create, list and revoke, with no update: editing what a credential already +deployed in CI can do, with no record of what it could do before, is worse +than requiring a rotation. Revoking a token that is not yours answers +not-found, since a 403 confirms it exists. + +The audit actor stays the human and names the credential alongside, so a +person clicking and their CI job are told apart." +``` + +--- + +### Task 8: Per-token rate limit + +**Files:** +- Create: `server/internal/api/ratelimit.go` +- Modify: `server/internal/api/handlers.go` (mount after `RequireScopes()`) +- Modify: `server/internal/auth/session.go` — add an exported accessor for the Redis client if none exists; check with `grep -n "func.*redis.Client\|var rdb" server/internal/auth/session.go` + +**Interfaces:** +- Consumes: `auth.IsToken`, `auth.TokenID`, the session Redis client. +- Produces: `api.RateLimitTokens() gin.HandlerFunc`; `auth.Redis() *redis.Client`. + +- [ ] **Step 1: Expose the Redis client** + +In `server/internal/auth/session.go`, add: + +```go +// Redis exposes the session client for callers that need a counter rather than +// a session. There is one Redis in this deployment and adding a second client +// would double the connection pool for no reason. +func Redis() *redis.Client { return rdb } +``` + +- [ ] **Step 2: Write the limiter** + +Create `server/internal/api/ratelimit.go`: + +```go +package api + +import ( + "net/http" + "strconv" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "github.com/gin-gonic/gin" +) + +// tokenRateLimit is per token per minute. It is not the general API +// rate-limiting project: it is only enough that a runaway script cannot take an +// instance down, and cookie sessions are deliberately untouched. +const tokenRateLimit = 600 + +// RateLimitTokens counts requests per token in a one-minute fixed window. +// +// A fixed window rather than a sliding one because the cost of a burst at a +// boundary is a script running twice as fast for one second, and a sliding +// window is a sorted set per token for that. +func RateLimitTokens() gin.HandlerFunc { + return func(c *gin.Context) { + if !auth.IsToken(c) { + c.Next() + return + } + rdb := auth.Redis() + if rdb == nil { + c.Next() + return + } + + window := time.Now().UTC().Unix() / 60 + key := "vantage:tokenrate:" + auth.TokenID(c) + ":" + strconv.FormatInt(window, 10) + + count, err := rdb.Incr(c.Request.Context(), key).Result() + if err != nil { + // Redis is already required for sessions, so it being down is a + // larger problem than this. Do not turn it into a second outage. + c.Next() + return + } + if count == 1 { + rdb.Expire(c.Request.Context(), key, 2*time.Minute) + } + if count > tokenRateLimit { + c.Header("Retry-After", "60") + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded for this API token", + "code": "rate_limited", + }) + return + } + c.Next() + } +} +``` + +- [ ] **Step 3: Mount it** + +In `server/internal/api/handlers.go`: + +```go + apiGroup.Use(RequireScopes()) + apiGroup.Use(RateLimitTokens()) + apiGroup.Use(RequireActiveLicense()) +``` + +- [ ] **Step 4: Verify** + +```bash +cd /go-projects/vantage && go build ./... && go vet ./server/... +``` + +Expected: no output. Then, with `$VT` a valid token: + +```bash +for i in $(seq 1 610); do + curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $VT" localhost:8080/api/servers +done | sort | uniq -c +``` + +Expected: roughly 600 lines of `200` and the remainder `429`. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/api/ratelimit.go server/internal/api/handlers.go server/internal/auth/session.go +git commit -m "feat: Rate limit API token requests + +600 per minute per token, in the Redis that sessions already require. +Cookie sessions are untouched. A Redis failure falls through rather than +refusing traffic — it is already a larger problem and should not become a +second outage." +``` + +--- + +### Task 9: API tokens card in web settings + +**Files:** +- Create: `web/components/settings/ApiTokensCard.tsx` +- Modify: `web/lib/api.ts` (types and client methods) +- Modify: `web/app/(app)/settings/page.tsx` (render the card in the Access group; add the policy field to the settings form) + +**Interfaces:** +- Consumes: `GET/POST /api/tokens`, `DELETE /api/tokens/:id`, `GET /api/tokens/scopes`, `api_token_max_days` on settings. +- Produces: `ApiToken` type; `api.listApiTokens`, `api.listTokenScopes`, `api.createApiToken`, `api.revokeApiToken`; the `` component. + +- [ ] **Step 1: Add the client methods** + +In `web/lib/api.ts`, beside the existing settings methods, add the type and the four calls, matching the file's existing `request` style: + +```ts +export type ApiToken = { + token_id: string; + name: string; + hint: string; + role: Role; + scopes: string[]; + expires_at?: string | null; + created_at: string; + last_used_at?: string | null; + user_id: string; + user_email?: string; +}; +``` + +```ts + listApiTokens(all = false): Promise<{ tokens: ApiToken[]; all: boolean }> { + return request<{ tokens: ApiToken[]; all: boolean }>(`/tokens${all ? "?all=true" : ""}`); + }, + + listTokenScopes(): Promise<{ scopes: string[] }> { + return request<{ scopes: string[] }>("/tokens/scopes"); + }, + + createApiToken(body: { name: string; role: Role; scopes: string[]; expires_in_days?: number | null }): Promise<{ token: string; record: ApiToken }> { + return request<{ token: string; record: ApiToken }>("/tokens", { + method: "POST", + body: JSON.stringify(body), + }); + }, + + revokeApiToken(tokenId: string): Promise<{ revoked: boolean }> { + return request<{ revoked: boolean }>(`/tokens/${tokenId}`, { method: "DELETE" }); + }, +``` + +Also extend the `Settings` type with `api_token_max_days?: number | null` and the `saveSettings` argument type with the same field. + +- [ ] **Step 2: Build the card** + +Create `web/components/settings/ApiTokensCard.tsx`, modelled directly on `MembersCard.tsx` — same imports, same `SectionCard` wrapper, same `Table`/`Badge`/`Modal`/`ConfirmDialog`/`useToast` usage, same `Field` and `inputClass`. + +Requirements the component must meet: + +- `useQuery({ queryKey: ["api-tokens", showAll], queryFn: () => api.listApiTokens(showAll) })`. +- Owner and admin see an "All tokens" toggle; members do not. Read the current user from `useAuth()` exactly as `MembersCard` does. +- Table columns: Name (with `hint` beneath in `font-mono text-xs text-text-secondary`), Owner (only when `showAll`), Role badge reusing `MembersCard`'s `roleVariant`, Scopes as `Badge` chips, Last used, Expires, and a Revoke button. +- Expiry cell states: `—` and the text "never" when `expires_at` is null; the date in `text-warning` when it is within 7 days; `text-danger` with the word "expired" when it has passed. +- When `settings.api_token_max_days > 0` and a listed token has no expiry or one beyond the cap, show a `text-warning` note on that row reading "outside the current policy — rotate when convenient". The policy is not applied retroactively, so this is a prompt, not a failure. +- Create opens a `Modal` with: name input, role select limited to roles at or below the current user's, a checkbox grid of scopes from `api.listTokenScopes()` grouped by resource with read and write side by side, and an expiry select of 30 / 60 / 90 / 365 days plus "Never". "Never" is disabled with an explanatory note when the policy caps lifetime, and any option above the cap is likewise disabled. +- On success the modal switches to a result state showing the plaintext in `bg-well rounded p-3 font-mono text-sm break-all`, a Copy button using `navigator.clipboard.writeText`, and the line "This is the only time this token will be shown. Store it now." Closing the result state invalidates the query. +- Errors surface through `friendlyMessage(error)` as `MembersCard` does. The 409, 403 and 422 codes must produce readable messages rather than raw JSON. +- No hex colours anywhere. Token classes only. + +- [ ] **Step 3: Render it and add the policy field** + +In `web/app/(app)/settings/page.tsx`: + +```tsx +import { ApiTokensCard } from "@/components/settings/ApiTokensCard"; +``` + +Render `` inside the Access group, immediately after `` and before ``. + +Add a number input for `api_token_max_days` to the existing settings form, following the `numberInputClass` and `Field` pattern already in that file. Label: "Maximum API token lifetime (days)". Helper text: "0 means no cap, and tokens may be created with no expiry. Changing this affects new tokens only." Visible to owner and admin only, matching how the file already gates owner/admin fields. + +- [ ] **Step 4: Verify** + +```bash +cd /go-projects/vantage/web && npm run lint && npx tsc --noEmit && npm run build +``` + +Expected: all three succeed with no errors. + +Then in the browser at `/settings`: + +1. Access group shows the API tokens card. +2. Create a token named `laptop`, role `member`, scopes `servers:read` and `workflows:write`, expiry 30 days. The plaintext appears once; Copy works. +3. Reload. The token is listed with its hint, role and scope chips, and no plaintext. +4. Set "Maximum API token lifetime" to 7, save, reopen the create modal. "Never", 30, 60, 90 and 365 are all disabled or refused with the policy message. +5. Revoke the token. It disappears and the audit page shows `token.revoked`. + +- [ ] **Step 5: Commit** + +```bash +git add web/components/settings/ApiTokensCard.tsx web/lib/api.ts "web/app/(app)/settings/page.tsx" +git commit -m "feat: Manage API tokens from settings + +A card in the Access group beside Members and single sign-on rather than a +new nav entry — /settings/instance was folded back in for exactly this +reason. The plaintext is shown once in a well block and never again. + +Tokens outside a newly tightened lifetime policy are flagged rather than +broken, because the policy governs issuance, not existing credentials." +``` + +--- + +### Task 10: OpenAPI 3.1 generation and the Scalar reference page + +**Files:** +- Create: `server/internal/api/docs/doc.go` (embed directives) +- Create: `server/internal/api/docs/scalar.standalone.js` (vendored) +- Create: `server/internal/api/docs/openapi.json` (generated, committed) +- Create: `server/internal/api/openapi.go` (the two routes and the HTML page) +- Modify: every handler file under `server/internal/api/` (annotations) +- Modify: `server/cmd/main.go` (top-level swag annotations) +- Modify: `server/internal/api/handlers.go` (register the doc routes, add them to `routeScopes`) +- Modify: `.gitea/workflows/server-deploy.yml` (regenerate-and-diff check) + +**Interfaces:** +- Consumes: everything above. +- Produces: `GET /api/openapi.json`, `GET /api/docs`. + +- [ ] **Step 1: Install swag v2 and confirm the version** + +```bash +cd /go-projects/vantage && go install github.com/swaggo/swag/v2/cmd/swag@latest && swag --version +``` + +Expected: a version line beginning with `v2.`. If the binary reports v1, stop and pin explicitly with `@v2.0.0-rc4` or the newest v2 tag from https://github.com/swaggo/swag/releases — v1 emits Swagger 2.0, which Scalar renders poorly, and the whole reason for choosing generation over a hand-written document was that the output is trustworthy. + +- [ ] **Step 2: Add the top-level API annotations** + +In `server/cmd/main.go`, above `func main()`: + +```go +// @title Vantage API +// @version 1.0 +// @description The Vantage control plane REST API. Authenticate with a browser session cookie, or with an API token created under Settings → API tokens. +// @BasePath /api +// +// @securityDefinitions.apikey cookieAuth +// @in cookie +// @name km_session +// +// @securityDefinitions.apikey bearerAuth +// @in header +// @name Authorization +// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring. +// +// @securityDefinitions.apikey esoAuth +// @in header +// @name Authorization +// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another. +``` + +- [ ] **Step 3: Annotate the handlers** + +Work file by file through `server/internal/api/`: `handlers.go`, `tokens.go`, `secrets.go`, `workflows.go`, `monitors.go`, `channels.go`, `console.go`, `vulnerabilities.go`, `workloads.go`, `licence.go`, `auth_providers.go`, `instance.go`. + +Each exported route handler gets a block in this shape — `listTokens` as the worked example: + +```go +// listTokens godoc +// +// @Summary List API tokens +// @Description Returns the caller's own tokens. Owner and admin may pass all=true to see every token in the instance. +// @Tags tokens +// @Produce json +// @Param all query bool false "Include every token in the instance (owner and admin only)" +// @Success 200 {object} ListTokensResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /tokens [get] +func listTokens(c *gin.Context) { +``` + +Anonymous inline response structs must become named types for swag to describe them. Create `server/internal/api/types.go` and move them there as they are converted, starting with: + +```go +package api + +import "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + +// ErrorResponse is the shape every failing endpoint answers with. Some also +// carry a machine-readable code; it is omitted when absent rather than empty. +type ErrorResponse struct { + Error string `json:"error"` + Code string `json:"code,omitempty"` +} + +type ListTokensResponse struct { + Tokens []models.APIToken `json:"tokens"` + All bool `json:"all"` +} + +type CreateTokenRequest struct { + Name string `json:"name"` + Role string `json:"role"` + Scopes []string `json:"scopes"` + ExpiresInDays *int `json:"expires_in_days,omitempty"` +} + +type CreateTokenResponse struct { + // Token is the plaintext, returned exactly once and stored nowhere. + Token string `json:"token"` + Record models.APIToken `json:"record"` +} +``` + +Replace the corresponding `gin.H{...}` literals in the handlers with these types as you go, so the annotation and the response cannot disagree. This is the bulk of the work in this plan and it touches handler code the feature otherwise has no business in — that is the accepted cost of generated documentation over a hand-written file. + +- [ ] **Step 4: Generate the document** + +```bash +cd /go-projects/vantage/server && swag init \ + --generalInfo cmd/main.go \ + --dir ./,../shared \ + --output internal/api/docs \ + --outputTypes json \ + --v3.1 +``` + +Expected: `internal/api/docs/openapi.json` written, no `cannot find type definition` errors. Each such error names a type still declared inline; convert it in `types.go` and rerun. + +Delete any `docs.go` or `swagger.yaml` swag also emits — only `openapi.json` is wanted, and a stray generated Go file in that package will fight the hand-written `doc.go` in the next step. + +- [ ] **Step 5: Vendor Scalar** + +```bash +cd /go-projects/vantage/server/internal/api/docs && \ + curl -fsSL -o scalar.standalone.js https://cdn.jsdelivr.net/npm/@scalar/api-reference@latest/dist/browser/standalone.js && \ + ls -lh scalar.standalone.js +``` + +Expected: a file of roughly 1MB. Record the version by pinning the URL you actually used in a comment in the next step — no CDN reference remains at runtime, because air-gapped self-hosted installs are supported and a reference page that fails closed offline is a support ticket. + +- [ ] **Step 6: Embed and serve** + +Create `server/internal/api/docs/doc.go`: + +```go +// Package docs holds the generated OpenAPI document and the vendored Scalar +// bundle that renders it. +// +// openapi.json is generated by `swag init` and committed rather than built into +// the image: server/Dockerfile produces a scratch runtime from a Go build +// stage, and adding codegen there means putting the toolchain in the image. +// server-deploy.yml regenerates and diffs it, so an annotation edited without +// regenerating fails the build. +// +// scalar.standalone.js is vendored from +// https://cdn.jsdelivr.net/npm/@scalar/api-reference@latest/dist/browser/standalone.js +// and refreshed by hand. Fetched at build time it would break an air-gapped +// install; fetched at page load it would break an air-gapped install more +// visibly. +package docs + +import _ "embed" + +//go:embed openapi.json +var OpenAPI []byte + +//go:embed scalar.standalone.js +var ScalarJS []byte +``` + +Create `server/internal/api/openapi.go`: + +```go +package api + +import ( + "net/http" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api/docs" + "github.com/gin-gonic/gin" +) + +// scalarPage renders the reference against this instance's own spec, so "Try +// it" acts on the reader's API with the reader's session. +const scalarPage = ` + + + Vantage API + + + + +
+ + + +` + +func getOpenAPI(c *gin.Context) { + c.Data(http.StatusOK, "application/json; charset=utf-8", docs.OpenAPI) +} + +func getScalarJS(c *gin.Context) { + c.Data(http.StatusOK, "application/javascript; charset=utf-8", docs.ScalarJS) +} + +func getAPIDocs(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarPage)) +} +``` + +Register in `server/internal/api/handlers.go` inside `apiGroup`: + +```go + apiGroup.GET("/openapi.json", getOpenAPI) + apiGroup.GET("/docs", getAPIDocs) + apiGroup.GET("/docs/scalar.js", getScalarJS) +``` + +and add to `routeScopes` in `scopes.go`: + +```go + "GET /api/openapi.json": "settings:read", + "GET /api/docs": "settings:read", + "GET /api/docs/scalar.js": "settings:read", +``` + +- [ ] **Step 7: Add the CI drift check** + +In `.gitea/workflows/server-deploy.yml`, add a step to the job that builds the `server` image, before the docker build, following the file's existing step style: + +```yaml + - name: Verify the OpenAPI document is current + run: | + go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc4 + cd server + swag init --generalInfo cmd/main.go --dir ./,../shared \ + --output internal/api/docs --outputTypes json --v3.1 + git diff --exit-code internal/api/docs/openapi.json +``` + +Pin the same version you installed in Step 1. Without this step the annotations drift while appearing authoritative, which is worse than a hand-written file nobody claimed was generated. + +- [ ] **Step 8: Verify** + +```bash +cd /go-projects/vantage && go build ./... && go vet ./server/... +``` + +Expected: no output. + +With the server running and a session cookie: + +```bash +curl -s -b cookies.txt localhost:8080/api/openapi.json | head -c 200 +curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -b cookies.txt localhost:8080/api/docs/scalar.js +``` + +Expected: JSON beginning `{"openapi":"3.1.0"`, then `200` with a size near 1000000. + +Open `http://localhost:8080/api/docs` in a browser: the Scalar reference renders, lists the tag groups, and shows both `cookieAuth` and `bearerAuth`. Confirm the network tab makes no request to any external host. + +Then confirm the drift check does its job: + +```bash +cd /go-projects/vantage/server && sed -i 's/@Summary List API tokens/@Summary List all API tokens/' internal/api/tokens.go +swag init --generalInfo cmd/main.go --dir ./,../shared --output internal/api/docs --outputTypes json --v3.1 +git diff --stat internal/api/docs/openapi.json +``` + +Expected: a non-empty diff. Revert the `sed` edit, regenerate, and confirm `git diff --exit-code internal/api/docs/openapi.json` is clean before committing. + +- [ ] **Step 9: Commit** + +```bash +git add server/internal/api/docs server/internal/api/openapi.go server/internal/api/types.go server/internal/api server/cmd/main.go .gitea/workflows/server-deploy.yml +git commit -m "feat: Publish an OpenAPI 3.1 document and a Scalar reference + +Generated from swaggo v2 annotations, committed rather than built into the +image: the runtime stage is scratch and adding codegen puts the toolchain +in the build. CI regenerates and diffs, so an annotation edited without +regenerating fails the build — without that the annotations would drift +while still looking authoritative. + +Scalar is vendored rather than loaded from a CDN, because air-gapped +self-hosted installs are supported and a reference page that fails closed +offline is a support ticket." +``` + +--- + +### Task 11: Documentation + +**Files:** +- Create: `docsite/docs/reference/api-tokens.md` +- Modify: `docsite/sidebars.ts` +- Modify: `CLAUDE.md` + +**Interfaces:** +- Consumes: everything above. +- Produces: user documentation. + +- [ ] **Step 1: Write the reference page** + +Create `docsite/docs/reference/api-tokens.md`, matching the front-matter and heading style of the other files in `docsite/docs/reference/`. Cover, in this order: + +1. What a token is and where to create one (Settings → API tokens). +2. That the value is shown once and stored nowhere, so a lost token is rotated, not recovered. +3. The scope table — all eight resources, read and write, and that write implies read. +4. That a token never exceeds its owner's role, and that demoting or removing the person demotes or removes the token. +5. Expiry, and the instance-wide maximum lifetime setting, including that changing the setting affects new tokens only. +6. A curl example: + + ```bash + curl -H "Authorization: Bearer vt_…" https://acme.vantage.example.com/api/servers + ``` + +7. The rate limit — 600 requests per minute per token, answered as 429 with `Retry-After`. +8. Rotation guidance: create the replacement, deploy it, then revoke the old one. +9. A pointer to `/api/docs` on the reader's own instance for the full reference. +10. An explicit warning that the External Secrets read token is a different credential and an API token must not be used in its place. + +Add the page to `docsite/sidebars.ts` in the Reference section, which is authored by hand. + +- [ ] **Step 2: Update CLAUDE.md** + +Three edits, each placed with the material it belongs to: + +- Under **REST API**, in the session-authed table, add: + + ``` + tokens GET /tokens · GET /tokens/scopes · POST /tokens · DELETE /tokens/:id + GET /openapi.json · GET /docs + ``` + +- Under **MongoDB Collections**, add `api_tokens` to the collection list, and a note beneath the existing bullets: + + > - `api_tokens` stores only `sha256` of the token, like `servers.agent_token_hash`. A token's effective role is `min(user.role, token.role)` **recomputed per request**, so demoting somebody demotes their tokens; deleting the user deletes them. Scopes are enforced from a map keyed on the registered gin route pattern, and `AssertScopeMapComplete` **fails boot** when an `/api` route is missing from it — a route added without an entry would otherwise be silently unreachable by every token. + +- Add a short subsection under **Subsystems** describing API tokens and the OpenAPI document, including that `openapi.json` is generated by swag v2, committed, and verified in CI by regenerate-and-diff, and that the Scalar bundle is vendored because air-gapped installs are supported. + +- [ ] **Step 3: Verify the docs build** + +```bash +cd /go-projects/vantage/docsite && npm run build +``` + +Expected: build succeeds, and the output mentions no broken links. A broken link is fatal in Docusaurus's default config, so a wrong sidebar path fails here rather than in production. + +- [ ] **Step 4: Commit** + +```bash +git add docsite/docs/reference/api-tokens.md docsite/sidebars.ts CLAUDE.md +git commit -m "docs: Document API tokens and the OpenAPI reference" +``` + +--- + +### Task 12: Refresh the knowledge graph + +**Files:** +- Modify: `graphify-out/` (regenerated) + +- [ ] **Step 1: Update the graph** + +```bash +cd /go-projects/vantage && graphify update . +``` + +Expected: completes without error; `graphify-out/graph.json` is newer than before. + +- [ ] **Step 2: Commit** + +```bash +git add graphify-out +git commit -m "chore: Refresh the knowledge graph after API tokens" +```