chore: replace em dashes with hyphens, add no-em-dash rule to CLAUDE.md
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Failing after 1m52s

This commit is contained in:
2026-09-10 09:18:55 +00:00
parent b36a696d0e
commit 6ee203f5e9
172 changed files with 860 additions and 856 deletions
@@ -4,7 +4,7 @@
**Goal:** Give the Windows agent working OS update check/apply and a working workload registry (services and containers, with control and logs), matching what the Linux agent already does.
**Architecture:** The platform split moves into the agent as Go build tags, following the existing `inventory/collect_linux.go` / `collect_windows.go` / `collect_other.go` pattern. Windows work is done by PowerShell scripts invoked through a small `winexec` helper; every script emits JSON, and the JSON parsers live in build-tag-free files so they are testable on a Linux development machine. The control plane stays OS-blind a Windows service is reported as the same `unit` kind a systemd service is so the only wire change in the whole project is one new `reboot_required` field on `InventoryReport`.
**Architecture:** The platform split moves into the agent as Go build tags, following the existing `inventory/collect_linux.go` / `collect_windows.go` / `collect_other.go` pattern. Windows work is done by PowerShell scripts invoked through a small `winexec` helper; every script emits JSON, and the JSON parsers live in build-tag-free files so they are testable on a Linux development machine. The control plane stays OS-blind - a Windows service is reported as the same `unit` kind a systemd service is - so the only wire change in the whole project is one new `reboot_required` field on `InventoryReport`.
**Tech Stack:** Go 1.26 (agent is its own module, `agent/go.mod`), PowerShell 5.1 (`powershell.exe`, present on every supported Windows), Windows Update COM (`Microsoft.Update.Session`), CIM (`Win32_Service`), `Get-WinEvent`, Next.js 16 + Tailwind for `web/`.
@@ -21,7 +21,7 @@
---
### Task 1: `winexec` running PowerShell from the agent
### Task 1: `winexec` - running PowerShell from the agent
**Files:**
- Create: `agent/internal/winexec/encode.go`
@@ -30,7 +30,7 @@
**Interfaces:**
- Consumes: nothing.
- Produces: `winexec.EncodeCommand(script string) string` (base64 of UTF-16LE, used by the runner and directly testable); `winexec.Run(ctx context.Context, script string) (string, error)` Windows-only, returns the script's stdout.
- Produces: `winexec.EncodeCommand(script string) string` (base64 of UTF-16LE, used by the runner and directly testable); `winexec.Run(ctx context.Context, script string) (string, error)` - Windows-only, returns the script's stdout.
Scripts are passed with `-EncodedCommand` rather than `-Command` or a temp `.ps1` file. `-Command` requires quoting a multi-line script through Go, `cmd.exe` and PowerShell's own parser, and every one of the scripts in this plan contains both quote characters. A temp file needs a writable path and cleanup on a host where the agent may be killed mid-run.
@@ -67,7 +67,7 @@ func TestEncodeCommandMultiline(t *testing.T) {
cd agent && go test ./internal/winexec/ -run TestEncodeCommand -v
```
Expected: FAIL `undefined: EncodeCommand`.
Expected: FAIL - `undefined: EncodeCommand`.
- [ ] **Step 3: Write the implementation**
@@ -76,8 +76,8 @@ Create `agent/internal/winexec/encode.go`:
```go
// Package winexec runs PowerShell on Windows hosts.
//
// It exists because three subsystems updates, workload collection and
// workload logs all need the same invocation, and because getting a
// It exists because three subsystems - updates, workload collection and
// workload logs - all need the same invocation, and because getting a
// multi-line script past Go quoting, cmd.exe quoting and PowerShell's own
// parser is a problem worth solving once.
package winexec
@@ -209,7 +209,7 @@ func RebootRequired() bool { return rebootRequired() }
- [ ] **Step 2: Move the Linux implementation into its own file**
Create `agent/internal/updates/updates_linux.go` containing every function the old `updates.go` had `detectPM`, `checkApt`, `checkDnfYum`, `checkPacman`, `checkZypper`, `checkApk`, `apkName`, `apkVersion` verbatim, with its imports (`bufio`, `bytes`, `context`, `os/exec`, `strings`, `time`), plus these three entry points. `CheckAvailable`'s old body becomes `checkAvailable`; `ApplyAll`'s old body becomes `applyAll`:
Create `agent/internal/updates/updates_linux.go` containing every function the old `updates.go` had - `detectPM`, `checkApt`, `checkDnfYum`, `checkPacman`, `checkZypper`, `checkApk`, `apkName`, `apkVersion` - verbatim, with its imports (`bufio`, `bytes`, `context`, `os/exec`, `strings`, `time`), plus these three entry points. `CheckAvailable`'s old body becomes `checkAvailable`; `ApplyAll`'s old body becomes `applyAll`:
```go
package updates
@@ -284,7 +284,7 @@ func rebootRequired() bool { return false }
cd agent && go build ./... && GOOS=windows go build ./...
```
Expected: the Linux build succeeds. The Windows build **fails** with `undefined: checkAvailable` Task 3 supplies it. Confirm the failure names exactly those three functions and nothing else; anything else means something was moved wrong.
Expected: the Linux build succeeds. The Windows build **fails** with `undefined: checkAvailable` - Task 3 supplies it. Confirm the failure names exactly those three functions and nothing else; anything else means something was moved wrong.
- [ ] **Step 5: Commit**
@@ -388,7 +388,7 @@ func TestParseUpdateSearchPrefixedKB(t *testing.T) {
cd agent && go test ./internal/updates/ -v
```
Expected: FAIL `undefined: parseUpdateSearch`.
Expected: FAIL - `undefined: parseUpdateSearch`.
- [ ] **Step 3: Write the parser**
@@ -657,7 +657,7 @@ In `agent/internal/sync/sync.go`, change `runInventory`'s `report` closure so th
r := inventory.Collect(static)
r.ServerId = cfg.ServerID
r.AgentToken = cfg.AgentToken
// Static snapshots only every 15 minutes, not every 30 seconds. On
// Static snapshots only - every 15 minutes, not every 30 seconds. On
// Windows this spawns a PowerShell process, which is not something to
// do twice a minute forever, and a host rebooted by hand clearing the
// flag within a quarter of an hour is soon enough.
@@ -760,7 +760,7 @@ func Collect(ctx context.Context) Result {
}
```
The `SystemdOK` / `SystemdError` names stay as they are. A Windows service is reported as the same `unit` kind, and renaming these would cost a proto change, both pb copies, the server model, the service layer and the web client to describe the same thing. The naming is corrected where it is read, in the UI, which knows the server's OS.
The `SystemdOK` / `SystemdError` names stay as they are. A Windows service is reported as the same `unit` kind, and renaming these would cost a proto change, both pb copies, the server model, the service layer and the web client - to describe the same thing. The naming is corrected where it is read, in the UI, which knows the server's OS.
- [ ] **Step 2: Rename the systemd collector and its entry point**
@@ -995,7 +995,7 @@ Create `agent/internal/workloads/units_other.go`:
```go
//go:build !linux && !windows
// The build constraint is load-bearing see updates_other.go.
// The build constraint is load-bearing - see updates_other.go.
package workloads
import (
@@ -1019,7 +1019,7 @@ func logsPlatform(context.Context, string, string, int) (string, error) {
- [ ] **Step 6: Remove the Linux gates from the reporting loop**
In `agent/internal/sync/workloads.go`, delete all three `runtime.GOOS != "linux"` early returns the two at the top of `runWorkloads` and `reportWorkloads` and drop the now-unused `runtime` import.
In `agent/internal/sync/workloads.go`, delete all three `runtime.GOOS != "linux"` early returns - the two at the top of `runWorkloads` and `reportWorkloads` - and drop the now-unused `runtime` import.
- [ ] **Step 7: Verify both platforms build**
@@ -1027,7 +1027,7 @@ In `agent/internal/sync/workloads.go`, delete all three `runtime.GOOS != "linux"
cd agent && go build ./... && GOOS=windows go build ./...
```
Expected: the Linux build succeeds. The Windows build fails with `undefined: collectUnits`, `undefined: controlPlatform`, `undefined: logsPlatform`, `undefined: isProtectedUnit`, `undefined: ownContainerID` and nothing else. Tasks 6 and 7 supply them.
Expected: the Linux build succeeds. The Windows build fails with `undefined: collectUnits`, `undefined: controlPlatform`, `undefined: logsPlatform`, `undefined: isProtectedUnit`, `undefined: ownContainerID` - and nothing else. Tasks 6 and 7 supply them.
- [ ] **Step 8: Commit**
@@ -1047,7 +1047,7 @@ git commit -m "refactor: Split the agent workloads package by build tag"
**Interfaces:**
- Consumes: `Workload` from `docker.go`; `winexec.Run` from Task 1; the `collectUnits` signature from Task 5.
- Produces: `parseServices(jsonText, systemRoot string) ([]Workload, error)`, `servicePath(pathName string) string`, `psQuote(s string) string` all build-tag-free and `collectUnits` for `GOOS=windows`.
- Produces: `parseServices(jsonText, systemRoot string) ([]Workload, error)`, `servicePath(pathName string) string`, `psQuote(s string) string` - all build-tag-free - and `collectUnits` for `GOOS=windows`.
- [ ] **Step 1: Write the failing tests**
@@ -1117,7 +1117,7 @@ func TestParseServicesFilters(t *testing.T) {
}
}
// 1077 means "no attempt to start since boot" a clean stopped service, not a
// 1077 means "no attempt to start since boot" - a clean stopped service, not a
// failure, and reporting it red would cry wolf on every host.
func TestParseServicesExitCode1077(t *testing.T) {
in := `[{"Name":"Idle","DisplayName":"Idle","State":"Stopped","StartMode":"Auto","PathName":"C:\\Idle\\i.exe","ExitCode":1077}]`
@@ -1161,7 +1161,7 @@ func TestPSQuote(t *testing.T) {
cd agent && go test ./internal/workloads/ -v
```
Expected: FAIL `undefined: servicePath`, `undefined: parseServices`, `undefined: psQuote`.
Expected: FAIL - `undefined: servicePath`, `undefined: parseServices`, `undefined: psQuote`.
- [ ] **Step 3: Write the parser**
@@ -1189,7 +1189,7 @@ type winService struct {
}
// exitCodeNeverStarted is ERROR_SERVICE_NEVER_STARTED. A stopped service
// carrying it has not failed it has not run since boot and painting that
// carrying it has not failed - it has not run since boot - and painting that
// red would cry wolf on every host.
const exitCodeNeverStarted = 1077
@@ -1439,7 +1439,7 @@ Add `"strings"` to that file's imports.
cd agent && go test ./internal/workloads/ -run TestParseEvents -v
```
Expected: FAIL `undefined: parseEvents`.
Expected: FAIL - `undefined: parseEvents`.
- [ ] **Step 3: Write the event parser**
@@ -1519,7 +1519,7 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
)
// AgentUnit is the service this agent runs as the NSSM service name written
// AgentUnit is the service this agent runs as - the NSSM service name written
// by installer/setup.ps1. Change one, change the other.
const AgentUnit = "VantageAgent"
@@ -1616,7 +1616,7 @@ func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error
// Timestamps are formatted PowerShell-side rather than left to
// ConvertTo-Json, whose DateTime rendering differs between PowerShell
// versions one of them emits /Date(1699...)/.
// versions - one of them emits /Date(1699...)/.
//
// -ErrorAction SilentlyContinue because Get-WinEvent treats "no events
// matched" as a terminating error, and a quiet service is normal.
@@ -1653,7 +1653,7 @@ ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
}
// serviceDisplayName resolves a service's display name, which is what Service
// Control Manager events name it by. An empty answer is fine the filter then
// Control Manager events name it by. An empty answer is fine - the filter then
// matches on the service name alone.
func serviceDisplayName(ctx context.Context, id string) string {
out, err := winexec.Run(ctx,
@@ -1697,7 +1697,7 @@ git commit -m "feat: Control Windows services and read their event log as worklo
---
### Task 8: Web Windows wording and the reboot badge
### Task 8: Web - Windows wording and the reboot badge
**Files:**
- Modify: `web/lib/api.ts:10-19` (`Inventory`)
@@ -1757,8 +1757,8 @@ and replace the empty state and the systemd status lines (currently lines 117
{/* One wire field, two honest words for it: the agent
reports Windows services under the same `unit` kind
systemd units use, and only the UI knows which host
this is. On Windows there is no "not in use" case
every Windows host has a service controller so a
this is. On Windows there is no "not in use" case -
every Windows host has a service controller - so a
failure is the only thing worth saying. */}
{data.systemd_error ? (
<p className="text-warning">
@@ -1864,7 +1864,7 @@ Install or update the agent on a Windows server registered to a development cont
In the Design Decisions list, replace the Windows line:
```markdown
- **Windows agents cover the fleet-management path** register, heartbeat, run
- **Windows agents cover the fleet-management path** - register, heartbeat, run
steps, report inventory, OS updates through the Windows Update COM API, and
workloads (services plus containers, with control and logs). They still do no
`authorized_keys` management, and no package inventory or CVE matching: the
@@ -1876,14 +1876,14 @@ In the "Workload registry" section, after the sentence beginning "A **workload**
```markdown
On Windows a workload is a Docker container or a Windows **service**, reported
under the same `unit` kind and the same `systemd_ok` / `systemd_error` fields
under the same `unit` kind and the same `systemd_ok` / `systemd_error` fields -
one wire shape, worded per platform in the UI, which is the only layer that
knows the host's OS. The platform split lives entirely in the agent, as build
tags (`systemd_linux.go` / `services_windows.go` and the matching `control_`
and `logs_` pairs); the control plane is OS-blind and needed no changes.
Windows collection runs PowerShell through `agent/internal/winexec`, and every
script emits JSON that a build-tag-free parser reads, so the parsers are tested
on Linux the agent module has no Windows CI.
on Linux - the agent module has no Windows CI.
```
In the "Inventory and OS updates" section, add:
@@ -31,7 +31,7 @@
.board__route{font-family:var(--mono);font-size:.7rem;color:var(--ink-3)}
.frame{border:1px solid var(--rule);border-radius:var(--r);background:var(--ground);box-shadow:var(--shadow);overflow:hidden}
/* address strip shows the URL scheme being approved */
/* address strip - shows the URL scheme being approved */
.addr{display:flex;align-items:center;gap:10px;background:var(--well);border-bottom:1px solid var(--rule);padding:9px 14px}
.addr__dots{display:flex;gap:5px}
.addr__dots i{width:8px;height:8px;border-radius:999px;background:var(--rule);display:block}
@@ -350,7 +350,7 @@
<div class="inc__top">
<div>
<p class="inc__title">Public API unavailable</p>
<p class="inc__meta">2 Aug 2026, 14:02 UTC resolved 14:19 UTC</p>
<p class="inc__meta">2 Aug 2026, 14:02 UTC - resolved 14:19 UTC</p>
</div>
<span class="pill pill--res">resolved</span>
</div>
@@ -360,7 +360,7 @@
<div class="inc__top">
<div>
<p class="inc__title">Slow dashboard loads in Europe</p>
<p class="inc__meta">17 Jul 2026, 08:30 UTC resolved 10:05 UTC</p>
<p class="inc__meta">17 Jul 2026, 08:30 UTC - resolved 10:05 UTC</p>
</div>
<span class="pill pill--res">resolved</span>
</div>
@@ -376,7 +376,7 @@
<ul class="notes">
<li><span class="k">Redacted</span><span>No target URL, host, port or failure text anywhere on this page. <b>Search index</b> shows the no-data tail as grey cells rather than claiming 100% for days before it existed.</span></li>
<li><span class="k">Maintenance</span><span><b>Object storage</b> reads as Maintenance, not Down but its uptime figure is untouched. The window changes how it is drawn, never what the numbers say.</span></li>
<li><span class="k">Maintenance</span><span><b>Object storage</b> reads as Maintenance, not Down - but its uptime figure is untouched. The window changes how it is drawn, never what the numbers say.</span></li>
<li><span class="k">Colour</span><span>Every state carries a word and a shape as well as a hue. The page is readable with colour vision differences and in greyscale print.</span></li>
</ul>
</section>
@@ -472,7 +472,7 @@
<div class="field">
<label for="f-id">Page address</label>
<input class="in in--mono" id="f-id" value="api" disabled>
<span class="hint">Fixed once created the link is already out there.</span>
<span class="hint">Fixed once created - the link is already out there.</span>
</div>
<div class="field">
<label for="f-desc">Description</label>
@@ -602,9 +602,9 @@
</div>
<ul class="notes">
<li><span class="k">Naming</span><span>The monitor's own identifier stays visible on the left; the <b>public name</b> is a separate field beside it. An empty field falls back to the identifier, which the placeholder shows so publishing an internal name is always a visible choice.</span></li>
<li><span class="k">Naming</span><span>The monitor's own identifier stays visible on the left; the <b>public name</b> is a separate field beside it. An empty field falls back to the identifier, which the placeholder shows - so publishing an internal name is always a visible choice.</span></li>
<li><span class="k">Address</span><span>The page address is fixed after creation and the record line carries the whole URL, click to copy. It is what gets pasted into a support article.</span></li>
<li><span class="k">Copy</span><span>Buttons name the outcome: <b>Open incident</b>, <b>Post update</b>, <b>Schedule maintenance</b> the same words the public timeline then shows.</span></li>
<li><span class="k">Copy</span><span>Buttons name the outcome: <b>Open incident</b>, <b>Post update</b>, <b>Schedule maintenance</b> - the same words the public timeline then shows.</span></li>
</ul>
</section>
@@ -4,7 +4,7 @@
**Goal:** Publish operator-configured, completely public status pages at `<slug>.vantage.<tld>/status/<page-id>`, showing chosen monitors plus hand-authored incidents and maintenance windows.
**Architecture:** Two new instance-scoped MongoDB collections (`status_pages`, `status_incidents`) hold the page and its authored incidents. A pure assembly function combines them with existing monitor, incident and rollup data into a purpose-built public struct that function is the redaction boundary and nothing else may serve monitor data to an anonymous caller. The public route is mounted on the gin root, outside `/api` and therefore outside authentication, scope enforcement and the licence gate; it is cached in Redis for 30s and rate limited per client address.
**Architecture:** Two new instance-scoped MongoDB collections (`status_pages`, `status_incidents`) hold the page and its authored incidents. A pure assembly function combines them with existing monitor, incident and rollup data into a purpose-built public struct - that function is the redaction boundary and nothing else may serve monitor data to an anonymous caller. The public route is mounted on the gin root, outside `/api` and therefore outside authentication, scope enforcement and the licence gate; it is cached in Redis for 30s and rate limited per client address.
**Tech Stack:** Go 1.x (gin, mongo-driver v2, go-redis), Next.js 16 App Router + React 18 + Tailwind 3 + TanStack Query.
@@ -12,7 +12,7 @@
**Design:** Approved 2026-08-24. Mockup of both screens:
`docs/superpowers/plans/2026-08-24-status-pages-mockup.html`, also published at
https://claude.ai/code/artifact/13cfe71a-dda7-4780-a8f0-57ea8ae0d57d open the
https://claude.ai/code/artifact/13cfe71a-dda7-4780-a8f0-57ea8ae0d57d - open the
local file in a browser if the link is unavailable. Tasks 9 and 10 implement
what it shows; where this plan's code and the mockup disagree, the mockup is
the approved artefact and the code is the error.
@@ -35,7 +35,7 @@ the approved artefact and the code is the error.
## File Structure
**Server created:**
**Server - created:**
| File | Responsibility |
| --- | --- |
@@ -48,7 +48,7 @@ the approved artefact and the code is the error.
| `server/internal/api/statuspages.go` | Authoring handlers |
| `server/internal/api/publicstatus.go` | The one public handler plus its rate limiter |
**Server modified:**
**Server - modified:**
| File | Change |
| --- | --- |
@@ -58,15 +58,15 @@ the approved artefact and the code is the error.
| `server/internal/api/scopes.go:27` | add the nine `status:*` route entries |
| `server/cmd/main.go` | `EnsureStatusPageIndexes`, `SetTrustedProxies` |
**Web created:** `web/app/status/[pageId]/page.tsx`, `web/app/status/[pageId]/StatusPageView.tsx`, `web/components/status/` (`ComponentRow.tsx`, `HistoryBar.tsx`, `IncidentCard.tsx`), `web/app/(app)/status-pages/page.tsx`, `web/app/(app)/status-pages/[pageId]/page.tsx`.
**Web - created:** `web/app/status/[pageId]/page.tsx`, `web/app/status/[pageId]/StatusPageView.tsx`, `web/components/status/` (`ComponentRow.tsx`, `HistoryBar.tsx`, `IncidentCard.tsx`), `web/app/(app)/status-pages/page.tsx`, `web/app/(app)/status-pages/[pageId]/page.tsx`.
**Web modified:** `web/lib/api.ts` (types + methods), `web/components/Sidebar.tsx:198` (Instance group), `web/next.config.ts:29` (`/public` rewrite).
**Web - modified:** `web/lib/api.ts` (types + methods), `web/components/Sidebar.tsx:198` (Instance group), `web/next.config.ts:29` (`/public` rewrite).
**Docs modified:** `docsite/docs/vantage/status-pages.md` (new), `docsite/sidebars.ts`, `CLAUDE.md`, `docsite/docs/reference/environment-variables.md`.
**Docs - modified:** `docsite/docs/vantage/status-pages.md` (new), `docsite/sidebars.ts`, `CLAUDE.md`, `docsite/docs/reference/environment-variables.md`.
---
### Task 1: Schema models, feature constant, scoped collections, indexes
### Task 1: Schema - models, feature constant, scoped collections, indexes
**Files:**
- Create: `server/internal/models/statuspage.go`
@@ -112,7 +112,7 @@ func TestStatusCollectionsAreScoped(t *testing.T) {
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./server/internal/services/ -run TestStatusCollectionsAreScoped -v`
Expected: FAIL `ScopedCollections is missing "status_pages"` and the same for `status_incidents`.
Expected: FAIL - `ScopedCollections is missing "status_pages"` and the same for `status_incidents`.
- [ ] **Step 3: Add the licence feature constant**
@@ -396,7 +396,7 @@ Add `"strings"` to that file's imports.
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./server/internal/services/ -run 'TestValidatePageID|TestStatusCacheKey' -v`
Expected: FAIL `undefined: ValidatePageID`, `undefined: statusCacheKey`.
Expected: FAIL - `undefined: ValidatePageID`, `undefined: statusCacheKey`.
- [ ] **Step 3: Implement**
@@ -440,7 +440,7 @@ git commit -m "feat: status page id validation and cache key"
---
### Task 3: The redaction boundary `assembleSnapshot`
### Task 3: The redaction boundary - `assembleSnapshot`
This is the security-critical task. Everything else is plumbing around it.
@@ -639,7 +639,7 @@ func TestAssembleSnapshotOnlyIncludesAuthoredIncidentsForThisPage(t *testing.T)
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./server/internal/services/ -run TestAssembleSnapshot -v`
Expected: FAIL `undefined: snapshotInput`, `undefined: assembleSnapshot`.
Expected: FAIL - `undefined: snapshotInput`, `undefined: assembleSnapshot`.
- [ ] **Step 3: Implement the public types and the assembler**
@@ -701,7 +701,7 @@ type PublicIncidentUpdate struct {
}
// PublicIncident covers both authored incidents and derived monitor outages.
// A derived one carries no updates and no impact and never a cause, which is
// A derived one carries no updates and no impact - and never a cause, which is
// where internal hostnames live.
type PublicIncident struct {
ID string `json:"id"`
@@ -1542,7 +1542,7 @@ git commit -m "feat: authored status incidents and maintenance windows"
---
### Task 6: `PublicStatusSnapshot` reads, feature gate, Redis cache
### Task 6: `PublicStatusSnapshot` - reads, feature gate, Redis cache
**Files:**
- Modify: `server/internal/services/statussnapshot.go`
@@ -1783,7 +1783,7 @@ import (
const publicStatusRateLimit = 120
// RateLimitPublicStatus counts requests per client address in a one-minute
// fixed window, exactly as RateLimitTokens does including the part that
// fixed window, exactly as RateLimitTokens does - including the part that
// matters most: when Redis is unavailable it allows rather than denies. A
// status page must survive the outage it exists to report.
func RateLimitPublicStatus() gin.HandlerFunc {
@@ -1870,7 +1870,7 @@ In `server/internal/api/handlers.go`, inside `RegisterRoutes`, after the `/auth/
- [ ] **Step 3: Configure trusted proxies**
Nothing calls `SetTrustedProxies` today, so gin trusts every proxy and `c.ClientIP()` returns whatever `X-Forwarded-For` says spoofable per request, which would make the limiter above decorative.
Nothing calls `SetTrustedProxies` today, so gin trusts every proxy and `c.ClientIP()` returns whatever `X-Forwarded-For` says - spoofable per request, which would make the limiter above decorative.
In `server/cmd/main.go`, immediately after `r := gin.New()`:
@@ -1879,7 +1879,7 @@ In `server/cmd/main.go`, immediately after `r := gin.New()`:
// caller wrote in X-Forwarded-For. That was survivable while ClientIP()
// only produced audit strings; the public status limiter makes it load
// bearing. Empty means trust nobody, which is correct for a direct
// exposure and wrong behind a proxy hence the explicit setting.
// exposure and wrong behind a proxy - hence the explicit setting.
if err := r.SetTrustedProxies(trustedProxies()); err != nil {
log.Fatalf("trusted proxies: %v", err)
}
@@ -1914,7 +1914,7 @@ Ensure `"os"` and `"strings"` are imported in `main.go`.
Add a row to the server table in `docsite/docs/reference/environment-variables.md`:
| `TRUSTED_PROXIES` | no | Comma-separated CIDRs or addresses of proxies allowed to set `X-Forwarded-For`. Unset trusts none, so the client address is the direct peer behind a reverse proxy that makes every visitor share one address for rate-limiting purposes. Set it to your proxy's range. |
| `TRUSTED_PROXIES` | no | Comma-separated CIDRs or addresses of proxies allowed to set `X-Forwarded-For`. Unset trusts none, so the client address is the direct peer - behind a reverse proxy that makes every visitor share one address for rate-limiting purposes. Set it to your proxy's range. |
- [ ] **Step 5: Build**
@@ -2329,14 +2329,14 @@ In `server/internal/api/handlers.go`, inside the `apiGroup` block alongside the
- [ ] **Step 6: Build and confirm the scope map is complete**
Run: `go build ./server/... && go run ./server/cmd 2>&1 | head -20`
Expected: no `api scope map:` fatal. If one appears it names the route missing from `routeScopes` add it rather than removing the assertion. Stop the process once it reports listening.
Expected: no `api scope map:` fatal. If one appears it names the route missing from `routeScopes` - add it rather than removing the assertion. Stop the process once it reports listening.
- [ ] **Step 7: Regenerate the OpenAPI document**
Run the same command `server-deploy.yml` uses (check the workflow for the exact invocation, it is `swag v2`), then:
Run: `git diff --stat server/internal/api/docs/openapi.json`
Expected: the ten new paths appear. Commit the regenerated file CI runs `git diff --exit-code` against it.
Expected: the ten new paths appear. Commit the regenerated file - CI runs `git diff --exit-code` against it.
- [ ] **Step 8: Manual check**
@@ -2378,7 +2378,7 @@ No test runner exists in `web/`. Verification is `npm run build`, `npm run lint`
**Interfaces:**
- Consumes: `GET /public/status/:pageId` from Task 7.
- Produces: TypeScript types `StatusSnapshot`, `PublicSection`, `PublicComponent`, `PublicDay`, `PublicIncident` exported from `web/lib/api.ts` (added in Task 10; declare them locally in `StatusPageView.tsx` for this task and move them in Task 10 or do Task 10's type block first if executing in order).
- Produces: TypeScript types `StatusSnapshot`, `PublicSection`, `PublicComponent`, `PublicDay`, `PublicIncident` exported from `web/lib/api.ts` (added in Task 10; declare them locally in `StatusPageView.tsx` for this task and move them in Task 10 - or do Task 10's type block first if executing in order).
- [ ] **Step 0: Open the approved mockup**
@@ -2415,7 +2415,7 @@ export const dynamic = "force-dynamic";
async function fetchSnapshot(host: string, pageId: string): Promise<StatusSnapshot | null> {
const base = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
// The instance is resolved server-side from the Host header, so it has to
// be forwarded explicitly the server-to-server fetch does not carry it.
// be forwarded explicitly - the server-to-server fetch does not carry it.
const res = await fetch(`${base}/public/status/${encodeURIComponent(pageId)}`, {
headers: { Host: host },
cache: "no-store",
@@ -2442,7 +2442,7 @@ export default async function PublicStatusPage({
export async function generateMetadata({ params }: { params: Promise<{ pageId: string }> }) {
const { pageId } = await params;
return { title: `Status ${pageId}` };
return { title: `Status - ${pageId}` };
}
```
@@ -2601,7 +2601,7 @@ export default function StatusPageView({
}
```
The class names above are the real ones from `web/tailwind.config.ts:24-54`. If a name is ever missing, add the token to the config never reach for a hex.
The class names above are the real ones from `web/tailwind.config.ts:24-54`. If a name is ever missing, add the token to the config - never reach for a hex.
Match the approved mockup for layout and copy: overall banner above the notice, incidents before components, sections in page order, the refresh line in the footer.
@@ -2713,7 +2713,7 @@ export default function IncidentCard({ incident }: { incident: PublicIncident })
<p className="mt-1 text-xs text-text-secondary">
{new Date(incident.started_at).toLocaleString()}
{incident.resolved_at
? ` resolved ${new Date(incident.resolved_at).toLocaleString()}`
? ` - resolved ${new Date(incident.resolved_at).toLocaleString()}`
: ""}
</p>
{incident.updates && incident.updates.length > 0 ? (
@@ -2773,7 +2773,7 @@ git commit -m "feat: public status page"
Open `docs/superpowers/plans/2026-08-24-status-pages-mockup.html` (artboard 2). It is the approved design for the editor: back link, title with the full public URL as a click-to-copy record line, View page and Save changes, then the Details, Components and Incidents panels in that order.
Two details are decisions: the monitor's own identifier stays visible beside the **public name** field, and the field's placeholder is that identifier so publishing an internal name is a visible choice rather than a default. And the page address is locked after creation, because the link has already been handed out.
Two details are decisions: the monitor's own identifier stays visible beside the **public name** field, and the field's placeholder is that identifier - so publishing an internal name is a visible choice rather than a default. And the page address is locked after creation, because the link has already been handed out.
- [ ] **Step 1: Add the types**
@@ -2834,7 +2834,7 @@ export interface StatusIncident {
}
// The public shapes. These mirror services.StatusSnapshot and must change with
// it the public endpoint is the contract between them.
// it - the public endpoint is the contract between them.
export interface PublicDay {
date: string;
state: "up" | "down" | "maintenance" | "no_data";
@@ -2962,11 +2962,11 @@ Read `web/app/(app)/monitors/page.tsx` first and follow its query keys, panel cl
Create `web/app/(app)/status-pages/[pageId]/page.tsx` with three panels:
1. **Details** title, description, logo URL, published toggle, banner (enabled, level, text). Saves via `api.updateStatusPage`.
2. **Sections** add or remove a named section; within each, add monitors from a picker fed by `api.listMonitors()`, with an optional display-name field per entry. Reorder is out of scope for v1; adding to the end is enough.
3. **Incidents** list from `api.listStatusIncidents(pageId)`, a form to open an incident or schedule maintenance, and a "post update" control on each open one calling `api.postStatusIncidentUpdate`.
1. **Details** - title, description, logo URL, published toggle, banner (enabled, level, text). Saves via `api.updateStatusPage`.
2. **Sections** - add or remove a named section; within each, add monitors from a picker fed by `api.listMonitors()`, with an optional display-name field per entry. Reorder is out of scope for v1; adding to the end is enough.
3. **Incidents** - list from `api.listStatusIncidents(pageId)`, a form to open an incident or schedule maintenance, and a "post update" control on each open one calling `api.postStatusIncidentUpdate`.
The monitor picker must show the monitor's real name (this is the authenticated side) while making clear the display name is what gets published label the field "Public name" with the monitor name as its placeholder. Build all three panels to match artboard 2 of the mockup, including its copy: buttons are named for their outcome ("Open incident", "Post update", "Schedule maintenance"), the published toggle spells out that unpublished pages return not found, and the notice field says clearing it removes the notice.
The monitor picker must show the monitor's real name (this is the authenticated side) while making clear the display name is what gets published - label the field "Public name" with the monitor name as its placeholder. Build all three panels to match artboard 2 of the mockup, including its copy: buttons are named for their outcome ("Open incident", "Post update", "Schedule maintenance"), the published toggle spells out that unpublished pages return not found, and the notice field says clearing it removes the notice.
- [ ] **Step 5: Add the sidebar entry**
@@ -2985,7 +2985,7 @@ Expected: both clean.
- [ ] **Step 7: Browser check**
As an owner: create a page, add a section with one monitor and a public name, publish it, open the public URL in a private window and confirm the public name appears rather than the monitor's own. Open an incident, post an update, and confirm it appears on the public page within a few seconds that verifies cache invalidation.
As an owner: create a page, add a section with one monitor and a public name, publish it, open the public URL in a private window and confirm the public name appears rather than the monitor's own. Open an incident, post an update, and confirm it appears on the public page within a few seconds - that verifies cache invalidation.
As a member: confirm `/status-pages` is absent from the sidebar and that visiting it directly is refused by the API.
@@ -3038,4 +3038,4 @@ git commit -m "docs: status pages"
## Deferred to Vantage HQ
The `status_pages` feature must be added to admin's `plans` rows per `(deployment, tier)`. Until that happens every instance reads the feature as absent and every status page renders "not enabled" the feature ships dark. That work is in the admin service and its plan seeding, not in this plan.
The `status_pages` feature must be added to admin's `plans` rows per `(deployment, tier)`. Until that happens every instance reads the feature as absent and every status page renders "not enabled" - the feature ships dark. That work is in the admin service and its plan seeding, not in this plan.
@@ -150,7 +150,7 @@ func TestWrongKeySizeRejected(t *testing.T) {
- [ ] **Step 2: Run the test and verify it fails**
Run: `cd shared && go test ./cryptobox/...`
Expected: FAIL the package does not compile, `undefined: Seal`.
Expected: FAIL - the package does not compile, `undefined: Seal`.
- [ ] **Step 3: Write the implementation**
@@ -383,7 +383,7 @@ func TestParseKeyAcceptsUppercase(t *testing.T) {
- [ ] **Step 2: Run the test and verify it fails**
Run: `cd shared && go test ./backup/...`
Expected: FAIL `undefined: FingerprintHex`.
Expected: FAIL - `undefined: FingerprintHex`.
- [ ] **Step 3: Write the implementation**
@@ -567,7 +567,7 @@ func TestCiphertextCollections(t *testing.T) {
- [ ] **Step 2: Run the test and verify it fails**
Run: `cd shared && go test ./backup/...`
Expected: FAIL `undefined: Manifest`.
Expected: FAIL - `undefined: Manifest`.
- [ ] **Step 3: Write the implementation**
@@ -916,7 +916,7 @@ Add `"archive/tar"` and `"compress/gzip"` to the test file's imports.
- [ ] **Step 2: Run the test and verify it fails**
Run: `cd shared && go test ./backup/...`
Expected: FAIL `undefined: NewWriter`.
Expected: FAIL - `undefined: NewWriter`.
- [ ] **Step 3: Write the implementation**
@@ -1023,8 +1023,8 @@ func indexMember(name string) string { return "indexes/" + name + ".json" }
// Reader is an opened archive.
//
// Open extracts to a temporary directory rather than streaming, because gzip
// offers no random access and the manifest which carries the checksums every
// other member is judged against is written last. Verifying before writing a
// offers no random access and the manifest - which carries the checksums every
// other member is judged against - is written last. Verifying before writing a
// single document to the target is worth one pass over local disk. This is why
// the container image needs a /tmp.
type Reader struct {
@@ -1474,7 +1474,7 @@ Add `"io"` and `"time"` to this file's imports.
- [ ] **Step 3: Run the test and verify it fails**
Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Dump`
Expected: FAIL `undefined: Dump`.
Expected: FAIL - `undefined: Dump`.
If no MongoDB is available locally, start one: `docker run -d --rm -p 27017:27017 --name vantage-test-mongo mongo:7`.
@@ -1974,7 +1974,7 @@ Add `"go.mongodb.org/mongo-driver/v2/mongo/options"` to this file's imports.
- [ ] **Step 2: Run the test and verify it fails**
Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Restore`
Expected: FAIL `undefined: Restore`.
Expected: FAIL - `undefined: Restore`.
- [ ] **Step 3: Write the implementation**
@@ -2055,7 +2055,7 @@ func (o RestoreOptions) warn(format string, args ...any) {
// The order is fixed and every check that can refuse does so before the first
// write: format, checksums (done by Open), key policy, then target inspection.
// A restore that has begun writing and then fails leaves a partial database
// which the next run refuses to touch, which is correct the alternative is a
// which the next run refuses to touch, which is correct - the alternative is a
// silent merge, and merging two control planes reconciles nothing.
func Restore(ctx context.Context, opt RestoreOptions) (RestoreResult, error) {
m := opt.Archive.Manifest()
@@ -2213,8 +2213,8 @@ func splitBSON(raw []byte) (bson.Raw, []byte, error) {
// replayIndexes recreates the archived indexes.
//
// A unique index that will not build means the restored data violates it, and
// the unique indexes here (instance_id, email), instance slug, settings
// instance, the ESO token hash are tenant-isolation properties rather than
// the unique indexes here - (instance_id, email), instance slug, settings
// instance, the ESO token hash - are tenant-isolation properties rather than
// optimisations. That aborts. A non-unique index failing is a performance
// problem and warns.
func replayIndexes(ctx context.Context, opt RestoreOptions, coll *mongo.Collection, name string) (int, error) {
@@ -2603,7 +2603,7 @@ func TestVerifyProbeAbsentCiphertextIsNotAFailure(t *testing.T) {
- [ ] **Step 2: Run the test and verify it fails**
Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Verify`
Expected: FAIL `undefined: Verify`.
Expected: FAIL - `undefined: Verify`.
- [ ] **Step 3: Write the implementation**
@@ -2721,8 +2721,8 @@ func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error {
rep.ProbeDecrypted = true
return nil
}
// No ciphertext anywhere is an ordinary state a deployment that has
// stored no secrets, keys or SSO configuration yet and is not a failure.
// No ciphertext anywhere is an ordinary state - a deployment that has
// stored no secrets, keys or SSO configuration yet - and is not a failure.
return nil
}
@@ -2994,7 +2994,7 @@ func TestVersionIsReported(t *testing.T) {
- [ ] **Step 4: Run the test and verify it fails**
Run: `cd vantagectl && go test ./internal/cmd/...`
Expected: FAIL `undefined: NewRoot`.
Expected: FAIL - `undefined: NewRoot`.
- [ ] **Step 5: Write the root command**
@@ -3208,7 +3208,7 @@ Expected: PASS, six tests.
cd server && go build ./... && cd ../admin && go build ./... && cd ../sitesvc && go build ./...
git diff --stat server/go.sum admin/go.sum sitesvc/go.sum
```
Expected: builds succeed, `git diff --stat` prints nothing cobra stayed out of their module graphs.
Expected: builds succeed, `git diff --stat` prints nothing - cobra stayed out of their module graphs.
- [ ] **Step 9: Commit**
@@ -3314,7 +3314,7 @@ func TestRenderManifestFlagsAMissingFingerprint(t *testing.T) {
- [ ] **Step 2: Run the test and verify it fails**
Run: `cd vantagectl && go test ./internal/cmd/... -run 'ArchiveName|RenderManifest'`
Expected: FAIL `undefined: archiveName`.
Expected: FAIL - `undefined: archiveName`.
- [ ] **Step 3: Write `inspect`**
@@ -3362,7 +3362,7 @@ func renderManifest(w io.Writer, m backup.Manifest) {
fmt.Fprintf(w, "Format version %d\n", m.FormatVersion)
if m.KeyFingerprint == nil {
fmt.Fprintf(w, "Key none recorded this archive cannot be checked "+
fmt.Fprintf(w, "Key none recorded - this archive cannot be checked "+
"against any KEY_ENCRYPTION_KEY\n")
} else {
fmt.Fprintf(w, "Key %s\n", *m.KeyFingerprint)
@@ -3533,7 +3533,7 @@ go run . inspect /tmp/vantage-backup-vantage_smoke-*.tar.gz
```
Expected: `backup` reports what it wrote; `inspect` prints the manifest with a
key fingerprint and a collection table. An empty database is fine the point
key fingerprint and a collection table. An empty database is fine - the point
here is that both commands run.
Then confirm the refusal:
@@ -3642,7 +3642,7 @@ func TestConfirmDestructionTTYFlagSkipsThePrompt(t *testing.T) {
- [ ] **Step 2: Run the test and verify it fails**
Run: `cd vantagectl && go test ./internal/cmd/... -run Confirm`
Expected: FAIL `undefined: confirmDestruction`.
Expected: FAIL - `undefined: confirmDestruction`.
- [ ] **Step 3: Write `restore`**
@@ -3753,8 +3753,8 @@ func newRestoreCmd() *cobra.Command {
// confirmDestruction gates a --force restore.
//
// On a terminal the operator types the database name. Without one a
// Kubernetes Job, a CI step, a cron entry the same assurance comes from
// On a terminal the operator types the database name. Without one - a
// Kubernetes Job, a CI step, a cron entry - the same assurance comes from
// --confirm-db, whose value must equal the target. Naming the database in the
// argument means a copy-pasted command carries its intended target with it and
// cannot destroy a different one.
@@ -3969,7 +3969,7 @@ RUN cd vantagectl && CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantagectl .
# Staged so the scratch image below can have a /tmp. It cannot mkdir one
# itself scratch has no shell.
# itself - scratch has no shell.
RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp
# Runtime stage
@@ -3999,7 +3999,7 @@ Expected: the help text lists `backup`, `restore`, `inspect` and `verify`.
Temporarily comment out the `COPY --from=builder /staging/tmp /tmp` line,
rebuild as `vantagectl:notmp`, and run a restore against any archive. It must
fail with a `/tmp` error. Restore the line and rebuild. This is a manual check,
not a committed test the point is that the next person to trim the Dockerfile
not a committed test - the point is that the next person to trim the Dockerfile
learns why the line is there.
- [ ] **Step 4: Add the release workflow**
@@ -4132,7 +4132,7 @@ shared/ now fans out to four Go images rather than three."
Run: `sed -n '1,80p' deploy/chart/vantage/templates/server.yaml`
Match whatever that file does for `MONGO_URI` and `KEY_ENCRYPTION_KEY` exactly.
The CronJob must reference the same secret keys rather than declaring its own
The CronJob must reference the same secret keys rather than declaring its own -
a backup job with its own copy of the encryption key is a second place for it to
be wrong.
@@ -4144,7 +4144,7 @@ Append to `deploy/chart/vantage/values.yaml`:
# Scheduled backups.
#
# Off by default, deliberately. A backup with nowhere durable to land is a
# false sense of safety, and the chart cannot know where that is pvcName
# false sense of safety, and the chart cannot know where that is - pvcName
# must name a volume you have decided will outlive the cluster.
#
# There is no restore manifest here on purpose: a restore is an operator
@@ -4266,7 +4266,7 @@ Append to `deploy/chart/vantage/templates/NOTES.txt`:
No backups are scheduled. Vantage encrypts SSH private keys, vault secrets and
SSO client secrets with KEY_ENCRYPTION_KEY, and that key is not stored anywhere
but your own configuration a database restored without it is permanently
but your own configuration - a database restored without it is permanently
unreadable.
Set backup.enabled, backup.image and backup.pvcName, and store
@@ -4318,7 +4318,7 @@ the sibling pages' shape. Content, in this order:
database restored without it is permanently unreadable. Store it wherever you
store the credentials you could not rebuild.
2. **What a backup holds:** every collection in the database, the index
definitions, and a SHA-256 fingerprint of the key never the key.
definitions, and a SHA-256 fingerprint of the key - never the key.
3. **What it does not hold:** Redis sessions (everyone signs in again, which is
already true whenever Redis restarts), the vulnerability database (re-pulled
automatically), and any agent state on managed servers. Agents reconnect on
@@ -108,7 +108,7 @@ input[type=text],select{font:inherit;background:var(--well);border:1px solid var
input[type=text]::placeholder{color:var(--ink-3)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:16px}
/* scope matrix in the modal one grid, not 9 cards */
/* scope matrix in the modal - one grid, not 9 cards */
.matrix{border:1px solid var(--rule);border-radius:var(--r);overflow:hidden}
.mx{display:grid;grid-template-columns:1fr 64px 64px;align-items:center}
.mx.head{background:var(--panel-2);border-bottom:1px solid var(--rule);font-family:var(--mono);font-size:11px;color:var(--ink-3)}
@@ -123,7 +123,7 @@ input[type=checkbox]{width:16px;height:16px;accent-color:var(--accent);backgroun
.linky{background:none;border:0;font:inherit;color:var(--accent);cursor:pointer;padding:0}
.linky:hover{color:var(--accent-hover);text-decoration:underline}
/* live preview line what this key will be able to do, in one sentence */
/* live preview line - what this key will be able to do, in one sentence */
.preview{background:var(--well);border:1px solid var(--rule-soft);border-radius:var(--r);padding:11px 13px;font-family:var(--mono);font-size:12px;color:var(--ink-2);line-height:1.7}
.preview b{color:var(--ink);font-weight:500}
.preview .cap{color:var(--pend)}
@@ -146,7 +146,7 @@ dl.meta dt{color:var(--ink-3)}
.two{grid-template-columns:1fr}
/* Each key becomes a stacked record. The header row is gone, so every
cell carries its own label an unlabelled date under an unlabelled
cell carries its own label - an unlabelled date under an unlabelled
scope list is unreadable once the columns are gone. */
.lhead{display:none}
.lrow{grid-template-columns:1fr;gap:12px;align-items:stretch;padding:16px 16px 12px;position:relative}
@@ -368,7 +368,7 @@ dl.meta dt{color:var(--ink-3)}
<label class="f">
<span>Expires <em>this instance caps new keys at 90 days</em></span>
<select><option>90 days 7 December 2026</option><option>60 days</option><option>30 days</option><option disabled>365 days (over the cap)</option><option disabled>Never (over the cap)</option></select>
<select><option>90 days - 7 December 2026</option><option>60 days</option><option>30 days</option><option disabled>365 days (over the cap)</option><option disabled>Never (over the cap)</option></select>
</label>
<p class="preview">
@@ -2,9 +2,9 @@
> **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:** Rebuild `/tokens` the API keys page and its create dialog around what an operator actually needs to decide: which credentials are about to expire, what each one can reach, and whether a new one is over-granted. The current page is a seven-column table where every column is a bare string.
**Goal:** Rebuild `/tokens` - the API keys page and its create dialog - around what an operator actually needs to decide: which credentials are about to expire, what each one can reach, and whether a new one is over-granted. The current page is a seven-column table where every column is a bare string.
**Reference mockup:** `docs/superpowers/plans/2026-09-08-api-keys-redesign-mockup.html`. Open it in a browser. It is the visual contract for this plan: the posture strip, the record layout, the lifetime bar, the scope matrix and both dialog states are all drawn there, in the app's own tokens and font stacks. Where this plan and the mockup disagree, the plan wins the mockup carries example data and static markup, not logic.
**Reference mockup:** `docs/superpowers/plans/2026-09-08-api-keys-redesign-mockup.html`. Open it in a browser. It is the visual contract for this plan: the posture strip, the record layout, the lifetime bar, the scope matrix and both dialog states are all drawn there, in the app's own tokens and font stacks. Where this plan and the mockup disagree, the plan wins - the mockup carries example data and static markup, not logic.
**Tech Stack:** Next.js 16 App Router, React 18, Tailwind 3 (tokens only, no hex), TanStack Query. No new dependencies.
@@ -12,21 +12,21 @@
## Global Constraints
- **No component may carry a hex value.** Every colour comes from the Tailwind token map (`accent`, `danger`, `warning`, `success`, `text-primary/secondary/tertiary`, `surface`, `surface-2`, `well`, `border`). This is a repository-wide rule, not a preference for this page see the Frontend section of `CLAUDE.md`.
- **No component may carry a hex value.** Every colour comes from the Tailwind token map (`accent`, `danger`, `warning`, `success`, `text-primary/secondary/tertiary`, `surface`, `surface-2`, `well`, `border`). This is a repository-wide rule, not a preference for this page - see the Frontend section of `CLAUDE.md`.
- **`web/` is dark only.** Do not add a light variant or a theme toggle.
- **State never reads by colour alone.** Every coloured element in the mockup also carries a text label an amber lifetime bar always sits above the words "5 days left".
- **State never reads by colour alone.** Every coloured element in the mockup also carries a text label - an amber lifetime bar always sits above the words "5 days left".
- **There is no test runner in `web/`.** Verification for each task is `npm run lint` and `npm run build` from `vantage-app/web`, plus a stated browser check. Pure logic goes in `web/lib/` so it is at least readable in isolation.
- **The API is the boundary; the UI is the courtesy.** Nothing here may be the only thing enforcing a rule. Disabled expiry options, hidden MCP scopes and the role cap are all mirrors of server behaviour that already exists.
- Conventional commits (`feat:`, `refactor:`, `fix:`), one per task.
- Branch: this work is UI-only and independent of the MCP server tasks, but it **collides with them in one file** read the next section before starting.
- Branch: this work is UI-only and independent of the MCP server tasks, but it **collides with them in one file** - read the next section before starting.
## Relationship to the MCP server plan
`docs/superpowers/plans/2026-09-08-mcp-server.md` is in progress on branch `feat/mcp-server`. Tasks 15 are committed: the `mcp` scope resource exists, and `api_tokens.tag_selector` is modelled, accepted at creation (`POST /api/tokens`) and enforced at the server-resolution chokepoints. Tasks 615 are not started.
**That plan's Task 12 rewrites the same file this plan rewrites**, and its file list is stale it names `web/app/(app)/settings/`, but the page moved to `web/app/(app)/tokens/` with its body in `web/components/apikeys/ApiKeysPanel.tsx`. Two plans editing one 500-line component from opposite ends is a guaranteed conflict.
**That plan's Task 12 rewrites the same file this plan rewrites**, and its file list is stale - it names `web/app/(app)/settings/`, but the page moved to `web/app/(app)/tokens/` with its body in `web/components/apikeys/ApiKeysPanel.tsx`. Two plans editing one 500-line component from opposite ends is a guaranteed conflict.
Resolution, and it is a decision this plan makes deliberately: **this plan absorbs MCP Task 12 steps 2, 3, 4 and 5** the tag selector field, the MCP scope gating, the tag chip in the list, and the agent access panel. They are built here, on the redesigned surfaces, because a tag selector is a field in the create dialog and a tag chip is a column in the ledger, and both are cheaper to design once than to design and then redesign.
Resolution, and it is a decision this plan makes deliberately: **this plan absorbs MCP Task 12 steps 2, 3, 4 and 5** - the tag selector field, the MCP scope gating, the tag chip in the list, and the agent access panel. They are built here, on the redesigned surfaces, because a tag selector is a field in the create dialog and a tag chip is a column in the ledger, and both are cheaper to design once than to design and then redesign.
What stays with MCP Task 12: **step 1 only**, the `Agent Access (MCP)` row on `settings/license/page.tsx`, which is a different file and a different page.
@@ -42,7 +42,7 @@ Tasks 6 and 7 are written to be skippable and are marked so. Nothing in Tasks 1
> it was built rather than deferred. MCP Task 12 has been amended in place: its
> steps 2 and 4 are struck as done here, steps 3 and 5 point at Task 7, and only
> its step 1 (the licence-page row) remains its own work. The six browser checks
> in the final checklist are the only items left unticked they need a running
> in the final checklist are the only items left unticked - they need a running
> instance with fixture keys.
Whichever route is taken, **strike steps 25 from MCP Task 12 and leave a pointer to this plan**, so the next worker through does not build the tag picker twice.
@@ -51,7 +51,7 @@ Whichever route is taken, **strike steps 25 from MCP Task 12 and leave a poin
### Task 1: The lifetime model
The redesign's one visual idea is that a key's expiry is a bar, not a date how much of its issued life is left, coloured by urgency. That calculation is the only real logic on the page, so it goes in a module of its own rather than inline in a cell.
The redesign's one visual idea is that a key's expiry is a bar, not a date - how much of its issued life is left, coloured by urgency. That calculation is the only real logic on the page, so it goes in a module of its own rather than inline in a cell.
**Files:**
- Create: `web/lib/keyLifetime.ts`
@@ -82,10 +82,10 @@ export type Lifetime = {
Rules the implementation must honour:
- `soon` is seven days or fewer remaining the same `SEVEN_DAYS_MS` threshold the current file already uses. Keep the constant here and delete it there.
- `soon` is seven days or fewer remaining - the same `SEVEN_DAYS_MS` threshold the current file already uses. Keep the constant here and delete it there.
- `remainingPct` is measured against the token's **own** issued span (`created_at``expires_at`), not against the instance cap. A 30-day key at day 15 is half gone; a 365-day key at day 15 is barely started. Clamp to 0100, and guard the zero-length span (`created_at === expires_at`) so it cannot divide by zero.
- A token with no `expires_at` is `eternal`, drawn full-width and grey. It is not `healthy` "runs forever" is the state the posture strip counts as a risk.
- `outsidePolicy` keeps the existing rule verbatim: with a cap set, a token that never expires, or that expires further out than the cap allows, is outside it. **The cap is not applied retroactively** this is a prompt to rotate, never an error, and the copy must not imply the key has stopped working.
- A token with no `expires_at` is `eternal`, drawn full-width and grey. It is not `healthy` - "runs forever" is the state the posture strip counts as a risk.
- `outsidePolicy` keeps the existing rule verbatim: with a cap set, a token that never expires, or that expires further out than the cap allows, is outside it. **The cap is not applied retroactively** - this is a prompt to rotate, never an error, and the copy must not imply the key has stopped working.
- Accept `now` as an argument with a `Date.now()` default. A function that reads the clock itself cannot be reasoned about.
- [x] **Step 2: Verify it compiles**
@@ -115,7 +115,7 @@ git commit -m "feat: model an api key's remaining lifetime as a single value"
- [x] **Step 1: Move the existing pieces out, unchanged**
This step is a pure refactor **no visual change, no behaviour change.** Move `summariseScopes` and `ScopeChips` into `ScopeChips.tsx` verbatim, exporting both. Move `ExpiryCell` into `LifetimeBar.tsx` as-is for now (Task 3 rewrites its body). Move the `<Table>` block into `KeyLedger.tsx`, the `<Modal>` block into `CreateKeyDialog.tsx`.
This step is a pure refactor - **no visual change, no behaviour change.** Move `summariseScopes` and `ScopeChips` into `ScopeChips.tsx` verbatim, exporting both. Move `ExpiryCell` into `LifetimeBar.tsx` as-is for now (Task 3 rewrites its body). Move the `<Table>` block into `KeyLedger.tsx`, the `<Modal>` block into `CreateKeyDialog.tsx`.
Keep every explanatory comment with the code it explains. Those comments are the record of why `write` implies `rw` in a chip and why Copy outranks Done, and they are worth more than the lines they sit above.
@@ -127,7 +127,7 @@ Keep every explanatory comment with the code it explains. Those comments are the
npm run lint && npm run build
```
Then run the app and compare `/tokens` against the page before the split key list, create dialog, revoke dialog, empty state. It must be pixel-identical. Any difference is a mistake made during the move, and it is far cheaper to find now than under the redesign.
Then run the app and compare `/tokens` against the page before the split - key list, create dialog, revoke dialog, empty state. It must be pixel-identical. Any difference is a mistake made during the move, and it is far cheaper to find now than under the redesign.
- [x] **Step 3: Commit**
@@ -156,19 +156,19 @@ Give the track `role="img"` with an `aria-label` carrying the same text as the v
- [x] **Step 2: Rewrite the row as a grid, not a `<Table>`**
The mockup's row is a CSS grid, because the identity column stacks four things and the existing `Table`/`Td` primitives assume one value per cell. Columns: `minmax(220px,1.5fr) minmax(180px,1.3fr) minmax(150px,1fr) 150px auto`, `gap-5`, rows separated by `border-border/60` reach for the `rule-soft` token if a softer divider is wanted; do not invent a colour.
The mockup's row is a CSS grid, because the identity column stacks four things and the existing `Table`/`Td` primitives assume one value per cell. Columns: `minmax(220px,1.5fr) minmax(180px,1.3fr) minmax(150px,1fr) 150px auto`, `gap-5`, rows separated by `border-border/60` - reach for the `rule-soft` token if a softer divider is wanted; do not invent a colour.
Keep the header row as a mono, tracked-out strip on `surface-2`. Keep the hover fill. Keep the owner column conditional on `showAll` but fold it **into** the identity column as a third line rather than adding a fifth grid column, exactly as the mockup does. `showAll` then changes what a record says, not how the page is laid out.
Keep the header row as a mono, tracked-out strip on `surface-2`. Keep the hover fill. Keep the owner column conditional on `showAll` - but fold it **into** the identity column as a third line rather than adding a fifth grid column, exactly as the mockup does. `showAll` then changes what a record says, not how the page is laid out.
Keep the role `Badge` inline in that identity column, and keep `roleVariant` as-is: `owner` accent, `admin` warning, `member` neutral.
- [x] **Step 3: Make the scope chips two-part**
Each chip becomes resource plus a tinted access half `rw` on `accent/18`, `r` on a neutral wash as in the mockup. `summariseScopes` already produces exactly this shape and does not change. A token with no scopes keeps its dashed "no scopes granted" chip rather than an em dash; an em dash reads as "unknown", and "this key can call nothing" is a fact worth stating.
Each chip becomes resource plus a tinted access half - `rw` on `accent/18`, `r` on a neutral wash - as in the mockup. `summariseScopes` already produces exactly this shape and does not change. A token with no scopes keeps its dashed "no scopes granted" chip rather than an em dash; an em dash reads as "unknown", and "this key can call nothing" is a fact worth stating.
- [x] **Step 4: Rewrite the mobile layout**
Below `900px` the grid collapses to a stacked record. Hide the header row and give each cell its own label via `data-label` and a `::before` rule, as the mockup does an unlabelled date sitting under an unlabelled chip list is unreadable once the columns are gone. The scope list scrolls horizontally in its own track instead of wrapping to four lines. Revoke pins to the top-right of the record and gains a border so it is a real tap target.
Below `900px` the grid collapses to a stacked record. Hide the header row and give each cell its own label via `data-label` and a `::before` rule, as the mockup does - an unlabelled date sitting under an unlabelled chip list is unreadable once the columns are gone. The scope list scrolls horizontally in its own track instead of wrapping to four lines. Revoke pins to the top-right of the record and gains a border so it is a real tap target.
Below `520px`: the posture strip goes single-column, the filter segment goes full width with its hint on its own line, and the dialog footer stacks with the primary button on top.
@@ -178,7 +178,7 @@ Copy these breakpoints from the mockup rather than re-deriving them; they were t
`TableSkeleton` assumes a table. Either keep it for the loading state and accept a one-frame shape change, or add a small ledger-shaped skeleton beside it. Do not leave the loading state as an empty box.
The empty state keeps both existing copy variants instance-wide versus personal and the "Create your first key" action.
The empty state keeps both existing copy variants - instance-wide versus personal - and the "Create your first key" action.
- [x] **Step 6: Verify**
@@ -207,7 +207,7 @@ Four counts above the list, answering "is anything wrong here" before the operat
- [x] **Step 1: Build it**
Four cells in a bordered grid: total keys, expiring within seven days (warning), never expiring (danger), and never used (muted). Derive all four from the `tokens` array already in hand with `keyLifetime` **no new request, and no new endpoint.**
Four cells in a bordered grid: total keys, expiring within seven days (warning), never expiring (danger), and never used (muted). Derive all four from the `tokens` array already in hand with `keyLifetime` - **no new request, and no new endpoint.**
"Never used" is `last_used_at == null`. It is muted rather than coloured: an unused key is a cleanup candidate, not an incident.
@@ -217,7 +217,7 @@ The counts describe the list as filtered, so the strip sits below the `My keys`
The `{count} key{s} · {scope}` line under the heading goes; the strip says it better. The masthead is left as the heading and the Create key button, vertically centred.
The descriptive paragraph about what API keys are for is **not** to be added it was in an earlier draft of the mockup and was cut deliberately. The `sha256` and role-cap facts appear in the create dialog and the reveal panel, where they are actionable.
The descriptive paragraph about what API keys are for is **not** to be added - it was in an earlier draft of the mockup and was cut deliberately. The `sha256` and role-cap facts appear in the create dialog and the reveal panel, where they are actionable.
- [x] **Step 3: Verify**
@@ -242,27 +242,27 @@ Name and role side by side, scopes as one matrix instead of nine mini-cards, an
- [x] **Step 1: Build the scope matrix**
One bordered grid: a resource per row, `read` and `write` checkbox columns, a mono header row. Resources come from `GET /api/tokens/scopes` exactly as now **do not hardcode the nine resources**, the endpoint is the source of truth and MCP is about to add a tenth.
One bordered grid: a resource per row, `read` and `write` checkbox columns, a mono header row. Resources come from `GET /api/tokens/scopes` exactly as now - **do not hardcode the nine resources**, the endpoint is the source of truth and MCP is about to add a tenth.
Each row carries a one-line description under the resource name ("fleet list, inventory, agent updates"). Those strings are UI copy with no server counterpart, so keep them in one exported record in this file, keyed by resource, and fall back to no description for an unknown key rather than rendering `undefined`.
The footer carries the running count ("3 of 9 resources · 5 scopes") and two bulk actions: **Read-only everywhere** and **Clear all**.
Checking `write` must also check `read` in the UI. The server treats write as satisfying read on the same resource, so a `:write`-only token works but a matrix that lets you tick write while read sits empty invites the reader to conclude the key cannot read.
Checking `write` must also check `read` in the UI. The server treats write as satisfying read on the same resource, so a `:write`-only token works - but a matrix that lets you tick write while read sits empty invites the reader to conclude the key cannot read.
- [x] **Step 2: Name the date in the expiry options**
Each option renders as "90 days 7 December 2026", computed from `Date.now()`. Options beyond the cap, and Never, stay `disabled` with the existing hint, and the existing effect that defaults to the shortest allowed option stays as it is.
Each option renders as "90 days - 7 December 2026", computed from `Date.now()`. Options beyond the cap, and Never, stay `disabled` with the existing hint, and the existing effect that defaults to the shortest allowed option stays as it is.
- [x] **Step 3: Add the preview line**
One mono line in a `well` box, assembled from the current form state: the name, the role, the resources it may read and write, and the date it stops working. It is the over-granting check reading "may read and write servers, workflows, secrets and keys" out loud is what makes someone go back and untick two boxes.
One mono line in a `well` box, assembled from the current form state: the name, the role, the resources it may read and write, and the date it stops working. It is the over-granting check - reading "may read and write servers, workflows, secrets and keys" out loud is what makes someone go back and untick two boxes.
Handle the empty states honestly: no name yet, no scopes granted, no expiry.
- [x] **Step 4: Rework the reveal panel**
Keep the warning bar, keep the `sha256` sentence, keep Copy as the primary action with Done as the ghost all three are existing decisions and all three were right. Add the `curl` example line from the mockup so nobody leaves the dialog to find out how to use what they just made. Put the plaintext key beside its Copy button, stacking below `520px` so Copy is reachable without scrolling 64 characters of hex sideways.
Keep the warning bar, keep the `sha256` sentence, keep Copy as the primary action with Done as the ghost - all three are existing decisions and all three were right. Add the `curl` example line from the mockup so nobody leaves the dialog to find out how to use what they just made. Put the plaintext key beside its Copy button, stacking below `520px` so Copy is reachable without scrolling 64 characters of hex sideways.
- [x] **Step 5: Verify**
@@ -281,7 +281,7 @@ git commit -m "feat: rebuild the create key dialog around a scope matrix and a p
---
### Task 6: Tag restriction absorbs MCP Task 12 steps 2 and 4
### Task 6: Tag restriction - absorbs MCP Task 12 steps 2 and 4
**Requires MCP Tasks 35, which are already committed on `feat/mcp-server`.** Skip this task entirely on a branch that does not have them; `tag_selector` will be rejected by a server without them.
@@ -291,19 +291,19 @@ git commit -m "feat: rebuild the create key dialog around a scope matrix and a p
- [x] **Step 1: Carry the field in the API client**
Add `tag_selector?: Record<string, string> | null` to the `ApiToken` type, and `tag_selector?: Record<string, string>` to `createApiToken`'s body. The server already models, accepts and enforces it `models/api_token.go` and `api/tokens.go` so this is the client catching up, not a new contract.
Add `tag_selector?: Record<string, string> | null` to the `ApiToken` type, and `tag_selector?: Record<string, string>` to `createApiToken`'s body. The server already models, accepts and enforces it - `models/api_token.go` and `api/tokens.go` - so this is the client catching up, not a new contract.
- [x] **Step 2: Add the field to the dialog**
Below the scope matrix, a "Restrict to servers tagged" control offering the key/value vocabulary from `GET /api/servers/tags` (`api.listKnownTags`, already in the client). Reuse the workflow target tag rows from `EditWorkflowModal` if that component can be lifted without dragging workflow state with it; build the smallest possible thing if it cannot.
Send `tag_selector` omitted or `{}` when unrestricted. **This field is not licence-gated** tag scoping ships useful on its own and is shown to everyone.
Send `tag_selector` omitted or `{}` when unrestricted. **This field is not licence-gated** - tag scoping ships useful on its own and is shown to everyone.
Two lines of copy earn their place here, because the asymmetry is genuinely surprising: an **empty** selector means unrestricted, and a selector matches a server only when **every** pair matches. Say both.
- [x] **Step 3: Show the restriction in the ledger**
Render a token's `tag_selector` as a chip beside its scopes `env=prod` in mono. An unrestricted token renders nothing at all, not an empty chip and not "unrestricted": most tokens are unrestricted, and a chip on every row for the common case is noise. Include the selector in the preview line's sentence.
Render a token's `tag_selector` as a chip beside its scopes - `env=prod` in mono. An unrestricted token renders nothing at all, not an empty chip and not "unrestricted": most tokens are unrestricted, and a chip on every row for the common case is noise. Include the selector in the preview line's sentence.
- [x] **Step 4: Verify**
@@ -318,7 +318,7 @@ git commit -m "feat: restrict an api key to tagged servers from the create dialo
---
### Task 7: Agent access absorbs MCP Task 12 steps 3 and 5
### Task 7: Agent access - absorbs MCP Task 12 steps 3 and 5
**Requires MCP Tasks 611 (the endpoint itself) and the `mcp` licence feature.** Skip on a branch without them.
@@ -328,13 +328,13 @@ git commit -m "feat: restrict an api key to tagged servers from the create dialo
- [x] **Step 1: Gate the MCP scopes in the matrix**
`mcp:read` and `mcp:write` arrive from `GET /api/tokens/scopes` with no client change. Hide that row when `license.features.mcp` is false, following whatever the console-gated UI already does check `web/lib/useLicense.ts` for the existing pattern rather than inventing a second one.
`mcp:read` and `mcp:write` arrive from `GET /api/tokens/scopes` with no client change. Hide that row when `license.features.mcp` is false, following whatever the console-gated UI already does - check `web/lib/useLicense.ts` for the existing pattern rather than inventing a second one.
- [x] **Step 2: Build the panel**
Below the ledger, visible only when `license.features.mcp` is true: the endpoint URL (`${window.location.origin}/api/mcp`) with a copy button, the copyable client configuration JSON from MCP Task 12 step 5, and one line saying the token needs `mcp:read`, plus `mcp:write` for tools that change anything, linking to the docs page from MCP Task 15.
Style it as a `well` block, not a card it is machine output being handed to the operator, the same treatment the install one-liner gets on `/servers/new`.
Style it as a `well` block, not a card - it is machine output being handed to the operator, the same treatment the install one-liner gets on `/servers/new`.
- [x] **Step 3: Verify**
+57 -57
View File
@@ -4,7 +4,7 @@
**Goal:** Expose Vantage to LLM agents as an MCP tool surface at `/api/mcp`, authenticated by the existing API token, gated by a new licence feature, and restricted by a new tag selector on API tokens.
**Architecture:** A new `server/internal/mcp` package registers task-shaped tools that call the existing service layer in-process. It is mounted inside the existing `/api` gin group, so bearer auth, rate limiting, licence checks and scope enforcement apply unchanged. Authority is never invented in the MCP layer: three gates (licence feature, `mcp:*` scope, per-tool resource scope) are all made of machinery that already exists, plus one new general capability tag-scoped API tokens that ships useful on its own.
**Architecture:** A new `server/internal/mcp` package registers task-shaped tools that call the existing service layer in-process. It is mounted inside the existing `/api` gin group, so bearer auth, rate limiting, licence checks and scope enforcement apply unchanged. Authority is never invented in the MCP layer: three gates (licence feature, `mcp:*` scope, per-tool resource scope) are all made of machinery that already exists, plus one new general capability - tag-scoped API tokens - that ships useful on its own.
**Tech Stack:** Go 1.26, gin, MongoDB (mongo-driver v2), `github.com/modelcontextprotocol/go-sdk`, Next.js 16 (web), and the `vantage-admin` HQ service plus Paddle for licensing.
@@ -12,7 +12,7 @@
## Global Constraints
- Three repos are touched: `vantage-shared` (licence constant), `vantage-app` (server + web), `vantage-admin` (catalogue + labels). They have **no import cycle and no build dependency between app and admin** do not create one.
- Three repos are touched: `vantage-shared` (licence constant), `vantage-app` (server + web), `vantage-admin` (catalogue + labels). They have **no import cycle and no build dependency between app and admin** - do not create one.
- Go module path for the app server is `gitea.hostxtra.co.uk/mrhid6/vantage/server`; for shared, `gitea.hostxtra.co.uk/vantage/vantage-shared`.
- The licence feature key is exactly `"mcp"`, constant `license.FeatureMCP`.
- The new scope resource is exactly `"mcp"`, producing `mcp:read` and `mcp:write`. Do **not** invent an `mcp:use` scope.
@@ -66,7 +66,7 @@ go get gitea.hostxtra.co.uk/vantage/vantage-shared@latest
go mod tidy
```
If the repos are wired with a `replace` directive to a local path, this step is a no-op check `go.mod` first and skip if so. Do the same from `vantage-admin/server`.
If the repos are wired with a `replace` directive to a local path, this step is a no-op - check `go.mod` first and skip if so. Do the same from `vantage-admin/server`.
- [ ] **Step 5: Commit the module bump if one happened**
@@ -139,7 +139,7 @@ func TestAllScopesAdvertisesMCP(t *testing.T) {
- [ ] **Step 2: Run the test to verify it fails**
Run: `go test ./internal/services/ -run 'TestMCP|TestAllScopes' -v`
Expected: FAIL `ValidScopes(mcp:read)` returns an invalid-scope error.
Expected: FAIL - `ValidScopes(mcp:read)` returns an invalid-scope error.
- [ ] **Step 3: Add the resource**
@@ -153,7 +153,7 @@ In `internal/services/scopes.go`, append to `ScopeResources`:
"mcp",
```
Update the doc comment above `ScopeResources` it says "Eight resources" and there are now ten. Count the slice and write the real number; the comment exists so the count is deliberate rather than drifted.
Update the doc comment above `ScopeResources` - it says "Eight resources" and there are now ten. Count the slice and write the real number; the comment exists so the count is deliberate rather than drifted.
- [ ] **Step 4: Run the tests to verify they pass**
@@ -184,7 +184,7 @@ git commit -m "feat: add the mcp scope resource"
- `services.ServerInTokenScope(srv models.Server, sel map[string]string) bool`
- `services.IntersectSelectors(caller, requested map[string]string) (map[string]string, bool)`
- `services.SelectorNarrowerOrEqual(child, parent map[string]string) bool`
- `services.CreateAPIToken(instanceID, userID, name, role string, scopes []string, tagSelector map[string]string, expiresInDays *int, ip string)` note the **new sixth parameter**, consumed by Task 4.
- `services.CreateAPIToken(instanceID, userID, name, role string, scopes []string, tagSelector map[string]string, expiresInDays *int, ip string)` - note the **new sixth parameter**, consumed by Task 4.
- [ ] **Step 1: Write the failing test**
@@ -286,7 +286,7 @@ func TestSelectorNarrowerOrEqual(t *testing.T) {
- [ ] **Step 2: Run the test to verify it fails**
Run: `go test ./internal/services/ -run 'TokenScope|Intersect|Narrower' -v`
Expected: FAIL to compile `ServerInTokenScope`, `IntersectSelectors` and `SelectorNarrowerOrEqual` are undefined.
Expected: FAIL to compile - `ServerInTokenScope`, `IntersectSelectors` and `SelectorNarrowerOrEqual` are undefined.
- [ ] **Step 3: Write the selector helpers**
@@ -394,12 +394,12 @@ And in the struct literal that builds `tok`, after `Scopes: scopes,`:
TagSelector: tagSelector,
```
The caller-narrowing check belongs in the handler rather than here, because the service does not know the caller Task 4 adds it.
The caller-narrowing check belongs in the handler rather than here, because the service does not know the caller - Task 4 adds it.
- [ ] **Step 7: Fix the call sites**
Run: `go build ./...`
Expected: FAIL, naming each caller of `CreateAPIToken` with the wrong argument count. Update each in `internal/api/tokens.go` pass the value from the request body (Task 4 adds the field; for now pass `nil`), and in any test or seed caller pass `nil`.
Expected: FAIL, naming each caller of `CreateAPIToken` with the wrong argument count. Update each - in `internal/api/tokens.go` pass the value from the request body (Task 4 adds the field; for now pass `nil`), and in any test or seed caller pass `nil`.
- [ ] **Step 8: Run the full service tests**
@@ -451,7 +451,7 @@ At the bottom of `internal/auth/middleware.go`, beside `Scopes` and `IsToken`:
```go
// ServerScope is the tag restriction the acting credential carries, or nil for
// an unrestricted token and for every cookie session. Callers pass it to
// services.ServerInTokenScope or services.IntersectSelectors nil means the
// services.ServerInTokenScope or services.IntersectSelectors - nil means the
// whole fleet, never nothing.
func ServerScope(c *gin.Context) map[string]string {
if s := GetSessionFromContext(c); s != nil {
@@ -611,7 +611,7 @@ func GetServerScoped(instanceID, serverID string, tokenScope map[string]string)
}
```
Use whatever not-found error `GetServer` already returns read it first and reuse that identifier rather than introducing a second one.
Use whatever not-found error `GetServer` already returns - read it first and reuse that identifier rather than introducing a second one.
- [ ] **Step 4: Point the handlers at the scoped versions**
@@ -677,12 +677,12 @@ func AssertServerScopeMapComplete(routes []string) error {
}
```
Read `AssertScopeMapComplete` in `internal/api/scopes.go` first and mirror how it obtains the route list and how it is called at boot; call the new assertion immediately after it, and restrict the routes it checks to those whose path contains `server`, `console` or `workflows/:id/run` so unrelated routes are not swept in. Adjust the map above to the real route list the grep produces the entries here are what the current `routeScopes` shows, and any route that exists but is absent must be added with a decision, not omitted.
Read `AssertScopeMapComplete` in `internal/api/scopes.go` first and mirror how it obtains the route list and how it is called at boot; call the new assertion immediately after it, and restrict the routes it checks to those whose path contains `server`, `console` or `workflows/:id/run` so unrelated routes are not swept in. Adjust the map above to the real route list the grep produces - the entries here are what the current `routeScopes` shows, and any route that exists but is absent must be added with a decision, not omitted.
- [ ] **Step 6: Verify boot and tests**
Run: `go build ./... && go test ./...`
Expected: PASS. If the assertion fires, that is the feature working add the missing route to the map with a true/false decision.
Expected: PASS. If the assertion fires, that is the feature working - add the missing route to the map with a true/false decision.
- [ ] **Step 7: Commit**
@@ -705,7 +705,7 @@ git commit -m "feat: enforce token tag restrictions at the server resolution cho
- `mcp.Tool` struct with fields `Name string`, `Description string`, `Scope string`, `Write bool`, `Handler ToolFunc`
- `mcp.Caller` struct with fields `InstanceID string`, `Scopes []string`, `TokenScope map[string]string`, `TokenName string`
- `mcp.Registry` with `Register(Tool)`, `Visible(Caller) []Tool`, `Lookup(name string) (Tool, bool)`
- `mcp.Allowed(t Tool, c Caller) (bool, string)` returns whether the call may proceed and, when not, the gate that refused
- `mcp.Allowed(t Tool, c Caller) (bool, string)` - returns whether the call may proceed and, when not, the gate that refused
- Consumed by Tasks 7, 8, 9, 10.
- [ ] **Step 1: Write the failing test**
@@ -809,7 +809,7 @@ func TestEveryRegisteredToolDeclaresAKnownScope(t *testing.T) {
- [ ] **Step 2: Run the test to verify it fails**
Run: `go test ./internal/mcp/ -v`
Expected: FAIL to build the package does not exist.
Expected: FAIL to build - the package does not exist.
- [ ] **Step 3: Write the registry**
@@ -821,8 +821,8 @@ Create `vantage-app/server/internal/mcp/registry.go`:
// It is a presentation layer over the service layer and introduces no authority
// of its own: every tool calls the same service functions the REST handlers
// call, and every decision about who may do what is made by machinery that
// already exists. Three gates apply to every call the licence feature, the
// mcp:* scope, and the tool's own resource scope and all three must pass.
// already exists. Three gates apply to every call - the licence feature, the
// mcp:* scope, and the tool's own resource scope - and all three must pass.
package mcp
import (
@@ -1035,7 +1035,7 @@ func TestCheckFanOutRequiresConfirmation(t *testing.T) {
- [ ] **Step 2: Run the test to verify it fails**
Run: `go test ./internal/mcp/ -run 'Summarise|FanOut' -v`
Expected: FAIL to build `SummariseArgs` and `CheckFanOut` are undefined.
Expected: FAIL to build - `SummariseArgs` and `CheckFanOut` are undefined.
- [ ] **Step 3: Write the implementation**
@@ -1305,7 +1305,7 @@ In the route registration in `internal/api/handlers.go`, alongside the other gro
mcpGroup.GET("", mcp.Handler())
```
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"` and the shared `license` package. Match the existing group registration style check how `registerWorkflowRoutes` and the status pages group are wired and follow whichever pattern the file uses.
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"` and the shared `license` package. Match the existing group registration style - check how `registerWorkflowRoutes` and the status pages group are wired and follow whichever pattern the file uses.
- [ ] **Step 4: Declare the scopes**
@@ -1322,7 +1322,7 @@ In `internal/api/scopes.go`, add to `routeScopes`:
- [ ] **Step 5: Verify boot**
Run: `go build ./... && go test ./...`
Expected: PASS. `AssertScopeMapComplete` failing here means the route pattern in the map does not match what gin registered print the registered routes and copy the exact pattern.
Expected: PASS. `AssertScopeMapComplete` failing here means the route pattern in the map does not match what gin registered - print the registered routes and copy the exact pattern.
- [ ] **Step 6: Commit**
@@ -1415,7 +1415,7 @@ func TestServerSummaryStaysSmall(t *testing.T) {
- [ ] **Step 2: Run the test to verify it fails**
Run: `go test ./internal/mcp/ -run 'ReadTools|SecretReveal|ServerSummary' -v`
Expected: FAIL the tools are not registered and `serverSummary` is undefined.
Expected: FAIL - the tools are not registered and `serverSummary` is undefined.
- [ ] **Step 3: Write the fleet tools**
@@ -1548,11 +1548,11 @@ func init() {
}
```
`models.Server` field names must be checked before writing this read `internal/models/server.go` and use the real names for hostname, OS, online state and tags. If `Online` is derived rather than stored, derive it the same way the REST handler does.
`models.Server` field names must be checked before writing this - read `internal/models/server.go` and use the real names for hostname, OS, online state and tags. If `Online` is derived rather than stored, derive it the same way the REST handler does.
- [ ] **Step 4: Write the monitor tools**
Create `vantage-app/server/internal/mcp/tools_health.go`. `list_monitors` is the second worked example, because the health tools project differently from the fleet ones a monitor's state matters more than its configuration:
Create `vantage-app/server/internal/mcp/tools_health.go`. `list_monitors` is the second worked example, because the health tools project differently from the fleet ones - a monitor's state matters more than its configuration:
```go
package mcp
@@ -1633,7 +1633,7 @@ func init() {
}
```
`models.Monitor.State` is a `MonitorState` struct read `internal/models/monitor.go` and use its real status field and type rather than the `.Status` guessed above.
`models.Monitor.State` is a `MonitorState` struct - read `internal/models/monitor.go` and use its real status field and type rather than the `.Status` guessed above.
- [ ] **Step 5: Write the remaining read tools**
@@ -1643,14 +1643,14 @@ Fourteen tools remain, each built exactly like the two worked examples: a projec
| --- | --- | --- | --- | --- |
| `get_monitor_status` | `monitors:read` | `monitor_id` | `GetMonitor` | name, type, state, last check time, last error |
| `list_incidents` | `monitors:read` | `monitor_id` (optional), `limit` | the incidents service the `/monitors/:id/incidents` route uses | incident ID, monitor name, started, resolved, cause |
| `get_monitor_samples` | `monitors:read` | `monitor_id`, `limit` | the samples service behind `/monitors/:id/samples` | timestamp, ok, latency capped hard, samples are numerous |
| `get_monitor_samples` | `monitors:read` | `monitor_id`, `limit` | the samples service behind `/monitors/:id/samples` | timestamp, ok, latency - capped hard, samples are numerous |
| `list_workflows` | `workflows:read` | `limit` | `ListWorkflows` | workflow ID, name, step count, target count, whether scheduled |
| `get_workflow` | `workflows:read` | `workflow_id` | `GetWorkflow` | name, ordered step names and IDs, targets, schedule |
| `get_run` | `workflows:read` | `run_id` | the run fetch behind `/runs/:runId` | run ID, workflow name, status, started, finished, per-server status counts |
| `get_run_logs` | `workflows:read` | `run_id`, `server_id`, `limit` | the log read behind `/runs/:runId/servers/:serverId/logs` | ordered lines, capped at 200 by default |
| `list_pending_updates` | `servers:read` | `server_id` or `tags` | `GetServerScoped` plus the stored update list | per server: hostname, package name, current and new version |
| `list_vulnerabilities` | `vulns:read` | `severity`, `status`, `limit` | the findings service behind `/vulnerabilities` | CVE, severity, package, affected server count, fixed_in |
| `get_server_packages` | `vulns:read` | `server_id`, `name` (optional filter) | the packages service behind `/servers/:id/packages` | package name, version filtered, never the whole 2000-entry set unfiltered |
| `get_server_packages` | `vulns:read` | `server_id`, `name` (optional filter) | the packages service behind `/servers/:id/packages` | package name, version - filtered, never the whole 2000-entry set unfiltered |
| `search_fleet` | `vulns:read` | `name`, `version_below` (optional) | `services` package search in `internal/services/packages.go` | per match: hostname, package, version |
| `list_audit_events` | `settings:read` | `event_type`, `limit` | `ListAuditEvents` | timestamp, type, actor, detail |
| `list_secret_names` | `secrets:read` | none | the secrets list service | group name and key names ONLY |
@@ -1659,7 +1659,7 @@ Four rules that apply to every one of them:
- **Scope every server-derived result.** Any tool reaching a server resolves it through `services.GetServerScoped` or `services.ListServersFiltered` with the intersected selector. `list_pending_updates`, `get_server_packages` and `search_fleet` are the three where this is easy to forget, and forgetting it is the bug this whole feature guards against.
- **`get_run_logs` must respect the run's own instance.** Check the run belongs to `c.InstanceID` before returning a line of it.
- **`list_secret_names` must never call the reveal path.** Write a comment saying so at the call site the next person to touch that file will be tempted.
- **`list_secret_names` must never call the reveal path.** Write a comment saying so at the call site - the next person to touch that file will be tempted.
- **`search_fleet` is the one tool with no REST equivalent.** It answers questions like "which hosts still run OpenSSL 1.1", and its description should say exactly that, because a model will not otherwise guess the tool exists for that purpose.
- [ ] **Step 6: Run the tests to verify they pass**
@@ -1740,7 +1740,7 @@ func TestWriteToolsHiddenWithoutMCPWrite(t *testing.T) {
- [ ] **Step 2: Run the test to verify it fails**
Run: `go test ./internal/mcp/ -run Write -v`
Expected: FAIL none of the five tools is registered.
Expected: FAIL - none of the five tools is registered.
- [ ] **Step 3: Write the write tools**
@@ -1825,14 +1825,14 @@ func stringSliceArg(args map[string]any, key string) []string {
}
```
`services.StartWorkflowRun` is a placeholder for whatever the REST run handler actually calls read `internal/api/workflows.go` for the run route and call the same function with the same arguments. Do not reimplement any part of the run path.
`services.StartWorkflowRun` is a placeholder for whatever the REST run handler actually calls - read `internal/api/workflows.go` for the run route and call the same function with the same arguments. Do not reimplement any part of the run path.
Then add the remaining four the same way:
- **`cancel_run`** (`workflows:write`) takes `run_id`, calls the same service the cancel route uses, and verifies the run belongs to the caller's instance.
- **`apply_updates`** (`servers:write`) takes `server_ids` and/or `tags`, resolves through `ResolveTargetsScoped`, applies `CheckFanOut`, calls the apply-updates service.
- **`update_agent`** (`servers:write`) same target resolution, calls the update-agent service.
- **`assign_key`** (`keys:write`) takes `key_id` and `server_ids`, resolves targets scoped, calls the key assignment service.
- **`cancel_run`** (`workflows:write`) - takes `run_id`, calls the same service the cancel route uses, and verifies the run belongs to the caller's instance.
- **`apply_updates`** (`servers:write`) - takes `server_ids` and/or `tags`, resolves through `ResolveTargetsScoped`, applies `CheckFanOut`, calls the apply-updates service.
- **`update_agent`** (`servers:write`) - same target resolution, calls the update-agent service.
- **`assign_key`** (`keys:write`) - takes `key_id` and `server_ids`, resolves targets scoped, calls the key assignment service.
Every one calls `LogCall` with the resolved server count before returning, and every one that resolves targets calls `CheckFanOut`.
@@ -1880,7 +1880,7 @@ git commit -m "feat: add mcp write tools with a fan-out guard"
**Interfaces:**
- Consumes: `mcp.Tool`, `mcp.Caller`, `mcp.LogCreated` (added in this task), `services.CreateStep`, `services.CreateWorkflow`, `services.CreateMonitor`, `models.WorkflowStep`, `models.Workflow`, `models.WorkflowStepRef`, `models.Monitor`.
- Produces: three registered write tools `create_step`, `create_workflow`, `create_monitor` and `mcp.LogCreated(c Caller, kind, id, name string)`.
- Produces: three registered write tools - `create_step`, `create_workflow`, `create_monitor` - and `mcp.LogCreated(c Caller, kind, id, name string)`.
These are the tools that make the surface generative rather than only observational, and the ones most able to surprise someone. Three rules on top of the ordinary write gates, all of them tested below: nothing created is armed, no step may reference a secret, and there is no update or delete counterpart.
@@ -2035,7 +2035,7 @@ func TestCreateMonitorRequiresNameAndType(t *testing.T) {
- [ ] **Step 2: Run the test to verify it fails**
Run: `go test ./internal/mcp/ -run 'Creation|Create|BuildWorkflow|NoUpdate' -v`
Expected: FAIL to build `buildStep`, `buildWorkflow` and `buildMonitor` are undefined.
Expected: FAIL to build - `buildStep`, `buildWorkflow` and `buildMonitor` are undefined.
- [ ] **Step 3: Write the builders and the tools**
@@ -2161,7 +2161,7 @@ func init() {
Write: true,
Scope: "workflows:write",
Description: "Create a reusable workflow step: a named script with an interpreter. " +
"The step is SAVED to this Vantage instance but is not run by creating it " +
"The step is SAVED to this Vantage instance but is not run by creating it - " +
"add it to a workflow with create_workflow, then run that with run_workflow. " +
"Steps created this way cannot reference secrets.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
@@ -2254,8 +2254,8 @@ func init() {
Three things must be checked against the real code before this compiles, rather than assumed:
- `services.CreateStep` takes `models.WorkflowStep` by value and `services.CreateMonitor` takes `*models.Monitor` confirmed, but check their return signatures and any validation errors worth surfacing verbatim to the model.
- `models.Monitor.Target` is a `MonitorTarget` struct, not a map. Read `internal/models/monitor.go`, and decode the `target` argument into it properly the test above only asserts that a target is required, so extend `buildMonitor` and its test together once the real shape is in front of you.
- `services.CreateStep` takes `models.WorkflowStep` by value and `services.CreateMonitor` takes `*models.Monitor` - confirmed, but check their return signatures and any validation errors worth surfacing verbatim to the model.
- `models.Monitor.Target` is a `MonitorTarget` struct, not a map. Read `internal/models/monitor.go`, and decode the `target` argument into it properly - the test above only asserts that a target is required, so extend `buildMonitor` and its test together once the real shape is in front of you.
- `OnFailure: "stop"` must match whatever value the existing step-ref validation accepts. Read the workflow create route and use its vocabulary.
- [ ] **Step 4: Add the creation audit event**
@@ -2267,7 +2267,7 @@ In `internal/mcp/audit.go`, beside `LogCall` and `LogDenied`:
//
// It is a distinct event type rather than another mcp.tool_call row because of
// the question a human will actually ask, which is "what has this agent added
// to my instance" an answer buried among hundreds of read rows is not an
// to my instance" - an answer buried among hundreds of read rows is not an
// answer.
func LogCreated(c Caller, kind, id, name string) {
services.LogEvent(c.InstanceID, "mcp.created", c.TokenName, "", "",
@@ -2299,12 +2299,12 @@ git commit -m "feat: let an agent create steps, workflows and monitors, inert un
**Files:**
- Modify: `vantage-app/web/app/(app)/settings/license/page.tsx:255-270`
> **Scope reduced.** Steps 2 and 4 of this task the tag restriction field and its
> chip in the key list **are already built** by
> **Scope reduced.** Steps 2 and 4 of this task - the tag restriction field and its
> chip in the key list - **are already built** by
> `docs/superpowers/plans/2026-09-08-api-keys-redesign.md`, which redesigned the
> same surfaces and absorbed them rather than have two plans rewrite one
> component from opposite ends. Steps 3 and 5 gating the `mcp:*` scopes and the
> agent access panel belong to that plan's Task 7, which waits on this plan's
> component from opposite ends. Steps 3 and 5 - gating the `mcp:*` scopes and the
> agent access panel - belong to that plan's Task 7, which waits on this plan's
> Tasks 611. **Only step 1 below is still this task's work.**
>
> Note also that the page is not under `settings/`: it is `/tokens`, rendered by
@@ -2324,27 +2324,27 @@ In `vantage-app/web/app/(app)/settings/license/page.tsx`, beside the existing fe
`licenceResponse.Features` is already a `map[string]bool` built from the licence, so no server change is needed for this to populate.
- [x] ~~**Step 2: Add the tag restriction field to the token form**~~ done in the API keys redesign, `web/components/apikeys/TagRestriction.tsx`.
- [x] ~~**Step 2: Add the tag restriction field to the token form**~~ - done in the API keys redesign, `web/components/apikeys/TagRestriction.tsx`.
Locate the token creation form (grep the settings directory for the scope checkbox list). Add a tag restriction control below the scopes, shown for **every** token regardless of licence tag scoping is not gated.
Locate the token creation form (grep the settings directory for the scope checkbox list). Add a tag restriction control below the scopes, shown for **every** token regardless of licence - tag scoping is not gated.
It offers the tag keys and values already in use across servers, which the fleet already exposes via `GET /api/servers/tags`. The workflow target selector already consumes that endpoint; reuse its component if one exists rather than building a second tag picker.
The field sends `tag_selector` as an object of key/value strings, omitted or `{}` when unrestricted.
- [ ] **Step 3: Add the MCP scopes and gate them** moved to the API keys redesign plan, Task 7.
- [ ] **Step 3: Add the MCP scopes and gate them** - moved to the API keys redesign plan, Task 7.
`mcp:read` and `mcp:write` arrive automatically in the scope list from `GET /api/tokens/scopes`, so no hardcoding is needed. Hide or disable those two entries when `license.features.mcp` is false, matching how console-gated UI is handled elsewhere.
- [x] ~~**Step 4: Show the restriction in the token list**~~ done in the API keys redesign, `TagChips` in the ledger.
- [x] ~~**Step 4: Show the restriction in the token list**~~ - done in the API keys redesign, `TagChips` in the ledger.
In the token list, render a token's `tag_selector` as a chip beside its scopes, so "what can this credential reach" is answerable at a glance. An unrestricted token shows nothing rather than an empty chip.
- [ ] **Step 5: Add the agent access panel** moved to the API keys redesign plan, Task 7.
- [ ] **Step 5: Add the agent access panel** - moved to the API keys redesign plan, Task 7.
On the API tokens settings page, add an **Agent access** panel visible only when `license.features.mcp` is true, containing:
- The endpoint URL for this instance `${window.location.origin}/api/mcp` with a copy button.
- The endpoint URL for this instance - `${window.location.origin}/api/mcp` - with a copy button.
- A copyable client configuration snippet:
```json
@@ -2447,7 +2447,7 @@ Show the user the exact product payload and wait for a yes. This writes to a rea
```json
{
"name": "Vantage Agent Access (MCP)",
"name": "Vantage - Agent Access (MCP)",
"description": "AI agent access to a Vantage instance over the Model Context Protocol",
"tax_category": "standard"
}
@@ -2462,7 +2462,7 @@ Use the connected `paddle-sandbox` MCP server. Record the returned `pro_…` pro
```json
{
"product_id": "pro_… from step 2",
"description": "Agent Access (MCP) monthly",
"description": "Agent Access (MCP) - monthly",
"unit_price": { "amount": "900", "currency_code": "GBP" },
"billing_cycle": { "interval": "month", "frequency": 1 }
}
@@ -2475,13 +2475,13 @@ Amounts are in minor units: `"900"` is £9.00. Record the returned `pri_…`.
```json
{
"product_id": "pro_… from step 2",
"description": "Agent Access (MCP) annual",
"description": "Agent Access (MCP) - annual",
"unit_price": { "amount": "9000", "currency_code": "GBP" },
"billing_cycle": { "interval": "year", "frequency": 1 }
}
```
`"9000"` is £90.00 ten months' money for twelve, matching the convention the other add-on rows use. Record the returned `pri_…`.
`"9000"` is £90.00 - ten months' money for twelve, matching the convention the other add-on rows use. Record the returned `pri_…`.
- [ ] **Step 5: Record the price IDs through the staff UI**
@@ -2495,7 +2495,7 @@ In HQ, build a checkout for a Professional plan with the MCP feature selected an
- [ ] **Step 7: Record what was created**
Post the product ID and both price IDs in the session so they are recoverable, and note that production prices are still outstanding they are created by hand in the Paddle dashboard at ship time and pasted into `price_ids.production` the same way.
Post the product ID and both price IDs in the session so they are recoverable, and note that production prices are still outstanding - they are created by hand in the Paddle dashboard at ship time and pasted into `price_ids.production` the same way.
---
@@ -2516,11 +2516,11 @@ Create `vantage-docs/docs/vantage/mcp.md` with front matter matching the sibling
- Connecting Claude and other clients, with the JSON configuration block from Task 11.
- The full tool list in a table: name, what it does, scope required.
- A short "what an agent can create" section: steps, workflows and monitors,
and that nothing it creates is armed a created workflow has no schedule, a
and that nothing it creates is armed - a created workflow has no schedule, a
created monitor is disabled, and neither runs or alerts until a human says so.
Note that agent-authored steps are badged in the UI and that an agent can
never edit or delete an existing definition.
- **What an agent cannot do** reveal secret plaintext, open a console or shell, exceed its tag restriction, act at all without `mcp:write`, or touch more than 25 servers without explicit confirmation. This section is the reason a cautious reader will turn the feature on, so give it real prominence rather than a footnote.
- **What an agent cannot do** - reveal secret plaintext, open a console or shell, exceed its tag restriction, act at all without `mcp:write`, or touch more than 25 servers without explicit confirmation. This section is the reason a cautious reader will turn the feature on, so give it real prominence rather than a footnote.
- That every tool call is recorded in the audit log, reads included.
- [ ] **Step 2: Document the tag restriction**
@@ -2551,7 +2551,7 @@ git commit -m "docs: document the mcp server and token tag restrictions"
Before calling this done, from `vantage-app/server`:
- [ ] `go build ./... && go test ./...` passes.
- [ ] The server boots both completeness assertions pass, which is the real check that no route was missed.
- [ ] The server boots - both completeness assertions pass, which is the real check that no route was missed.
- [ ] A token with only `servers:read` gets 403 at `/api/mcp`.
- [ ] A token with `mcp:read` lists read tools and no write tools.
- [ ] A token with `mcp:write` lists both.
@@ -54,7 +54,7 @@ agent/internal/updates/
updates.go # PackageUpdate; CheckAvailable/ApplyAll declared once
updates_linux.go # existing detectPM, checkApt/DnfYum/Pacman/Zypper/Apk, ApplyAll
updates_windows.go # Windows Update COM, driven through PowerShell
updates_other.go # //go:build !linux && !windows no-ops
updates_other.go # //go:build !linux && !windows - no-ops
```
`updates_other.go` carries the build constraint for the same reason
@@ -116,7 +116,7 @@ A new field `reboot_required` on `InventoryReport`, added to
It travels on the inventory report rather than the update report because it is a
host property like the kernel version, and it is set on the **static** snapshot
only every 15 minutes rather than every 30 seconds. A host rebooted by hand
only - every 15 minutes rather than every 30 seconds. A host rebooted by hand
clears the flag in a quarter of an hour instead of showing it for up to a full
one, and the detection costs a PowerShell process on Windows, which is not
something to spawn twice a minute forever.
@@ -142,7 +142,7 @@ Both platforms set it, since parity is free here:
```
agent/internal/workloads/
workloads.go # Result, Collect, Hash Collect calls collectUnits
workloads.go # Result, Collect, Hash - Collect calls collectUnits
docker.go # unchanged, shared: shells to the docker binary
systemd_linux.go # was systemd.go
services_windows.go # new: Win32_Service collection
@@ -167,7 +167,7 @@ not responding, and running nothing.
### Collecting Windows services
`Get-CimInstance Win32_Service` converted to JSON not `Get-Service`, which
`Get-CimInstance Win32_Service` converted to JSON - not `Get-Service`, which
exposes neither `PathName` nor `StartMode`, and the filter needs both.
A service is reported when its executable does **not** resolve under
@@ -193,7 +193,7 @@ Field mapping:
`Kind: "unit"` and the existing `systemd_ok` / `systemd_error` fields are reused
rather than a `service` kind and `services_ok` fields being added. That would
cost a proto change, both pb copies, the server model, the service layer and the
web client, and would teach every existing consumer a second kind to describe
web client, and would teach every existing consumer a second kind - to describe
the same thing. The naming is corrected where it is read, in the UI, which knows
the server's OS.
@@ -206,8 +206,8 @@ The protected set stays computed and enforced agent-side, as it is on Linux: the
control plane may name a target, but the agent decides what it will do to
itself.
On Windows the protected workload is the `VantageAgent` service the NSSM
service name written by `installer/setup.ps1` matched case-insensitively,
On Windows the protected workload is the `VantageAgent` service - the NSSM
service name written by `installer/setup.ps1` - matched case-insensitively,
because Windows service names are. `detectOwnContainer` and its
`/proc/self/cgroup` read move to `control_linux.go`; the Windows build returns
no own-container ID.
@@ -257,13 +257,13 @@ rather than on `os_type`. `os_type` is stored and serialised but unread by
the two come to disagree. `WorkloadList` takes the result as a prop, since it
receives only a `serverId`:
1. `web/components/workloads/WorkloadList.tsx` takes an `isWindows` prop from
1. `web/components/workloads/WorkloadList.tsx` - takes an `isWindows` prop from
the server detail page, and the systemd status lines become
platform-worded. On Windows the error line reads "Windows services could not
be read" and the "systemd is not in use on this server" line is not rendered
at all. The empty-state line drops "on Linux only". The Docker lines are
unchanged.
2. Server detail a `Reboot required` pill beside the update count when the
2. Server detail - a `Reboot required` pill beside the update count when the
flag is set, placed with the update panel because that is what caused it.
3. The Updates panel's Windows copy describes a list of KB articles rather than
package upgrades, since `current_version` is empty on that platform.
@@ -272,7 +272,7 @@ receives only a `serverId`:
The Windows collectors are, in substance, parsers of PowerShell output. Parsing
is separated from invocation and table-tested against captured real output. The
`agent` module has no tests at all today, so these are the first they live
`agent` module has no tests at all today, so these are the first - they live
beside the parsers as ordinary `_test.go` files, run with `go test ./...` from
`agent/`, and need no new dependency:
@@ -292,6 +292,6 @@ service start/stop/restart, a protected refusal on `VantageAgent`, and logs on
both a chatty service and a silent one.
`GOOS=windows go build ./...` and `GOOS=linux go build ./...` both belong in the
implementation plan as explicit steps a build-tag split is exactly the change
implementation plan as explicit steps - a build-tag split is exactly the change
that compiles on the machine you are sitting at and nowhere else. CI already
cross-builds the agent on release, so no workflow change is needed.
@@ -36,7 +36,7 @@ instance:
Three things do not exist: any concept of a page, any operator-authored
incident, and any unauthenticated read path. The third is the constraint that
shapes the rest every route under `/api` carries `auth.Middleware`,
shapes the rest - every route under `/api` carries `auth.Middleware`,
`RequireScopes`, `RateLimitTokens` and `RequireActiveLicense` by virtue of where
it is mounted, and `AssertScopeMapComplete` fails boot on an `/api` route with
no scope entry.
@@ -77,7 +77,7 @@ pages; a random identifier would be unguessable and unmemorable in equal
measure.
`published` exists so a page can be composed before anyone sees it. An
unpublished page answers the same 404 as a page that does not exist a
unpublished page answers the same 404 as a page that does not exist - a
distinct 403 would confirm it exists.
Sections are page-local and unrelated to `Monitor.Group`, which is a display
@@ -128,7 +128,7 @@ end and duration.
refused` lives.
Copying auto-incidents into `status_incidents` would be a second writer for the
same fact, arriving by a different route with its own opportunity to disagree
same fact, arriving by a different route with its own opportunity to disagree -
the same argument that keeps `RefreshWorkloadsCmd` from returning workloads
inline.
@@ -155,7 +155,7 @@ is private by default rather than published by accident.
What the snapshot contains, per entry: display name, current status, uptime
percentage over the last 90 days, and a 90-day history bar of one cell per day.
A cell is up, down, under maintenance, or no-data `no-data` for days before
A cell is up, down, under maintenance, or no-data - `no-data` for days before
the monitor existed, which is a distinct thing from a day it was down. No
latency, no addresses, no failure text.
@@ -167,7 +167,7 @@ GET /public/status/:pageId
Mounted on the gin root, not under `apiGroup`. Putting it under `/api` would
require exempting it from authentication, scope enforcement, token rate
limiting and the licence gate four holes, each one something a later change
limiting and the licence gate - four holes, each one something a later change
can widen. Outside `/api` it needs none of them.
The instance is resolved from the request host through `auth.InstanceFromHost`.
@@ -208,7 +208,7 @@ cache separately and two visitors would see different states during an incident.
### Rate limit
Per client address, one-minute fixed window, 120 requests, 429 with
`Retry-After` the same shape as `RateLimitTokens`, including its most
`Retry-After` - the same shape as `RateLimitTokens`, including its most
important property: **when Redis is unavailable, allow rather than deny.** A
status page must survive the outage it exists to report.
@@ -240,7 +240,7 @@ are required, not optional: `AssertScopeMapComplete` fails boot on an `/api`
route with no scope entry, which is exactly the safeguard working.
Handlers need `@…` annotations and `openapi.json` must be regenerated and
committed `server-deploy.yml` runs `git diff --exit-code` against the
committed - `server-deploy.yml` runs `git diff --exit-code` against the
committed copy, so a handler whose annotation drifted fails CI.
## Frontend
@@ -252,7 +252,7 @@ against the Go endpoint, with a client refresh every 60 seconds.
`web/next.config.ts` gains a `/public/:path*` rewrite so that client refresh
reaches the server.
The page stays dark, like the rest of `web/`, and carries no hex values the
The page stays dark, like the rest of `web/`, and carries no hex values - the
existing token palette covers every state it needs.
Authoring UI at `/status-pages` inside `(app)`, in the **Instance** sidebar
@@ -277,7 +277,7 @@ boundary made executable:
## Migration and rollout
No migration is needed both collections are new and absent means empty. Index
No migration is needed - both collections are new and absent means empty. Index
builders follow the `EnsureWorkflowIndexes` precedent and warn rather than being
fatal: a missing index on a small collection degrades to a scan, which is no
reason to refuse to serve the fleet.
@@ -6,8 +6,8 @@ Status: approved, ready for implementation planning
## Problem
Vantage has no backup story. A self-hosted deployment holds its entire state in
MongoDB and encrypts the sensitive half of it SSH private keys, key
passphrases, vault secrets, OIDC client secrets, RDP and VNC credentials with
MongoDB and encrypts the sensitive half of it - SSH private keys, key
passphrases, vault secrets, OIDC client secrets, RDP and VNC credentials - with
AES-256-GCM under a single 32-byte key supplied as the `KEY_ENCRYPTION_KEY`
environment variable.
@@ -48,7 +48,7 @@ such assertion available, so a second hand-maintained registry would drift
silently and the first symptom would be a restore missing a collection nobody
noticed was added.
`--exclude` accepts collection names for the volume-heavy ones
`--exclude` accepts collection names for the volume-heavy ones -
`workflow_log_lines`, `monitor_samples`, `audit_logs`. Whatever is excluded is
recorded in the manifest, so an archive can never claim to be complete when it
is not.
@@ -72,7 +72,7 @@ into the server binary.
cobra command tree and nothing else. A separate module rather than a package
under `shared/` because adding cobra to `shared/go.mod` would put cobra and
pflag into the module graph of `server`, `admin` and `sitesvc`, none of which
use them. Binaries are unaffected Go links only what is imported but three
use them. Binaries are unaffected - Go links only what is imported - but three
`go.sum` files would grow and three CI builds would fetch a dependency they do
not need. `agent/` is already a separate module for the same reason.
@@ -80,7 +80,7 @@ The tool imports nothing from `server/`. No `db.Col()`, no `services`, no config
loader, and it never dials the REST or gRPC API. It needs only network reach to
MongoDB, a database name, and `KEY_ENCRYPTION_KEY` in its own environment. This
is what lets it run against a control plane that is down, half-migrated, or was
deleted an hour ago which is the only condition under which anyone runs a
deleted an hour ago - which is the only condition under which anyone runs a
restore.
### Dump implementation
@@ -149,7 +149,7 @@ environment:
- Fingerprints differ: refuse, printing both.
- Archive has a fingerprint, environment has no key: refuse.
- `--ignore-key-mismatch`: proceed, having first printed exactly which
collections hold ciphertext that will be undecryptable `keys`, `secrets`,
collections hold ciphertext that will be undecryptable - `keys`, `secrets`,
`auth_providers`, `console_sessions`, `settings`.
### Restore semantics
@@ -169,7 +169,7 @@ The order is fixed:
Restore is not idempotent, and says so. A second run without `--force` is
refused because step 4 now finds data. A restore interrupted during step 5
leaves a partial database that the next run refuses to touch correct, because
leaves a partial database that the next run refuses to touch - correct, because
the alternative is a silent merge. There are no merge or upsert semantics at
all: merging two control planes reconciles nothing and produces a fleet that
half works, and upserting by `_id` resurrects rows deleted since the backup,
@@ -178,8 +178,8 @@ costume of a convenience.
Index replay is fatal per collection when a unique index fails to build, and a
warning when a non-unique one does. A unique index that cannot be created means
the restored data violates it, and the unique indexes here `(instance_id,
email)`, instance slug, settings instance, the ESO token hash are
the restored data violates it, and the unique indexes here - `(instance_id,
email)`, instance slug, settings instance, the ESO token hash - are
tenant-isolation properties rather than optimisations. The failure names the
offending index.
@@ -187,7 +187,7 @@ offending index.
Restore under `--force` requires a typed confirmation when stdin is a TTY.
When stdin is not a TTY a Kubernetes Job, a CI step, a cron entry the
When stdin is not a TTY - a Kubernetes Job, a CI step, a cron entry - the
confirmation comes from `--confirm-db <name>`, whose value must equal the
resolved target database name or restore refuses. Naming the database in the
argument means a copy-pasted restore command carries its intended target with
@@ -219,13 +219,13 @@ system; this tool reads no configuration file, and pulling it in to call
`os.Getenv` would make the largest dependency in the binary the one doing the
smallest job.
`inspect` prints the manifest when the archive was made, by what version,
`inspect` prints the manifest - when the archive was made, by what version,
which collections it holds, how many documents, what was excluded, and the key
fingerprint and contacts no database. It is what an operator runs to find out
fingerprint - and contacts no database. It is what an operator runs to find out
whether an archive they have found is worth anything.
`verify` adds a live check: whether the archive's fingerprint matches the key in
the current environment, and when `--mongo-uri` is given whether that key
the current environment, and - when `--mongo-uri` is given - whether that key
actually decrypts the target database. The second half is a probe: read one
ciphertext field from `secrets`, `keys` or `auth_providers` and attempt to open
it. A fingerprint comparison proves two archives agree; only a probe proves the
@@ -235,8 +235,8 @@ the documentation recommends running it on a schedule.
The probe needs AES-256-GCM open, which today lives in
`server/internal/services/crypto.go` and cannot be imported from another module.
Rather than copy it the exact hazard `CLAUDE.md` names around mirrored token
blocks and `web/lib/targets.ts` the primitives move to a new `shared/cryptobox`
Rather than copy it - the exact hazard `CLAUDE.md` names around mirrored token
blocks and `web/lib/targets.ts` - the primitives move to a new `shared/cryptobox`
package, and `services/crypto.go` becomes a thin delegation that keeps its
existing unexported function names and its `KEY_ENCRYPTION_KEY` lookup. One
implementation of the cipher, two callers.
@@ -255,11 +255,11 @@ Kubernetes, or neither.
`linux/arm64`, `darwin/arm64` and `windows/amd64` with `CGO_ENABLED=0`, writes
`checksums.txt`, and creates a Gitea release.
**Container image.** `vantagectl/Dockerfile` the repo's convention is a
**Container image.** `vantagectl/Dockerfile` - the repo's convention is a
Dockerfile per module built from the repository root, because every Go module
depends on `shared` through a replace directive produces a `scratch`
depends on `shared` through a replace directive - produces a `scratch`
image holding the static binary and an explicitly copied `/tmp`, which the
archive is staged in before compression the same omission that silently
archive is staged in before compression - the same omission that silently
disabled `vulnsched` on a scratch image. Pushed by `server-deploy.yml` as an
eighth image.
@@ -281,8 +281,8 @@ Restore in Kubernetes is the same image run as a one-shot `Job`. The chart ships
no restore manifest: a restore is an operator decision with a confirmation
attached to it, and must never be something a `helm upgrade` can trigger.
`server-deploy.yml`'s rebuild trigger table gains a `vantagectl` row
`vantagectl/`, `shared/`, `go.work` which makes `shared/` fan out to four Go
`server-deploy.yml`'s rebuild trigger table gains a `vantagectl` row -
`vantagectl/`, `shared/`, `go.work` - which makes `shared/` fan out to four Go
images rather than three. That table is already called out in `CLAUDE.md` as a
place where a missed entry ships a stale image.
@@ -294,8 +294,8 @@ variable that skips when unset.
Required cases:
- Round trip: seed one document of every awkward BSON type `ObjectId`,
`Decimal128`, `DateTime`, binary, null, nested arrays back up, restore into
- Round trip: seed one document of every awkward BSON type - `ObjectId`,
`Decimal128`, `DateTime`, binary, null, nested arrays - back up, restore into
a second database, assert byte-equal BSON.
- A single corrupted byte in a `.bson` member causes restore to refuse before
writing anything.
@@ -317,7 +317,7 @@ Fingerprint computation is a pure function and is tested without a database.
- A restore drill: restore into a scratch database and run `verify`, because an
untested backup is a hypothesis.
- What is not covered: Redis sessions, the vulnerability database (re-pulled
automatically), and agent state on managed servers agents reconnect on their
automatically), and agent state on managed servers - agents reconnect on their
own and `servers.agent_token_hash` is in the backup, so no re-enrolment is
needed.
@@ -5,8 +5,8 @@ Date: 2026-09-08
## Goal
Expose Vantage to LLM agents as a first-class tool surface, so that an agent
acting for a user can answer questions about the fleet and when explicitly
permitted act on it, under the same identity, scopes, licence and audit trail
acting for a user can answer questions about the fleet and - when explicitly
permitted - act on it, under the same identity, scopes, licence and audit trail
as every other API caller.
Concretely: a user mints a Vantage API token, points Claude (or any MCP client)
@@ -67,8 +67,8 @@ unchanged.
The design principle throughout: **MCP is a presentation layer over the service
layer, and introduces no new authority.** It calls the same service functions
the REST handlers call, and every decision about who may do what is made by
machinery that already exists. Where MCP needs something new tag-scoped
tokens that thing is built as a general capability of the API, not as an MCP
machinery that already exists. Where MCP needs something new - tag-scoped
tokens - that thing is built as a general capability of the API, not as an MCP
feature.
Three independent gates gate every tool call, and all three must pass:
@@ -84,16 +84,16 @@ Three independent gates gate every tool call, and all three must pass:
them, `ScopeSatisfied` already implements write-implies-read, and the token
creation UI advertises them without modification.
A bespoke `mcp:use` scope was rejected. The vocabulary is deliberately uniform
every resource has exactly `:read` and `:write` and one special-cased action
A bespoke `mcp:use` scope was rejected. The vocabulary is deliberately uniform -
every resource has exactly `:read` and `:write` - and one special-cased action
verb would be the first exception in a table whose value is having none.
The meanings:
- **`mcp:read`** the token may reach `/api/mcp` at all. A token without it is
- **`mcp:read`** - the token may reach `/api/mcp` at all. A token without it is
not an agent token, whatever else it holds. Read tools are listed and callable
subject to their own resource scopes.
- **`mcp:write`** write tools are listed and callable, again subject to their
- **`mcp:write`** - write tools are listed and callable, again subject to their
own resource scopes. Implied by the existing rule when a token holds
`mcp:write`, so `mcp:read` need not be requested separately.
@@ -120,7 +120,7 @@ TagSelector map[string]string `bson:"tag_selector,omitempty" json:"tag_selector,
Validated on creation by the existing `services.ValidateTags`, so a token
selector cannot express a tag a server could never carry. A caller may only
create a token whose selector is at least as narrow as their own the same
create a token whose selector is at least as narrow as their own - the same
rule `ScopeSatisfied` already enforces for scopes, applied to tags.
`auth.Session` carries `TagSelector`, populated in `sessionFromToken` and always
@@ -166,13 +166,13 @@ console and OIDC being opt-in per customer.
Enforced in three places:
1. **Route** `RequireFeature(license.FeatureMCP)` on the `/api/mcp` group,
1. **Route** - `RequireFeature(license.FeatureMCP)` on the `/api/mcp` group,
answering the standard `feature_unavailable` 403.
2. **Token minting** creating a token with `mcp:read` or `mcp:write` is
2. **Token minting** - creating a token with `mcp:read` or `mcp:write` is
refused without the feature. A licence downgrade should not leave live agent
credentials that fail confusingly mid-conversation, and the same
guard-at-source thinking is already in `services/packages.go`.
3. **UI** the token form's MCP scopes and the MCP connection panel are hidden
3. **UI** - the token form's MCP scopes and the MCP connection panel are hidden
when the licence does not grant it, as console is today.
Existing tokens are unaffected: absent the new scopes, no token can reach the
@@ -185,7 +185,7 @@ returns either a JSON response or an SSE stream. The transport is stateless
rather than session-resuming precisely so each request can stand alone and
sit behind ordinary request middleware with no special-casing, and that
stateless mode leaves no session for a server-to-client stream to resume
against so `GET /api/mcp` is registered but answers the protocol's 405
against - so `GET /api/mcp` is registered but answers the protocol's 405
rather than opening a stream. A client probing the endpoint therefore learns
"POST-only here" rather than seeing a bare 404, which is what the MCP spec
expects from a server that does not offer the GET/SSE leg.
@@ -197,7 +197,7 @@ duplicate auth and double every request's cost for no benefit.
`routeScopes` gains `POST /api/mcp` and `GET /api/mcp`, both mapped to
`mcp:read`, satisfying `AssertScopeMapComplete`. Per-tool scope enforcement
happens inside the handler, because one route serves many operations this is
happens inside the handler, because one route serves many operations - this is
the first route where the route-level scope is a floor rather than the whole
answer, and the map entry's comment says so.
@@ -275,7 +275,7 @@ someone, so they carry extra rules on top of the ordinary write gates:
tell at a glance what a model wrote. Workflows and monitors get the same
treatment through their audit event rather than a new field.
- **Script validation.** `create_step` runs the same parse and scan the existing
step-create route runs (`services.CreateStep` already does this) an agent
step-create route runs (`services.CreateStep` already does this) - an agent
gets no laxer a path than the UI.
## Audit
@@ -292,14 +292,14 @@ instance".
Event type `mcp.tool_call`; actor is the token name, as REST token actions
already record; detail is the tool name, a compact argument summary, and the
number of servers affected. Failures record `mcp.tool_denied` with the gate that
refused licence, MCP scope, resource scope, or tag selector which is what
refused - licence, MCP scope, resource scope, or tag selector - which is what
turns "the agent said it couldn't" into a diagnosable event.
Arguments are summarised, never dumped verbatim: an argument could carry
arbitrary text from a model, and the audit log is read by humans in a UI.
A chatty agent can produce many events. If that becomes a problem the throttle
pattern already used for `token.expired_use` applies, but v1 records everything
pattern already used for `token.expired_use` applies, but v1 records everything -
under-recording a new and sensitive surface is the worse failure.
## Errors
@@ -310,7 +310,7 @@ workflows:write". The agent must be able to read the refusal and adapt or tell
its user, and a transport-level failure is invisible to the model.
Out-of-scope hosts are not-found, matching the REST rule. Upstream service
errors are summarised a raw Mongo error is neither useful to a model nor safe
errors are summarised - a raw Mongo error is neither useful to a model nor safe
to expose.
## HQ, catalogue and Paddle
@@ -321,7 +321,7 @@ to expose.
more `KindFeature` row at `ScopeShared`, sold by every paid plan at one price.
`SeedCatalogue` is `$setOnInsert` only, so the row appears empty on deploy and
staff-entered price IDs are never blanked. The comment naming the row count
("nine rows") is updated the file explicitly asks the next person to keep that
("nine rows") is updated - the file explicitly asks the next person to keep that
number deliberate.
`catalogue.LineItems` needs no change: a `KindFeature` row the customer selected
@@ -349,7 +349,7 @@ One product, two prices, created in the sandbox environment first:
| Field | Value |
| --- | --- |
| Product name | Vantage Agent Access (MCP) |
| Product name | Vantage - Agent Access (MCP) |
| Description | AI agent access to a Vantage instance over the Model Context Protocol |
| Tax category | `standard` |
| Currency | GBP |
@@ -362,7 +362,7 @@ rows use.
Creation runs through the connected `paddle-sandbox` MCP server during
implementation, with the exact payload confirmed before each call. The resulting
price IDs are recorded in the catalogue row's `price_ids.sandbox` map through
the existing staff pricing page not by a migration, because that page is the
the existing staff pricing page - not by a migration, because that page is the
only place price IDs are meant to be entered and a migration writing them would
be a second source of truth.
@@ -379,8 +379,8 @@ the licence grants the feature:
- A short client configuration snippet, again copyable.
- A link to the docs page.
The token creation form gains the two MCP scopes in its scope list no special
UI, they are ordinary scopes and a **tag restriction** field, which is shown
The token creation form gains the two MCP scopes in its scope list - no special
UI, they are ordinary scopes - and a **tag restriction** field, which is shown
for every token regardless of licence because tag scoping is not gated. The
field offers the tag keys and values already in use on servers, as the workflow
target selector does.
@@ -418,7 +418,7 @@ is the thing that must stay correct as tools are added:
- **Audit.** A successful call and a refused call each write exactly one event
of the expected type.
- **Response size.** `list_servers` over a seeded fleet stays under a stated
byte budget a regression here degrades every agent interaction and is
byte budget - a regression here degrades every agent interaction and is
otherwise invisible.
`services/statuspages_test.go` is the style model.