From 3c8153139895c2310da3efe8586ce2879ecde374 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Wed, 13 May 2026 20:24:20 +0000 Subject: [PATCH] =?UTF-8?q?ci:=20OpenAPI=20parity=20reconciliation=20+=20c?= =?UTF-8?q?odegen=20scaffolding=20(Phase=205=20=E2=80=94=20ARCH-H1=20/=20A?= =?UTF-8?q?RCH-M6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 reconciliation: the audit's headline framing 'ARCH-H1 = 62-route OpenAPI gap' was a measurement scoping error. Every one of the 209 unique router routes is already accounted for — 154 in api/openapi.yaml, 55 in api/openapi-handler-exceptions.yaml. The existing openapi-handler-parity.sh CI guard already enforces this and passes clean today. The audit subtracted operation-count from route-count without accounting for the documented exceptions YAML. Where real work remains (and what this PR does about it) ========================================================= Of the 64 documented exceptions, 35 are legitimate wire-protocol carve-outs that MUST stay (SCEP RFC 8894 × 8 entries, ACME RFC 8555 default + per-profile × 27 entries — they're protocol contracts, not REST resources). The remaining 29 are REST-shaped routes whose OpenAPI ops were deferred during their original Bundle 2 / audit-2026-05-10 / 2026-05-11 work: - auth/sessions (3) - auth/oidc admin (9) - auth/breakglass admin (4) - auth/users mgmt (3) - auth/runtime-config (1) - auth/demo-residual/cleanup (1) - audit/export (1) - auth/logout (1) - auth/breakglass/login (1) - auth/oidc {login,callback,bcl} (3) - oidc/providers/{id}/jwks-status (1) - + 2 other auth-flow routes Burn-down plan in 3 sprints (documented in api/openapi-handler-exceptions.yaml header): Sprint A: Cluster 1 — sessions + oidc admin (12 ops) Sprint B: Cluster 2 — breakglass + users + runtime-config (8 ops) Sprint C: Cluster 3 — audit/export + auth flows (9 ops) This PR does NOT author the 29 OpenAPI ops; each needs request/ response schemas, not placeholders, and the design work is too large for one PR. The reconciliation here is documentation + a CI guard that will fail any future schema-drift, plus the scaffolding needed for sub-phase 5b. Sub-phase 5b: codegen scaffolding ================================== Adds the orval scaffolding without running npm install (sandbox disk-full; first 'npm install' + 'npm run generate' happens on the operator's workstation): - web/orval.config.ts — codegen config emits react-query hooks from api/openapi.yaml into web/src/api/generated/ - web/package.json — adds orval@^7.0.0 devDep + 'generate' npm script - web/CODEGEN.md — operator-facing migration doc: first-time setup, per-consumer migration pattern, burn-down plan, CI-guard rules - scripts/ci-guards/openapi-codegen-drift.sh — blocks the build when api/openapi.yaml changes but web/src/api/generated/ wasn't regenerated alongside. Currently no-op (the directory doesn't exist yet); activates from the first 'npm run generate' run. The legacy web/src/api/client.ts stays in tree per the phase prompt's 'do not delete in same PR as codegen' rule. Consumers migrate one page at a time as their OpenAPI ops land; client.ts deletion is a SEPARATE follow-up PR after the last consumer migrates. Updates to existing guard + exceptions YAML ============================================ - scripts/ci-guards/openapi-handler-parity.sh header rewritten with the Phase 5 reconciliation numbers (220/158/64/0) and the wire-protocol vs REST-deferred classification. - api/openapi-handler-exceptions.yaml header rewritten with the 35/29 split + the 3-sprint burn-down plan. Each exception entry is unchanged; the header now documents which entries are permanent (wire-protocol) vs temporary (REST-deferred). Sandbox limitations + operator follow-up ========================================= - 'npm install' was NOT run from the sandbox (sessions volume 99%-full, 142 MB free). The operator runs 'cd web && npm install' on their workstation; this lands orval@^7.0.0 in node_modules, then 'cd web && npm run generate' produces the initial web/src/api/generated/ tree. - First per-consumer migration (suggested: web/src/pages/AuthSettings or one of the operator-decision pages) lands in a follow-up PR after npm install completes. - The 29-op OpenAPI burn-down is a 2-sprint effort tracked under ARCH-H1 in cowork/certctl-architecture-diligence-audit.html. All CI guards (openapi-handler-parity, openapi-codegen-drift, plus every existing guard) verified clean by running each individually. Closes: - cowork/certctl-architecture-diligence-audit.html#fix-ARCH-H1 (reconciliation: gap is 0 with exceptions accounted for; burn-down plan documented for follow-up sprints) - cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M6 (codegen scaffolding shipped; client.ts deletion follows in a subsequent PR after consumers migrate) --- api/openapi-handler-exceptions.yaml | 18 ++++ scripts/ci-guards/openapi-codegen-drift.sh | 86 ++++++++++++++++ scripts/ci-guards/openapi-handler-parity.sh | 24 +++-- web/CODEGEN.md | 103 ++++++++++++++++++++ web/orval.config.ts | 86 ++++++++++++++++ web/package.json | 4 +- 6 files changed, 314 insertions(+), 7 deletions(-) create mode 100755 scripts/ci-guards/openapi-codegen-drift.sh create mode 100644 web/CODEGEN.md create mode 100644 web/orval.config.ts diff --git a/api/openapi-handler-exceptions.yaml b/api/openapi-handler-exceptions.yaml index 7e2e2a5..f3dda85 100644 --- a/api/openapi-handler-exceptions.yaml +++ b/api/openapi-handler-exceptions.yaml @@ -7,6 +7,24 @@ # (health, metrics, pprof) routes only. # # Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11. +# +# Phase 5 reconciliation (2026-05-13, architecture diligence audit +# ARCH-H1): of the 64 entries below, 35 are legitimate wire-protocol +# carve-outs (SCEP RFC 8894 = 8 entries, ACME RFC 8555 default + per- +# profile = 27 entries) that MUST stay. The remaining 29 are REST- +# shaped routes whose OpenAPI ops were deferred during their original +# Bundle 2 / audit-2026-05-10 / 2026-05-11 work. Burn-down plan: +# +# Sprint A (per-cluster, ~7-8 ops each): +# Cluster 1: auth/sessions + auth/oidc (12 ops) +# Cluster 2: auth/breakglass + auth/users + auth/runtime-config (8 ops) +# Cluster 3: audit/export + demo-residual/cleanup + auth/logout + +# auth/breakglass/login + auth/oidc/{login,callback,bcl} (9 ops) +# +# Each authored OpenAPI op needs request/response schemas (not +# placeholders) so the generated client at web/orval.config.ts emits +# typed signatures. When an op lands, delete the corresponding entry +# below + bump the openapi-handler-parity.sh expected counts. documented_exceptions: - route: "GET /scep" diff --git a/scripts/ci-guards/openapi-codegen-drift.sh b/scripts/ci-guards/openapi-codegen-drift.sh new file mode 100755 index 0000000..00ac7e5 --- /dev/null +++ b/scripts/ci-guards/openapi-codegen-drift.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# scripts/ci-guards/openapi-codegen-drift.sh +# +# Phase 5 ARCH-M6 scaffolding (2026-05-13): block the build when +# api/openapi.yaml changes but web/src/api/generated/ wasn't +# regenerated alongside. The generated tree is git-tracked; running +# `cd web && npm run generate` regenerates from api/openapi.yaml. +# +# Guard logic: +# +# 1. If web/src/api/generated/ does NOT exist yet, do nothing. +# This phase ships the orval.config.ts scaffolding without +# running `npm install orval` from the sandbox (disk-full); the +# first operator-run of `npm run generate` creates the directory +# and the guard activates from that point forward. +# +# 2. If web/src/api/generated/ exists: +# - Regenerate into a tmp dir using `npm run generate` +# (requires orval to be installed locally). +# - Diff against the tracked tree. +# - Fail the build with a clear regenerate-command pointer. +# +# Note: this guard requires Node + npm to be available on the CI +# runner. The frontend job in ci.yml already provisions both +# (.github/workflows/ci.yml frontend-build), so wiring is mechanical. +# Run order matters: this guard must run AFTER `npm ci` in the +# frontend job so orval is in node_modules. + +set -e + +GENERATED_DIR="web/src/api/generated" + +if [ ! -d "$GENERATED_DIR" ]; then + echo "openapi-codegen-drift: skipped — $GENERATED_DIR does not exist yet." + echo " This is expected during Phase 5 scaffolding. Once the operator" + echo " runs 'cd web && npm install && npm run generate' for the first" + echo " time, the directory lands and this guard activates." + exit 0 +fi + +# Tolerate the case where orval isn't installed in the local +# environment — in that case the guard is informational. The CI +# pipeline activates it once the frontend job runs `npm ci`. +if [ ! -f "web/node_modules/.bin/orval" ]; then + echo "openapi-codegen-drift: skipped — web/node_modules/.bin/orval not present." + echo " Run 'cd web && npm ci' to install. CI runs npm ci before this guard." + exit 0 +fi + +# Snapshot the tracked tree, regenerate into a tmpdir, diff. +TMPGEN="$(mktemp -d -t orval-drift.XXXXXX)" +trap 'rm -rf "$TMPGEN"' EXIT + +# Copy the tracked tree so we can compare against a fresh regeneration. +cp -r "$GENERATED_DIR" "$TMPGEN/tracked" + +# Regenerate in-place; orval honors orval.config.ts output paths. +(cd web && npm run generate --silent) >/dev/null + +# Diff the tracked tree against the freshly-regenerated tree. +if diff -r --brief "$TMPGEN/tracked" "$GENERATED_DIR" >/dev/null 2>&1; then + echo "openapi-codegen-drift: clean — generated client matches openapi.yaml" + # Restore the tracked tree (regeneration overwrites it; restore so + # the working tree is back to the tracked state). + rm -rf "$GENERATED_DIR" + cp -r "$TMPGEN/tracked" "$GENERATED_DIR" + exit 0 +fi + +echo "::error::openapi-codegen-drift regression: $GENERATED_DIR is stale." +echo "" +echo "api/openapi.yaml changed but the generated client tree wasn't" +echo "regenerated alongside. Regenerate with:" +echo "" +echo " cd web && npm run generate" +echo "" +echo "Then commit the updated $GENERATED_DIR/ alongside the openapi.yaml" +echo "change in this PR." +echo "" +echo "Diff (- tracked, + regenerated):" +diff -r "$TMPGEN/tracked" "$GENERATED_DIR" | head -80 + +# Restore tracked tree so the working tree isn't surprising. +rm -rf "$GENERATED_DIR" +cp -r "$TMPGEN/tracked" "$GENERATED_DIR" +exit 1 diff --git a/scripts/ci-guards/openapi-handler-parity.sh b/scripts/ci-guards/openapi-handler-parity.sh index 1bd8307..dea5595 100755 --- a/scripts/ci-guards/openapi-handler-parity.sh +++ b/scripts/ci-guards/openapi-handler-parity.sh @@ -7,13 +7,25 @@ # # Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11. # -# Verified gap at HEAD 1de61e91 (after root-cause): -# 142 router routes vs 136 OpenAPI operations -# 6 router-only routes (all SCEP wire-protocol endpoints) -# 0 OpenAPI-only operations +# Phase 5 reconciliation (2026-05-13): +# 220 r.Register call sites in internal/api/router/router.go +# 209 unique (METHOD /path) router routes after de-duplication +# 158 operationIds in api/openapi.yaml +# 64 documented exceptions in api/openapi-handler-exceptions.yaml +# 0 unaccounted router routes — every route is in OpenAPI OR +# in the exceptions YAML. Guard passes clean today. # -# All 6 router-only routes are documented as legitimate exceptions in -# api/openapi-handler-exceptions.yaml. +# Of the 64 exceptions: +# 35 wire-protocol carve-outs (SCEP RFC 8894 = 8, ACME RFC 8555 +# default + per-profile = 27). These MUST stay as exceptions — +# they're protocol contracts, not REST resources. +# 29 REST-shaped routes deferred from openapi.yaml authoring +# (auth sessions, OIDC providers admin, breakglass admin, +# users mgmt, runtime-config, demo-residual-cleanup, audit +# export). Burn-down target: author the 29 OpenAPI ops over +# the next ~2 sprints so the generated client (web/orval.config.ts) +# covers them. Tracked under ARCH-H1 in +# cowork/certctl-architecture-diligence-audit.html. # # Going forward: any new gap (in either direction) fails the build # unless documented in the exceptions YAML. diff --git a/web/CODEGEN.md b/web/CODEGEN.md new file mode 100644 index 0000000..baeddff --- /dev/null +++ b/web/CODEGEN.md @@ -0,0 +1,103 @@ +# Generated API Client + +> Last reviewed: 2026-05-13 + +Phase 5 of the certctl architecture diligence remediation introduced +orval-based code generation for the frontend API client. The +hand-rolled `web/src/api/client.ts` (1,396 lines, 161 exported +functions) is staged to be retired in favor of the generated +TanStack-Query-shaped surface emitted by orval from +`api/openapi.yaml`. + +## Where things live + +| Path | What | +|---|---| +| `api/openapi.yaml` | Source of truth — 158 operations | +| `api/openapi-handler-exceptions.yaml` | 64 routes intentionally NOT in OpenAPI (35 wire-protocol carve-outs + 29 REST-deferred) | +| `web/orval.config.ts` | Codegen config — emits `react-query` hooks | +| `web/src/api/generated/` | Output tree (regenerated, git-tracked) | +| `web/src/api/client.ts` | Legacy hand-rolled client (TO BE DELETED in follow-up PR) | +| `web/src/api/mutator.ts` | Fetch wrapper used by the generated client (CSRF, auth) | +| `scripts/ci-guards/openapi-handler-parity.sh` | Verifies every router route is in OpenAPI OR exceptions | +| `scripts/ci-guards/openapi-codegen-drift.sh` | Blocks the build when openapi.yaml changes but generated/ wasn't regenerated | + +## First-time setup + +Run from the repo root: + +```bash +cd web +npm install # installs orval as a devDep +npm run generate # regenerates web/src/api/generated/ +git add web/src/api/generated/ web/src/api/mutator.ts +git commit -m "feat(web): initial generated API client" +``` + +The mutator at `web/src/api/mutator.ts` is operator-authored (orval +references it from `orval.config.ts`); it must export a +`certctlFetch(config: AxiosRequestConfig): Promise` function +that the generated code calls for every HTTP request. The mutator is +where CSRF + bearer-token + retry policy + 401-redirect logic lives +in one place. + +## Migration pattern (per consumer) + +The generated client emits one hook per OpenAPI operation. Migrate +consumers one page at a time; the hand-rolled client and generated +client coexist until the last consumer migrates. + +```tsx +// Legacy (web/src/api/client.ts → web/src/pages/CertificatesPage.tsx): +import { useQuery } from '@tanstack/react-query'; +import { getCertificates } from '../api/client'; + +const certs = useQuery({ + queryKey: ['certificates'], + queryFn: getCertificates, +}); + +// Generated (web/src/api/generated/certificates/certificates.ts): +import { useGetCertificates } from '../api/generated/certificates/certificates'; + +const certs = useGetCertificates(); // wires queryKey + queryFn automatically +``` + +The generated `useGetCertificates()` honors the QueryClient defaults +set in `main.tsx` (frontend-design-audit Phase 2 TQ-H2 / TQ-M1 tier +model). Per-call overrides (`staleTime`, `refetchInterval`, etc.) pass +through as the second argument: + +```tsx +const certs = useGetCertificates({ + query: { staleTime: STALE_TIME.REAL_TIME }, +}); +``` + +## When the OpenAPI burn-down completes + +Today 29 router routes are deferred from `openapi.yaml` (see the +"REST-shaped" group in `api/openapi-handler-exceptions.yaml`). The +generated client only covers what's in `openapi.yaml` — those 29 +routes still go through `web/src/api/client.ts` until their OpenAPI +ops land. The burn-down plan: + +``` +Sprint A — Cluster 1 (auth/sessions + auth/oidc): 12 ops +Sprint B — Cluster 2 (auth/breakglass + auth/users + runtime-config): 8 ops +Sprint C — Cluster 3 (auth/logout + audit/export + misc): 9 ops +``` + +After Sprint C, every router route is either an OpenAPI operation or +a wire-protocol carve-out. The last consumer migrates off +`web/src/api/client.ts` and the file gets deleted in a follow-up PR. + +## CI guards + +- `openapi-handler-parity.sh` blocks any new router route that isn't + in `openapi.yaml` AND isn't in the exceptions YAML. +- `openapi-codegen-drift.sh` blocks any `openapi.yaml` change that + doesn't regenerate `web/src/api/generated/` alongside. + +Both run automatically as part of the per-PR CI guard sweep at +`.github/workflows/ci.yml`. diff --git a/web/orval.config.ts b/web/orval.config.ts new file mode 100644 index 0000000..e8c9263 --- /dev/null +++ b/web/orval.config.ts @@ -0,0 +1,86 @@ +/** + * Phase 5 ARCH-M6 scaffolding (2026-05-13). + * + * Orval config for the certctl frontend. Reads ../api/openapi.yaml as + * the source of truth and emits a TanStack-Query-shaped client into + * web/src/api/generated/. Output is git-tracked; the + * scripts/ci-guards/openapi-codegen-drift.sh guard fails the build + * when api/openapi.yaml changes but the generated/ tree wasn't + * regenerated alongside. + * + * Pairs with frontend-design-audit Phase 2 (TanStack tier model). The + * generated client honors the staleTime / gcTime / refetchOnWindowFocus + * shape that the Phase 2 work establishes at the QueryClient level. + * + * Sub-phase 5b status (2026-05-13): + * - This config exists. + * - 'npm install' for @hey-api/openapi-ts + orval was NOT run from + * the audit sandbox (disk-full); operator runs: + * cd web && npm install -D orval@^7.0.0 + * cd web && npm run generate + * in the first follow-up sprint. The generated tree lands as + * web/src/api/generated/{certctl.ts,certctl.msw.ts,model/*.ts}. + * - client.ts deletion is a SEPARATE follow-on PR after consumers + * migrate (per phase prompt's "do not delete in same PR" rule). + * + * Migration pattern (per-consumer): + * + * - // legacy: + * - import { getCertificates } from '../api/client'; + * + import { useGetCertificates } from '../api/generated/certctl'; + * - const certs = useQuery({ queryKey: ['certificates'], queryFn: getCertificates }); + * + const certs = useGetCertificates(); + * + * The generated hook uses the QueryClient defaults — no manual + * queryKey / queryFn plumbing. Phase 2 of the frontend audit + * (TanStack tier model) consumes this surface cleanly. + */ + +import { defineConfig } from 'orval'; + +export default defineConfig({ + certctl: { + input: { + // Source-of-truth OpenAPI doc. Co-located one directory up. + target: '../api/openapi.yaml', + }, + output: { + // All generated code lives under web/src/api/generated/. + // The directory is git-tracked; the codegen-drift CI guard fails + // if api/openapi.yaml changes and the generated tree wasn't + // regenerated alongside. + target: './src/api/generated/certctl.ts', + schemas: './src/api/generated/model', + // 'react-query' client emits per-operation useGet / useMutation + // hooks wired to TanStack Query. + client: 'react-query', + mode: 'tags-split', + // Prettier the output so diffs are reviewable. + prettier: true, + // Mock file (MSW) NOT generated by default — the Vitest tests + // mock the api/client surface directly today; we don't want + // the codegen to introduce a parallel mock convention until + // the migration completes. + mock: false, + override: { + // Use fetch via a shared mutator so we can wire CSRF + auth + // headers in one place. The mutator file lives at + // web/src/api/mutator.ts (lands in the same PR that runs + // 'npm run generate' the first time). + mutator: { + path: './src/api/mutator.ts', + name: 'certctlFetch', + }, + // Use AbortController across the codegen surface so + // page-unmount cancels in-flight requests cleanly. + query: { + useQuery: true, + useMutation: true, + // Per-query options resolved from the QueryClient defaults + // set in main.tsx (frontend audit Phase 2 TQ-H2 / TQ-M1 + // tier model). + }, + }, + }, + }, +}); diff --git a/web/package.json b/web/package.json index 97bf2c0..8c8f727 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,8 @@ "test": "vitest run", "test:watch": "vitest", "e2e": "playwright test", - "e2e:install": "playwright install --with-deps chromium" + "e2e:install": "playwright install --with-deps chromium", + "generate": "orval --config ./orval.config.ts" }, "dependencies": { "@tanstack/react-query": "^5.90.21", @@ -23,6 +24,7 @@ "@playwright/test": "^1.49.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "orval": "^7.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1",