a73x

2823c631

feat(arch): every crossing is owned — R7 through R10

a73x   2026-07-26 20:08

Commit message
feat(arch): every crossing is owned — R7 through R10

R7: process execution anywhere in internal/ requires an entry in the
sanctioned allowlist — cloudhv (the data plane's one exec funnel), cli
(interactive ssh must be the real OpenSSH client), shape (the governance
tooling's own go-list shell-out). R8: internal/cli is importable only by
cmd/eitri; its exec sanction and ~/.ssh side effects stay fenced inside
the one binary that owns them. R9: the whole wire plane is leaf,
membership driven by the shape classifier, so a new wire package inherits
the rule the moment it is classified. R10: modernc.org/sqlite is imported
by internal/server/store alone — schema application and single-connection
write serialization cannot be bypassed.

docs/decisions.md records the principle these enforce: one owner per API
surface.

Makefile
Old New
@@ -52,22 +52,6 @@ proto:
52 protoc --go_out=. --go_opt=module=github.com/a73x/eitri \ 52 protoc --go_out=. --go_opt=module=github.com/a73x/eitri \
53 proto/eitri/v1/sync.proto 53 proto/eitri/v1/sync.proto
54 54
55 # Regenerate the API contract artifacts: docs/openapi.json from the route
56 # table, and the TypeScript types from the spec (needs the web toolchain,
57 # like `make web`).
58 api:
59 go run ./cmd/eitri-apispec
60 @if [ -x web/node_modules/.bin/openapi-typescript ]; then \
61 cd web && npm run gen:api; \
62 else \
63 echo "api: openapi-typescript not installed (run 'make web') — skipping TS type generation (enforced in CI)"; \
64 fi
65
66 # Merge gate: the committed spec and TS types must match the route table.
67 api-check:
68 @$(MAKE) api && git diff --exit-code -- docs/openapi.json web/src/lib/api-types.ts || \
69 { echo "api-check: API contract artifacts are stale — run 'make api'"; exit 1; }
70
71 # Roll freshly-built HEAD to the live fleet: local eitri-server + every remote 55 # Roll freshly-built HEAD to the live fleet: local eitri-server + every remote
72 # eitri-agent (restart-based; running VMs survive the agent bounce). Config from 56 # eitri-agent (restart-based; running VMs survive the agent bounce). Config from
73 # $$EITRI_DEPLOY_ENV (default ~/eitri-deploy/deploy.env); see scripts/deploy.env.example. 57 # $$EITRI_DEPLOY_ENV (default ~/eitri-deploy/deploy.env); see scripts/deploy.env.example.
@@ -134,6 +118,22 @@ shape-check:
134 git diff --exit-code docs/shape.json docs/shape.html || \ 118 git diff --exit-code docs/shape.json docs/shape.html || \
135 { echo "shape-check: docs/shape.{json,html} are stale — run 'make shape'"; exit 1; } 119 { echo "shape-check: docs/shape.{json,html} are stale — run 'make shape'"; exit 1; }
136 120
121 # Regenerate the API contract artifacts: docs/openapi.json from the route
122 # table, and the TypeScript types from the spec (needs the web toolchain,
123 # like `make web`).
124 api:
125 go run ./cmd/eitri-apispec
126 @if [ -x web/node_modules/.bin/openapi-typescript ]; then \
127 cd web && npm run gen:api; \
128 else \
129 echo "api: openapi-typescript not installed (run 'make web') — skipping TS type generation (enforced in CI)"; \
130 fi
131
132 # Merge gate: the committed spec and TS types must match the route table.
133 api-check:
134 @$(MAKE) api && git diff --exit-code -- docs/openapi.json web/src/lib/api-types.ts || \
135 { echo "api-check: API contract artifacts are stale — run 'make api'"; exit 1; }
136
137 # Generate the eitri.sh static site into site/dist. Picks up the newest 137 # Generate the eitri.sh static site into site/dist. Picks up the newest
138 # dist/<version> (from `make release`) for the downloads page when one 138 # dist/<version> (from `make release`) for the downloads page when one
139 # exists; renders a docs-only preview otherwise. 139 # exists; renders a docs-only preview otherwise.
docs/decisions.md
Old New
@@ -55,6 +55,20 @@ fleet-only, and no principal crosses tenant boundaries. Instead of a global
55 admin identity. Cross-tenant capability is the thing multi-tenancy exists to 55 admin identity. Cross-tenant capability is the thing multi-tenancy exists to
56 prevent, so it is unrepresentable rather than merely forbidden. 56 prevent, so it is unrepresentable rather than merely forbidden.
57 57
58 ### One owner per API surface
59
60 Each capability has exactly one package that owns its surface, enforced by
61 fitness functions wherever the import graph can see it: SQLite belongs to
62 internal/server/store (R10), process execution to the R7 sanctioned set, the
63 wire types to the leaf plane's one shared contract (R9), the client's ssh to
64 internal/cli, importable only by its binary (R8), and the server's HTTP API
65 to internal/server/api/client over the internal/server/api/types leaf
66 (R11/R12), with the generated OpenAPI spec drift-gated by api-check. Instead
67 of convention plus review, which let three parallel HTTP clients of the same
68 endpoints accumulate before anyone noticed. When a second implementation of a
69 surface appears, the fix is consolidation behind the owner, not a second
70 owner.
71
58 ### Agents pin the server cert by fingerprint 72 ### Agents pin the server cert by fingerprint
59 73
60 Agents trust the server's self-signed QUIC cert by fingerprint from the join 74 Agents trust the server's self-signed QUIC cert by fingerprint from the join
docs/openapi.json
Old New
@@ -732,6 +732,15 @@
732 "schema": { 732 "schema": {
733 "type": "string" 733 "type": "string"
734 } 734 }
735 },
736 {
737 "description": "purge VM rows and remove the host immediately (dead hardware escape hatch)",
738 "in": "query",
739 "name": "force",
740 "required": false,
741 "schema": {
742 "type": "string"
743 }
735 } 744 }
736 ], 745 ],
737 "responses": { 746 "responses": {
@@ -754,7 +763,7 @@
754 "adminToken": [] 763 "adminToken": []
755 } 764 }
756 ], 765 ],
757 "summary": "Decommission a host: tombstone its VMs and drain gracefully (202)." 766 "summary": "Decommission a host: tombstone its VMs and drain gracefully (202). With ?force, purge and remove immediately, returning 200."
758 } 767 }
759 }, 768 },
760 "/api/v1/hosts/{id}/revoke-credential": { 769 "/api/v1/hosts/{id}/revoke-credential": {
internal/arch/arch_test.go
Old New
@@ -5,6 +5,8 @@ import (
5 "sort" 5 "sort"
6 "strings" 6 "strings"
7 "testing" 7 "testing"
8
9 "github.com/a73x/eitri/internal/shape"
8 ) 10 )
9 11
10 // module is the import-path prefix shared by every package in this repo. 12 // module is the import-path prefix shared by every package in this repo.
@@ -106,18 +108,60 @@ func TestControlAndDataPlaneAreDisjoint(t *testing.T) {
106 } 108 }
107 } 109 }
108 110
109 // R3: the wire contract is the only thing shared across the two planes, so it 111 // R9: every wire-plane package is a leaf — it may import no other internal
110 // must stay dependency-light and leaf-like. internal/pb (generated protobuf) 112 // package. The wire plane is the only code shared across the control and
111 // and internal/transport (framing + TLS) must import no other internal package 113 // data planes, so a single heavy dependency there (the store, the transport
112 // — otherwise a heavy dependency (e.g. the SQLite store) would leak across the 114 // stack) would leak into both binaries at once. Generalizes R3's hardcoded
113 // plane boundary into both binaries. 115 // pb/transport pair: membership comes from the shape classifier, so a new
114 func TestWireContractIsLeaf(t *testing.T) { 116 // wire package inherits the rule the moment it's classified.
115 g := internalImports(t) 117 func TestWirePlaneIsLeaf(t *testing.T) {
116 for _, leaf := range []string{module + "/internal/pb", module + "/internal/transport"} { 118 // directImports (not internalImports) so leaf packages with zero internal
117 for _, d := range g[leaf] { 119 // deps still show up as keys — internalImports only carries packages that
118 t.Errorf("%s must not import any internal package, but imports %s", short(leaf), short(d)) 120 // already have at least one internal edge, which would silently drop the
121 // very packages this rule exists to sweep.
122 g := directImports(t)
123 swept := 0
124 for pkg, deps := range g {
125 rel := strings.TrimPrefix(pkg, module+"/")
126 if shape.Classify(rel) != shape.PlaneWire {
127 continue
128 }
129 swept++
130 for _, d := range deps {
131 if strings.HasPrefix(d, module+"/") {
132 t.Errorf("wire package %s must not import any internal package, but imports %s", short(pkg), short(d))
133 }
134 }
135 }
136 if swept < 8 {
137 t.Errorf("wire-plane sweep saw only %d packages — classifier drift? (expect pb, transport, joinblob, cloudinit, names, random, version, relmanifest)", swept)
138 }
139 }
140
141 // R10: modernc.org/sqlite is imported by internal/server/store and nowhere
142 // else. The store owns schema application and the single-connection write
143 // serialization; a second driver import would bypass both silently. The
144 // database/sql package itself stays free (sql.ErrNoRows is an idiomatic
145 // sentinel for store callers).
146 func TestSQLiteDriverIsStoresAlone(t *testing.T) {
147 g := directImports(t)
148 const driver = "modernc.org/sqlite"
149 const owner = module + "/internal/server/store"
150 found := false
151 for pkg, deps := range g {
152 for _, d := range deps {
153 if d != driver {
154 continue
155 }
156 found = true
157 if pkg != owner {
158 t.Errorf("package %s imports %s directly, but only %s may", short(pkg), driver, short(owner))
159 }
119 } 160 }
120 } 161 }
162 if !found {
163 t.Errorf("sweep found no importer of %s at all — did the driver move or get vendored differently?", driver)
164 }
121 } 165 }
122 166
123 // R4: domain/state packages hold pure logic and must not depend on the 167 // R4: domain/state packages hold pure logic and must not depend on the
@@ -183,10 +227,55 @@ func TestOnlyCloudhvImportsOsExecInDataPlane(t *testing.T) {
183 } 227 }
184 } 228 }
185 229
186 // The API contract is consumed through the client. internal/server/api/types 230 // R7: external process execution anywhere in internal/ is confined to a
231 // sanctioned allowlist — cloudhv (the data plane's one exec funnel, R6's
232 // narrower story), cli (interactive ssh must be the real OpenSSH client), and
233 // shape (architecture tooling that shells out to `go list`, the same
234 // introspection internal/arch's own tests do). R2/R6 tell the per-plane
235 // stories; R7 is the whole-tree backstop that makes a new exec site anywhere
236 // an explicit, reviewed decision.
237 func TestExecIsConfinedToSanctionedPackages(t *testing.T) {
238 g := directImports(t)
239 allowed := map[string]bool{
240 module + "/internal/agent/cloudhv": true, // the data plane's one sanctioned exec funnel (R6's story)
241 module + "/internal/cli": true, // interactive sessions must be the real OpenSSH client
242 module + "/internal/shape": true, // architecture tooling: shells `go list -json` to build the module graph — the same shell-out internal/arch's own tests make
243 }
244 for pkg, offenders := range execViolations(g, module, "internal/", allowed) {
245 for _, o := range offenders {
246 t.Errorf("package %s reaches os/exec via %s — exec anywhere in internal/ requires an explicit allowlist entry (see R7)", short(pkg), short(o))
247 }
248 }
249 }
250
251 // R8: the client CLI is a leaf of its own binary. internal/cli carries the
252 // exec sanction (R7) and touches the user's machine (~/.ssh); letting a
253 // server, agent, or tooling package import it would smuggle both across a
254 // plane boundary. Only cmd/eitri may import it.
255 func TestClientCLIIsOnlyImportedByItsBinary(t *testing.T) {
256 g := internalImports(t)
257 target := module + "/internal/cli"
258 allowedImporter := module + "/cmd/eitri"
259 for pkg, deps := range g {
260 if pkg == target || pkg == allowedImporter {
261 continue
262 }
263 for _, d := range deps {
264 if d == target {
265 t.Errorf("package %s must not import %s — only %s may", short(pkg), short(target), short(allowedImporter))
266 }
267 }
268 }
269 }
270
271 // R11: the API contract is consumed through the client. internal/server/api/types
187 // is the wire contract, but no package outside internal/server/* may import it — 272 // is the wire contract, but no package outside internal/server/* may import it —
188 // consumers get the wire structs via internal/server/api/client's re-exported 273 // consumers get the wire structs via internal/server/api/client's re-exported
189 // aliases (client.Host and friends), so the client is the only door. 274 // aliases (client.Host and friends), so the client is the only door. This rule
275 // stops a fourth hand-rolled client at the import graph: the first three
276 // (mcpserver/eitriapi.go, cmd/eitri-smoke/client.go, internal/cli's raw HTTP)
277 // each began as "just import the types and fmt.Sprintf the paths", and each
278 // would have tripped this exact sweep on its first commit.
190 func TestAPIContractIsConsumedThroughTheClient(t *testing.T) { 279 func TestAPIContractIsConsumedThroughTheClient(t *testing.T) {
191 g := internalImports(t) 280 g := internalImports(t)
192 target := module + "/internal/server/api/types" 281 target := module + "/internal/server/api/types"
@@ -208,7 +297,7 @@ func TestAPIContractIsConsumedThroughTheClient(t *testing.T) {
208 } 297 }
209 } 298 }
210 299
211 // internal/server/api/types is a leaf — stdlib imports only. The contract 300 // R12: internal/server/api/types is a leaf — stdlib imports only. The contract
212 // is consumed by the spec generator, the client, and the handlers; a single 301 // is consumed by the spec generator, the client, and the handlers; a single
213 // internal import would drag server internals into every consumer at once and 302 // internal import would drag server internals into every consumer at once and
214 // break the reflection-based OpenAPI generator's "types package = the whole 303 // break the reflection-based OpenAPI generator's "types package = the whole
internal/mcpserver/config.go
Old New
@@ -1,7 +1,8 @@
1 // Package mcpserver implements the eitri-mcp server: MCP tools that let a 1 // Package mcpserver implements the eitri-mcp server: MCP tools that let a
2 // model create, control (SSH exec/files), and destroy eitri VMs. It is an API 2 // model create, control (SSH exec/files), and destroy eitri VMs. It is an API
3 // CLIENT of the control plane — it must not import internal/server, and the 3 // CLIENT of the control plane — it speaks to it only through the shared API
4 // admin token it holds must never appear in tool results or errors. 4 // client (internal/server/api/client), never any other server internals, and
5 // the admin token it holds must never appear in tool results or errors.
5 package mcpserver 6 package mcpserver
6 7
7 import ( 8 import (
internal/server/api/api.go
Old New
@@ -304,13 +304,15 @@ func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) {
304 http.Error(w, "internal error", http.StatusInternalServerError) 304 http.Error(w, "internal error", http.StatusInternalServerError)
305 return 305 return
306 } 306 }
307 writeJSON(w, http.StatusCreated, types.EnrollTokenResponse{Token: tok, Join: join}) 307 writeJSON(w, http.StatusCreated, types.EnrollTokenResponse{Join: join, Token: tok})
308 } 308 }
309 309
310 // --- hosts --- 310 // --- hosts ---
311 311
312 // toHostResponse merges a durable host row with its live registry state into 312 // toHostResponse merges a durable host row with its live registry state into
313 // the wire shape (types.Host). 313 // the wire shape (types.Host). Handler-side facts behind the contract's
314 // sync-health fields: Stale trips when the last report is older than
315 // registry.StaleWindow; AgentVersion is registry-held from the agent's Hello.
314 func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Alloc) types.Host { 316 func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Alloc) types.Host {
315 hr := types.Host{ 317 hr := types.Host{
316 ID: h.ID, 318 ID: h.ID,
internal/server/api/client/client.go
Old New
@@ -117,7 +117,7 @@ func (c *Client) ListHosts(ctx context.Context) ([]Host, error) {
117 return hosts, c.do(ctx, http.MethodGet, "/api/v1/hosts", nil, &hosts) 117 return hosts, c.do(ctx, http.MethodGet, "/api/v1/hosts", nil, &hosts)
118 } 118 }
119 119
120 // ListVMs returns every VM the caller can see. 120 // ListVMs returns every VM the caller's tenant can see.
121 func (c *Client) ListVMs(ctx context.Context) ([]VM, error) { 121 func (c *Client) ListVMs(ctx context.Context) ([]VM, error) {
122 var vms []VM 122 var vms []VM
123 return vms, c.do(ctx, http.MethodGet, "/api/v1/vms", nil, &vms) 123 return vms, c.do(ctx, http.MethodGet, "/api/v1/vms", nil, &vms)
internal/server/api/events.go
Old New
@@ -123,7 +123,8 @@ func parseLimit(w http.ResponseWriter, r *http.Request) (int, bool) {
123 123
124 // auditRowsToResponse maps store rows to the wire shape, embedding each detail 124 // auditRowsToResponse maps store rows to the wire shape, embedding each detail
125 // as raw JSON and defensively re-marshalling anything that isn't valid JSON so 125 // as raw JSON and defensively re-marshalling anything that isn't valid JSON so
126 // the endpoint never emits a malformed body. 126 // the endpoint never emits a malformed body (detail is always a marshaled
127 // object — see store.AppendAudit callers).
127 func auditRowsToResponse(rows []store.AuditEntry) []types.AuditEvent { 128 func auditRowsToResponse(rows []store.AuditEntry) []types.AuditEvent {
128 out := make([]types.AuditEvent, len(rows)) 129 out := make([]types.AuditEvent, len(rows))
129 for i, e := range rows { 130 for i, e := range rows {
internal/server/api/routes.go
Old New
@@ -130,7 +130,8 @@ var routeTable = []Route{
130 Auth: AuthAdmin, 130 Auth: AuthAdmin,
131 Kind: KindJSON, 131 Kind: KindJSON,
132 Success: http.StatusAccepted, 132 Success: http.StatusAccepted,
133 Doc: "Decommission a host: tombstone its VMs and drain gracefully (202).", 133 Query: []QueryParam{{Name: "force", Doc: "purge VM rows and remove the host immediately (dead hardware escape hatch)"}},
134 Doc: "Decommission a host: tombstone its VMs and drain gracefully (202). With ?force, purge and remove immediately, returning 200.",
134 handler: (*API).handleDecommissionHost, 135 handler: (*API).handleDecommissionHost,
135 }, 136 },
136 { 137 {
internal/server/api/spec/spec_test.go
Old New
@@ -55,8 +55,8 @@ func TestEveryRouteHasAnOperation(t *testing.T) {
55 t.Errorf("%s %s: no operation in paths", r.Method, r.Path) 55 t.Errorf("%s %s: no operation in paths", r.Method, r.Path)
56 continue 56 continue
57 } 57 }
58 // {id} path params must be declared required string params. 58 // {id}/{tenant} path params must be declared required string params.
59 for _, name := range []string{"id"} { 59 for _, name := range []string{"id", "tenant"} {
60 if !strings.Contains(r.Path, "{"+name+"}") { 60 if !strings.Contains(r.Path, "{"+name+"}") {
61 continue 61 continue
62 } 62 }
@@ -129,6 +129,14 @@ func TestRequiredArrays(t *testing.T) {
129 t.Errorf("Host required not sorted: %q before %q", required[i-1], required[i]) 129 t.Errorf("Host required not sorted: %q before %q", required[i-1], required[i])
130 } 130 }
131 } 131 }
132 // Pointer fields are nullable, not required.
133 for _, name := range []string{"last_seen", "seconds_since_last_seen", "metrics"} {
134 for _, r := range required {
135 if r == name {
136 t.Errorf("pointer field %q must not be required", name)
137 }
138 }
139 }
132 } 140 }
133 141
134 func TestSecurity(t *testing.T) { 142 func TestSecurity(t *testing.T) {
@@ -166,6 +174,28 @@ func TestHostSchemaFields(t *testing.T) {
166 if ref := dig(t, props, "capacity", "$ref").(string); !strings.HasSuffix(ref, "Capacity") { 174 if ref := dig(t, props, "capacity", "$ref").(string); !strings.HasSuffix(ref, "Capacity") {
167 t.Errorf("capacity $ref = %q", ref) 175 t.Errorf("capacity $ref = %q", ref)
168 } 176 }
177
178 // Pointer-to-struct field → anyOf [$ref, {type: null}].
179 anyOf, ok := dig(t, props, "metrics", "anyOf").([]any)
180 if !ok || len(anyOf) != 2 {
181 t.Fatalf("metrics anyOf = %v", dig(t, props, "metrics"))
182 }
183 if ref, _ := anyOf[0].(map[string]any)["$ref"].(string); !strings.HasSuffix(ref, "Metrics") {
184 t.Errorf("metrics anyOf[0] = %v, want $ref ...Metrics", anyOf[0])
185 }
186 if typ, _ := anyOf[1].(map[string]any)["type"].(string); typ != "null" {
187 t.Errorf("metrics anyOf[1] = %v, want {type: null}", anyOf[1])
188 }
189
190 // Pointer-to-time field → type ["string","null"] with date-time format.
191 lastSeen := dig(t, props, "last_seen").(map[string]any)
192 typJSON := string(mustJSON(t, lastSeen["type"]))
193 if typJSON != `["string","null"]` {
194 t.Errorf("last_seen type = %s, want [\"string\",\"null\"]", typJSON)
195 }
196 if lastSeen["format"] != "date-time" {
197 t.Errorf("last_seen format = %v, want date-time", lastSeen["format"])
198 }
169 } 199 }
170 200
171 func TestSSEAndWSResponses(t *testing.T) { 201 func TestSSEAndWSResponses(t *testing.T) {
internal/server/api/testdata/audit-event.golden.json
Old New
@@ -1,8 +0,0 @@
1 {
2 "at": "2026-07-27T12:02:00Z",
3 "action": "host.enroll",
4 "detail": {
5 "host_id": "h-1234",
6 "remote": "203.0.113.7"
7 }
8 }
internal/server/api/testdata/audit.golden.json
Old New
@@ -0,0 +1,9 @@
1 [
2 {
3 "at": "2026-07-27T12:02:00Z",
4 "action": "vm.create",
5 "detail": {
6 "vm_id": "v-1"
7 }
8 }
9 ]
internal/server/api/testdata/enroll-token-response.golden.json
Old New
@@ -1,4 +1,4 @@
1 { 1 {
2 "join": "eitri-join-blob-base64url", 2 "join": "eyJqb2luIjoiYmxvYiJ9",
3 "token": "tok-secret-01" 3 "token": "tok-secret-01"
4 } 4 }
internal/server/api/testdata/host.golden.json
Old New
@@ -8,10 +8,10 @@
8 "status": "active", 8 "status": "active",
9 "enrolled_at": "2026-07-27T12:00:00Z", 9 "enrolled_at": "2026-07-27T12:00:00Z",
10 "online": true, 10 "online": true,
11 "last_seen": null, 11 "last_seen": "2026-07-27T12:00:30Z",
12 "seconds_since_last_seen": null, 12 "seconds_since_last_seen": 42,
13 "stale": false, 13 "stale": true,
14 "sessions": 0, 14 "sessions": 3,
15 "capacity": { 15 "capacity": {
16 "vcpus": 16, 16 "vcpus": 16,
17 "mem_mb": 32768, 17 "mem_mb": 32768,
internal/server/api/testdata/snapshot.golden.json
Old New
@@ -10,10 +10,10 @@
10 "status": "active", 10 "status": "active",
11 "enrolled_at": "2026-07-27T12:00:00Z", 11 "enrolled_at": "2026-07-27T12:00:00Z",
12 "online": true, 12 "online": true,
13 "last_seen": null, 13 "last_seen": "2026-07-27T12:00:30Z",
14 "seconds_since_last_seen": null, 14 "seconds_since_last_seen": 42,
15 "stale": false, 15 "stale": true,
16 "sessions": 0, 16 "sessions": 3,
17 "capacity": { 17 "capacity": {
18 "vcpus": 16, 18 "vcpus": 16,
19 "mem_mb": 32768, 19 "mem_mb": 32768,
internal/server/api/testdata/stream-ticket-response.golden.json
Old New
@@ -1,3 +1,3 @@
1 { 1 {
2 "ticket": "ticket-opaque-01" 2 "ticket": "tkt-onetime-01"
3 } 3 }
internal/server/api/types/types.go
Old New
@@ -2,7 +2,12 @@
2 // response JSON shape the API speaks, and nothing else. It is a leaf — it 2 // response JSON shape the API speaks, and nothing else. It is a leaf — it
3 // imports only the standard library — so the contract can be consumed by the 3 // imports only the standard library — so the contract can be consumed by the
4 // spec generator, the shared client, and the handlers without dragging in 4 // spec generator, the shared client, and the handlers without dragging in
5 // server internals. 5 // server internals. Enforced by an arch fitness test.
6 //
7 // One deliberate mirror exists outside this package:
8 // internal/agent/enrollclient types the enroll exchange itself, because the
9 // agent plane must not import server packages. docs/openapi.json (generated
10 // from these types) is the cross-check that the mirror stays honest.
6 package types 11 package types
7 12
8 import ( 13 import (
@@ -120,6 +125,25 @@ type StateSnapshot struct {
120 LatestVersion string `json:"latest_version"` // "" until the manifest is known 125 LatestVersion string `json:"latest_version"` // "" until the manifest is known
121 } 126 }
122 127
128 // AuditEvent is the wire shape of one audit row, served by GET /api/v1/audit
129 // and GET /api/v1/vms/{id}/events; detail is embedded as raw JSON (it is
130 // always a marshaled object).
131 type AuditEvent struct {
132 At time.Time `json:"at"`
133 Action string `json:"action"`
134 Detail json.RawMessage `json:"detail"`
135 }
136
137 // RevokedCert is the wire form of one revoked SSH cert, served by
138 // GET /api/v1/ssh-certs/revoked. Serial is a STRING, not a JSON number: a
139 // uint64 serial routinely exceeds 2^53 and would lose precision in a
140 // JavaScript client that parsed it as a double.
141 type RevokedCert struct {
142 Serial string `json:"serial"`
143 RevokedAt time.Time `json:"revoked_at"`
144 Reason string `json:"reason"`
145 }
146
123 // EnrollRequest is the POST /api/v1/enroll body: an agent redeeming an 147 // EnrollRequest is the POST /api/v1/enroll body: an agent redeeming an
124 // enrollment token to join the fleet. 148 // enrollment token to join the fleet.
125 type EnrollRequest struct { 149 type EnrollRequest struct {
@@ -153,6 +177,26 @@ type PatchVMRequest struct {
153 PowerState string `json:"power_state"` 177 PowerState string `json:"power_state"`
154 } 178 }
155 179
180 // UserCARequest is the body of both POST and DELETE
181 // /api/v1/tenants/{tenant}/user-cas: a BYO user-CA public key (authorized_keys
182 // line) to register or remove, with an optional label on upload.
183 type UserCARequest struct {
184 PublicKey string `json:"public_key"`
185 Label string `json:"label"`
186 }
187
188 // RevokeSSHCertRequest is the POST /api/v1/ssh-certs/revoke body. It accepts
189 // EITHER a raw serial OR a full cert authorized-key line (from which the serial
190 // is extracted) — the by-line form is the ergonomic one (paste the cert you
191 // minted), the by-serial form is for programmatic callers. Serial is a pointer
192 // so an absent field is distinguishable from an explicit 0. Reason is optional
193 // audit metadata.
194 type RevokeSSHCertRequest struct {
195 Serial *uint64 `json:"serial"`
196 Certificate string `json:"certificate"`
197 Reason string `json:"reason"`
198 }
199
156 // The response shapes below replace handlers' inline map[string]string 200 // The response shapes below replace handlers' inline map[string]string
157 // literals. Their fields are ordered ALPHABETICALLY BY JSON KEY on purpose: 201 // literals. Their fields are ordered ALPHABETICALLY BY JSON KEY on purpose:
158 // encoding/json marshals map keys sorted, so keeping struct fields in that 202 // encoding/json marshals map keys sorted, so keeping struct fields in that
@@ -200,42 +244,3 @@ type UserCA struct {
200 Label string `json:"label"` 244 Label string `json:"label"`
201 PubKey string `json:"pubkey"` 245 PubKey string `json:"pubkey"`
202 } 246 }
203
204 // AuditEvent is the wire shape of one audit row, served by GET /api/v1/audit
205 // and GET /api/v1/vms/{id}/events; detail is embedded as raw JSON (it is
206 // always a marshaled object).
207 type AuditEvent struct {
208 At time.Time `json:"at"`
209 Action string `json:"action"`
210 Detail json.RawMessage `json:"detail"`
211 }
212
213 // RevokedCert is the wire form of one revoked SSH cert, served by
214 // GET /api/v1/ssh-certs/revoked. Serial is a STRING, not a JSON number: a
215 // uint64 serial routinely exceeds 2^53 and would lose precision in a
216 // JavaScript client that parsed it as a double.
217 type RevokedCert struct {
218 Serial string `json:"serial"`
219 RevokedAt time.Time `json:"revoked_at"`
220 Reason string `json:"reason"`
221 }
222
223 // UserCARequest is the body of both POST and DELETE
224 // /api/v1/tenants/{tenant}/user-cas: a BYO user-CA public key (authorized_keys
225 // line) to register or remove, with an optional label on upload.
226 type UserCARequest struct {
227 PublicKey string `json:"public_key"`
228 Label string `json:"label"`
229 }
230
231 // RevokeSSHCertRequest is the POST /api/v1/ssh-certs/revoke body. It accepts
232 // EITHER a raw serial OR a full cert authorized-key line (from which the serial
233 // is extracted) — the by-line form is the ergonomic one (paste the cert you
234 // minted), the by-serial form is for programmatic callers. Serial is a pointer
235 // so an absent field is distinguishable from an explicit 0. Reason is optional
236 // audit metadata.
237 type RevokeSSHCertRequest struct {
238 Serial *uint64 `json:"serial"`
239 Certificate string `json:"certificate"`
240 Reason string `json:"reason"`
241 }
internal/server/api/wire_golden_test.go
Old New
@@ -48,6 +48,8 @@ func goldenCheck(t *testing.T, name string, v any) {
48 // dropped field, renamed tag, or swapped tag shows up as a byte diff. 48 // dropped field, renamed tag, or swapped tag shows up as a byte diff.
49 func TestWireGolden(t *testing.T) { 49 func TestWireGolden(t *testing.T) {
50 base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) 50 base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
51 lastSeen := base.Add(30 * time.Second)
52 sinceLastSeen := int64(42)
51 53
52 host := types.Host{ 54 host := types.Host{
53 ID: "h-1234", 55 ID: "h-1234",
@@ -59,6 +61,10 @@ func TestWireGolden(t *testing.T) {
59 Status: "active", 61 Status: "active",
60 EnrolledAt: base, 62 EnrolledAt: base,
61 Online: true, 63 Online: true,
64 LastSeen: &lastSeen,
65 SecondsSinceLastSeen: &sinceLastSeen,
66 Stale: true,
67 Sessions: 3,
62 Capacity: types.Capacity{VCPUs: 16, MemMB: 32768, DiskGB: 512}, 68 Capacity: types.Capacity{VCPUs: 16, MemMB: 32768, DiskGB: 512},
63 Allocated: types.Capacity{VCPUs: 4, MemMB: 8192, DiskGB: 100}, 69 Allocated: types.Capacity{VCPUs: 4, MemMB: 8192, DiskGB: 100},
64 AgentVersion: "v0.0.1-agent", 70 AgentVersion: "v0.0.1-agent",
@@ -111,6 +117,18 @@ func TestWireGolden(t *testing.T) {
111 LatestVersion: "v0.0.2-test", 117 LatestVersion: "v0.0.2-test",
112 }) 118 })
113 119
120 goldenCheck(t, "audit", []types.AuditEvent{{
121 At: base.Add(2 * time.Minute),
122 Action: "vm.create",
123 Detail: json.RawMessage(`{"vm_id":"v-1"}`),
124 }})
125
126 goldenCheck(t, "revoked-cert", []types.RevokedCert{{
127 Serial: "18446744073709551615",
128 RevokedAt: base.Add(3 * time.Minute),
129 Reason: "key compromised",
130 }})
131
114 goldenCheck(t, "enroll-request", types.EnrollRequest{ 132 goldenCheck(t, "enroll-request", types.EnrollRequest{
115 Token: "tok-secret-01", 133 Token: "tok-secret-01",
116 Name: "host-nine", 134 Name: "host-nine",
@@ -160,7 +178,7 @@ func TestWireGolden(t *testing.T) {
160 }) 178 })
161 179
162 goldenCheck(t, "enroll-token-response", types.EnrollTokenResponse{ 180 goldenCheck(t, "enroll-token-response", types.EnrollTokenResponse{
163 Join: "eitri-join-blob-base64url", 181 Join: "eyJqb2luIjoiYmxvYiJ9",
164 Token: "tok-secret-01", 182 Token: "tok-secret-01",
165 }) 183 })
166 184
@@ -174,7 +192,7 @@ func TestWireGolden(t *testing.T) {
174 }) 192 })
175 193
176 goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{ 194 goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{
177 Ticket: "ticket-opaque-01", 195 Ticket: "tkt-onetime-01",
178 }) 196 })
179 197
180 goldenCheck(t, "user-ca-upload-response", types.UserCAUploadResponse{ 198 goldenCheck(t, "user-ca-upload-response", types.UserCAUploadResponse{
@@ -186,16 +204,4 @@ func TestWireGolden(t *testing.T) {
186 Label: "team-alpha-ca", 204 Label: "team-alpha-ca",
187 PubKey: "ssh-ed25519 AAAAC3Nza ca-comment", 205 PubKey: "ssh-ed25519 AAAAC3Nza ca-comment",
188 }}) 206 }})
189
190 goldenCheck(t, "audit-event", types.AuditEvent{
191 At: base.Add(2 * time.Minute),
192 Action: "host.enroll",
193 Detail: json.RawMessage(`{"host_id":"h-1234","remote":"203.0.113.7"}`),
194 })
195
196 goldenCheck(t, "revoked-cert", []types.RevokedCert{{
197 Serial: "18446744073709551615",
198 RevokedAt: base.Add(3 * time.Minute),
199 Reason: "key compromised",
200 }})
201 } 207 }
internal/shape/build.go
Old New
@@ -24,7 +24,7 @@ func Build(raw []rawPackage) Model {
24 sort.Strings(imps) 24 sort.Strings(imps)
25 pkgs = append(pkgs, Package{ 25 pkgs = append(pkgs, Package{
26 ImportPath: rel, 26 ImportPath: rel,
27 Plane: classify(rel), 27 Plane: Classify(rel),
28 Synopsis: synopsis(r.Doc), 28 Synopsis: synopsis(r.Doc),
29 Imports: imps, 29 Imports: imps,
30 }) 30 })
internal/shape/classify.go
Old New
@@ -52,3 +52,8 @@ func classify(rel string) Plane {
52 return PlaneUnclassified 52 return PlaneUnclassified
53 } 53 }
54 } 54 }
55
56 // Classify reports the plane for a module-relative import path; exported so
57 // the architecture fitness tests can drive plane-wide rules from the same
58 // source of truth as the diagram.
59 func Classify(rel string) Plane { return classify(rel) }
scripts/coverage.sh
Old New
@@ -25,8 +25,8 @@ declare -A FLOOR=(
25 [internal/agent/cloudhv]=40 25 [internal/agent/cloudhv]=40
26 [internal/agent/syncclient]=74 26 [internal/agent/syncclient]=74
27 [internal/server/api]=73 27 [internal/server/api]=73
28 [internal/server/api/client]=73 28 [internal/server/api/client]=89
29 [internal/server/api/spec]=81 29 [internal/server/api/spec]=91
30 [internal/server/store]=73 30 [internal/server/store]=73
31 [internal/server/registry]=95 31 [internal/server/registry]=95
32 [internal/server/release]=90 32 [internal/server/release]=90
@@ -34,7 +34,7 @@ declare -A FLOOR=(
34 [internal/agent/bootstrap]=70 34 [internal/agent/bootstrap]=70
35 [internal/server/hosttoken]=95 35 [internal/server/hosttoken]=95
36 [internal/server/hub]=90 36 [internal/server/hub]=90
37 [internal/server/syncsvc]=72 37 [internal/server/syncsvc]=75
38 [internal/server/web]=90 38 [internal/server/web]=90
39 [internal/transport]=77 39 [internal/transport]=77
40 [internal/shape]=88 40 [internal/shape]=88
web/src/lib/api-types.ts
Old New
@@ -249,10 +249,13 @@ export interface paths {
249 get?: never; 249 get?: never;
250 put?: never; 250 put?: never;
251 post?: never; 251 post?: never;
252 /** Decommission a host: tombstone its VMs and drain gracefully (202). */ 252 /** Decommission a host: tombstone its VMs and drain gracefully (202). With ?force, purge and remove immediately, returning 200. */
253 delete: { 253 delete: {
254 parameters: { 254 parameters: {
255 query?: never; 255 query?: {
256 /** @description purge VM rows and remove the host immediately (dead hardware escape hatch) */
257 force?: string;
258 };
256 header?: never; 259 header?: never;
257 path: { 260 path: {
258 id: string; 261 id: string;