Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .derived/codebase-index/by-spec/006-fleet.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@
"specStatus": "approved"
},
"schemaVersion": "1.1.0",
"shardHash": "e25b8557650cf22cab7912070689d2036eeb60e1362b8ad3c73f26ac956ad639"
"shardHash": "f047fde050b7ef249853c6c497c7a1bbabe267ca679b36528ecf4e47e1301020"
}
2 changes: 1 addition & 1 deletion .derived/codebase-index/by-spec/007-governance-webapp.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@
"specStatus": "approved"
},
"schemaVersion": "1.1.0",
"shardHash": "d9aa5ed843da57648fc0ac0c61fe43893ca3b6dc4e4a803d2bf202c5e4f2bcc3"
"shardHash": "bc5f55d0e0343788f055162535d7e8ff44e15cc42abe0455d5469998183eb568"
}
5 changes: 3 additions & 2 deletions .derived/spec-registry/by-spec/006-fleet.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 3 additions & 2 deletions .derived/spec-registry/by-spec/007-governance-webapp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
8 changes: 7 additions & 1 deletion backend/fleet/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 16 additions & 3 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,29 @@ export async function apiSend<T>(
path: string,
body?: unknown,
): Promise<T> {
// 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<string, unknown>)
.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<Response> => {
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();
Expand Down
33 changes: 24 additions & 9 deletions frontend/src/routes/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>"
// (or github=error); surface the outcome rather than swallowing it.
const [params] = useSearchParams();
Expand All @@ -32,20 +38,29 @@ export function Dashboard() {

<div className="page-head">
<h1>Tenants</h1>
<Link className="btn btn-primary" to="/tenants/new">
New tenant
</Link>
<div className="page-actions">
<a className="btn" href={installUrl}>
Install into a new org
</a>
<Link className="btn btn-primary" to="/tenants/new">
New tenant
</Link>
</div>
</div>

{list.length === 0 ? (
<div className="card empty">
<p>No tenants yet.</p>
<p className="muted">
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.
</p>
<Link className="btn btn-primary" to="/tenants/new">
Create your first tenant
<a className="btn btn-primary" href={installUrl}>
Install the GitHub App
</a>
<Link className="btn" to="/tenants/new">
Create an empty tenant instead
</Link>
</div>
) : (
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions specs/006-fleet/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions specs/007-governance-webapp/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading