diff --git a/.derived/codebase-index/by-spec/006-fleet.json b/.derived/codebase-index/by-spec/006-fleet.json index d711924..cbc7a86 100644 --- a/.derived/codebase-index/by-spec/006-fleet.json +++ b/.derived/codebase-index/by-spec/006-fleet.json @@ -28,5 +28,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "e25b8557650cf22cab7912070689d2036eeb60e1362b8ad3c73f26ac956ad639" + "shardHash": "f047fde050b7ef249853c6c497c7a1bbabe267ca679b36528ecf4e47e1301020" } diff --git a/.derived/codebase-index/by-spec/007-governance-webapp.json b/.derived/codebase-index/by-spec/007-governance-webapp.json index 6816542..5cb463b 100644 --- a/.derived/codebase-index/by-spec/007-governance-webapp.json +++ b/.derived/codebase-index/by-spec/007-governance-webapp.json @@ -32,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "d9aa5ed843da57648fc0ac0c61fe43893ca3b6dc4e4a803d2bf202c5e4f2bcc3" + "shardHash": "bc5f55d0e0343788f055162535d7e8ff44e15cc42abe0455d5469998183eb568" } diff --git a/.derived/spec-registry/by-spec/006-fleet.json b/.derived/spec-registry/by-spec/006-fleet.json index f617186..32619aa 100644 --- a/.derived/spec-registry/by-spec/006-fleet.json +++ b/.derived/spec-registry/by-spec/006-fleet.json @@ -24,13 +24,14 @@ "2026-07-16 (later): live E2E passed on deployd.xyz; 006 complete", "5. Out of scope", "2026-07-20: the addon transferred out; this spec narrowed, not retired", - "Amendment (2026-07-21): spec 011 tenant lifecycle" + "Amendment (2026-07-21): spec 011 tenant lifecycle", + "Amendment (2026-07-22): remove-gate confirmation attributes" ], "specPath": "specs/006-fleet/spec.md", "status": "approved", "summary": "Milestone M3: operate stamped apps. The unit of placement is \"one EnRaHiTu container + one volume + one ingress\" on the existing hetzner-k3s cluster. deployd-api-rs (the OAP-era Rust K8s orchestrator, axum + hiqlite) donates its orchestration core as a napi-rs addon (the hiqlite-native pattern): the axum HTTP layer disappears, the K8s knowledge stays. A fleet/ Encore service exposes deploy / status / update / backup over the addon. Done-when is a fleet of ten stamped apps on one box with update and backup exercised.\n", "title": "Fleet: deployd's core as an in-process addon, placing EnRaHiTu apps" }, - "shardHash": "f7071b3fa01280fd54be10cea714bced00ab912332902a72f2c30f24b670ab7e", + "shardHash": "2519cf1793b565a51af9968b30333967442bc1e027055e6a5fa352659724fad7", "specVersion": "1.1.0" } diff --git a/.derived/spec-registry/by-spec/007-governance-webapp.json b/.derived/spec-registry/by-spec/007-governance-webapp.json index 99d093e..a841def 100644 --- a/.derived/spec-registry/by-spec/007-governance-webapp.json +++ b/.derived/spec-registry/by-spec/007-governance-webapp.json @@ -22,13 +22,14 @@ "Update 2026-07-15 (live verification -> `implementation: complete`)", "Update 2026-07-16 (chassis realignment: `webapp/` -> `frontend/`, back to `in-progress`)", "Update 2026-07-18 (`webapp/` -> `frontend/` landed; `implementation: complete`)", - "Amendment (2026-07-21): spec 011 tenant lifecycle" + "Amendment (2026-07-21): spec 011 tenant lifecycle", + "Amendment (2026-07-22): DELETE transport encoding + the self-serve install entry" ], "specPath": "specs/007-governance-webapp/spec.md", "status": "approved", "summary": "Replaces the chassis's placeholder Vue SPA with the control plane's real face: a Vite + React Router v7 single-page app served by the chassis web service from web/dist, same-origin with the API and the embedded rauthy IdP. v1 surface: login, tenant list/create, GitHub App install flow, stamp launcher + job progress, fleet table with operation buttons. The platform UI is deliberately NOT a template flavor: it owes nothing to template stack choices (thesis §3).\n", "title": "Governance UI: Vite + React Router v7 webapp" }, - "shardHash": "1ed72c25eb77d53d699de6f741eb4ce7045cdbe7fb13a4878547c0a8fdc6278d", + "shardHash": "abc1e95959f9e4fb4348729b7e4fcc52e045453091b17e8d0d167068fdbd497a", "specVersion": "1.1.0" } diff --git a/backend/fleet/api.ts b/backend/fleet/api.ts index 75bad51..2e19348 100644 --- a/backend/fleet/api.ts +++ b/backend/fleet/api.ts @@ -296,7 +296,13 @@ export const remove = api( throw APIError.invalidArgument(`confirm must equal the app name "${app.name}"`); } - const gated = await gateOrDeny("remove", { tenantId: app.tenantId, app: app.name, appId }, "strict"); + // The gate's confirm-name-required check (governance-native gate.v1) demands + // the typed confirmation echoed as confirm_name against subject_name. + const gated = await gateOrDeny( + "remove", + { tenantId: app.tenantId, app: app.name, appId, subject_name: app.name, confirm_name: confirm }, + "strict", + ); const op = await startOp(app.id, "remove"); try { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c620773..a81b94b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -74,16 +74,29 @@ export async function apiSend( path: string, body?: unknown, ): Promise { + // Encore decodes DELETE payloads from the query string, not the body; a JSON + // body on DELETE fails with "unable to decode query string". + let url = path; + let payload = body; + if (method === "DELETE" && body !== undefined) { + const qs = new URLSearchParams( + Object.entries(body as Record) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => [k, String(v)]), + ).toString(); + if (qs) url += (url.includes("?") ? "&" : "?") + qs; + payload = undefined; + } const send = async (): Promise => { const token = await csrfToken(); - return fetch(path, { + return fetch(url, { method, credentials: "same-origin", headers: { "X-CSRF-Token": token, - ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(payload !== undefined ? { "Content-Type": "application/json" } : {}), }, - body: body !== undefined ? JSON.stringify(body) : undefined, + body: payload !== undefined ? JSON.stringify(payload) : undefined, }); }; let res = await send(); diff --git a/frontend/src/routes/dashboard.tsx b/frontend/src/routes/dashboard.tsx index e21aed6..2ebf5b9 100644 --- a/frontend/src/routes/dashboard.tsx +++ b/frontend/src/routes/dashboard.tsx @@ -4,12 +4,18 @@ import { tenants, type TenantView } from "../lib/api"; import { formatDate } from "../lib/ui"; export async function dashboardLoader() { - const { tenants: list } = await tenants.list(); - return { tenants: list }; + const [{ tenants: list }, { url: installUrl }] = await Promise.all([ + tenants.list(), + tenants.installUrlForUser(), + ]); + return { tenants: list, installUrl }; } export function Dashboard() { - const { tenants: list } = useLoaderData() as { tenants: TenantView[] }; + const { tenants: list, installUrl } = useLoaderData() as { + tenants: TenantView[]; + installUrl: string; + }; // GitHub's App-install flow redirects back to "/?github=installed&tenant=" // (or github=error); surface the outcome rather than swallowing it. const [params] = useSearchParams(); @@ -32,9 +38,14 @@ export function Dashboard() {

Tenants

- - New tenant - +
+ + Install into a new org + + + New tenant + +
{list.length === 0 ? ( @@ -42,10 +53,14 @@ export function Dashboard() {

No tenants yet.

A tenant is a customer GitHub org you install the statecraft App into. Stamped repos - are born in that org. + are born in that org. Installing the App creates the tenant for you (spec 011 §5.6); + GitHub asks which org during the install.

- - Create your first tenant + + Install the GitHub App + + + Create an empty tenant instead ) : ( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d0e5265..8570a9d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -103,6 +103,12 @@ a { margin: 0; } +.page-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + .breadcrumb { font-size: 0.85rem; margin-bottom: 0.5rem; diff --git a/specs/006-fleet/spec.md b/specs/006-fleet/spec.md index bc0c939..5b2f70d 100644 --- a/specs/006-fleet/spec.md +++ b/specs/006-fleet/spec.md @@ -368,3 +368,14 @@ installation (teardown stays ungated, spec 011 §5.7), and adds an internal `tenantAppSummary` endpoint the tenants service calls to enforce the delete-tenant precondition without a module cycle. See specs/011-tenant-lifecycle/spec.md §5.5, §5.7, §5.8. + +## Amendment (2026-07-22): remove-gate confirmation attributes + +Spec 011's live acceptance walk surfaced that `remove`'s `gateOrDeny` call +never forwarded the typed confirmation into the gate's ActionContext, so +the governance-native `confirm-name-required` check (gate.v1, active on +action `remove`) denied every fleet remove in production. `api.ts` now +echoes `subject_name` (the app name) and `confirm_name` (the caller's +typed confirm) in the remove gate attributes. The endpoint-level +name-confirm guard is unchanged; the gate now sees the same evidence it +demands. See specs/011-tenant-lifecycle/spec.md §9. diff --git a/specs/007-governance-webapp/spec.md b/specs/007-governance-webapp/spec.md index 2c400e6..5b21b8b 100644 --- a/specs/007-governance-webapp/spec.md +++ b/specs/007-governance-webapp/spec.md @@ -240,3 +240,16 @@ disables Stamp when the tenant has no active installation; `routes/fleet.tsx` disables the deploy form under the same condition; `routes.tsx` wires the operator route and the tenant-detail action. See specs/011-tenant-lifecycle/spec.md §5.7, §5.8. + +## Amendment (2026-07-22): DELETE transport encoding + the self-serve install entry + +Two fixes from spec 011's live acceptance walk, in this spec's `frontend/` +territory. `lib/api.ts`: Encore decodes DELETE payloads from the query +string, so `apiSend` now encodes DELETE bodies as query parameters (tenant +delete, fleet remove, and operator membership revoke were all unusable +from the SPA before this). `routes/dashboard.tsx` plus a `page-actions` +rule in `styles.css`: the tenant-less install URL (spec 011 §5.6) gains +its UI entry, with the empty state leading on "Install the GitHub App" +and the page header offering "Install into a new org"; GitHub's own +install picker does the org selection, so no in-app org chooser exists. +See specs/011-tenant-lifecycle/spec.md §9.