diff --git a/docs/superpowers/plans/2026-07-20-fleet-inventory.md b/docs/superpowers/plans/2026-07-20-fleet-inventory.md
new file mode 100644
index 0000000..eb436c2
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-20-fleet-inventory.md
@@ -0,0 +1,653 @@
+# Fleet Inventory Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Agents collect CPU/RAM/swap/disk/partition inventory and report it to the server via a new `ReportInventory` RPC; the server stores the latest snapshot per server and the UI displays it.
+
+**Architecture:** New unary gRPC `ReportInventory` (mirrors existing `ReportUpdates`). Agent runs a 30s metrics ticker (CPU/RAM/swap usage) and, every 15 min, a full static collection (disks, partitions, CPU model, kernel). Server upserts an embedded `inventory` sub-doc on the `servers` document with merge rules that preserve static fields between slow ticks.
+
+**Tech Stack:** Go (gin, mongo-driver v2, hand-written JSON-codec gRPC), `/proc` readers, Next.js 16 + react-query + Tailwind.
+
+## Global Constraints
+
+- **No tests this iteration.** Verify with `go build ./...`, `go vet ./...`, `npm run build`.
+- gRPC uses a JSON codec: edit **both** `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go` identically, plus `proto/vantage/v1/vantage.proto` as documentation. No codegen. Mirror the existing `ReportUpdates` RPC wiring exactly (service interface, `_Vantage_*_Handler`, client method, `Vantage_ServiceDesc`).
+- Mongo: `db.Col("servers")`, `context.WithTimeout`. Follow `server/internal/services/servers.go`.
+- Agent already runs as root; `/proc` is readable. Linux is primary; Windows collectors may return empty.
+- Module path `github.com/mrhid6/vantage`.
+- Do not add heavy dependencies; implement `/proc` parsing directly.
+
+---
+
+## Task 1: Inventory model + gRPC messages
+
+**Files:**
+- Modify: `server/internal/models/server.go`
+- Modify: `proto/vantage/v1/vantage.proto`
+- Modify: `server/internal/grpc/pb/vantage.pb.go`
+- Modify: `agent/internal/grpc/pb/vantage.pb.go`
+
+**Interfaces:**
+- Produces: `models.Inventory` (+ `CPUInfo`, `MemInfo`, `Partition`) and `Server.Inventory *Inventory`. pb structs `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse`. Service method `ReportInventory` on both client and server interfaces.
+
+- [ ] **Step 1: Add model structs**
+
+In `server/internal/models/server.go` add (keep the existing `import "time"`):
+
+```go
+type CPUInfo struct {
+ Model string `bson:"model,omitempty" json:"model,omitempty"`
+ Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
+ UsagePct float64 `bson:"usage_pct" json:"usage_pct"`
+ Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
+}
+
+type MemInfo struct {
+ TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
+ UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
+}
+
+type Partition struct {
+ Device string `bson:"device" json:"device"`
+ Mountpoint string `bson:"mountpoint" json:"mountpoint"`
+ Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
+ TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
+ UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
+}
+
+type Inventory struct {
+ CPU CPUInfo `bson:"cpu" json:"cpu"`
+ Memory MemInfo `bson:"memory" json:"memory"`
+ SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
+ SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
+ Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
+ Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
+ MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
+ StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
+}
+```
+
+Add to the `Server` struct: `Inventory *Inventory \`bson:"inventory,omitempty" json:"inventory,omitempty"\``.
+
+- [ ] **Step 2: Document RPC in proto**
+
+In `proto/vantage/v1/vantage.proto`, add to the service: `rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);` and the messages `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse` per spec §4.
+
+- [ ] **Step 3: Add pb structs + RPC wiring (server pb)**
+
+In `server/internal/grpc/pb/vantage.pb.go` add the message structs:
+
+```go
+type CPUReport struct {
+ Model string `json:"model,omitempty"`
+ Cores int `json:"cores,omitempty"`
+ UsagePct float64 `json:"usage_pct"`
+ Load1 float64 `json:"load1,omitempty"`
+}
+type MemReport struct {
+ TotalBytes uint64 `json:"total_bytes"`
+ UsedBytes uint64 `json:"used_bytes"`
+}
+type PartitionReport struct {
+ Device string `json:"device"`
+ Mountpoint string `json:"mountpoint"`
+ Fstype string `json:"fstype,omitempty"`
+ TotalBytes uint64 `json:"total_bytes"`
+ UsedBytes uint64 `json:"used_bytes"`
+}
+type InventoryReport struct {
+ ServerId string `json:"server_id"`
+ AgentToken string `json:"agent_token"`
+ IncludeStatic bool `json:"include_static"`
+ CPU *CPUReport `json:"cpu,omitempty"`
+ Memory *MemReport `json:"memory,omitempty"`
+ SwapTotal uint64 `json:"swap_total"`
+ SwapUsed uint64 `json:"swap_used"`
+ Partitions []PartitionReport `json:"partitions,omitempty"`
+ Kernel string `json:"kernel,omitempty"`
+}
+type InventoryReportResponse struct{}
+```
+
+Then mirror the `ReportUpdates` RPC plumbing for `ReportInventory`. Locate every `ReportUpdates` reference in this file and add the parallel `ReportInventory`:
+- `VantageServer` interface: add `ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)`.
+- `UnimplementedVantageServer`: add the stub returning `Unimplemented`.
+- `VantageClient` interface + `keyManagerClient`: add the client method `Invoke`-ing `/vantage.v1.Vantage/ReportInventory`.
+- `Vantage_ServiceDesc.Methods`: add `{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler}`.
+- Add `_Vantage_ReportInventory_Handler` copied from `_Vantage_ReportUpdates_Handler` with types swapped.
+
+- [ ] **Step 4: Mirror pb structs + wiring (agent pb)**
+
+Apply the identical additions to `agent/internal/grpc/pb/vantage.pb.go`.
+
+- [ ] **Step 5: Verify build**
+
+Run: `cd server && go build ./... && cd ../agent && go build ./...`
+Expected: both succeed.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/internal/models/server.go proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go agent/internal/grpc/pb/vantage.pb.go
+git commit -m "feat(proto): add ReportInventory RPC and inventory model"
+```
+
+---
+
+## Task 2: Server handler + store service
+
+**Files:**
+- Create: `server/internal/services/inventory.go`
+- Modify: `server/internal/grpc/server.go`
+
+**Interfaces:**
+- Consumes: `pb.InventoryReport` (T1), `db.Col("servers")`.
+- Produces: `services.StoreInventory(serverID string, r *pb.InventoryReport) error`; gRPC method `(*vantageServer).ReportInventory`.
+
+- [ ] **Step 1: Write the store service**
+
+```go
+package services
+
+import (
+ "context"
+ "time"
+
+ "github.com/mrhid6/vantage/server/internal/db"
+ "github.com/mrhid6/vantage/server/internal/grpc/pb"
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+// StoreInventory upserts the latest inventory snapshot onto the server document.
+// Metrics fields update every call; static fields only when r.IncludeStatic.
+func StoreInventory(serverID string, r *pb.InventoryReport) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ now := time.Now()
+ set := bson.M{"inventory.metrics_at": now}
+ if r.CPU != nil {
+ set["inventory.cpu.usage_pct"] = r.CPU.UsagePct
+ set["inventory.cpu.load1"] = r.CPU.Load1
+ }
+ if r.Memory != nil {
+ set["inventory.memory.used_bytes"] = r.Memory.UsedBytes
+ }
+ set["inventory.swap_used_bytes"] = r.SwapUsed
+
+ if r.IncludeStatic {
+ set["inventory.static_at"] = now
+ set["inventory.swap_total_bytes"] = r.SwapTotal
+ set["inventory.kernel"] = r.Kernel
+ if r.CPU != nil {
+ set["inventory.cpu.model"] = r.CPU.Model
+ set["inventory.cpu.cores"] = r.CPU.Cores
+ }
+ if r.Memory != nil {
+ set["inventory.memory.total_bytes"] = r.Memory.TotalBytes
+ }
+ parts := make([]bson.M, 0, len(r.Partitions))
+ for _, p := range r.Partitions {
+ parts = append(parts, bson.M{
+ "device": p.Device, "mountpoint": p.Mountpoint, "fstype": p.Fstype,
+ "total_bytes": p.TotalBytes, "used_bytes": p.UsedBytes,
+ })
+ }
+ set["inventory.partitions"] = parts
+ }
+
+ _, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, bson.M{"$set": set})
+ return err
+}
+```
+
+- [ ] **Step 2: Add the gRPC handler**
+
+In `server/internal/grpc/server.go`, add (mirroring the existing `ReportUpdates` handler that validates the agent token):
+
+```go
+func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
+ srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
+ if err != nil {
+ return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
+ }
+ if err := services.StoreInventory(srv.ServerID, req); err != nil {
+ log.Printf("store inventory for %s: %v", srv.ServerID, err)
+ }
+ return &pb.InventoryReportResponse{}, nil
+}
+```
+
+Confirm `status`, `codes`, `log` are already imported in the file (they are, used by other handlers).
+
+- [ ] **Step 3: Verify build**
+
+Run: `cd server && go build ./... && go vet ./...`
+Expected: success.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add server/internal/services/inventory.go server/internal/grpc/server.go
+git commit -m "feat(server): store inventory and handle ReportInventory RPC"
+```
+
+---
+
+## Task 3: Agent collectors
+
+**Files:**
+- Create: `agent/internal/inventory/collect_linux.go`
+- Create: `agent/internal/inventory/collect_other.go`
+- Create: `agent/internal/inventory/inventory.go`
+
+**Interfaces:**
+- Produces: `inventory.Collect(includeStatic bool) *pb.InventoryReport`.
+
+- [ ] **Step 1: Common entry (`inventory.go`)**
+
+```go
+package inventory
+
+import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
+
+// Collect gathers metrics always and static hardware info when includeStatic.
+// Platform specifics are provided by collect_linux.go / collect_other.go.
+func Collect(includeStatic bool) *pb.InventoryReport {
+ r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
+ collect(r, includeStatic)
+ return r
+}
+```
+
+- [ ] **Step 2: Linux collector (`collect_linux.go`)**
+
+Build-tagged `//go:build linux`. Implement `collect(r *pb.InventoryReport, includeStatic bool)`:
+- CPU usage: read `/proc/stat` first line twice ~100ms apart, compute `1 - idleDelta/totalDelta` × 100 → `r.CPU.UsagePct`.
+- Load: first field of `/proc/loadavg` → `r.CPU.Load1`.
+- Mem/swap: parse `/proc/meminfo` (`MemTotal`, `MemAvailable`, `SwapTotal`, `SwapFree`; used = total − available; swap used = swaptotal − swapfree) → `r.Memory.*`, `r.SwapUsed`, and on static `r.SwapTotal`.
+- Static only: `/proc/cpuinfo` (`model name`, count `processor` lines) → `r.CPU.Model/Cores`; `/proc/meminfo MemTotal` → `r.Memory.TotalBytes`; kernel via `syscall.Uname` or read `/proc/sys/kernel/osrelease` → `r.Kernel`; partitions from `/proc/mounts` filtered to fstypes in {ext4,xfs,btrfs,zfs,vfat,ntfs} then `syscall.Statfs` for total/used → `r.Partitions`.
+
+```go
+//go:build linux
+
+package inventory
+
+import (
+ "bufio"
+ "os"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/mrhid6/vantage/agent/internal/grpc/pb"
+)
+
+func collect(r *pb.InventoryReport, includeStatic bool) {
+ r.CPU.UsagePct = cpuUsage()
+ r.CPU.Load1 = load1()
+ memTotal, memAvail, swapTotal, swapFree := meminfo()
+ if memTotal > memAvail {
+ r.Memory.UsedBytes = memTotal - memAvail
+ }
+ if swapTotal > swapFree {
+ r.SwapUsed = swapTotal - swapFree
+ }
+ if includeStatic {
+ r.Memory.TotalBytes = memTotal
+ r.SwapTotal = swapTotal
+ r.CPU.Model, r.CPU.Cores = cpuStatic()
+ r.Kernel = kernel()
+ r.Partitions = partitions()
+ }
+}
+
+func readProc(path string) string { b, _ := os.ReadFile(path); return string(b) }
+
+func cpuSample() (idle, total uint64) {
+ f, err := os.Open("/proc/stat")
+ if err != nil {
+ return
+ }
+ defer f.Close()
+ sc := bufio.NewScanner(f)
+ if sc.Scan() {
+ fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ...
+ for i, v := range fields[1:] {
+ n, _ := strconv.ParseUint(v, 10, 64)
+ total += n
+ if i == 3 { // idle
+ idle = n
+ }
+ }
+ }
+ return
+}
+
+func cpuUsage() float64 {
+ i1, t1 := cpuSample()
+ time.Sleep(100 * time.Millisecond)
+ i2, t2 := cpuSample()
+ dt := float64(t2 - t1)
+ if dt <= 0 {
+ return 0
+ }
+ return (1 - float64(i2-i1)/dt) * 100
+}
+
+func load1() float64 {
+ fields := strings.Fields(readProc("/proc/loadavg"))
+ if len(fields) > 0 {
+ v, _ := strconv.ParseFloat(fields[0], 64)
+ return v
+ }
+ return 0
+}
+
+func meminfo() (total, avail, swapTotal, swapFree uint64) {
+ f, err := os.Open("/proc/meminfo")
+ if err != nil {
+ return
+ }
+ defer f.Close()
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ fields := strings.Fields(sc.Text())
+ if len(fields) < 2 {
+ continue
+ }
+ kb, _ := strconv.ParseUint(fields[1], 10, 64)
+ b := kb * 1024
+ switch strings.TrimSuffix(fields[0], ":") {
+ case "MemTotal":
+ total = b
+ case "MemAvailable":
+ avail = b
+ case "SwapTotal":
+ swapTotal = b
+ case "SwapFree":
+ swapFree = b
+ }
+ }
+ return
+}
+
+func cpuStatic() (model string, cores int) {
+ f, err := os.Open("/proc/cpuinfo")
+ if err != nil {
+ return
+ }
+ defer f.Close()
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := sc.Text()
+ if strings.HasPrefix(line, "processor") {
+ cores++
+ } else if strings.HasPrefix(line, "model name") && model == "" {
+ if i := strings.Index(line, ":"); i >= 0 {
+ model = strings.TrimSpace(line[i+1:])
+ }
+ }
+ }
+ return
+}
+
+func kernel() string {
+ return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease"))
+}
+
+func partitions() []pb.PartitionReport {
+ allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true}
+ f, err := os.Open("/proc/mounts")
+ if err != nil {
+ return nil
+ }
+ defer f.Close()
+ var out []pb.PartitionReport
+ seen := map[string]bool{}
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ fields := strings.Fields(sc.Text())
+ if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] {
+ continue
+ }
+ seen[fields[1]] = true
+ var st syscall.Statfs_t
+ if syscall.Statfs(fields[1], &st) != nil {
+ continue
+ }
+ total := st.Blocks * uint64(st.Bsize)
+ free := st.Bavail * uint64(st.Bsize)
+ out = append(out, pb.PartitionReport{
+ Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
+ TotalBytes: total, UsedBytes: total - free,
+ })
+ }
+ return out
+}
+```
+
+- [ ] **Step 3: Non-linux stub (`collect_other.go`)**
+
+```go
+//go:build !linux
+
+package inventory
+
+import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
+
+// collect is a no-op best-effort stub on non-Linux platforms.
+func collect(r *pb.InventoryReport, includeStatic bool) {}
+```
+
+- [ ] **Step 4: Verify build**
+
+Run: `cd agent && go build ./... && go vet ./...`
+Expected: success (build both native and, if convenient, `GOOS=windows go build ./...`).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add agent/internal/inventory/
+git commit -m "feat(agent): /proc-based inventory collectors"
+```
+
+---
+
+## Task 4: Agent client method + scheduler
+
+**Files:**
+- Modify: `agent/internal/grpc/client.go`
+- Modify: the agent main loop (`agent/cmd/main.go` or `agent/internal/sync/sync.go` — wherever the poll loop/tickers live).
+
+**Interfaces:**
+- Consumes: `inventory.Collect` (T3), pb (T1).
+- Produces: `(*Client).ReportInventory(report *pb.InventoryReport) error`; a running ticker that reports metrics every 30s and static every 15 min.
+
+- [ ] **Step 1: Add client method**
+
+In `agent/internal/grpc/client.go`, mirroring `ReportUpdates`:
+
+```go
+func (c *Client) ReportInventory(report *pb.InventoryReport) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _, err := c.client.ReportInventory(ctx, report)
+ return err
+}
+```
+
+The report already carries `ServerId`/`AgentToken`; ensure the caller sets them (see Step 2).
+
+- [ ] **Step 2: Add the scheduler to the agent loop**
+
+Find where the agent starts its poll loop (the goroutine that calls `SyncKeys`/`ReportUpdates`). Add a parallel inventory ticker. `serverID`, `agentToken`, and the `*Client` are in scope there:
+
+```go
+ go func() {
+ tick := 0
+ t := time.NewTicker(30 * time.Second)
+ defer t.Stop()
+ report := func(static bool) {
+ r := inventory.Collect(static)
+ r.ServerId = serverID
+ r.AgentToken = agentToken
+ if err := client.ReportInventory(r); err != nil {
+ log.Printf("report inventory: %v", err)
+ }
+ }
+ report(true) // send a full snapshot on startup
+ for range t.C {
+ tick++
+ report(tick%30 == 0) // every 30th tick = 15 min → include static
+ }
+ }()
+```
+
+Add imports `"github.com/mrhid6/vantage/agent/internal/inventory"`, `time`, `log` if missing. Match variable names to the actual loop (e.g. the client may be named `c`).
+
+- [ ] **Step 3: Verify build**
+
+Run: `cd agent && go build ./... && go vet ./...`
+Expected: success.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add agent/internal/grpc/client.go agent/
+git commit -m "feat(agent): schedule inventory reporting (30s metrics, 15m static)"
+```
+
+---
+
+## Task 5: Frontend — inventory panel on server detail
+
+**Files:**
+- Modify: `web/lib/api.ts` (extend the `Server`/server-detail type with `inventory`)
+- Modify: `web/app/servers/[id]/page.tsx` (add panel; enable polling)
+
+**Interfaces:**
+- Consumes: server-detail query.
+
+- [ ] **Step 1: Add the inventory type**
+
+In `web/lib/api.ts`, add and attach to the server type used by the detail page:
+
+```ts
+export interface Inventory {
+ cpu: { model?: string; cores?: number; usage_pct: number; load1?: number };
+ memory: { total_bytes: number; used_bytes: number };
+ swap_total_bytes: number;
+ swap_used_bytes: number;
+ partitions?: { device: string; mountpoint: string; fstype?: string; total_bytes: number; used_bytes: number }[];
+ kernel?: string;
+ metrics_at?: string;
+ static_at?: string;
+}
+```
+
+Add `inventory?: Inventory;` to the server detail interface.
+
+- [ ] **Step 2: Add a `formatBytes` helper + Inventory panel**
+
+In `web/app/servers/[id]/page.tsx`, add a helper and a panel component. Enable polling on the server-detail `useQuery` with `refetchInterval: 30000`.
+
+```tsx
+function formatBytes(n: number): string {
+ if (!n) return "0 B";
+ const u = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(n) / Math.log(1024));
+ return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
+}
+
+function UsageBar({ used, total }: { used: number; total: number }) {
+ const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
+ return (
+
+
90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
+
+ );
+}
+
+function InventoryPanel({ inv }: { inv: Inventory }) {
+ return (
+
+ Inventory
+
+
+
CPU{inv.cpu.usage_pct.toFixed(0)}%
+
+
{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}
+
+
+
Memory{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}
+
+
Swap{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}
+
+
+
+ {inv.partitions && inv.partitions.length > 0 && (
+
+
Partitions
+
+ {inv.partitions.map((p) => (
+
+
+ {p.mountpoint}
+ {formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}
+
+
+
+ ))}
+
+
+ )}
+ {inv.kernel && Kernel {inv.kernel}
}
+
+ );
+}
+```
+
+Render `{server.inventory &&
}` in the page body (ensure `Card`, `Inventory` are imported). Match how the page currently reads the server object.
+
+- [ ] **Step 3: Verify build**
+
+Run: `cd web && npm run build`
+Expected: success.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/lib/api.ts web/app/servers/[id]/page.tsx
+git commit -m "feat(web): inventory panel on server detail"
+```
+
+---
+
+## Task 6: End-to-end manual verification
+
+- [ ] **Step 1: Build all**
+
+Run: `cd server && go build ./... && cd ../agent && go build ./... && cd ../web && npm run build`
+Expected: all succeed.
+
+- [ ] **Step 2: Smoke (if environment available)**
+
+With server + Mongo + a connected Linux agent: within ~30s the server detail page shows CPU %, RAM/swap bars; within 15 min (or on agent restart, which sends a full snapshot immediately) partitions, CPU model and kernel appear. Confirm metrics update roughly every 30s.
+
+- [ ] **Step 3: Commit any fixes**
+
+```bash
+git add -A
+git commit -m "fix: fleet inventory verification fixes"
+```
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** §3 model → T1; §4 RPC → T1; §5 collectors + scheduler → T3, T4; §6 handler/store → T2; §7 frontend → T5. Split cadence (30s metrics / 15m static) in T4 scheduler; merge rules preserving static in T2 `StoreInventory`. Tests omitted per Global Constraints.
+- **Startup snapshot:** agent sends `Collect(true)` immediately so static fields populate without waiting 15 min.
+- **Types consistent:** `InventoryReport` field names identical across proto, both pb files, store service, and TS interface (`usage_pct`, `used_bytes`, `total_bytes`, `swap_*`).
+- **Follow-ups (out of scope):** time-series history, usage alerting, Windows collectors, servers-list CPU/RAM badges.
diff --git a/docs/superpowers/plans/2026-07-20-saas-auth-orgs.md b/docs/superpowers/plans/2026-07-20-saas-auth-orgs.md
new file mode 100644
index 0000000..a8a4203
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-20-saas-auth-orgs.md
@@ -0,0 +1,1174 @@
+# SaaS: Auth + Organizations Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the global Authentik OIDC with local email/password accounts, introduce organizations that scope every domain object via `org_id`, and let org admins configure their own per-org OpenID provider.
+
+**Architecture:** Local accounts (bcrypt) are primary auth; sessions carry `{user_id, org_id, role, email}`. A per-org OIDC resolver builds providers on demand from an `org_oidc` collection. Every scoped service function takes an `orgID` and filters on it; handlers derive `orgID` from the session (never from the client). A one-shot idempotent migration backfills existing data into a "Default" org.
+
+**Tech Stack:** Go (gin, mongo-driver v2, Redis sessions, `go-oidc`/`oauth2`, `golang.org/x/crypto/bcrypt`), Next.js 16 + react-query + Tailwind.
+
+## Global Constraints
+
+- **No tests this iteration.** Verify with `go build ./...`, `go vet ./...`, `npm run build`.
+- **Org isolation is a security boundary:** handlers MUST derive `org_id` from the session via `auth.OrgID(c)`; never accept it in a request body/query. Every scoped Mongo query includes `"org_id": orgID` in its filter and on insert.
+- Reuse `server/internal/services/crypto.go` (`encryptString`/`decryptString`) for the org OIDC client secret.
+- bcrypt cost ≥ 12. Passwords never serialized to JSON (`json:"-"`).
+- Module path `github.com/mrhid6/vantage`. Sessions live in Redis (`server/internal/auth/session.go`).
+- Scoped collections: `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit`.
+- Frontend: `@/lib/api`, `@/components/ui`, Tailwind tokens, react-query. Follow `web/app/secrets/page.tsx` conventions.
+
+---
+
+## Task 1: Org + user + org-OIDC models
+
+**Files:**
+- Create: `server/internal/models/org.go`
+
+**Interfaces:**
+- Produces: `models.Org`, `models.User`, `models.OrgOIDC`.
+
+- [ ] **Step 1: Write models**
+
+```go
+package models
+
+import "time"
+
+type Org struct {
+ ID string `bson:"_id,omitempty" json:"-"`
+ OrgID string `bson:"org_id" json:"org_id"`
+ Name string `bson:"name" json:"name"`
+ CreatedAt time.Time `bson:"created_at" json:"created_at"`
+}
+
+type User struct {
+ ID string `bson:"_id,omitempty" json:"-"`
+ UserID string `bson:"user_id" json:"user_id"`
+ OrgID string `bson:"org_id" json:"org_id"`
+ Email string `bson:"email" json:"email"`
+ PasswordHash string `bson:"password_hash,omitempty" json:"-"`
+ Role string `bson:"role" json:"role"` // owner|admin|member
+ AuthSource string `bson:"auth_source" json:"auth_source"` // local|oidc
+ CreatedAt time.Time `bson:"created_at" json:"created_at"`
+ LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
+}
+
+type OrgOIDC struct {
+ ID string `bson:"_id,omitempty" json:"-"`
+ OrgID string `bson:"org_id" json:"org_id"`
+ Issuer string `bson:"issuer" json:"issuer"`
+ ClientID string `bson:"client_id" json:"client_id"`
+ ClientSecretEnc string `bson:"client_secret_enc" json:"-"`
+ RedirectURL string `bson:"redirect_url" json:"redirect_url"`
+ Enabled bool `bson:"enabled" json:"enabled"`
+ UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `cd server && go build ./...`
+Expected: success.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add server/internal/models/org.go
+git commit -m "feat(models): org, user, org-oidc models"
+```
+
+---
+
+## Task 2: Org + user services (bcrypt)
+
+**Files:**
+- Create: `server/internal/services/orgs.go`
+- Create: `server/internal/services/users.go`
+
+**Interfaces:**
+- Produces:
+ - orgs: `EnsureAuthIndexes() error`, `CreateOrg(name string) (*models.Org, error)`, `GetOrg(orgID string) (*models.Org, error)`, `CountUsers() (int64, error)`
+ - users: `CreateUser(orgID, email, password, role string) (*models.User, error)`, `GetUserByEmail(email string) (*models.User, error)`, `GetUserByID(userID string) (*models.User, error)`, `VerifyPassword(u *models.User, password string) bool`, `ListUsers(orgID string) ([]models.User, error)`, `UpdateUserRole(orgID, userID, role string) error`, `DeleteUser(orgID, userID string) error`, `ProvisionOIDCUser(orgID, email string) (*models.User, error)`, `TouchLastLogin(userID string)`.
+
+- [ ] **Step 1: Write orgs.go**
+
+```go
+package services
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mrhid6/vantage/server/internal/db"
+ "github.com/mrhid6/vantage/server/internal/models"
+ "go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo"
+ "go.mongodb.org/mongo-driver/v2/mongo/options"
+)
+
+func authCtx() (context.Context, context.CancelFunc) {
+ return context.WithTimeout(context.Background(), 10*time.Second)
+}
+
+func EnsureAuthIndexes() error {
+ ctx, cancel := authCtx()
+ defer cancel()
+ if _, err := db.Col("users").Indexes().CreateOne(ctx, mongo.IndexModel{
+ Keys: bson.D{{Key: "email", Value: 1}}, Options: options.Index().SetUnique(true),
+ }); err != nil {
+ return err
+ }
+ if _, err := db.Col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{
+ Keys: bson.D{{Key: "org_id", Value: 1}}, Options: options.Index().SetUnique(true),
+ }); err != nil {
+ return err
+ }
+ _, err := db.Col("org_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
+ Keys: bson.D{{Key: "org_id", Value: 1}}, Options: options.Index().SetUnique(true),
+ })
+ return err
+}
+
+func CreateOrg(name string) (*models.Org, error) {
+ ctx, cancel := authCtx()
+ defer cancel()
+ o := models.Org{OrgID: uuid.New().String(), Name: name, CreatedAt: time.Now()}
+ if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil {
+ return nil, err
+ }
+ return &o, nil
+}
+
+func GetOrg(orgID string) (*models.Org, error) {
+ ctx, cancel := authCtx()
+ defer cancel()
+ var o models.Org
+ err := db.Col("orgs").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o)
+ if err == mongo.ErrNoDocuments {
+ return nil, fmt.Errorf("org not found")
+ }
+ return &o, err
+}
+
+func CountUsers() (int64, error) {
+ ctx, cancel := authCtx()
+ defer cancel()
+ return db.Col("users").CountDocuments(ctx, bson.M{})
+}
+```
+
+- [ ] **Step 2: Write users.go**
+
+```go
+package services
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mrhid6/vantage/server/internal/db"
+ "github.com/mrhid6/vantage/server/internal/models"
+ "go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo"
+ "go.mongodb.org/mongo-driver/v2/mongo/options"
+ "golang.org/x/crypto/bcrypt"
+)
+
+func normalizeEmail(e string) string { return strings.ToLower(strings.TrimSpace(e)) }
+
+func CreateUser(orgID, email, password, role string) (*models.User, error) {
+ ctx, cancel := authCtx()
+ defer cancel()
+ u := models.User{
+ UserID: uuid.New().String(), OrgID: orgID, Email: normalizeEmail(email),
+ Role: role, AuthSource: "local", CreatedAt: time.Now(),
+ }
+ if password != "" {
+ h, err := bcrypt.GenerateFromPassword([]byte(password), 12)
+ if err != nil {
+ return nil, err
+ }
+ u.PasswordHash = string(h)
+ }
+ if _, err := db.Col("users").InsertOne(ctx, u); err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
+
+func GetUserByEmail(email string) (*models.User, error) {
+ ctx, cancel := authCtx()
+ defer cancel()
+ var u models.User
+ err := db.Col("users").FindOne(ctx, bson.M{"email": normalizeEmail(email)}).Decode(&u)
+ if err == mongo.ErrNoDocuments {
+ return nil, fmt.Errorf("user not found")
+ }
+ return &u, err
+}
+
+func GetUserByID(userID string) (*models.User, error) {
+ ctx, cancel := authCtx()
+ defer cancel()
+ var u models.User
+ err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID}).Decode(&u)
+ if err == mongo.ErrNoDocuments {
+ return nil, fmt.Errorf("user not found")
+ }
+ return &u, err
+}
+
+func VerifyPassword(u *models.User, password string) bool {
+ if u.PasswordHash == "" {
+ return false
+ }
+ return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil
+}
+
+func ListUsers(orgID string) ([]models.User, error) {
+ ctx, cancel := authCtx()
+ defer cancel()
+ cur, err := db.Col("users").Find(ctx, bson.M{"org_id": orgID},
+ options.Find().SetSort(bson.D{{Key: "email", Value: 1}}))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ users := []models.User{}
+ if err := cur.All(ctx, &users); err != nil {
+ return nil, err
+ }
+ return users, nil
+}
+
+func UpdateUserRole(orgID, userID, role string) error {
+ ctx, cancel := authCtx()
+ defer cancel()
+ _, err := db.Col("users").UpdateOne(ctx, bson.M{"org_id": orgID, "user_id": userID},
+ bson.M{"$set": bson.M{"role": role}})
+ return err
+}
+
+func DeleteUser(orgID, userID string) error {
+ ctx, cancel := authCtx()
+ defer cancel()
+ _, err := db.Col("users").DeleteOne(ctx, bson.M{"org_id": orgID, "user_id": userID})
+ return err
+}
+
+func ProvisionOIDCUser(orgID, email string) (*models.User, error) {
+ if existing, err := GetUserByEmail(email); err == nil {
+ if existing.OrgID != orgID {
+ return nil, fmt.Errorf("email belongs to another organization")
+ }
+ return existing, nil
+ }
+ ctx, cancel := authCtx()
+ defer cancel()
+ u := models.User{
+ UserID: uuid.New().String(), OrgID: orgID, Email: normalizeEmail(email),
+ Role: "member", AuthSource: "oidc", CreatedAt: time.Now(),
+ }
+ if _, err := db.Col("users").InsertOne(ctx, u); err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
+
+func TouchLastLogin(userID string) {
+ ctx, cancel := authCtx()
+ defer cancel()
+ now := time.Now()
+ _, _ = db.Col("users").UpdateOne(ctx, bson.M{"user_id": userID}, bson.M{"$set": bson.M{"last_login": now}})
+}
+```
+
+- [ ] **Step 3: Add bcrypt dependency**
+
+Run: `cd server && go get golang.org/x/crypto/bcrypt && go mod tidy`
+Expected: module added.
+
+- [ ] **Step 4: Register indexes at startup**
+
+In `server/cmd/main.go`, call `services.EnsureAuthIndexes()` next to the other `Ensure*Indexes()` calls, with the same error handling.
+
+- [ ] **Step 5: Verify build**
+
+Run: `cd server && go build ./... && go vet ./...`
+Expected: success.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/internal/services/orgs.go server/internal/services/users.go server/cmd/main.go server/go.mod server/go.sum
+git commit -m "feat(server): org and user services with bcrypt auth"
+```
+
+---
+
+## Task 3: Session carries user/org/role
+
+**Files:**
+- Modify: `server/internal/auth/session.go`
+- Modify: `server/internal/auth/middleware.go`
+
+**Interfaces:**
+- Produces: `Session{UserID, OrgID, Email, Role}`; `auth.OrgID(c) string`, `auth.UserID(c) string`, `auth.Role(c) string`; `auth.CreateSession(ctx, *Session) (token string, err error)` (or extend the existing creator).
+
+- [ ] **Step 1: Read the current Session struct**
+
+Open `server/internal/auth/session.go`, find the `Session` struct and `GetSession`/save functions. Extend `Session`:
+
+```go
+type Session struct {
+ UserID string `json:"user_id"`
+ OrgID string `json:"org_id"`
+ Email string `json:"email"`
+ Role string `json:"role"`
+}
+```
+
+Keep existing fields if any are still used; add these. Ensure the create/save path (currently used by the OIDC callback) accepts a full `*Session`. If the current signature is `SaveSession(ctx, token, email)`, add `CreateSession(ctx context.Context, s *Session) (string, error)` that generates a token (reuse `randomHex`), stores the JSON under `sessionPrefix+token` with `sessionTTL`, and returns the token.
+
+- [ ] **Step 2: Add context helpers to middleware.go**
+
+```go
+func OrgID(c *gin.Context) string {
+ if s := GetSessionFromContext(c); s != nil {
+ return s.OrgID
+ }
+ return ""
+}
+func UserID(c *gin.Context) string {
+ if s := GetSessionFromContext(c); s != nil {
+ return s.UserID
+ }
+ return ""
+}
+func Role(c *gin.Context) string {
+ if s := GetSessionFromContext(c); s != nil {
+ return s.Role
+ }
+ return ""
+}
+
+// RequireRole aborts unless the session role is in allowed.
+func RequireRole(allowed ...string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ role := Role(c)
+ for _, a := range allowed {
+ if role == a {
+ c.Next()
+ return
+ }
+ }
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
+ }
+}
+```
+
+- [ ] **Step 3: Verify build**
+
+Run: `cd server && go build ./...`
+Expected: may fail where the old session creator is called (OIDC). That's fixed in Task 5. If the failure is only in `oidc.go`, proceed; otherwise fix references in session.go.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add server/internal/auth/session.go server/internal/auth/middleware.go
+git commit -m "feat(auth): session carries user/org/role + role middleware"
+```
+
+---
+
+## Task 4: Local auth + bootstrap handlers
+
+**Files:**
+- Create: `server/internal/auth/local.go`
+
+**Interfaces:**
+- Consumes: user/org services (T2), session (T3).
+- Produces: gin handlers `HandleLocalLogin`, `HandleLogout` (may reuse existing), `HandleMe`, `HandleBootstrapStatus`, `HandleBootstrap`.
+
+- [ ] **Step 1: Write local.go**
+
+```go
+package auth
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/mrhid6/vantage/server/internal/services"
+)
+
+func setSessionCookie(c *gin.Context, token string) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: sessionCookieName, Value: token, Path: "/",
+ HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
+ Expires: time.Now().Add(sessionTTL),
+ })
+}
+
+func HandleBootstrapStatus(c *gin.Context) {
+ n, err := services.CountUsers()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0})
+}
+
+// HandleBootstrap creates the first org + owner. Only allowed when no users exist.
+func HandleBootstrap(c *gin.Context) {
+ n, err := services.CountUsers()
+ if err != nil || n > 0 {
+ c.JSON(http.StatusForbidden, gin.H{"error": "setup already completed"})
+ return
+ }
+ var body struct {
+ OrgName string `json:"org_name" binding:"required"`
+ Email string `json:"email" binding:"required"`
+ Password string `json:"password" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ org, err := services.CreateOrg(body.OrgName)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ user, err := services.CreateUser(org.OrgID, body.Email, body.Password, "owner")
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ token, err := CreateSession(c.Request.Context(), &Session{
+ UserID: user.UserID, OrgID: org.OrgID, Email: user.Email, Role: user.Role,
+ })
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ setSessionCookie(c, token)
+ c.JSON(http.StatusCreated, gin.H{"org": org, "user": user})
+}
+
+func HandleLocalLogin(c *gin.Context) {
+ var body struct {
+ Email string `json:"email" binding:"required"`
+ Password string `json:"password" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ u, err := services.GetUserByEmail(body.Email)
+ if err != nil || !services.VerifyPassword(u, body.Password) {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
+ return
+ }
+ token, err := CreateSession(c.Request.Context(), &Session{
+ UserID: u.UserID, OrgID: u.OrgID, Email: u.Email, Role: u.Role,
+ })
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ services.TouchLastLogin(u.UserID)
+ setSessionCookie(c, token)
+ c.JSON(http.StatusOK, gin.H{"user": u})
+}
+
+func HandleMe(c *gin.Context) {
+ s := GetSessionFromContext(c)
+ if s == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
+ return
+ }
+ org, _ := services.GetOrg(s.OrgID)
+ c.JSON(http.StatusOK, gin.H{
+ "user": gin.H{"user_id": s.UserID, "email": s.Email, "role": s.Role},
+ "org": org,
+ })
+}
+```
+
+Note: if `HandleLogout` already exists in `oidc.go` and clears the cookie/session generically, reuse it; otherwise add one clearing `sessionCookieName` and deleting the Redis key.
+
+- [ ] **Step 2: Verify build**
+
+Run: `cd server && go build ./...`
+Expected: success (aside from any not-yet-updated OIDC references from T3, addressed in T5).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add server/internal/auth/local.go
+git commit -m "feat(auth): local login, bootstrap, and me handlers"
+```
+
+---
+
+## Task 5: Per-org OIDC resolver (replace global)
+
+**Files:**
+- Modify: `server/internal/auth/oidc.go`
+- Create: `server/internal/services/org_oidc.go`
+
+**Interfaces:**
+- Consumes: `models.OrgOIDC`, crypto (`encryptString`/`decryptString`), session (T3), `ProvisionOIDCUser` (T2).
+- Produces: `services.GetOrgOIDC(orgID) (*models.OrgOIDC, error)`, `services.SaveOrgOIDC(orgID string, in models.OrgOIDC, secret string) error`; handlers `HandleOIDCStart`, `HandleOIDCCallback`.
+
+- [ ] **Step 1: Write org_oidc.go service**
+
+```go
+package services
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/mrhid6/vantage/server/internal/db"
+ "github.com/mrhid6/vantage/server/internal/models"
+ "go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo"
+ "go.mongodb.org/mongo-driver/v2/mongo/options"
+)
+
+func GetOrgOIDC(orgID string) (*models.OrgOIDC, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ var o models.OrgOIDC
+ err := db.Col("org_oidc").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o)
+ if err == mongo.ErrNoDocuments {
+ return nil, fmt.Errorf("no oidc config")
+ }
+ return &o, err
+}
+
+// GetOrgOIDCSecret returns the decrypted client secret.
+func GetOrgOIDCSecret(o *models.OrgOIDC) (string, error) {
+ return decryptString(o.ClientSecretEnc)
+}
+
+// SaveOrgOIDC upserts config; if secret != "" it is encrypted and stored.
+func SaveOrgOIDC(orgID string, in models.OrgOIDC, secret string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ set := bson.M{
+ "issuer": in.Issuer, "client_id": in.ClientID, "redirect_url": in.RedirectURL,
+ "enabled": in.Enabled, "updated_at": time.Now(),
+ }
+ if secret != "" {
+ enc, err := encryptString(secret)
+ if err != nil {
+ return err
+ }
+ set["client_secret_enc"] = enc
+ }
+ _, err := db.Col("org_oidc").UpdateOne(ctx, bson.M{"org_id": orgID},
+ bson.M{"$set": set}, options.UpdateOne().SetUpsert(true))
+ return err
+}
+```
+
+- [ ] **Step 2: Rewrite oidc.go around the per-org resolver**
+
+Replace the env-based `InitOIDC`/global `oidcProvider`/`oauth2Cfg`/`authEnabled` with an on-demand, per-org resolver. Keep `randomHex`, `SaveState`/state handling. Reference the current callback logic for the token-exchange + claims parsing, but bind to the org from state.
+
+```go
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sync"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "github.com/gin-gonic/gin"
+ "github.com/mrhid6/vantage/server/internal/services"
+ "golang.org/x/oauth2"
+)
+
+type orgProvider struct {
+ provider *oidc.Provider
+ cfg *oauth2.Config
+}
+
+var (
+ provCache = map[string]*orgProvider{}
+ provMu sync.Mutex
+)
+
+func providerForOrg(ctx context.Context, orgID string) (*orgProvider, error) {
+ cfg, err := services.GetOrgOIDC(orgID)
+ if err != nil || !cfg.Enabled {
+ return nil, fmt.Errorf("org sso not configured")
+ }
+ provMu.Lock()
+ defer provMu.Unlock()
+ if p, ok := provCache[orgID]; ok {
+ return p, nil
+ }
+ provider, err := oidc.NewProvider(ctx, cfg.Issuer)
+ if err != nil {
+ return nil, err
+ }
+ secret, _ := services.GetOrgOIDCSecret(cfg)
+ op := &orgProvider{
+ provider: provider,
+ cfg: &oauth2.Config{
+ ClientID: cfg.ClientID, ClientSecret: secret, RedirectURL: cfg.RedirectURL,
+ Endpoint: provider.Endpoint(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
+ },
+ }
+ provCache[orgID] = op
+ return op, nil
+}
+
+// InvalidateOrgProvider drops the cache after config changes.
+func InvalidateOrgProvider(orgID string) {
+ provMu.Lock()
+ delete(provCache, orgID)
+ provMu.Unlock()
+}
+
+// HandleOIDCStart resolves the org, stores state carrying org_id, redirects.
+func HandleOIDCStart(c *gin.Context) {
+ orgID := c.Query("org")
+ if orgID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "org required"})
+ return
+ }
+ op, err := providerForOrg(c.Request.Context(), orgID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ state, _ := randomHex(16)
+ // State value encodes org so the callback can rebuild the provider.
+ if err := SaveStateValue(c.Request.Context(), state, orgID); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
+ return
+ }
+ c.Redirect(http.StatusFound, op.cfg.AuthCodeURL(state))
+}
+
+func HandleOIDCCallback(c *gin.Context) {
+ ctx := c.Request.Context()
+ state := c.Query("state")
+ orgID, err := ConsumeStateValue(ctx, state)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
+ return
+ }
+ op, err := providerForOrg(ctx, orgID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ oauth2Token, err := op.cfg.Exchange(ctx, c.Query("code"))
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "token exchange failed"})
+ return
+ }
+ rawID, _ := oauth2Token.Extra("id_token").(string)
+ idToken, err := op.provider.Verifier(&oidc.Config{ClientID: op.cfg.ClientID}).Verify(ctx, rawID)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "id token verify failed"})
+ return
+ }
+ var claims struct {
+ Email string `json:"email"`
+ }
+ if err := idToken.Claims(&claims); err != nil || claims.Email == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "no email claim"})
+ return
+ }
+ user, err := services.ProvisionOIDCUser(orgID, claims.Email)
+ if err != nil {
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+ token, err := CreateSession(ctx, &Session{UserID: user.UserID, OrgID: user.OrgID, Email: user.Email, Role: user.Role})
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ services.TouchLastLogin(user.UserID)
+ setSessionCookie(c, token)
+ c.Redirect(http.StatusFound, "/")
+ _ = json.Marshal // keep import if unused elsewhere; remove if lint complains
+}
+```
+
+- [ ] **Step 3: Add state-with-value helpers**
+
+The current code has `SaveState(ctx, state)`. Add value-carrying variants in `session.go` (Redis, `statePrefix+state` → orgID, short TTL ~10 min):
+
+```go
+func SaveStateValue(ctx context.Context, state, value string) error {
+ return rdb.Set(ctx, statePrefix+state, value, 10*time.Minute).Err()
+}
+func ConsumeStateValue(ctx context.Context, state string) (string, error) {
+ v, err := rdb.GetDel(ctx, statePrefix+state).Result()
+ if err != nil {
+ return "", err
+ }
+ return v, nil
+}
+```
+
+Match the actual Redis client variable name used in `session.go` (shown as `rdb`).
+
+- [ ] **Step 4: Remove global-auth bypass**
+
+In `middleware.go`, delete the `if !authEnabled { c.Next(); return }` bypass so auth is always enforced (login/register/bootstrap routes stay outside the protected group — Task 7). Remove references to the deleted `authEnabled`/`InitOIDC` from `server/cmd/main.go` (drop the `InitOIDC` call).
+
+- [ ] **Step 5: Verify build**
+
+Run: `cd server && go build ./... && go vet ./...`
+Expected: success. Resolve any leftover references to removed globals.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/internal/auth/oidc.go server/internal/auth/session.go server/internal/auth/middleware.go server/internal/services/org_oidc.go server/cmd/main.go
+git commit -m "feat(auth): per-org OIDC resolver, remove global Authentik"
+```
+
+---
+
+## Task 6: Org-scope existing services
+
+**Files:**
+- Modify: `server/internal/services/servers.go`, `keys.go`, `secrets.go`, `sync.go`, `audit.go`, `workflows.go`, `workflow_runner.go` (workflows only if the Workflows plan has already been executed — otherwise skip those two and note it).
+
+**Interfaces:**
+- Produces: each scoped read/list/create/get/delete function gains a leading `orgID string` param and includes `"org_id": orgID` in filters and inserts.
+
+This is the isolation boundary. Work one collection at a time.
+
+- [ ] **Step 1: Scope `servers.go`**
+
+Add `org_id` to inserts in `CreateServer` (accept `orgID string`), and `"org_id": orgID` to filters in `ListServers`, `GetServer`, `DeleteServer`, `GetAssignmentsWithKeysForServer`, etc. Example transform:
+
+```go
+func ListServers(orgID string) ([]models.Server, error) {
+ // ...
+ cur, err := db.Col("servers").Find(ctx, bson.M{"org_id": orgID}, /* sort */)
+ // ...
+}
+```
+
+For agent-facing lookups keyed by `server_id` (e.g. `ValidateAgentToken`, `UpdateServerLastSeen`): these do NOT get an `orgID` param — they resolve by `server_id` alone. Instead, ensure `CreateServer` stamps `org_id` so the server record carries it; downstream org queries then work.
+
+- [ ] **Step 2: Scope `keys.go`, `secrets.go`, `audit.go`, `sync.go`**
+
+Same treatment. `sync.go` builds desired state per server: it can read the server's own `org_id` (from the server doc) and query keys/assignments within that org. `LogEvent` gains an `orgID` first param and stamps `org_id` on the audit doc; `ListAuditEvents(orgID, limit)` filters by it.
+
+- [ ] **Step 3: Scope workflow services (only if Workflows plan already merged)**
+
+If `services/workflows.go` exists, add `orgID` to all list/get/create/update/delete and to run queries. If not yet implemented, add a note in the Workflows plan to include `org_id` when it is built, and skip here.
+
+- [ ] **Step 4: Verify build**
+
+Run: `cd server && go build ./...`
+Expected: FAILS at call sites in `api/` (handlers not yet passing orgID). That is expected and fixed in Task 7. Confirm the only errors are missing-argument at handler call sites.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/internal/services/
+git commit -m "feat(server): org-scope domain services"
+```
+
+---
+
+## Task 7: Wire handlers + routes to org + auth
+
+**Files:**
+- Modify: `server/internal/api/handlers.go` (+ `secrets.go`, `console.go`, `workflows.go` handlers as needed)
+
+**Interfaces:**
+- Consumes: `auth.OrgID(c)`, scoped services (T6), local/oidc/bootstrap handlers (T4, T5).
+
+- [ ] **Step 1: Update route registration**
+
+In `RegisterRoutes`, replace the auth endpoints block:
+
+```go
+ // Unauthenticated auth endpoints
+ r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
+ r.POST("/auth/bootstrap", auth.HandleBootstrap)
+ r.POST("/auth/login", auth.HandleLocalLogin)
+ r.GET("/auth/logout", auth.HandleLogout)
+ r.GET("/auth/oidc/start", auth.HandleOIDCStart)
+ r.GET("/auth/oidc/callback", auth.HandleOIDCCallback)
+```
+
+Inside the session-protected `apiGroup`, add org-admin routes:
+
+```go
+ orgAdmin := apiGroup.Group("/org")
+ orgAdmin.GET("/users", listOrgUsers)
+ orgAdmin.POST("/users", auth.RequireRole("owner", "admin"), createOrgUser)
+ orgAdmin.PUT("/users/:id/role", auth.RequireRole("owner", "admin"), updateOrgUserRole)
+ orgAdmin.DELETE("/users/:id", auth.RequireRole("owner", "admin"), deleteOrgUser)
+ orgAdmin.GET("/oidc", auth.RequireRole("owner", "admin"), getOrgOIDC)
+ orgAdmin.PUT("/oidc", auth.RequireRole("owner", "admin"), putOrgOIDC)
+ apiGroup.GET("/me", auth.HandleMe)
+```
+
+- [ ] **Step 2: Pass `auth.OrgID(c)` into every scoped service call**
+
+Update each existing handler (`listServers`, `createServer`, `getServer`, `deleteServer`, `listKeys`, `createKey`, secrets handlers, `listAuditEvents`, etc.) to pass `auth.OrgID(c)` as the new first argument. Also update every `services.LogEvent(...)` call to pass `auth.OrgID(c)` first. Example:
+
+```go
+func listServers(c *gin.Context) {
+ servers, err := services.ListServers(auth.OrgID(c))
+ // ...
+}
+```
+
+- [ ] **Step 3: Add org-user + org-oidc handlers**
+
+Create `server/internal/api/org.go`:
+
+```go
+package api
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/mrhid6/vantage/server/internal/auth"
+ "github.com/mrhid6/vantage/server/internal/models"
+ "github.com/mrhid6/vantage/server/internal/services"
+)
+
+func listOrgUsers(c *gin.Context) {
+ users, err := services.ListUsers(auth.OrgID(c))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, users)
+}
+
+func createOrgUser(c *gin.Context) {
+ var body struct {
+ Email string `json:"email" binding:"required"`
+ Password string `json:"password" binding:"required"`
+ Role string `json:"role"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if body.Role == "" {
+ body.Role = "member"
+ }
+ u, err := services.CreateUser(auth.OrgID(c), body.Email, body.Password, body.Role)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ services.LogEvent(auth.OrgID(c), "org.user_created", actorFromCtx(c), "", u.UserID, "user "+u.Email+" created")
+ c.JSON(http.StatusCreated, u)
+}
+
+func updateOrgUserRole(c *gin.Context) {
+ var body struct{ Role string `json:"role" binding:"required"` }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if err := services.UpdateUserRole(auth.OrgID(c), c.Param("id"), body.Role); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"updated": true})
+}
+
+func deleteOrgUser(c *gin.Context) {
+ if err := services.DeleteUser(auth.OrgID(c), c.Param("id")); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"deleted": true})
+}
+
+func getOrgOIDC(c *gin.Context) {
+ cfg, err := services.GetOrgOIDC(auth.OrgID(c))
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{}) // no config yet
+ return
+ }
+ c.JSON(http.StatusOK, cfg)
+}
+
+func putOrgOIDC(c *gin.Context) {
+ var body struct {
+ Issuer string `json:"issuer"`
+ ClientID string `json:"client_id"`
+ Secret string `json:"client_secret"`
+ RedirectURL string `json:"redirect_url"`
+ Enabled bool `json:"enabled"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ orgID := auth.OrgID(c)
+ if err := services.SaveOrgOIDC(orgID, models.OrgOIDC{
+ Issuer: body.Issuer, ClientID: body.ClientID, RedirectURL: body.RedirectURL, Enabled: body.Enabled,
+ }, body.Secret); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ auth.InvalidateOrgProvider(orgID)
+ c.JSON(http.StatusOK, gin.H{"saved": true})
+}
+```
+
+- [ ] **Step 4: Verify build**
+
+Run: `cd server && go build ./... && go vet ./...`
+Expected: success once every scoped call passes `auth.OrgID(c)`. Fix remaining arity errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/internal/api/
+git commit -m "feat(api): org-scoped handlers, org admin routes, auth wiring"
+```
+
+---
+
+## Task 8: One-shot migration (backfill org_id)
+
+**Files:**
+- Create: `server/internal/services/migrate.go`
+
+**Interfaces:**
+- Produces: `MigrateToOrgs() error` — idempotent; run once at startup.
+
+- [ ] **Step 1: Write the migration**
+
+```go
+package services
+
+import (
+ "context"
+ "time"
+
+ "github.com/mrhid6/vantage/server/internal/db"
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+var scopedCollections = []string{
+ "servers", "keys", "assignments", "secrets",
+ "workflows", "workflow_steps", "workflow_runs", "audit",
+}
+
+// MigrateToOrgs backfills a default org onto legacy documents lacking org_id.
+// Idempotent via a marker in the `migrations` collection.
+func MigrateToOrgs() error {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ marker := db.Col("migrations").FindOne(ctx, bson.M{"_id": "orgs-backfill"})
+ if marker.Err() == nil {
+ return nil // already done
+ }
+
+ // Only create a default org if there is legacy data but no org yet.
+ orgCount, _ := db.Col("orgs").CountDocuments(ctx, bson.M{})
+ if orgCount > 0 {
+ _, _ = db.Col("migrations").InsertOne(ctx, bson.M{"_id": "orgs-backfill", "at": time.Now()})
+ return nil
+ }
+
+ hasLegacy := false
+ for _, col := range scopedCollections {
+ n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
+ if n > 0 {
+ hasLegacy = true
+ break
+ }
+ }
+ if !hasLegacy {
+ _, _ = db.Col("migrations").InsertOne(ctx, bson.M{"_id": "orgs-backfill", "at": time.Now()})
+ return nil
+ }
+
+ org, err := CreateOrg("Default")
+ if err != nil {
+ return err
+ }
+ for _, col := range scopedCollections {
+ if _, err := db.Col(col).UpdateMany(ctx,
+ bson.M{"org_id": bson.M{"$exists": false}},
+ bson.M{"$set": bson.M{"org_id": org.OrgID}}); err != nil {
+ return err
+ }
+ }
+ _, err = db.Col("migrations").InsertOne(ctx, bson.M{"_id": "orgs-backfill", "org_id": org.OrgID, "at": time.Now()})
+ return err
+}
+```
+
+- [ ] **Step 2: Call at startup**
+
+In `server/cmd/main.go`, after DB connect + `EnsureAuthIndexes()`, call `services.MigrateToOrgs()` with error logging.
+
+- [ ] **Step 3: Verify build**
+
+Run: `cd server && go build ./... && go vet ./...`
+Expected: success.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add server/internal/services/migrate.go server/cmd/main.go
+git commit -m "feat(server): idempotent org_id backfill migration"
+```
+
+---
+
+## Task 9: Frontend — login, setup, auth gating
+
+**Files:**
+- Create: `web/app/login/page.tsx`
+- Create: `web/app/setup/page.tsx`
+- Modify: `web/lib/api.ts` (auth methods + types)
+- Modify: `web/components/AuthProvider.tsx` (gate on `/auth/me`, redirect to `/login` or `/setup`)
+
+**Interfaces:**
+- Consumes: `api.bootstrapStatus`, `api.bootstrap`, `api.login`, `api.me`, `api.oidcStartUrl`.
+
+- [ ] **Step 1: Add auth API methods/types**
+
+In `web/lib/api.ts`:
+
+```ts
+export interface Me { user: { user_id: string; email: string; role: string }; org: { org_id: string; name: string } | null; }
+
+// add to api object:
+ bootstrapStatus: () => req<{ needs_setup: boolean }>("/auth/bootstrap-status"),
+ bootstrap: (org_name: string, email: string, password: string) =>
+ req("/auth/bootstrap", { method: "POST", body: JSON.stringify({ org_name, email, password }) }),
+ login: (email: string, password: string) =>
+ req("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }),
+ me: () => req
("/api/me"),
+```
+
+- [ ] **Step 2: Setup page**
+
+`web/app/setup/page.tsx` — client form (org name, email, password) → `api.bootstrap` → on success `router.push("/")`. Use `inputClass` + `Button`/`Card` styling from secrets page.
+
+- [ ] **Step 3: Login page**
+
+`web/app/login/page.tsx` — email/password form → `api.login` → `router.push("/")`. Plus an "Organization SSO" section: an org-id input and a "Sign in with SSO" button that navigates to `/auth/oidc/start?org=`. On mount, call `api.bootstrapStatus()`; if `needs_setup`, redirect to `/setup`.
+
+- [ ] **Step 4: Gate the app**
+
+In `web/components/AuthProvider.tsx`, query `api.me()`; on 401 redirect to `/login` (or `/setup` when bootstrap needed). Show a loader while resolving. Leave `/login` and `/setup` routes ungated. Show current org name + user email in the sidebar/header (pass via context).
+
+- [ ] **Step 5: Verify build**
+
+Run: `cd web && npm run build`
+Expected: success.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/app/login/page.tsx web/app/setup/page.tsx web/lib/api.ts web/components/AuthProvider.tsx
+git commit -m "feat(web): login, first-run setup, and auth gating"
+```
+
+---
+
+## Task 10: Frontend — org settings (members + OIDC)
+
+**Files:**
+- Create: `web/app/settings/org/page.tsx`
+
+**Interfaces:**
+- Consumes: `api.listOrgUsers`, `api.createOrgUser`, `api.updateOrgUserRole`, `api.deleteOrgUser`, `api.getOrgOIDC`, `api.putOrgOIDC` (add these to `web/lib/api.ts` following the same pattern).
+
+- [ ] **Step 1: Add org API methods**
+
+```ts
+ listOrgUsers: () => req("/api/org/users"),
+ createOrgUser: (email: string, password: string, role: string) =>
+ req("/api/org/users", { method: "POST", body: JSON.stringify({ email, password, role }) }),
+ updateOrgUserRole: (id: string, role: string) =>
+ req(`/api/org/users/${id}/role`, { method: "PUT", body: JSON.stringify({ role }) }),
+ deleteOrgUser: (id: string) => req(`/api/org/users/${id}`, { method: "DELETE" }),
+ getOrgOIDC: () => req("/api/org/oidc"),
+ putOrgOIDC: (cfg: { issuer: string; client_id: string; client_secret?: string; redirect_url: string; enabled: boolean }) =>
+ req("/api/org/oidc", { method: "PUT", body: JSON.stringify(cfg) }),
+```
+
+- [ ] **Step 2: Build the page**
+
+Two cards: **Members** (table of users with role select + remove, an "Add user" inline form: email/password/role) and **Organization SSO** (form: issuer, client_id, client_secret, redirect_url, enabled toggle → `api.putOrgOIDC`). Admin-only actions; hide mutations if `me.user.role === "member"`. Reuse secrets-page styling.
+
+- [ ] **Step 3: Verify build**
+
+Run: `cd web && npm run build`
+Expected: success.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/app/settings/org/page.tsx web/lib/api.ts
+git commit -m "feat(web): org settings — members and SSO config"
+```
+
+---
+
+## Task 11: End-to-end manual verification
+
+- [ ] **Step 1: Build all**
+
+Run: `cd server && go build ./... && cd ../web && npm run build`
+Expected: success.
+
+- [ ] **Step 2: Smoke (if environment available)**
+
+1. Fresh DB → visiting the app redirects to `/setup`; create org + owner → land logged in.
+2. Existing DB with legacy servers/keys → migration stamps them into "Default" org; owner (created via setup) sees them.
+3. Create a `member` user in org settings; log in as them in a private window; confirm they see the same org's resources and cannot access org-admin mutations (403).
+4. Configure org OIDC (issuer/client id/secret/redirect, enabled); from `/login` enter the org id, click SSO; complete provider login; confirm a user is provisioned in that org and logged in.
+5. Confirm a user cannot see another org's data (create a second org via a second setup only possible on empty DB — verify at the query level or by inspecting that all queries carry `org_id`).
+
+- [ ] **Step 3: Commit fixes**
+
+```bash
+git add -A
+git commit -m "fix: saas auth/org verification fixes"
+```
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** §3 models → T1; §4 flows (local login/bootstrap/me → T4; org user mgmt → T7; per-org OIDC → T5) ; §5 scoping → T6/T7 (`auth.OrgID`, `RequireRole`); §6 remove Authentik → T5 step 4; §7 migration → T8; §8 frontend → T9/T10; §9 security (bcrypt T2, secret encryption T5, org derived from session T7, state binds org T5). Tests omitted per Global Constraints.
+- **Isolation invariant:** every scoped handler passes `auth.OrgID(c)`; agent-facing lookups resolve by `server_id` and rely on `CreateServer` stamping `org_id`.
+- **Ordering caveat:** Task 6 intentionally leaves the build broken at API call sites until Task 7 — the two must land together (or as one review unit) for a green build. Workflows services are scoped only if that plan already merged (T6 step 3).
+- **Follow-ups (out of scope):** billing/plan limits, email-based invites, org switching, SAML/SCIM, second-org creation UX (currently only via empty-DB bootstrap).
diff --git a/docs/superpowers/specs/2026-07-20-fleet-inventory-design.md b/docs/superpowers/specs/2026-07-20-fleet-inventory-design.md
new file mode 100644
index 0000000..b449c7a
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-20-fleet-inventory-design.md
@@ -0,0 +1,142 @@
+# Fleet Inventory — Design
+
+**Date:** 2026-07-20
+**Status:** Approved (design) — ready for implementation planning
+**Scope:** Fleet Inventory only. Server Workflows and SaaS/auth are separate sub-projects.
+
+---
+
+## 1. Summary
+
+Each agent collects hardware/OS inventory about its host and reports it to the server, which stores the latest snapshot per server and surfaces it in the UI. Two cadences:
+
+- **Metrics (near-real-time):** CPU load/usage, RAM used/total, swap used/total — every **30s** (aligned with existing poll rhythm).
+- **Static inventory (slow):** disks, partitions and their usage, CPU model/cores, total RAM, OS details — every **15 min**.
+
+Transport: a **new unary gRPC `ReportInventory` RPC** (mirrors the existing `ReportUpdates` pattern). No streaming.
+
+---
+
+## 2. Locked decisions
+
+| Topic | Decision |
+|-------|----------|
+| Transport | New `ReportInventory` unary RPC. |
+| Cadence | Metrics every 30s; static inventory every 15 min. One RPC carries both, but static fields are only populated on the 15-min tick (empty/omitted otherwise → server keeps prior static snapshot). |
+| Storage | Latest snapshot embedded on the `servers` document (`inventory` sub-doc). No history/time-series in v1. |
+| Collection | Pure-Go where practical (`/proc`, `gopsutil`-style). Agent already runs as root. |
+| Platform | Linux primary; Windows agent populates what it can, leaves the rest empty. |
+
+---
+
+## 3. Data model
+
+Add an `Inventory` sub-document to the existing `Server` model (`server/internal/models/server.go`):
+
+```go
+type CPUInfo struct {
+ Model string `bson:"model,omitempty" json:"model,omitempty"`
+ Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
+ UsagePct float64 `bson:"usage_pct" json:"usage_pct"` // metrics tick
+ Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
+}
+
+type MemInfo struct {
+ TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
+ UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"` // metrics tick
+}
+
+type Partition struct {
+ Device string `bson:"device" json:"device"`
+ Mountpoint string `bson:"mountpoint" json:"mountpoint"`
+ Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
+ TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
+ UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
+}
+
+type Inventory struct {
+ CPU CPUInfo `bson:"cpu" json:"cpu"`
+ Memory MemInfo `bson:"memory" json:"memory"`
+ SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
+ SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
+ Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
+ Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
+ MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
+ StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
+}
+```
+
+Add `Inventory *Inventory` field to `Server`.
+
+Server-side update rules:
+- Metrics fields (`cpu.usage_pct`, `cpu.load1`, `memory.used_bytes`, swap used) always updated + `metrics_at`.
+- Static fields (`cpu.model/cores`, `memory.total_bytes`, `partitions`, `kernel`, swap total) updated only when the report includes them (non-zero/non-empty) + `static_at`.
+
+---
+
+## 4. gRPC protocol (`proto/vantage/v1/vantage.proto` + both `pb.go` files)
+
+```protobuf
+rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
+
+message InventoryReport {
+ string server_id = 1;
+ string agent_token = 2;
+ bool include_static = 3; // true on the 15-min tick
+ CPUReport cpu = 4;
+ MemReport memory = 5;
+ uint64 swap_total = 6;
+ uint64 swap_used = 7;
+ repeated PartitionReport partitions = 8; // only when include_static
+ string kernel = 9; // only when include_static
+}
+message CPUReport { string model = 1; int32 cores = 2; double usage_pct = 3; double load1 = 4; }
+message MemReport { uint64 total_bytes = 1; uint64 used_bytes = 2; }
+message PartitionReport { string device = 1; string mountpoint = 2; string fstype = 3; uint64 total_bytes = 4; uint64 used_bytes = 5; }
+message InventoryReportResponse {}
+```
+
+Hand-written JSON-codec structs added to `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`, plus the RPC method wiring (service interface, client method, handler registration) mirroring `ReportUpdates`.
+
+---
+
+## 5. Agent collection (`agent/internal/inventory/`)
+
+- `Collect(includeStatic bool) *pb.InventoryReport` — reads:
+ - CPU usage: sample `/proc/stat` delta; load from `/proc/loadavg`; model/cores from `/proc/cpuinfo` (static).
+ - Memory/swap: `/proc/meminfo`.
+ - Partitions: `/proc/mounts` filtered to real filesystems + `statfs` for total/used (static).
+ - Kernel: `uname` / `/proc/version` (static).
+ - Windows: best-effort via `wmic`/PS or leave empty.
+- Scheduler in the agent main loop: a 30s ticker calls `Collect(false)` and `ReportInventory`; every 30th tick (15 min) calls `Collect(true)`.
+- Reuse existing gRPC client; add `Client.ReportInventory(...)` like `ReportUpdates`.
+
+Prefer implementing the `/proc` readers directly (no new heavy deps) unless a `gopsutil` dependency is already vendored.
+
+---
+
+## 6. Server handler + service
+
+- gRPC handler `ReportInventory` in `server/internal/grpc/server.go`: validate agent token (`ValidateAgentToken`), then call `services.StoreInventory(serverID, report)`.
+- `services.StoreInventory` (in `server/internal/services/inventory.go`): builds the `$set` per the update rules in §3 and `UpdateOne` on `servers`.
+
+---
+
+## 7. Frontend
+
+Surface inventory on the existing server detail page (`web/app/servers/[id]/page.tsx`) — add an "Inventory" panel:
+- CPU usage gauge + model/cores, load.
+- RAM used/total bar, swap bar.
+- Partitions table: device, mount, fstype, used/total with a usage bar.
+- "Updated Xs ago" from `metrics_at`/`static_at`.
+
+Optionally add compact CPU/RAM badges to the servers list (`web/app/servers/page.tsx`). Reuse `@/components/ui` + Tailwind tokens. Poll the server detail query while the page is open (react-query `refetchInterval` ~30s) so metrics stay fresh.
+
+---
+
+## 8. Out of scope
+
+- Time-series history / graphs (only latest snapshot stored).
+- Alerting thresholds on usage (settings/alerts is a separate concern).
+- Per-process / network / GPU inventory.
+- Tests (skipped, consistent with the Workflows iteration).
diff --git a/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md b/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md
new file mode 100644
index 0000000..8c774b8
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md
@@ -0,0 +1,142 @@
+# SaaS: Auth + Organizations — Design
+
+**Date:** 2026-07-20
+**Status:** Approved (design) — ready for implementation planning
+**Scope:** Local auth + organizations + per-org OIDC, and org-scoping of existing data. Billing/plan-limits explicitly deferred. Fleet Inventory and Server Workflows are separate sub-projects.
+
+---
+
+## 1. Summary
+
+Turn Vantage from a single-admin, single global-OIDC tool into a multi-tenant app:
+
+1. **Replace** the global Authentik/env-based OIDC with **local email/password accounts** as the primary login.
+2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit) carries an `org_id` and all queries are scoped to the caller's org.
+3. **Per-org OpenID** — an org admin can configure their own OIDC provider (issuer/client id/secret); users in that org can then sign in through it.
+
+No billing, no seat/server limits this iteration (schema leaves room).
+
+---
+
+## 2. Locked decisions
+
+| Topic | Decision |
+|-------|----------|
+| Primary auth | Local email + password (bcrypt). Replaces global Authentik. |
+| Org SSO | Per-org OIDC provider, configured by org admin, resolved dynamically at login. |
+| Isolation | `org_id` on every collection; every service query filtered by org. Enforced in the request layer via session→org. |
+| Roles | `owner`, `admin`, `member` (v1: owner/admin can manage users + org OIDC + all resources; member can use resources). Keep minimal. |
+| Bootstrapping | First-run creates the initial org + owner account (setup flow) when no users exist. |
+| Sessions | Keep existing Redis session store; session now carries `user_id`, `org_id`, `role`, `email`. |
+| Agent auth | Unchanged (per-server agent tokens). Servers gain `org_id`; agent RPCs resolve org from the server record. |
+
+---
+
+## 3. Data model
+
+### `orgs`
+```json
+{ "_id":"ObjectId", "org_id":"uuid", "name":"Acme", "created_at":"ISODate" }
+```
+
+### `users`
+```json
+{
+ "_id":"ObjectId", "user_id":"uuid", "org_id":"uuid",
+ "email":"a@b.com", "password_hash":"bcrypt...", "role":"owner|admin|member",
+ "auth_source":"local|oidc", "created_at":"ISODate", "last_login":"ISODate|null"
+}
+```
+Unique index on `email` (global — email identifies the account and its org).
+
+### `org_oidc` (per-org provider config)
+```json
+{
+ "_id":"ObjectId", "org_id":"uuid",
+ "issuer":"https://id.acme.com", "client_id":"...",
+ "client_secret_enc":"AES...", // encrypted with existing crypto.go
+ "redirect_url":"https://vantage.../auth/oidc/callback",
+ "enabled": true, "updated_at":"ISODate"
+}
+```
+
+### Existing collections — add `org_id`
+`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7).
+
+---
+
+## 4. Auth flows
+
+### Local
+- `POST /auth/register` — only allowed during first-run bootstrap (creates org + owner) OR by an org admin inviting a user (see below). Not open self-serve.
+- `POST /auth/login` — email + password → verify bcrypt → create session with `{user_id, org_id, role, email}`.
+- `POST /auth/logout` — destroy session.
+- `GET /auth/me` — returns current user + org.
+
+### Org-admin user management
+- `GET /api/org/users` / `POST /api/org/users` (create local user in caller's org) / `PUT /api/org/users/:id/role` / `DELETE /api/org/users/:id`.
+
+### Per-org OIDC
+- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted.
+- `GET /auth/oidc/start?org=` — look up org's `org_oidc`, build the OIDC provider on demand (cache per org), redirect to authorize.
+- `GET /auth/oidc/callback` — exchange code, match/provision the user by email **within that org**, create session.
+ - If the email exists in the org → log in. If not → provision a `member` with `auth_source=oidc` (org admin can promote). Reject if email belongs to a different org.
+
+### First-run bootstrap
+- `GET /auth/bootstrap-status` → `{ needs_setup: bool }` (true when `users` is empty).
+- Setup page collects org name + owner email/password → creates org + owner → session.
+
+---
+
+## 5. Request scoping
+
+- `auth.Middleware` already loads the session; extend `Session` to include `OrgID`, `UserID`, `Role`. Add helper `auth.OrgID(c) string`.
+- **Every service function that reads/writes a scoped collection takes an `orgID` argument** and adds `"org_id": orgID` to its filter and on insert. Handlers pass `auth.OrgID(c)`.
+- Add a `requireRole(role)` gin middleware for admin-only routes (org user mgmt, org OIDC).
+- Agent-facing gRPC: resolve `org_id` from the `servers` record (already tied to `server_id`); inventory/keys/sync operate on that org implicitly.
+
+---
+
+## 6. Removing global Authentik
+
+- Delete/retire env-driven `InitOIDC` global provider (`OIDC_ISSUER` etc.). Keep the `go-oidc`/`oauth2` machinery but move it behind the per-org resolver.
+- `authEnabled` global replaced by "auth always on" (there is always local auth). Update `middleware.go` accordingly (no more `if !authEnabled { next }` bypass — except the bootstrap endpoints and login/register which are unauthenticated).
+- Login page (`web/app/login` or existing) offers: email/password form + "Sign in with your organization's SSO" (enter org, redirect to `/auth/oidc/start`).
+
+---
+
+## 7. Migration
+
+One-shot migration run at startup (idempotent):
+1. If `orgs` is empty AND `servers`/`keys`/etc. contain documents without `org_id`: create a **default org** ("Default").
+2. Set `org_id = ` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` documents missing it.
+3. If `OIDC_ISSUER` env was set previously and an admin email is known, optionally seed an owner user (documented manual step) — otherwise first-run bootstrap handles owner creation.
+Guard with a marker (e.g. a `migrations` collection entry) so it runs once.
+
+---
+
+## 8. Frontend
+
+- **Login/Setup:** `web/app/login/page.tsx` (email/password + org SSO entry) and `web/app/setup/page.tsx` (first-run). Redirect logic based on `bootstrap-status` and `auth/me`.
+- **Org settings:** `web/app/settings/org/` — members list + invite/create user + role management; OIDC provider form (issuer/client id/secret/enabled).
+- Existing pages unchanged functionally but now implicitly org-scoped by the backend. Show current org + user in the sidebar/header.
+
+---
+
+## 9. Security
+
+- Passwords: bcrypt (cost ≥ 12). Never returned.
+- Org OIDC client secret encrypted at rest (reuse `services/crypto.go` AES).
+- Cross-org access prevented at the service layer (org_id in every filter) — the primary isolation boundary. Handlers must never accept an `org_id` from the client; always derive from session.
+- OIDC callback must bind the returned identity to the org that initiated the flow (state carries org_id) to prevent org-mixing.
+- Role checks on all org-admin mutations.
+
+---
+
+## 10. Out of scope
+
+- Billing, plans, seat/server limits.
+- Cross-org resource sharing, org switching for a single user (one user = one org in v1).
+- SCIM / directory sync, SAML.
+- Email delivery for invites (create-user sets a password or invite token; email sending deferred — document as manual/console output).
+- Tests (skipped, consistent with prior iterations).