diff --git a/.planning/phases/10-admin-vue-spa/deferred-items.md b/.planning/phases/10-admin-vue-spa/deferred-items.md index aa482d3..7f987c6 100644 --- a/.planning/phases/10-admin-vue-spa/deferred-items.md +++ b/.planning/phases/10-admin-vue-spa/deferred-items.md @@ -14,5 +14,9 @@ Phase 10 changes and are not fixed here. fonoteka.go `feaca6b`), so `go test ./...` in fonoteka.go was already red in this package. Fix belongs to a Phase 9 follow-up: add the cabana history table and the three backend tables to the parity allow-lists with a reason. + status: resolved + **Resolved:** both tests pass since fonoteka.go `21c0f12` (fix(09): update parity + expectations for the backend admin schema); Plan 10.1-04 removed them from the + `check-phase10.sh` allow-list (`9aeb0e1`), and `check-phase10.1.sh` allow-lists nothing. - **`gofmt -l` lists `internal/build/registry.go`** in summercms.go. Pre-existing, untouched by Phase 10. diff --git a/.planning/phases/10.1-runtime-admin-extension-point/10.1-SECURITY-REVIEW.md b/.planning/phases/10.1-runtime-admin-extension-point/10.1-SECURITY-REVIEW.md new file mode 100644 index 0000000..71400b6 --- /dev/null +++ b/.planning/phases/10.1-runtime-admin-extension-point/10.1-SECURITY-REVIEW.md @@ -0,0 +1,85 @@ +--- +phase: "10.1" +reviewed: "2026-09-29" +threats_open: 0 +gate: "scripts/check-phase10.1.sh --all" +--- + +# Phase 10.1 Security Review + +This is a fresh code-and-test review of every threat in the registers of Plans 10.1-01 to 10.1-04 (T-10.1-01 to T-10.1-22 and T-10.1-SC). A high threat counts as mitigated only when its named test or gate stage fails with the protection removed. Each one was checked by changing the production code (or the gate script), running the named test, and restoring the file byte for byte, as recorded under "Removal checks" below. The accepted threat keeps its rationale verbatim from its originating plan. + +Commands run from `summercms.go`. `../fonoteka.go` tests run inside that repository. Gate stages are modes of `scripts/check-phase10.1.sh`. Cabana tests are in `modules/cabana` (`phase101_schema_test.go`, `phase101_render_test.go`, `phase101_assets_test.go`, `phase101_actions_test.go`). SPA suites are under `admin/tests`. + +| Threat | Category | Component | Severity | Disposition | Production mitigation | Test or gate stage | Observed result | Residual risk | +|--------|----------|-----------|----------|-------------|-----------------------|--------------------|-----------------|---------------| +| T-10.1-01 | Information Disclosure | cabana plugin asset route | high | mitigate | `cabana/extension.go` `compileClientAssets` reads only declared paths that pass `checkAssetPath` (clean, under `assets/`, `.js`/`.mjs` or `.css`, embedded) and keys them `vendor/plugin/`. `cabana/plugin_assets.go` `pluginAsset` serves by exact key; a miss goes to the SPA handler. The AdminFS is never served wholesale | `TestPhase101Assets` (undeclared YAML, `_stats.htm`, encoded traversal, other plugin all fall through; every boot path rule), `TestPhase10OpenAPIConformance` (`assertPluginAsset`), `TestPhase101AlbumsExtension/declared_assets…` (fonoteka); stages `--security`, `--postgres` | pass; removal checks RC-01 and RC-02 fail TestPhase101Assets | A plugin can still declare and publish any file it puts under `assets/`; that is its own choice, like its Go code | +| T-10.1-02 | Tampering | plugin asset responses (MIME sniffing) | medium | mitigate | `boardwalk.ContentType` gives explicit `text/javascript; charset=utf-8` and `text/css; charset=utf-8`. `pluginAsset` sets `boardwalk.SetSecurityHeaders` (nosniff, CSP `script-src 'self'`, DENY, same-origin referrer, noindex) and `Cross-Origin-Resource-Policy: same-origin` | `TestPhase101Assets` (hit headers), `TestPhase101BoardwalkExports`, `TestPhase101AlbumsExtension` (fonoteka assets); stage `--security` | pass | None known | +| T-10.1-03 | Tampering | stale plugin JS after a rebuild | low | mitigate | Schema URLs carry `?v=` plus the first 12 hex characters of the file's sha256. Responses are `Cache-Control: no-cache` with a sha256 ETag, never immutable | `TestPhase101Assets` (`?v=` equals the hash prefix, ETag, 304 on If-None-Match, 200 on a stale ETag, no `immutable`); stage `--security` | pass | None known | +| T-10.1-04 | Tampering | widget and toolbar POST routes (CSRF) | high | mitigate | `cabana/http.go` mounts both routes through `requireAjax`; a cookie request without `X-Requested-With` is refused before the body is read | `TestPhase10CSRF` (walks every unsafe mounted route, pins 11), `TestPhase101Actions/cookie_POSTs_need_X-Requested-With`, `TestPhase101AlbumsSmoke/toolbar` (fonoteka); stage `--security` | pass; removal check RC-03 fails TestPhase10CSRF and TestPhase101Actions | Same as Phase 10 T-10-02: relies on browsers not sending custom headers cross-origin without a CORS preflight | +| T-10.1-05 | Elevation of Privilege | action execution | high | mitigate | `protect()` checks the controller permissions, then `allowAction` checks the action's own `Permissions` (403, logged). `compileContributions` validates action permission codes at boot. `listSchema` filters `toolbarActions` to what the principal may run | `TestPhase101Actions/action_permission_on_top_of_the_controller's` (403 on widget and toolbar, empty toolbarActions), `TestPhase101Toolbar/labels_localize…permissions`, `TestPhase101FormExtensionSchema/unknown_action_permission…`, `TestPhase09PermissionMatrix`; stages `--security`, `--postgres` | pass; removal checks RC-04 and RC-05 fail TestPhase101Actions and TestPhase101Toolbar | None known | +| T-10.1-06 | Elevation of Privilege | record_id on widget POST and ?id= on partial GET (IDOR) | high | mitigate | `cabana/actions.go` `readScopedRecord` loads through the controller's `FormExtendQuery`; missing and out-of-scope ids are both 404. `partial` accepts `?id=` only for a form partial and only a positive integer. Toolbar bodies must be `{}` | `TestPhase101Actions` (out-of-scope and missing widget record 404 without running the action; `?id=` on a header partial, `abc`, `0`, `-1`, out-of-scope all 404; toolbar `record_id` 422); stage `--postgres` | pass; removal check RC-06 fails TestPhase101Actions | None known | +| T-10.1-07 | Tampering | fill write-back (mass assignment) | high | mitigate | Boot requires every fill key to be a writable scalar field of the same form (`compileExtension`). `onlyFillScalars` drops non-fill keys and non-scalar values from both the action's `Values` and its `Fill`. A later save still runs `ProjectWritableFields` and the model rules | `TestPhase101Actions` (action receives only fill-key scalars; response drops `tenant`, `group`, `id` and a non-scalar), `TestPhase101FormExtensionSchema` (protected `collection_id`, relation, repeated and unknown fill keys fail boot); stages `--security`, `--postgres` | pass; removal checks RC-07 and RC-08 fail TestPhase101Actions and TestPhase101FormExtensionSchema | None known | +| T-10.1-08 | Tampering | partial output (XSS, server half) | high | mitigate | `cabana/partial_render.go`: html/template contextual escaping of view-model data, then `html.ParseFragment` and `sanitizePartialNodes`: an element allowlist, 19 tags dropped with their subtree, other elements unwrapped, a per-tag attribute allowlist plus `aria-*`/`data-*`, `safePartialURL` for `a[href]` and `img[src]`, comments dropped. No HTML string leaves the server | `TestPhase101PartialSanitizer` (every dropped tag, unwrapping, attribute table, 16 URL cases, comments, view-model markup stays text); stage `--security` | pass; removal checks RC-09 and RC-10 fail TestPhase101PartialSanitizer | The allowlist is only as good as x/net/html's parse; the client re-checks every node (T-10.1-14) | +| T-10.1-09 | Information Disclosure | partial view models | medium | mitigate | `pact.AdminPartialData` documents a curated view model. `refusedViewModel` refuses the controller's model type, a pointer, slice, array or map of it, and any type carrying html/template trusted content types; the handler answers a logged 500. Records are loaded by cabana, not by the plugin | `TestPhase101PartialSanitizer/view-model_guard`, `TestPhase101PartialSanitizer/the_partial_route_refuses_a_model_view_model`; stage `--security` | pass; removal check RC-11 fails TestPhase101PartialSanitizer | A plugin can still copy a sensitive column into a curated struct; review of plugin view models stays a code-review duty | +| T-10.1-10 | Elevation of Privilege | Albums plugin JS running in the admin origin | medium | mitigate | Plugin JS is trusted compiled code (like plugin Go); the element makes no network request and reads no cookie or storage, and signals only through summer-action (D-05); the session cookie is HttpOnly. `hygiene_101` refuses `fetch(`, `XMLHttpRequest`, `document.cookie`, `localStorage`, `sessionStorage`, `indexedDB`, `sendBeacon`, `WebSocket` and `EventSource` in any application `plugins/*/*/assets/**/*.js` | `check-phase10.1.sh --hygiene`, `--self-test` (network, cookie and storage plants each refused for this rule only); stage `--hygiene` | pass; removal check RC-19 (rule disabled) fails `--self-test` | A determined plugin author can obfuscate a call past a grep; plugin JS stays trusted code | +| T-10.1-11 | Tampering | custom-element name collisions across plugins | low | mitigate | `checkWidgetTag`: the valid-name pattern (lowercase, a hyphen), the reserved-name list, and the owning plugin's `{vendor}-{plugin}-` prefix | `TestPhase101FormExtensionSchema` (no hyphen, uppercase, another plugin's prefix, `font-face-src` under plugin `font.face`); stage `--security` | pass | None known | +| T-10.1-12 | Denial of Service | partial rendering | medium | mitigate | `cappedBuffer` (64 KiB), `partialBudget` (2000 nodes) and the depth-32 check return errors, never a truncated tree; the handler logs and answers 500 | `TestPhase101PartialSanitizer/caps` (at-cap passes, one over fails for size, nodes and depth); stage `--security` | pass; removal check RC-12 fails TestPhase101PartialSanitizer | A slow `PartialData` query is not capped by these limits | +| T-10.1-13 | Tampering | pluginAssets loader (foreign script URL from a schema) | medium | mitigate | `admin/src/app/pluginAssets.ts` `assetAllowed` refuses any URL outside `${runtime.base}/assets/`, dot segments (including percent-encoded ones, fixed in this plan), backslashes, whitespace and control characters; CSP `script-src 'self'` backs it up | `tests/app/pluginAssets.test.ts` (18 refused URL shapes, no element appended for a refused URL); stage `--spa` | pass; removal check RC-20 fails the suite | Schema URLs come from the server; the check is defence in depth | +| T-10.1-14 | Tampering | PartialHost node rendering (XSS, client half) | high | mitigate | `admin/src/components/partial/partialNodes.ts` rebuilds only allowlisted tags and attributes with `h()`, drops removed tags with their subtree, unwraps unknown ones, keeps text as text and never parses strings. The Phase 10 hygiene rule refuses `v-html`/`innerHTML`/`insertAdjacentHTML`; `hygiene_101` refuses `setHTML`, `setHTMLUnsafe`, `createContextualFragment`, `DOMParser`, `srcdoc` and `document.write` in admin/src | `tests/list/PartialHost.test.ts` (every allowed and dropped tag, attribute and URL table incl. `javascript:`, depth and node caps, text stays text), `tests/smoke/extension.smoke.test.ts`; stages `--spa`, `--hygiene` (proven by the `--self-test` parser and `document.write` plants) | pass; removal checks RC-13 and RC-14 fail tests/list/PartialHost.test.ts | Vue's own escaping of text nodes is trusted | +| T-10.1-15 | Information Disclosure | Albums statistics strip | high | mitigate | fonoteka `albumsAdminController.statsView` builds each count query fresh and passes it through `scopeAlbums` (collection binding, `1 = 0` when unbound); the view model holds labels and integers only | `TestPhase101AlbumsExtension` (two collections: empty admin collection shows `All albums 0` while the other has four; No shelf only above zero), `TestPhase101AlbumsSmoke/stats` (fonoteka); stage `--postgres` | pass; removal check RC-15 fails TestPhase101AlbumsExtension | None known | +| T-10.1-16 | Tampering | plugin CSS bleeding across controllers | low | mitigate | `activateStyles` disables stylesheet links owned by other controllers on every list and form mount | `tests/app/pluginAssets.test.ts` (enable own, disable others, unknown controller disables all), `tests/smoke/extension.smoke.test.ts` (controller stylesheets); stage `--spa` | pass; removal check RC-21 fails the suite | Plugin CSS still applies globally while its controller is open | +| T-10.1-17 | Tampering | client-side fill write-back | medium | mitigate | `WidgetField` patches only keys in `field.fill` that the response returns; the save still goes through the server's writable projection | `tests/form/WidgetField.test.ts` (undeclared `weight` never patched), `tests/form/FormView.test.ts` (patch acts like an edit); stage `--spa` | pass; removal check RC-22 fails tests/form/WidgetField.test.ts | None known | +| T-10.1-18 | Information Disclosure | plugin custom element receiving credentials | high | mitigate | `WidgetField` sets attributes only (record id, field name, locale, fill snapshot, labels) and performs the POST itself through the typed client; no property or function is assigned; the session cookie stays HttpOnly and no token exists in JS (Phase 10 T-10-01) | `tests/form/WidgetField.test.ts` (exact attribute set, `Object.keys(element)` empty), `tests/smoke/extension.smoke.test.ts`; stage `--spa` | pass; removal check RC-16 fails tests/form/WidgetField.test.ts | The element runs in the admin origin and could still call the API with the cookie; see T-10.1-10 | +| T-10.1-19 | Repudiation | Phase 10.1 acceptance evidence | high | mitigate | `scripts/check-phase10.1.sh`: `phase101_detect` refuses failed, skipped, zero-test, non-JSON and build-failed runs and required tests that did not pass; OpenAPI and dist drift, hygiene and evidence stages; this review names a failing-when-broken test and a removal check per high threat | `scripts/check-phase10.1.sh --self-test` (pass, fail, skip, zero, non-JSON, build, build flag, package, required, required-failed, allowed, allowed-other, wrong-package, now-passes cases; eight hygiene plants); stage `--all` | pass; removal check RC-17 (skip refusal removed) fails `--self-test` | None known | +| T-10.1-20 | Tampering | framework/app boundary and extension conventions | low | mitigate | `--hygiene` runs the Phase 10 rules (`check-phase10.sh --hygiene`, including no application names in modules/cabana tests and testdata) plus `hygiene_101`: HTML-string parsers in admin/src, network, cookie or storage access in application plugin asset JS, and script, `style=` or inline `on…=` markup in application partial templates | `check-phase10.1.sh --hygiene`, `--self-test` (each plant refused for its own rule only; clean look-alike markup accepted); stage `--hygiene` | pass; removal check RC-19 fails `--self-test` | Greps can be evaded by obfuscation; the rules catch mistakes, not an adversarial plugin | +| T-10.1-21 | Elevation of Privilege | discogsLookup and discogsSync stubs | medium | mitigate | Both require golem15.fonoteka.access_albums on top of the controller permission; a Genres-only admin gets 403 (Task 3) | `TestPhase101AlbumsExtension/a_Genres-only_admin…` (403 on widget, toolbar and partial; both actions declare the permission), `TestPhase101AlbumsSmoke/toolbar` (fonoteka); stage `--postgres` | pass; removal check RC-18 fails TestPhase101AlbumsExtension | None known | +| T-10.1-22 | Tampering | stub fill payload | low | accept | The stub only patches the unsaved form; nothing persists until the admin saves, and the save runs the Album rules (year between 1889 and 2100, format in the option list). | Evidence: `TestPhase101AlbumsExtension/Discogs_widget…` (the action persists nothing; the save persists year 1977 and format LP) | pass | Accepted: Phase 14 replaces the stub with the Discogs client | +| T-10.1-SC | Tampering | npm and Go dependencies | high | mitigate | No npm package added or re-pinned in Phase 10.1 (`git diff --quiet b2845e0 -- admin/package.json admin/package-lock.json`); no coverage provider. The only Go change is golang.org/x/net promoted from indirect to direct at the same v0.58.0 (Plan 10.1-01, named by RESEARCH); no fonoteka.go module change. `--spa` installs with `npm ci` from the committed lockfile; swag stays pinned at v1.16.6 in `check-admin-openapi.sh` | `check-phase10.1.sh --spa` (`npm ci`), `check-admin-openapi.sh --check` (stage `--openapi`), `check-admin-dist.sh` (stage `--dist`) | pass; removal check RC-23: a package.json entry missing from the lockfile makes `npm ci` exit 1 (EUSAGE) | Transitive updates inside existing pins are not reviewed here | + +## Removal checks + +Each row changed one production file (or a scratch copy of the gate), ran the named command, and restored the file byte for byte (`git status` clean afterwards). Go checks were driven by a scratch harness that refuses to run when its anchor text is not found exactly once. A mitigation counts only if its test fails. + +| Check | Threat | Mutation | Command | Observed | +|-------|--------|----------|---------|----------| +| RC-01 | T-10.1-01 | `compileClientAssets` no longer calls `checkAssetPath` | `go test ./modules/cabana -run '^TestPhase101Assets$' -count=1` | exit 1, `--- FAIL: TestPhase101Assets` | +| RC-02 | T-10.1-01 | `pluginAsset` serves some declared file on a key miss instead of falling through | `go test ./modules/cabana -run '^TestPhase101Assets$' -count=1` | exit 1, `--- FAIL: TestPhase101Assets` | +| RC-03 | T-10.1-04 | the widget route is mounted without `requireAjax` | `go test ./modules/cabana -run '^(TestPhase101Actions\|TestPhase10CSRF)$' -count=1` | exit 1, `--- FAIL: TestPhase10CSRF` | +| RC-04 | T-10.1-05 | `widgetAction` skips `allowAction` | `go test ./modules/cabana -run '^TestPhase101Actions$' -count=1` | exit 1, `--- FAIL: TestPhase101Actions` | +| RC-05 | T-10.1-05 | `listSchema` offers every registered toolbar action regardless of permission | `go test ./modules/cabana -run '^TestPhase101Toolbar$' -count=1` | exit 1, `--- FAIL: TestPhase101Toolbar` | +| RC-06 | T-10.1-06 | `readScopedRecord` skips the controller's `FormExtendQuery` | `go test ./modules/cabana -run '^TestPhase101Actions$' -count=1` | exit 1, `--- FAIL: TestPhase101Actions` | +| RC-07 | T-10.1-07 | `onlyFillScalars` returns every key and value unfiltered | `go test ./modules/cabana -run '^TestPhase101Actions$' -count=1` | exit 1, `--- FAIL: TestPhase101Actions` | +| RC-08 | T-10.1-07 | the boot check that a fill key is a writable scalar field is disabled | `go test ./modules/cabana -run '^TestPhase101FormExtensionSchema$' -count=1` | exit 1, `--- FAIL: TestPhase101FormExtensionSchema` | +| RC-09 | T-10.1-08 | `sanitizePartialNode` ignores `partialDroppedTags` (they are unwrapped instead) | `go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' -count=1` | exit 1, `--- FAIL: TestPhase101PartialSanitizer` | +| RC-10 | T-10.1-08 | `safePartialURL` accepts every non-empty URL | `go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' -count=1` | exit 1, `--- FAIL: TestPhase101PartialSanitizer` | +| RC-11 | T-10.1-09 | the partial handler ignores `refusedViewModel` | `go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' -count=1` | exit 1, `--- FAIL: TestPhase101PartialSanitizer` | +| RC-12 | T-10.1-12 | `partialBudget.take` never runs out | `go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' -count=1` | exit 1, `--- FAIL: TestPhase101PartialSanitizer` | +| RC-13 | T-10.1-14 | `renderPartialNodes` no longer drops `PARTIAL_DROPPED_TAGS` | `npm --prefix admin test -- tests/list/PartialHost.test.ts` | exit 1, 20 of 127 tests fail | +| RC-14 | T-10.1-14 | `partialAttrAllowed` allows every attribute name | `npm --prefix admin test -- tests/list/PartialHost.test.ts` | exit 1, 10 of 127 tests fail | +| RC-15 | T-10.1-15 | the stats queries skip `scopeAlbums` | fonoteka `go test ./plugins/golem15/fonoteka -run '^TestPhase101AlbumsExtension$' -count=1` | exit 1, `--- FAIL: TestPhase101AlbumsExtension` | +| RC-16 | T-10.1-18 | `WidgetField` also assigns the API client to the element (`Object.assign(created, { api })`) | `npm --prefix admin test -- tests/form/WidgetField.test.ts` | exit 1, 1 of 22 tests fails | +| RC-17 | T-10.1-19 | `phase101_detect` no longer refuses a skipped test (scratch copy of the gate) | `bash --self-test` | exit 1, `refuse: self-test skip: detector exit 3, want 2` | +| RC-18 | T-10.1-21 | `discogsSync` declares no permission of its own | fonoteka `go test ./plugins/golem15/fonoteka -run '^TestPhase101AlbumsExtension$' -count=1` | exit 1, `--- FAIL: TestPhase101AlbumsExtension` | +| RC-19 | T-10.1-10, T-10.1-20 | `hygiene_101` no longer reports the plugin asset rule (scratch copy of the gate) | `bash --self-test` | exit 1, `refuse: self-test hygiene_101 accepted a planted network` | +| RC-20 | T-10.1-13 | `assetAllowed` drops the `{base}/assets/` prefix check | `npm --prefix admin test -- tests/app/pluginAssets.test.ts` | exit 1, 11 of 30 tests fail | +| RC-21 | T-10.1-16 | `activateStyles` enables every link | `npm --prefix admin test -- tests/app/pluginAssets.test.ts` | exit 1, 2 of 30 tests fail | +| RC-22 | T-10.1-17 | `WidgetField` patches every key the response returns | `npm --prefix admin test -- tests/form/WidgetField.test.ts` | exit 1, 1 of 22 tests fails | +| RC-23 | T-10.1-SC | a scratch copy of admin/package.json gains `@vitest/coverage-v8` without a lockfile update | `npm ci --no-audit --no-fund --dry-run` in the scratch copy | exit 1, `EUSAGE … package.json and package-lock.json are in sync` | + +Two removal checks initially survived and led to stronger tests before this review was written: the model-type guard (RC-11) passed because the header template failed on the model anyway, so the test now renders the form partial whose template reads a field the model also has; and the Discogs action permission (RC-18) was masked by the controller permission, so the acceptance test now asserts the permission each action declares. + +## Findings fixed during the review + +- **Percent-encoded dot segments in plugin asset URLs (T-10.1-13).** `assetAllowed` refused `..` but accepted `%2e%2e`, which the URL parser resolves as `..`, so `{base}/assets/%2e%2e/api/v1/...` escaped the asset prefix. Fixed in `6b0ac15` with its suite and a rebuilt dist. +- **Stale parity allow-list in the gates.** The two fonoteka parity failures the Phase 10 gate allow-listed pass since fonoteka.go `21c0f12`, and the detector refuses an allow-listed failure that passes, so `check-phase10.sh --go` failed. Both gates now allow-list nothing. + +## Phase 10 threats revisited + +- **T-10-16 (SPA rendering, XSS).** Its residual risk now reads: plugin HTML reaches the DOM only as a sanitized node tree. The server allowlist (T-10.1-08) and the client `h()` rebuild (T-10.1-14) both apply; no raw-HTML sink was added to admin/src. +- **T-10-04 (framing, sniffing).** Plugin assets reuse `boardwalk.SetSecurityHeaders` unchanged; the CSP needed no carve-out for plugin scripts because they are served same-origin under `{base}/assets/`. + +## Residual risk + +- Plugin JS and Go run with the admin's authority by design (compiled plugins, D-04). The hygiene rules catch mistakes in application assets and partials, not a hostile plugin. +- Real-browser behaviour (CSP enforcement of module scripts, custom-element upgrade, the 768px layout backstops, the Vite `/assets` dev proxy) is outside what happy-dom can prove; it stays with the human checks of 10.1-02 Task 3 and 10.1-03 Task 3 at `/gsd-verify-work`. diff --git a/.planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md b/.planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md index 6774ac1..7d8d72e 100644 --- a/.planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md +++ b/.planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md @@ -3,15 +3,16 @@ phase: "10.1" slug: "runtime-admin-extension-point" # status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6) # audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117) -status: draft -nyquist_compliant: false -wave_0_complete: false +status: validated +nyquist_compliant: true +wave_0_complete: true created: "2026-09-28" +validated: "2026-09-29" --- # Phase 10.1 — Validation Strategy -> Per-phase validation contract for feedback sampling during execution. Seeded from `10.1-RESEARCH.md` § Validation Architecture; task IDs are filled in once PLAN.md files exist. +> Per-phase validation contract for feedback sampling during execution. Seeded from `10.1-RESEARCH.md` § Validation Architecture; task IDs and statuses are filled in from the executed plans 10.1-01 to 10.1-04 and the final run of `scripts/check-phase10.1.sh --all`. Requirement: ADMIN-07. --- @@ -23,8 +24,8 @@ created: "2026-09-28" | **Config file** | `admin/vitest.config.ts`; `go.mod` / `go.work` | | **Quick run command** | `go test ./modules/cabana -run 'TestPhase101' -count=1` and `npm --prefix admin test -- tests/form tests/list tests/app` | | **Full suite command** | `go vet ./... && go test ./...` in both repos, `npm --prefix admin run typecheck && npm --prefix admin test` | -| **Phase gate** | `scripts/check-phase10.1.sh --all` (new) plus `scripts/check-phase10.sh --all` staying green | -| **Estimated runtime** | ~120 seconds with warm caches (Postgres containers dominate) | +| **Phase gate** | `scripts/check-phase10.1.sh --all` plus `scripts/check-phase10.sh --all` staying green | +| **Estimated runtime** | ~120 seconds with warm caches for the test commands; the full gate (npm ci, both repositories, every Postgres suite) takes several minutes | --- @@ -39,25 +40,33 @@ created: "2026-09-28" ## Per-Task Verification Map +Task IDs are `{plan}-T{n}`. A row lists the task that built the behaviour and the 10.1-04 task that brought its full tests. Every row's command passed in the final gate run. + | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| -| TBD | TBD | TBD | D-06, D-09 | T-10.1-11 | widget/partial types; per-type keys; tag prefix `{vendor}-{plugin}-`; unknown key fails boot | unit | `go test ./modules/cabana -run '^TestPhase101FormExtensionSchema$' -count=1` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-07 | T-10.1-05, T-10.1-06, T-10.1-07 | fill ⊆ writable fields; server drops extra keys; action permission + scoped record load | unit + Postgres | `go test ./modules/cabana -run '^TestPhase101Actions$' -count=1` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-11 | — | `headerPartial` compiles; missing template / parse error / missing view model fails boot | unit | `go test ./modules/cabana -run '^TestPhase101PartialSchema$' -count=1` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-10, D-17 | T-10.1-08, T-10.1-09, T-10.1-12 | allowlisted node tree; script/on*/style/javascript: dropped; record data escaped; size caps | unit | `go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' -count=1` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-12 | T-10.1-05 | toolbar names resolved against registered actions; unknown fails boot; permission-filtered | unit | `go test ./modules/cabana -run '^TestPhase101Toolbar$' -count=1` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-13, D-16 | T-10.1-01, T-10.1-02, T-10.1-03 | exact asset allowlist; MIME + nosniff + CORP; ETag/304; traversal/undeclared → SPA fall-through | unit | `go test ./modules/cabana -run '^TestPhase101Assets$' -count=1` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-05 | T-10.1-04 | new POSTs refused without X-Requested-With | unit | `go test ./modules/cabana -run '^TestPhase10CSRF$' -count=1` | ✅ | ⬜ pending | -| TBD | TBD | TBD | D-05, D-12 | T-10.1-04 | route inventory, permission matrix and OpenAPI conformance cover the new routes | unit + Postgres | `go test ./modules/cabana -run '^(TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10OpenAPIConformance)$' -count=1 && scripts/check-admin-openapi.sh --check` | ✅ extend | ⬜ pending | -| TBD | TBD | TBD | D-14 | T-10.1-11, T-10.1-13 | loader idempotent; foreign URL refused; CSS disabled off-controller | vitest | `npm --prefix admin test -- tests/app/pluginAssets.test.ts` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-05, D-07, D-08 | T-10.1-07 | widget attributes; event → POST; patch only fill keys; create mode | vitest | `npm --prefix admin test -- tests/form/WidgetField.test.ts` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-17 | T-10.1-08 | PartialHost renders via h(); unknown tag/attr dropped; text stays text | vitest | `npm --prefix admin test -- tests/list/PartialHost.test.ts tests/form/PartialField.test.ts` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-03, D-12 | — | list header slot; custom toolbar button → POST → toast → reload | vitest | `npm --prefix admin test -- tests/list/ListToolbar.test.ts tests/views/ListView.test.ts` | ✅ extend | ⬜ pending | -| TBD | TBD | TBD | D-09 | — | widget/partial registered, excluded from save body, render on create | vitest | `npm --prefix admin test -- tests/form/registry.test.ts tests/form/formState.test.ts` | ✅ extend | ⬜ pending | -| TBD | TBD | TBD | D-17 | T-10.1-08, T-10.1-10 | no raw-HTML sinks; plugin assets contain no fetch/XMLHttpRequest/document.cookie | gate | `scripts/check-phase10.1.sh --self-test && scripts/check-phase10.1.sh --hygiene` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-04 | — | committed dist matches source | gate | `scripts/check-admin-dist.sh` | ✅ | ⬜ pending | -| TBD | TBD | TBD | D-01, D-02, D-03, D-12 | T-10.1-05, T-10.1-06, T-10.1-09 | Albums stats strip scoped per collection; widget stub fills; toolbar action toasts; limited admin 403; assets served | integration (Postgres) | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase101AlbumsExtension$' -count=1` | ❌ W0 | ⬜ pending | -| TBD | TBD | TBD | D-01 | — | framework repo has no Płytarium names | gate | `scripts/check-phase10.sh --hygiene` | ✅ | ⬜ pending | +| 10.1-01-T1, 10.1-04-T1 | 10.1-01, 10.1-04 | 1, 4 | ADMIN-07 (D-06, D-09) | T-10.1-11 | widget/partial types; per-type keys; tag prefix `{vendor}-{plugin}-`; reserved names; unknown key and settings-form use fail boot | unit | `go test ./modules/cabana -run '^TestPhase101FormExtensionSchema$' -count=1` | ✅ | ✅ green | +| 10.1-01-T1, 10.1-04-T1 | 10.1-01, 10.1-04 | 1, 4 | ADMIN-07 (D-07) | T-10.1-05, T-10.1-06, T-10.1-07 | fill ⊆ writable fields; server drops extra keys; action permission + scoped record load | unit + Postgres | `go test ./modules/cabana -run '^TestPhase101Actions$' -count=1` | ✅ | ✅ green | +| 10.1-01-T3, 10.1-04-T1 | 10.1-01, 10.1-04 | 1, 4 | ADMIN-07 (D-11) | — | `headerPartial` compiles; missing template / parse error / missing view model / Winter path fails boot | unit | `go test ./modules/cabana -run '^TestPhase101PartialSchema$' -count=1` | ✅ | ✅ green | +| 10.1-01-T3, 10.1-04-T1 | 10.1-01, 10.1-04 | 1, 4 | ADMIN-07 (D-10, D-17) | T-10.1-08, T-10.1-09, T-10.1-12 | allowlisted node tree; script/on*/style/javascript: dropped; record data escaped; size, node and depth caps; model view model refused | unit | `go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' -count=1` | ✅ | ✅ green | +| 10.1-01-T2, 10.1-04-T1 | 10.1-01, 10.1-04 | 1, 4 | ADMIN-07 (D-12) | T-10.1-05 | toolbar names resolved against registered actions; unknown fails boot; permission-filtered and localized per request | unit | `go test ./modules/cabana -run '^TestPhase101Toolbar$' -count=1` | ✅ | ✅ green | +| 10.1-01-T2, 10.1-04-T1 | 10.1-01, 10.1-04 | 1, 4 | ADMIN-07 (D-13, D-16) | T-10.1-01, T-10.1-02, T-10.1-03 | exact asset allowlist; MIME + nosniff + CORP; ETag/304; traversal/undeclared → SPA fall-through | unit | `go test ./modules/cabana -run '^TestPhase101Assets$' -count=1` | ✅ | ✅ green | +| 10.1-01-T2, 10.1-04-T1 | 10.1-01, 10.1-04 | 1, 4 | ADMIN-07 (D-16) | T-10.1-02 | exported `ContentType` and `SetSecurityHeaders` | unit | `go test ./modules/boardwalk -run '^TestPhase101BoardwalkExports$' -count=1` | ✅ | ✅ green | +| 10.1-01-T1, 10.1-01-T2 | 10.1-01 | 1 | ADMIN-07 (D-05) | T-10.1-04 | new POSTs refused without X-Requested-With | unit | `go test ./modules/cabana -run '^TestPhase10CSRF$' -count=1` | ✅ | ✅ green | +| 10.1-01-T1, 10.1-01-T2, 10.1-01-T3 | 10.1-01 | 1 | ADMIN-07 (D-05, D-12) | T-10.1-04 | route inventory, permission matrix and OpenAPI conformance cover the new routes | unit + Postgres | `go test ./modules/cabana -run '^(TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10OpenAPIConformance)$' -count=1 && scripts/check-admin-openapi.sh --check` | ✅ | ✅ green | +| 10.1-02-T1, 10.1-02-T3, 10.1-04-T2 | 10.1-02, 10.1-04 | 2, 4 | ADMIN-07 (D-14) | T-10.1-13, T-10.1-16 | loader idempotent; foreign and dot-segment URLs refused (encoded too); CSS disabled off-controller | vitest | `npm --prefix admin test -- tests/app/pluginAssets.test.ts` | ✅ | ✅ green | +| 10.1-02-T1, 10.1-04-T2 | 10.1-02, 10.1-04 | 2, 4 | ADMIN-07 (D-05, D-07, D-08) | T-10.1-17, T-10.1-18 | widget attributes only; event → POST once while busy; patch only fill keys; 5000 ms timeout; create mode | vitest | `npm --prefix admin test -- tests/form/WidgetField.test.ts` | ✅ | ✅ green | +| 10.1-02-T2, 10.1-04-T2 | 10.1-02, 10.1-04 | 2, 4 | ADMIN-07 (D-09, D-17) | T-10.1-14 | PartialHost renders via h(); unknown tag/attr dropped; text stays text; skeleton, empty, failure and busy-refetch states | vitest | `npm --prefix admin test -- tests/list/PartialHost.test.ts tests/form/PartialField.test.ts` | ✅ | ✅ green | +| 10.1-02-T3, 10.1-04-T2 | 10.1-02, 10.1-04 | 2, 4 | ADMIN-07 (D-03, D-12) | — | list header slot; custom toolbar button → POST {} → toast → reload; header refetch only after bulk delete and actions | vitest | `npm --prefix admin test -- tests/list/ListToolbar.test.ts tests/list/ListView.test.ts` | ✅ | ✅ green | +| 10.1-02-T1, 10.1-02-T2, 10.1-04-T2 | 10.1-02, 10.1-04 | 2, 4 | ADMIN-07 (D-09) | — | widget/partial registered, excluded from save body, span labels, render on create, form context provided | vitest | `npm --prefix admin test -- tests/form/registry.test.ts tests/form/formState.test.ts tests/form/FormField.test.ts tests/form/FormView.test.ts` | ✅ | ✅ green | +| 10.1-02-T1, 10.1-02-T2, 10.1-02-T3 | 10.1-02 | 2 | ADMIN-07 (D-04, D-05, D-17) | T-10.1-14, T-10.1-18 | end-to-end SPA smoke of widget, partials, toolbar actions and scoped stylesheets; framework strings resolve | vitest + unit | `npm --prefix admin test -- tests/smoke && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1` | ✅ | ✅ green | +| 10.1-04-T3 | 10.1-04 | 4 | ADMIN-07 (D-17) | T-10.1-08, T-10.1-10, T-10.1-14, T-10.1-19, T-10.1-20 | no raw-HTML sinks or HTML-string parsers; plugin assets contain no network, cookie or storage access; partial templates carry no script or handlers; detectors fail closed | gate | `scripts/check-phase10.1.sh --self-test && scripts/check-phase10.1.sh --hygiene` | ✅ | ✅ green | +| 10.1-02-T1, 10.1-02-T2, 10.1-02-T3, 10.1-04-T2 | 10.1-02, 10.1-04 | 2, 4 | ADMIN-07 (D-04) | T-10.1-SC | committed dist matches source | gate | `scripts/check-admin-dist.sh` | ✅ | ✅ green | +| 10.1-03-T1 | 10.1-03 | 3 | ADMIN-07 (D-01, D-03) | T-10.1-15 | Albums stats strip scoped per collection; copy in pl and en | integration (Postgres) | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke\|TestPhase10LangCatalog\|TestAlbumsAdminList\|TestAlbumsAdminRegistration\|TestPhase10ControllerCopy)$' -count=1` | ✅ | ✅ green | +| 10.1-03-T2 | 10.1-03 | 3 | ADMIN-07 (D-02, D-06, D-07) | T-10.1-21, T-10.1-22 | Discogs widget stub fills year and format; a save persists them (json.Number fill) | integration (Postgres) + unit | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke\|TestAlbumsAdminForm\|TestAlbumsAdminCRUD\|TestPhase10Controllers\|TestPhase10AlbumRelations\|TestPhase10AssembledAcceptance)$' -count=1 && go test ./modules/lagoon -run '^TestFillJSONNumber$' -count=1` | ✅ | ✅ green | +| 10.1-03-T3 | 10.1-03 | 3 | ADMIN-07 (D-12) | T-10.1-21 | Sync with Discogs toolbar stub toasts; limited admin 403 | integration (Postgres) | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -count=1` | ✅ | ✅ green | +| 10.1-03-T1, 10.1-03-T2, 10.1-03-T3, 10.1-04-T1 | 10.1-03, 10.1-04 | 3, 4 | ADMIN-07 (D-01, D-02, D-03, D-12) | T-10.1-05, T-10.1-06, T-10.1-09, T-10.1-15, T-10.1-21 | Albums stats strip over two collections; widget stub fills and saves; toolbar action toasts; Genres-only admin 403; assets served; labels resolve in pl and en; every route template in admin.json | integration (Postgres) | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsExtension\|TestPhase101AlbumsSmoke)$' -count=1` | ✅ | ✅ green | +| 10.1-03-T3, 10.1-04-T1 | 10.1-03, 10.1-04 | 3, 4 | ADMIN-07 (D-01) | T-10.1-20 | framework repo has no application names | gate | `scripts/check-phase10.sh --hygiene` | ✅ | ✅ green | +| 10.1-04-T3 | 10.1-04 | 4 | ADMIN-07 | T-10.1-19, T-10.1-SC | the whole phase: go, security, postgres, spa, openapi, dist, hygiene and evidence stages; Phase 10 gate still green | gate | `scripts/check-phase10.1.sh --all && scripts/check-phase10.sh --all` | ✅ | ✅ green | *Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* @@ -65,12 +74,12 @@ created: "2026-09-28" ## Wave 0 Requirements -- [ ] `modules/cabana/testdata/extension/` — acme fixture plugin tree (config YAML, `_stats.htm`, `_summary.htm`, fields/columns, `assets/js/lookup.js`, `assets/css/gadgets.css`) -- [ ] `modules/cabana/phase101_*_test.go` — schema, sanitizer, assets, actions, toolbar -- [ ] `admin/tests/fixtures/extension.*.json` — list/form schema with assets, widget, partial, toolbar actions; partial nodes -- [ ] `admin/tests/app/pluginAssets.test.ts`, `tests/form/WidgetField.test.ts`, `tests/form/PartialField.test.ts`, `tests/list/PartialHost.test.ts` -- [ ] `../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_albums_test.go` -- [ ] `scripts/check-phase10.1.sh` with `--self-test`, `--go`, `--security`, `--postgres`, `--spa`, `--openapi`, `--dist`, `--hygiene`, `--evidence`, `--all` +- [x] `modules/cabana/testdata/extension/` — acme fixture plugin tree (config YAML, `_stats.htm`, `_summary.htm`, fields/columns, `assets/js/lookup.js`, `assets/css/gadgets.css`, en/pl lang) +- [x] `modules/cabana/phase101_*_test.go` — schema, sanitizer, assets, actions, toolbar +- [x] `admin/tests/fixtures/extension.*.json` — list/form schema with assets, widget, partial, toolbar actions; partial nodes (from 10.1-02, reused) +- [x] `admin/tests/app/pluginAssets.test.ts`, `tests/form/WidgetField.test.ts`, `tests/form/PartialField.test.ts`, `tests/list/PartialHost.test.ts` +- [x] `../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_albums_test.go` +- [x] `scripts/check-phase10.1.sh` with `--self-test`, `--go`, `--security`, `--postgres`, `--spa`, `--openapi`, `--dist`, `--hygiene`, `--evidence`, `--all` No framework install needed. @@ -80,17 +89,17 @@ No framework install needed. | Behavior | Requirement | Why Manual | Test Instructions | |----------|-------------|------------|-------------------| -| Browser loads plugin module script under CSP `script-src 'self'`; widget renders; fill then save persists | D-13, D-16, D-07 | happy-dom does not enforce CSP or real module loading | `summer serve` for fonoteka, open `/plytadmin` Albums form in a browser, check console for CSP errors, click the Discogs widget, save, reload | +| Browser loads plugin module script under CSP `script-src 'self'`; widget renders; fill then save persists; strip and buttons wrap at 768px with pl labels; plugin CSS off on other controllers; Vite `/assets` dev proxy | ADMIN-07 (D-13, D-16, D-07) | happy-dom does not enforce CSP, load module scripts or lay out | The human-check blocks of 10.1-02 Task 3 and 10.1-03 Task 3, collected at `/gsd-verify-work`: `summer serve` for fonoteka, open `/plytadmin` Albums in a browser, check the console for CSP errors, click the Discogs widget, save, reload | --- ## Validation Sign-Off -- [ ] All tasks have `` verify or Wave 0 dependencies -- [ ] Sampling continuity: no 3 consecutive tasks without automated verify -- [ ] Wave 0 covers all MISSING references -- [ ] No watch-mode flags -- [ ] Feedback latency < 120s -- [ ] `nyquist_compliant: true` set in frontmatter +- [x] All tasks have `` verify or Wave 0 dependencies +- [x] Sampling continuity: no 3 consecutive tasks without automated verify +- [x] Wave 0 covers all MISSING references +- [x] No watch-mode flags +- [x] Feedback latency < 120s for the per-task commands +- [x] `nyquist_compliant: true` set in frontmatter -**Approval:** pending +**Approval:** validated 2026-09-29 by `scripts/check-phase10.1.sh --all`