Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aba063f723 | |||
| ee624100ef | |||
| 11be08d680 | |||
| d0b7fe4571 | |||
| 9447fedca0 | |||
| 6340bb0dbc | |||
| ae1dc75a35 | |||
| b08e8bebba | |||
| 1534b5744b |
@@ -999,7 +999,7 @@ Plans:
|
||||
**Goal:** Plugins can register their own admin form field types (todo: cabana-richeditor-field-type), and the Journal plugin ships a first-class Blog Editor formwidget for post content — beyond the current markdown field or a plain rich editor — that edits imported HTML posts losslessly.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 16.1
|
||||
**Plans:** 6/6 plans executed
|
||||
**Plans:** 6/7 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -1016,6 +1016,9 @@ Plans:
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 16.2-06-PLAN.md — Unit tests: resave contract at every layer, strip-and-report and notices, policy cross-check corpus (both policies, identical copies), category strip, full Go and JS coverage, Postgres tests for a Docker host, phase gate scripts/check-phase16.2.sh, validation map
|
||||
|
||||
**Wave 5** *(gap closure, blocked on Wave 4 completion)*
|
||||
- [ ] 16.2-07-PLAN.md — Gap closure G-16.2-1 and G-16.2-2: conformance cases for GET /field-types and the field upload route, Docker-free route inventory in the gate, TestJournalSVGEmbed fix; Journal post/category admin saves purge the host Nuxt route cache after commit through a token-protected vue-journal-layer route (browsers revalidate); full gate incl. postgres, release summercms v0.1.20 and vue-journal-layer v0.3.0, UAT redeploy, re-run of UAT Tests 3 and 2
|
||||
|
||||
### Phase 16.3: Backend admin personal API tokens with scopes (INSERTED)
|
||||
|
||||
**Goal:** Backend users can create personal API tokens with a scope they pick at creation time (for example Journal only, or system-wide; later read-only Payment Gateway), so an agent or integration can call the admin and plugin APIs as that backend user, with no more access than the token's scope and the user's own permissions allow. This is the successor of Apparatus `PersonalApiToken` (todo `backend-admin-api-tokens.md`, note `apparatus-dissolved-into-framework.md`) and reverses the Phase 15 D-15 rule that Journal writes accept only a backend JWT.
|
||||
|
||||
118
.planning/debug/category-content-not-on-site.md
Normal file
118
.planning/debug/category-content-not-on-site.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
status: diagnosed
|
||||
trigger: "UAT gap G-16.2-2: Updated content in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/categories/2 (\"Te grzyby, szczególnie\" changed into \"Te grzyby, a szczególnie\") and i see the change in backend even after refresh but not on frontend"
|
||||
created: 2026-10-10T17:20:00+02:00
|
||||
updated: 2026-10-10T17:32:00+02:00
|
||||
goal: find_root_cause_only
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
hypothesis: CONFIRMED (H3) — the host Nuxt app's Nitro swr route cache (`routeRules '/**': { swr: 300 }`) served /cordyceps rendered at 14:59:08 UTC, before the first save; every check the user made (14:59:52–15:00:30) was inside the 300 s fresh window, so Nitro returned that entry without revalidating, and nothing purges it on admin save. The save, the DB and the API are correct.
|
||||
test: done — DB SELECTs, API GET, two Nuxt GETs straddling a lazy revalidation, nginx access log timeline, deployed nitro.mjs cache code
|
||||
expecting: n/a
|
||||
next_action: return ROOT CAUSE FOUND (goal: find_root_cause_only); the gap is a cache-window/UAT-procedure issue, plus a product decision whether editors need instant visibility (purge on save)
|
||||
bug_class: Bohrbug (deterministic: any page view within maxAge of the last render returns the cached entry; the first view after expiry returns it too)
|
||||
reasoning_checkpoint:
|
||||
hypothesis: "The site showed the old text because Nitro's swr route cache for /** (maxAge 300 s, swr: true, in-memory) held a /cordyceps render from 14:59:08 UTC; all the user's post-save views (last at 15:00:30) were before that entry expired at 15:04:08, and Nitro only re-renders an entry when a request arrives after expiry (serving the stale copy to that request). No code path invalidates the entry when the admin saves."
|
||||
confirming_evidence:
|
||||
- "DB row 2 and GET /_journal/api/v1/categories return the new text with identical md5 4053158bd1a58eb7f9547c18cb5a1724"
|
||||
- "127.0.0.1:3016/cordyceps at 15:24:01: last-modified 14:59:08, old text; at 15:25:00: last-modified 15:24:01, new text"
|
||||
- "nginx access log: render 14:59:08, PUTs 14:59:48–15:00:27, user views 14:59:52–15:00:30 all 13269 bytes or 304, no further views until 15:23"
|
||||
- "deployed nitro.mjs: /** cache { swr: true, maxAge: 300 }; get() returns the unexpired entry without resolving and the expired entry stale-first"
|
||||
falsification_test: "If a /cordyceps GET more than 300 s after the save, followed by a second GET, still returned the old text, or the access log showed a user view after 15:04:08 that returned old content and a later one that also did, the hypothesis would be wrong. Observed: post-expiry GET pair returned old then new, exactly as predicted."
|
||||
fix_rationale: "Not applicable (diagnose only). Any fix must either shorten/bypass the cache for editors or invalidate the Nitro cache entry on save; changing the 16.2 save path cannot help, because the save is already correct."
|
||||
blind_spots: "I did not observe the user's browser directly (no client-side cache test), but the access log shows their requests reaching Nuxt and getting the cached body. The swr cache key for _payload.json follows the same rule and was not separately timed. I did not verify whether the 7 PUTs each changed content (only the final state matters)."
|
||||
candidate_causes:
|
||||
- "config: Nitro routeRules swr 300 s with no purge on save (CONFIRMED)"
|
||||
- "code: save writes a different column/locale than the API reads (ELIMINATED — one column, no translation rows)"
|
||||
- "code: Go API caches or returns a stale field (ELIMINATED — API md5 == DB md5)"
|
||||
- "environment: nginx proxy_cache (ELIMINATED — none; reproduced on 127.0.0.1:3016)"
|
||||
- "environment: broken swr revalidation (ELIMINATED — refresh observed at 15:24:01, no SWR errors logged)"
|
||||
- "data: phrase rendered from elsewhere (ELIMINATED — grep of app/layer/build/posts)"
|
||||
- "process: UAT step 'the site shows the change' omits the swr window (CONTRIBUTING)"
|
||||
and_gate: "yes — the visible failure needed the swr cache with no invalidation AND the user viewing the page only within the 300 s window of a render made just before the edit (their own 14:59:08 view). With either one absent (no cache, or a second view after 15:04:08) the change would have shown."
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: changing one word in a Journal category body in the admin and saving shows no removal notice, and the public site shows the change (Phase 16.2 D-19)
|
||||
actual: the save persisted (admin shows "Te grzyby, a szczególnie" after reload), no error reported, but the public Nuxt site still shows "Te grzyby, szczególnie"
|
||||
errors: none reported
|
||||
reproduction: UAT Test 2 — edit category 2 body at https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/categories/2, save, view the public category page
|
||||
started: discovered during Phase 16.2 UAT, edit made ~2026-10-10 17:00 CEST
|
||||
|
||||
## Eliminated
|
||||
|
||||
- hypothesis: H1 — two columns or a translation row: the admin saves one column/locale and the site renders another that is not regenerated
|
||||
evidence: golem15_journal_categories has a single `content` column (no content_html); row 2 updated_at 2026-10-10 15:00:27.94 UTC holds "Te grzyby, a szczególnie" at char 589 and no old phrase; golem15_translate_attributes/indexes have no category rows (only 4 Post rows); en locale is disabled
|
||||
timestamp: 2026-10-10T15:22Z
|
||||
|
||||
- hypothesis: H2 — the public Journal API returns old content (server-side cache in Go, or a different field)
|
||||
evidence: GET http://127.0.0.1:8096/_journal/api/v1/categories?include_empty=1 on the UAT host at 15:24:13 UTC returns category 2 content with "Te grzyby, a szczeg" at 577, no old phrase, md5 4053158bd1a58eb7f9547c18cb5a1724 == DB md5(content); no cache headers
|
||||
timestamp: 2026-10-10T15:24Z
|
||||
|
||||
- hypothesis: H3 variant — the Nitro swr cache is broken (background revalidation errors or hangs, so the entry never refreshes)
|
||||
evidence: the first GET after expiry (15:24:01) triggered a refresh; the next GET (15:25:00) carried last-modified 15:24:01 and the new text; no `[cache] SWR handler error` in /var/log/supervisor/grzybyfunkcjonalne-nuxt.log
|
||||
timestamp: 2026-10-10T15:25Z
|
||||
|
||||
- hypothesis: H3 variant — an nginx proxy cache serves stale pages
|
||||
evidence: sites-enabled/uat.grzybyfunkcjonalne.pl has no proxy_cache; the stale page reproduced against 127.0.0.1:3016 directly (bypassing nginx)
|
||||
timestamp: 2026-10-10T15:24Z
|
||||
|
||||
- hypothesis: H4 — the old text is rendered from somewhere else (hardcoded, another category, a post excerpt)
|
||||
evidence: no match for the old phrase in the app, the layer, the deployed .output or any post column; it was only in category 2 content, and the refreshed render shows the new phrase
|
||||
timestamp: 2026-10-10T15:29Z
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: 2026-10-10T15:22Z
|
||||
checked: UAT DB, SELECT on golem15_journal_categories (all rows) and golem15_translate_* for category model types
|
||||
found: category 2 (cordyceps) content md5 4053158bd1a58eb7f9547c18cb5a1724, length 13903, updated_at 15:00:27.940875 UTC, new phrase present, old phrase absent; no translation rows for categories
|
||||
implication: the save persisted the edit in the only column the API reads; the stale copy is downstream of the DB
|
||||
|
||||
- timestamp: 2026-10-10T15:23Z
|
||||
checked: nginx site uat.grzybyfunkcjonalne.pl (sites-enabled) and the host Nuxt app nuxt.config.ts
|
||||
found: nginx has no proxy_cache; `/` proxies to Nuxt on 127.0.0.1:3016. nuxt.config.ts routeRules `'/**': { swr: 300 }, '/blog/**': { swr: 120 }` (Nitro route cache on the Node server); category pages are at `/:category` (so /cordyceps falls under `/**` swr 300)
|
||||
implication: Nitro's swr route cache is the only HTTP cache between the API and the browser
|
||||
|
||||
- timestamp: 2026-10-10T15:24Z
|
||||
checked: curl -D - http://127.0.0.1:3016/cordyceps on the UAT host (bypasses nginx/basic auth)
|
||||
found: 200, `last-modified: Sat, 10 Oct 2026 14:59:08 GMT`, `cache-control: s-maxage=300, stale-while-revalidate`, body contains the OLD "Te grzyby, szczególnie Cordyceps sine"
|
||||
implication: Nuxt serves a page rendered 79 s BEFORE the save, 25 min after it — far past the 300 s swr TTL. The cache entry is not being refreshed (H3 territory), or a refresh re-renders from stale data
|
||||
|
||||
- timestamp: 2026-10-10T15:24Z
|
||||
checked: vue-journal-layer app/journal-pages/CategoryPage.vue, app/utils/client.ts, app/composables/useJournalApi.ts and the host override sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/composables/useJournalApi.ts
|
||||
found: the category page resolves the slug via GET /categories?include_empty=1 and renders found.content through sanitizeJournalHtml; the server fetcher is a plain $fetch to runtimeConfig.journal.apiBase with no cachedFunction/useStorage/getCachedData
|
||||
implication: no data-level cache in the layer or app; the only cache is the Nitro route (swr) cache around the page renderer
|
||||
|
||||
- timestamp: 2026-10-10T15:25Z
|
||||
checked: second curl of http://127.0.0.1:3016/cordyceps on the UAT host, 59 s after the 15:24:01 request (also /, /blog, /lions-mane)
|
||||
found: /cordyceps now `last-modified: Sat, 10 Oct 2026 15:24:01 GMT` and the body contains the NEW "Te grzyby, a szczególnie"; /lions-mane last-modified 12:40:55, / 14:37:04, /blog 12:40:53 (entries only move when visited after expiry)
|
||||
implication: revalidation works; it is lazy. My 15:24:01 GET (first request after expiry) was served the stale page and triggered the background re-render; the next request got the fresh page. Nothing was broken in the render/refresh path at 15:24
|
||||
|
||||
- timestamp: 2026-10-10T15:26Z
|
||||
checked: nginx access log /var/log/nginx/grzybyfunkcjonalne-uat-access.log, 14:00–15:25 UTC, requests to /cordyceps and the categories admin API
|
||||
found: 14:59:08 GET /cordyceps 200 13320 (the user's pre-edit view; cache entry mtime = 14:59:08, fresh until 15:04:08). PUT /gfadmin/api/v1/golem15/journal/categories/2 x7 at 14:59:48, 14:59:57, 15:00:06, 15:00:13, 15:00:16, 15:00:23, 15:00:27 (all 200). GET /cordyceps at 14:59:52, 14:59:53 (304), 14:59:54 (304), 14:59:55 (304), 15:00:00 x2, 15:00:07–15:00:09 x6, 15:00:30 — every one 200 13269 or 304 (same etag). No /cordyceps request between 15:00:30 and my 15:23:42 one
|
||||
implication: every page view the user made after saving fell inside the 300 s fresh window of the entry rendered at 14:59:08, so Nitro returned it without even scheduling a revalidation; identical 13269-byte bodies and 304s confirm one cached entry was served throughout
|
||||
|
||||
- timestamp: 2026-10-10T15:27Z
|
||||
checked: deployed /srv/grzybyfunkcjonalne/nuxt/.output/server/chunks/nitro/nitro.mjs (defineCachedFunction get(), routeRules, storage mounts)
|
||||
found: routeRules `/**` → `cache: { swr: true, maxAge: 300 }`, `/blog/**` → maxAge 120. get(): `expired = ... ttl && Date.now() - entry.mtime > ttl`; not expired → returns the entry, no resolve; expired + swr → returns the stale entry and re-renders in the background (event.waitUntil). Cache storage is the root in-memory driver (only `data` is mounted to fsLite). Nuxt log has no `[cache] SWR handler error`
|
||||
implication: the observed behaviour is exactly Nitro swr semantics: an edit becomes visible only after the TTL has expired AND one request has triggered the re-render, i.e. on the second visit after expiry; worst case is unbounded in wall time for a page nobody visits, best case is ~300 s + one throwaway load
|
||||
|
||||
- timestamp: 2026-10-10T15:28Z
|
||||
checked: grep for purge/revalidate/invalidate/swr in sm-journal-plugin, vue-journal-layer, sm-grzybyfunkcjonalne-app; planning docs (16-CONTEXT D-07, DEPLOY.md, host README, 16.2-VERIFICATION)
|
||||
found: no cache purge or invalidation hook anywhere (Go save path does not notify Nuxt; Nuxt exposes no purge route). Phase 16 D-07 chose swr deliberately ("a newly published post shows up without a rebuild"), TTLs were Claude's discretion; host README: "a newly published post appears within the TTL"; DEPLOY.md: "The Nuxt page caches (swr) expire by themselves." 16.2-VERIFICATION human item (line 39/211) and the UAT step say "the site shows the change" with no mention of the cache window
|
||||
implication: the staleness is designed behaviour of the Phase 16 host app, not a 16.2 regression; the UAT step omitted the wait, and there is no editor-facing way to see an edit immediately
|
||||
|
||||
- timestamp: 2026-10-10T15:29Z
|
||||
checked: grep for "Te grzyby, szczeg" in the host app (app/, i18n/), vue-journal-layer (app/, i18n/), the deployed Nuxt .output, and golem15_journal_posts content/content_html/excerpt
|
||||
found: no matches anywhere; the phrase exists only in category 2 content (now edited)
|
||||
implication: H4 eliminated — the old text on the site came from the category content in the cached render
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: "Not a 16.2 save defect. The save, the DB row and the Journal API all carry the edit. The host app's Nitro swr route cache (vue-grzybyfunkcjonalne-app nuxt.config.ts routeRules '/**': { swr: 300 }, deployed as cache { swr: true, maxAge: 300 }, in-memory) served /cordyceps rendered at 14:59:08 UTC. That render came from the user's own pre-edit view, 40 s before the first PUT (14:59:48). Every post-save view (14:59:52 to 15:00:30) fell inside the entry's 300 s fresh window (to 15:04:08), and Nitro returns an unexpired entry without revalidating. Nothing invalidates the entry on admin save: there is no purge hook in sm-journal-plugin, vue-journal-layer or the host app. After expiry, the first view still gets the stale copy and triggers the re-render, so an edit shows only on the second view more than 300 s after the last render. Contributing: the 16.2 UAT/VERIFICATION step 'the site shows the change' does not account for the Phase 16 D-07 swr window."
|
||||
fix: (not applied — goal find_root_cause_only)
|
||||
verification: "Observed self-healing without any write: 127.0.0.1:3016/cordyceps returned the old text at 15:24:01 UTC (last-modified 14:59:08) and the new 'Te grzyby, a szczególnie' at 15:25:00 (last-modified 15:24:01)"
|
||||
files_changed: []
|
||||
152
.planning/debug/postgres-gate-field-types-conformance.md
Normal file
152
.planning/debug/postgres-gate-field-types-conformance.md
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
status: diagnosed
|
||||
trigger: "UAT gap G-16.2-1: bash scripts/check-phase16.2.sh postgres fails in ./modules/cabana: TestPhase10OpenAPIConformance: openapi_conformance_test.go:418: admin route GET /field-types has no conformance case"
|
||||
created: 2026-10-10T17:10:00+02:00
|
||||
updated: 2026-10-10T17:28:00+02:00
|
||||
goal: find_root_cause_only
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
hypothesis: CONFIRMED — the postgres stage is red for two code defects, both written in 16.2 and both hidden because the checks only run in Docker-only test binaries that never ran before UAT: (1) two 16.2 admin routes have no conformCase in TestPhase10OpenAPIConformance; (2) sm-journal-plugin TestJournalSVGEmbed still asserts post content `type: mlmarkdown` after 16.2-03 changed it to `mlblogeditor`.
|
||||
test: done — static diff, 3x direct repro, full cabana and Journal postgres-stage runs on this Docker host
|
||||
expecting: n/a
|
||||
next_action: return ROOT CAUSE FOUND (goal: find_root_cause_only); plan-phase --gaps owns the fix
|
||||
bug_class: Bohrbug (deterministic; Go map iteration only changes WHICH of the two missing keys line 418 reports first)
|
||||
reasoning_checkpoint:
|
||||
hypothesis: "TestPhase10OpenAPIConformance fails because cabana.AdminAPIRoutes() (phase09Routes non-spa keys) has 56 entries and the conformCase list 54: GET /field-types and POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload, both added to the inventory in 16.2-01, have no case; once fixed, the same stage fails next on TestJournalSVGEmbed because it asserts `type: mlmarkdown` in models/post/fields.yaml, which 16.2-03 replaced with `type: mlblogeditor`."
|
||||
confirming_evidence:
|
||||
- "static diff of security_coverage_test.go phase09Routes vs conformCase keys: uncovered = [GET /field-types, POST .../fields/{field}/upload]; not-inventoried = []"
|
||||
- "go test -count=3 -run '^TestPhase10OpenAPIConformance$' ./modules/cabana: FAIL 3/3, line 418 reports upload, field-types, upload"
|
||||
- "Journal `go test -json -count=1 ./...` on this Docker host: only failure is TestJournalSVGEmbed, plugin_test.go:123 'post fields.yaml content is not mlmarkdown'; fields.yaml line 19 is `type: mlblogeditor` since fbd1c85"
|
||||
- "cabana with -skip TestPhase10OpenAPIConformance: package PASS, 291 top-level tests, TestFieldTypeSavePostgres and TestListRelationSearchPostgres PASS with real Postgres queries in the output"
|
||||
falsification_test: "If adding conformCases for exactly those two keys (and fixing the mlmarkdown assertion) still left the postgres stage red, or if the inventory/case diff showed a third key, the hypothesis would be incomplete."
|
||||
fix_rationale: "Adding the two cases satisfies the exhaustiveness invariant and checks the real responses against admin.json (FieldTypesResponse, Envelope-cabana_FieldUploadResponse); updating the stale Journal assertion aligns it with the intended 16.2 field type. Separating the static inventory check from the Docker setup closes the masking path."
|
||||
blind_spots: "The conformance cases themselves have not been written, so it is unverified that the fixture-backed responses decode cleanly with DisallowUnknownFields (e.g. FieldTypesResponse meta is `{}` from WriteData, which decodes into SuccessMeta; the upload case needs a conform plugin field type with Upload). Admin/editor/layer/docs stages were not re-run in this session."
|
||||
candidate_causes:
|
||||
- "code: missing conformCase entries for the two 16.2 routes (CONFIRMED)"
|
||||
- "code: stale TestJournalSVGEmbed assertion vs the intended mlblogeditor change (CONFIRMED)"
|
||||
- "environment: no Docker on the 16.2 execution host, so every testcontainers binary failed on docker.sock and was waved through as 'Docker-only' (CONFIRMED contributing)"
|
||||
- "config/process: gate go stage FRAMEWORK_RUN omits TestPhase10OpenAPIConformance; the static inventory check runs after newConformEnv -> adminGorm; 16.2-01 PLAN/PATTERNS name phase09Routes + admin_openapi.go but not openapi_conformance_test.go (CONFIRMED contributing)"
|
||||
- "data/environment: container start failure or flaky Postgres (ELIMINATED — containers start, 291 cabana + 66 Journal top-level tests pass)"
|
||||
and_gate: "yes — the UAT failure needed the code defects AND the masking (static checks reachable only through testcontainers, no Docker during execution, go stage regex omits the test). Either code defect alone turns the stage red; the masking is why neither was caught before UAT."
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: `bash scripts/check-phase16.2.sh postgres` passes on a Docker host (cabana testcontainers suites incl. TestFieldTypeSavePostgres; Journal admin harness in ../sm-journal-plugin incl. TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor)
|
||||
actual: run 2026-10-10 16:57 CEST on a Docker host, the stage fails in ./modules/cabana: `--- FAIL: TestPhase10OpenAPIConformance` — `openapi_conformance_test.go:418: admin route GET /field-types has no conformance case`. The script stops there; the Journal Postgres suites never ran.
|
||||
errors: openapi_conformance_test.go:418: admin route GET /field-types has no conformance case
|
||||
reproduction: bash scripts/check-phase16.2.sh postgres (UAT Test 1)
|
||||
started: Phase 16.2 (GET /field-types added in 16.2; discovered during UAT)
|
||||
|
||||
## Eliminated
|
||||
|
||||
- hypothesis: the postgres stage fails because of Docker/testcontainers environment problems (container start, timeouts)
|
||||
evidence: in the UAT log the cabana package ran 90s and only TestPhase10OpenAPIConformance failed; in this session cabana (skip conformance) passed 291 top-level tests in 77s and the Journal run passed every package but root, with real SQL in the logs
|
||||
timestamp: 2026-10-10T17:21:00+02:00
|
||||
|
||||
- hypothesis: only GET /field-types is missing a conformance case
|
||||
evidence: the inventory/case diff shows POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload is uncovered too; `go test -count=3` reported the upload key in runs 1 and 3 and field-types in run 2
|
||||
timestamp: 2026-10-10T17:24:00+02:00
|
||||
|
||||
- hypothesis: the gap in the Journal suites is limited to the cabana abort (they would pass once cabana is green)
|
||||
evidence: running the Journal stage command directly fails on TestJournalSVGEmbed (stale mlmarkdown assertion), which is in PG_JOURNAL_REQUIRE
|
||||
timestamp: 2026-10-10T17:20:00+02:00
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: 2026-10-10T17:12:00+02:00
|
||||
checked: .planning/debug/knowledge-base.md (Phase 0)
|
||||
found: no knowledge base file exists; no prior pattern to test
|
||||
implication: open-ended investigation
|
||||
|
||||
- timestamp: 2026-10-10T17:13:00+02:00
|
||||
checked: scratchpad/pg.log (UAT run of the postgres stage)
|
||||
found: detect_go reports failed only "modules/cabana TestPhase10OpenAPIConformance" plus the package; openapi_conformance_test.go:418 "admin route GET /field-types has no conformance case"; package took 90s (containers started fine)
|
||||
implication: the only failing cabana test in that run was the conformance test; run_postgres aborts (set -e) before the Journal go_json call
|
||||
|
||||
- timestamp: 2026-10-10T17:15:00+02:00
|
||||
checked: modules/cabana/openapi_conformance_test.go:129-450
|
||||
found: TestPhase10OpenAPIConformance calls newConformEnv(t) FIRST (line 130), then builds the conformCase list, then requires covered == inventory where inventory = cabana.AdminAPIRoutes() (export_test.go: every non-spa phase09Routes key). Missing key -> t.Fatalf at line 418 inside a `for key := range inventory` map loop (random order, so only the first missing key found is reported).
|
||||
implication: the exhaustiveness check is purely static but cannot run without a Postgres container
|
||||
|
||||
- timestamp: 2026-10-10T17:16:00+02:00
|
||||
checked: modules/cabana/auth_test.go:321-365 adminGorm
|
||||
found: testing.Short() -> t.Skip; otherwise testcontainers postgres:16-alpine; a container error -> t.Fatalf("postgres: %v")
|
||||
implication: on a host without Docker the conformance test fails at setup with a docker.sock error, masking the inventory check
|
||||
|
||||
- timestamp: 2026-10-10T17:17:00+02:00
|
||||
checked: grep of conformCase keys vs modules/cabana/security_coverage_test.go phase09Routes
|
||||
found: TWO inventoried non-spa routes have no conformCase: "GET /field-types" (inventory line 46) and "POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload" (inventory line 92). `grep 'fields/{field}/upload\|/field-types' openapi_conformance_test.go` -> no match.
|
||||
implication: fixing only GET /field-types would leave the test failing on the upload route next
|
||||
|
||||
- timestamp: 2026-10-10T17:18:00+02:00
|
||||
checked: git log -S on security_coverage_test.go and git log of openapi_conformance_test.go
|
||||
found: GET /field-types entered the inventory in 717d46e (feat(16.2-01) compile, decode and load plugin field types), the upload route in 8fa92db (feat(16.2-01) ... uploads). openapi_conformance_test.go was last touched by 64d21c8/143eca5 (My account), b492e79 (markdown preview) — earlier route additions did add a conformCase in the same change; 16.2 did not.
|
||||
implication: a convention ("new admin route => conformCase") that held before 16.2 was missed in 16.2-01
|
||||
|
||||
- timestamp: 2026-10-10T17:19:00+02:00
|
||||
checked: .planning/phases/16.2-*/ SUMMARY and VERIFICATION docs
|
||||
found: no 16.2 plan mentions TestPhase10OpenAPIConformance; 16.2-01/03/05/06 SUMMARYs record "No Docker on this host (docker.sock permission denied)" and "the only failures ... are the testcontainers suites failing on docker.sock" — cabana is in that list. VERIFICATION marks the postgres stage "? SKIP (human item 1)".
|
||||
implication: the conformance failure was hidden inside the blanket "Docker-only failure" bucket; nobody ran cabana's testcontainers suites before UAT
|
||||
|
||||
- timestamp: 2026-10-10T17:20:00+02:00
|
||||
checked: scripts/check-phase16.2.sh go stage
|
||||
found: FRAMEWORK_RUN regex lists TestPhase09ContractInventory and TestPhase09PermissionMatrix (static, both pass: they compare the mount with phase09Routes and admin.json paths) but NOT TestPhase10OpenAPIConformance; the go stage runs with -run "$FRAMEWORK_RUN" so the conformance test never runs there. The postgres stage runs the whole ./modules/cabana package and is the only stage that reaches it.
|
||||
implication: no non-Docker gate covers the inventory-vs-conformance-case invariant
|
||||
|
||||
- timestamp: 2026-10-10T17:21:00+02:00
|
||||
checked: admin/openapi/admin.json for both routes; modules/cabana/admin_openapi.go:264-276; field_type.go:834-871; contracts.go:245
|
||||
found: GET /field-types 200 documents $ref cabana.FieldTypesResponse ({data: []cabana.FieldTypeAssets, meta: cabana.SuccessMeta}, all of name/element/translatable/scripts/styles required); the handler writes WriteData(200, []FieldTypeAssets, nil) => {"data":[...],"meta":{}}. POST .../fields/{field}/upload 201 documents cabana.Envelope-cabana_FieldUploadResponse ({data:{url}, meta}); multipart part named "file". conformPlugin (openapi_conformance_test.go:818-843) does NOT implement pact.HasFormFieldTypes and the gadget fields.yaml has no plugin-typed field.
|
||||
implication: a GET /field-types case can decode into cabana.FieldTypesResponse with ref "cabana.FieldTypesResponse" today (empty list); a meaningful case and any upload case need the conform fixture to register a plugin field type (with Upload, scripts) and a gadget field of that type
|
||||
|
||||
- timestamp: 2026-10-10T17:19:00+02:00
|
||||
checked: route registrations added since 16.1/16.2 started (git diff 1ef21b0~1..HEAD of non-test modules/**/*.go in summercms.go; c60abc6..HEAD of non-test *.go in sm-journal-plugin)
|
||||
found: summercms.go adds exactly two routes: g.Get("/field-types") and g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload"). sm-journal-plugin adds none (editor uploads go through the cabana route).
|
||||
implication: the conformance gap is exactly those two routes; no other 16.2 route is missing a case
|
||||
|
||||
- timestamp: 2026-10-10T17:19:30+02:00
|
||||
checked: ran the Journal postgres-stage command exactly as run_postgres does (`go test -json -count=1 -timeout 20m ./...` in ../sm-journal-plugin, which go.work/replace points at ../summercms.go) on this Docker host
|
||||
found: packages classes, controllers, controllers/api, models, updates PASS; root package FAIL. Required tests: TestPostsFormCompiles PASS, TestPostsAdminCreateSmoke PASS, TestPostsAdminResaveKeepsHTML PASS, TestPostsAdminSaveSanitizesHTML PASS, TestCategoriesAdminContent PASS, TestCategoriesAdminBlogEditor PASS, TestJournalSVGEmbed FAIL — plugin_test.go:123 "post fields.yaml content is not mlmarkdown". 66 top-level tests passed, no skips, no "no tests to run".
|
||||
implication: a SECOND, independent failure is queued behind the cabana one; fixing only the conformance case still leaves the postgres stage red
|
||||
|
||||
- timestamp: 2026-10-10T17:20:00+02:00
|
||||
checked: sm-journal-plugin plugin_test.go:118-124, models/post/fields.yaml, git show fbd1c85 / 47f3032
|
||||
found: TestJournalSVGEmbed asserts strings.Contains(fields.yaml, "type: mlmarkdown") (a Phase 15 assertion). 16.2-03 commit fbd1c85 "feat(admin): post content on the Blog Editor field type" changed post content from `type: mlmarkdown` to `type: mlblogeditor` (+ htmlColumn, uploadFolder); no other post field is mlmarkdown. 47f3032 (16.2-02) extended the same test with bundle checks but did not touch the mlmarkdown assertion. The root package TestMain needs testcontainers, so the test never ran on the no-Docker 16.2 execution host (16.2-02-SUMMARY line 263/278/293).
|
||||
implication: stale assertion contradicting an intended 16.2 change; same masking mechanism (Docker-only suite never executed before UAT)
|
||||
|
||||
- timestamp: 2026-10-10T17:22:00+02:00
|
||||
checked: static diff (python over security_coverage_test.go phase09Routes and the conformCase literal keys)
|
||||
found: inventory non-spa 56, cases 54; uncovered = ['GET /field-types', 'POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload']; not inventoried = []
|
||||
implication: exactly two cases are missing
|
||||
|
||||
- timestamp: 2026-10-10T17:23:00+02:00
|
||||
checked: cabana run on this Docker host: `go test -json -count=1 -timeout 15m -skip '^TestPhase10OpenAPIConformance$' ./modules/cabana`
|
||||
found: package PASS (77s), 291 top-level tests passed, no fails, no skips, no "no tests to run". TestFieldTypeSavePostgres PASS (real SELECT ... FOR UPDATE on ft_notes in its output), TestListRelationSearchPostgres PASS, TestMLOpenAPIConformance PASS.
|
||||
implication: TestPhase10OpenAPIConformance is the only cabana failure in the postgres stage
|
||||
|
||||
- timestamp: 2026-10-10T17:24:00+02:00
|
||||
checked: `go test -count=3 -timeout 10m -run '^TestPhase10OpenAPIConformance$' ./modules/cabana`
|
||||
found: FAIL 3/3: run1 "POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload has no conformance case", run2 "GET /field-types has no conformance case", run3 upload again
|
||||
implication: deterministic failure; which key is named depends on map iteration order
|
||||
|
||||
- timestamp: 2026-10-10T17:25:00+02:00
|
||||
checked: 16.2-01-PLAN.md tasks 2/3 and 16.2-PATTERNS.md line 24/109
|
||||
found: both name admin_openapi.go + phase09Routes (+ check-admin-openapi.sh) as the places to register a new admin route; neither names openapi_conformance_test.go. Task automated verifies run only TestPhase09ContractInventory/TestPhase09PermissionMatrix (static), which pass.
|
||||
implication: the route-registration checklist given to the executor was incomplete — the third location of the invariant was not in it
|
||||
|
||||
- timestamp: 2026-10-10T17:26:00+02:00
|
||||
checked: compileFieldTypes (field_type.go ~160-190), conformPlugin, admin.json schemas
|
||||
found: a registered type needs an element under the plugin's widget tag prefix and, for scripts/styles, pact.AdminAssets with the files in AdminFS (conformPlugin already has AdminFS with js/css assets). FieldTypesResponse decodes {"data":[...],"meta":{}} (SuccessMeta all-omitempty). Upload 201 is Envelope-cabana_FieldUploadResponse with multipart part "file".
|
||||
implication: fix-direction constraints for the plan (see Resolution)
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: "(1) Phase 16.2-01 added GET /field-types (717d46e) and POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload (8fa92db) to the admin route inventory (phase09Routes) and admin.json but added no conformCase to TestPhase10OpenAPIConformance (modules/cabana/openapi_conformance_test.go), whose exhaustiveness check (line 416-419) fails on whichever of the two it meets first; (2) sm-journal-plugin TestJournalSVGEmbed (plugin_test.go:122-123, in PG_JOURNAL_REQUIRE) still asserts `type: mlmarkdown` in models/post/fields.yaml after 16.2-03 (fbd1c85) changed post content to `type: mlblogeditor`; (3) contributing: both checks are static but live in testcontainers-only binaries (newConformEnv -> adminGorm runs before the inventory check; the Journal root TestMain starts Postgres), the 16.2 execution host had no Docker and the summaries filed every cabana/Journal-root failure as docker.sock noise, check-phase16.2.sh's go-stage FRAMEWORK_RUN omits TestPhase10OpenAPIConformance, and the 16.2-01 PLAN/PATTERNS route checklist omitted openapi_conformance_test.go."
|
||||
fix: ""
|
||||
verification: ""
|
||||
files_changed: []
|
||||
suggested_fix_direction: |
|
||||
- Add two conformCases. GET /field-types: status 200, ref "cabana.FieldTypesResponse", decode into[cabana.FieldTypesResponse](); register a plugin field type on the conform fixture (conformPlugin implements pact.HasFormFieldTypes; element under its widget tag prefix; a script already in conformFS) so the list is non-empty and the ?v= asset URL can be checked with assertPluginAsset. POST .../fields/{field}/upload: status 201, ref "cabana.Envelope-cabana_FieldUploadResponse", decode into[cabana.Envelope[cabana.FieldUploadResponse]](); a gadget fields.yaml field of that alias whose type has Upload, multipart part named "file", sent with requireAjax headers. Mind that a plugin-typed field on the gadget form also runs Decode/Load in the existing create/update/show cases — make them benign or give conformGadget a column for it.
|
||||
- Fix TestJournalSVGEmbed to assert the intended `type: mlblogeditor` (optionally htmlColumn: content_html) for post content.
|
||||
- Recurrence guard: split the static inventory check out of the Docker setup (e.g. move `env := newConformEnv(t)` after the coverage loop, or a package-level conformCaseKeys + a Docker-free TestPhase10ConformanceInventory) and add it to FRAMEWORK_RUN in check-phase16.2.sh (and later gates); add openapi_conformance_test.go to the "new admin route" checklist next to phase09Routes and admin_openapi.go.
|
||||
@@ -0,0 +1,690 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 07
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["16.2-01", "16.2-02", "16.2-03", "16.2-04", "16.2-05", "16.2-06"]
|
||||
gap_closure: true
|
||||
gap_ids: [G-16.2-1, G-16.2-2]
|
||||
files_modified:
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- scripts/check-phase16.2.sh
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md
|
||||
- ../sm-journal-plugin/classes/site_cache.go
|
||||
- ../sm-journal-plugin/classes/site_cache_test.go
|
||||
- ../sm-journal-plugin/plugin.go
|
||||
- ../sm-journal-plugin/controllers/categories.go
|
||||
- ../sm-journal-plugin/controllers/posts.go
|
||||
- ../sm-journal-plugin/controllers/admin_registry.go
|
||||
- ../sm-journal-plugin/site_cache_admin_test.go
|
||||
- ../sm-journal-plugin/plugin_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../vue-journal-layer/nuxt.config.ts
|
||||
- ../vue-journal-layer/app/utils/siteCache.ts
|
||||
- ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts
|
||||
- ../vue-journal-layer/server/plugins/journal-revalidate.ts
|
||||
- ../vue-journal-layer/testing/fixture-api.mjs
|
||||
- ../vue-journal-layer/testing/purge-check.mjs
|
||||
- ../vue-journal-layer/.playground/nuxt.config.ts
|
||||
- ../vue-journal-layer/tests/siteCache.test.ts
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
autonomous: false
|
||||
requirements: [D-03, D-13, D-18, D-19]
|
||||
user_setup:
|
||||
- service: UAT admin (https://uat.grzybyfunkcjonalne.pl/gfadmin)
|
||||
why: "The executor holds no UAT basic-auth or admin credentials. A human re-saves posts 1 and 64 untouched (UAT Test 3, D-03) and edits one word of category 2 (UAT Test 2, D-19), then checks the public category page in a browser."
|
||||
dashboard_config:
|
||||
- task: "Log in to the admin through the UAT basic-auth realm with an admin account that may edit posts and categories"
|
||||
location: "browser, https://uat.grzybyfunkcjonalne.pl/gfadmin"
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "G-16.2-1: on this Docker host `PHASE162_REQUIRE_POSTGRES=1 bash scripts/check-phase16.2.sh --all` prints `PASS postgres` and `PASS cache` and ends with `Phase 16.2 gate passed`; the postgres stage requires TestPhase10OpenAPIConformance, TestJournalSVGEmbed and every Journal admin harness suite named in UAT Test 1 (TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor) by name, and they pass."
|
||||
- "G-16.2-1 (D-13, D-18): TestPhase10OpenAPIConformance calls GET /field-types (200, decoded into cabana.FieldTypesResponse with unknown fields disallowed, admin.json ref cabana.FieldTypesResponse, the fixture plugin's field type listed with a served script) and POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload (201, decoded into cabana.Envelope[cabana.FieldUploadResponse], ref cabana.Envelope-cabana_FieldUploadResponse, multipart part `file`)."
|
||||
- "G-16.2-1 recurrence guard: the Docker-free TestPhase10ConformanceInventory fails, naming every missing key at once, when an inventoried admin route has no conformance case; it passes with -short and no database, and the gate's go stage requires it by name; 16.2-PATTERNS.md names modules/cabana/openapi_conformance_test.go next to phase09Routes and admin_openapi.go."
|
||||
- "G-16.2-2 (D-19): after the transaction of an admin create, update or delete of a Journal post or category commits, the Journal plugin sends exactly one POST to golem15.journal.site_cache.purge_url with `Authorization: Bearer <purge_token>` and a JSON body naming the kind and slug; a save that fails sends none; a failing, refused or slow purge (2 s timeout) is logged without the token and never changes the save response."
|
||||
- "G-16.2-2: the vue-journal-layer route POST /api/__journal__/cache/purge removes every Nitro route-cache entry (pages, _payload.json and feeds) and answers 200 {purged}; it answers 404 when no token of at least 32 characters is configured or the request carries a proxy header, 401 for a missing or wrong bearer token, and it is never route-cached itself, so every call runs."
|
||||
- "G-16.2-2: on a built site with swr route rules, a page cached before a content change shows the change on the first request after a purge, and route-cached pages and their 304 revalidations tell browsers max-age=0 while shared caches keep s-maxage; testing/purge-check.mjs proves this on the layer playground and on the host app build."
|
||||
- "Release: summercms v0.1.20 (the tag after v0.1.19), vue-journal-layer v0.3.0 (check-git-install passes), sm-journal-plugin main requiring v0.1.20, vue-grzybyfunkcjonalne-app main pinning #v0.3.0 and the host gitlinks are on origin; UAT runs the binary and Nuxt output built from those heads (sha256 equal); the purge URL and token exist once in go/.env and the same token once in nuxt/.env, generated on the server and never printed; the on-server smoke passes; an on-server purge answers 200 with purged >= 1 while a POST through nginx answers 403 and one without a token answers 401."
|
||||
- "UAT Test 3 (D-03) on the deployed build: after the human saves posts 1 and 64 untouched, the rows read 1:a026264105f900175b9770e637e5ec5c:a026264105f900175b9770e637e5ec5c and 64:0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c, the post 64 en translation reads 64:en:862fc5b362cc9856e6736a2078dca3ba, and both updated_at values moved past the restart."
|
||||
- "UAT Test 2 (D-19, D-10): after the human changes one word of category 2 and saves, no removal notice appears, the Go log has a successful site cache purge line for kind=category, the /cordyceps page served on the server is not older than the category's updated_at, and the human sees the change on the first view in the browser."
|
||||
artifacts:
|
||||
- path: "modules/cabana/openapi_conformance_test.go"
|
||||
provides: "conformance cases for both 16.2 routes and the Docker-free inventory test"
|
||||
contains: "TestPhase10ConformanceInventory"
|
||||
- path: "scripts/check-phase16.2.sh"
|
||||
provides: "gate with the inventory test in the go stage, the purge tests and the cache stage"
|
||||
contains: "TestPhase10ConformanceInventory"
|
||||
- path: "../sm-journal-plugin/classes/site_cache.go"
|
||||
provides: "SiteCachePurger, NewSiteCachePurger, PurgeSiteCacheAfterCommit"
|
||||
contains: "PurgeSiteCacheAfterCommit"
|
||||
- path: "../vue-journal-layer/server/api/__journal__/cache/purge.post.ts"
|
||||
provides: "token-protected route-cache purge"
|
||||
contains: "purgeRouteCache"
|
||||
- path: "../vue-journal-layer/server/plugins/journal-revalidate.ts"
|
||||
provides: "max-age=0 for browsers on route-cached responses"
|
||||
contains: "privateRevalidate"
|
||||
- path: "../vue-journal-layer/testing/purge-check.mjs"
|
||||
provides: "end-to-end purge check against a built server"
|
||||
contains: "purge-check: ok"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/controllers/categories.go and posts.go (FormAfterCreate, FormAfterUpdate, FormAfterDelete)"
|
||||
to: "classes.PurgeSiteCacheAfterCommit"
|
||||
via: "lagoon.AfterCommit on the cabana save transaction"
|
||||
pattern: "PurgeSiteCacheAfterCommit"
|
||||
- from: "../sm-journal-plugin/classes/site_cache.go SiteCachePurger.Purge"
|
||||
to: "../vue-journal-layer/server/api/__journal__/cache/purge.post.ts"
|
||||
via: "HTTP POST on loopback with the shared bearer token"
|
||||
pattern: "api/__journal__/cache/purge"
|
||||
- from: "../vue-journal-layer/server/api/__journal__/cache/purge.post.ts"
|
||||
to: "Nitro cache storage, group nitro/routes"
|
||||
via: "useStorage('cache') getKeys and removeItem"
|
||||
pattern: "nitro:routes"
|
||||
- from: "UAT go/.env SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN"
|
||||
to: "UAT nuxt/.env NUXT_JOURNAL_CACHE_PURGE_TOKEN"
|
||||
via: "the same server-generated value"
|
||||
pattern: "NUXT_JOURNAL_CACHE_PURGE_TOKEN"
|
||||
prohibitions:
|
||||
- statement: "The executor runs only SELECT queries on the UAT database; post and category data change only through human admin saves."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The executor never requests, prints, stores or commits a credential: UAT basic auth, admin password, server .env values and the purge token (generated on the server, compared there, and passed to curl on stdin, never in argv or output)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The live Winter site, its vhost, php-fpm and the PHP database are not touched (Phase 16 D-10)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No pushed tag is moved or deleted; a defect after a push gets the next tag (v0.1.21, v0.3.1)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework packages, READMEs and docs under summercms.go stay free of consuming-application names; the Journal and layer READMEs use neutral names (the host application, acme, blog)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Tests are not weakened: the only changed assertion is TestJournalSVGEmbed's stale mlmarkdown check, which now asserts the intended mlblogeditor type."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice: close the two gaps UAT found (16.2-UAT.md, diagnosed 2026-10-10), then release and redeploy so UAT Tests 2 and 3 can be re-run on the deployed build.
|
||||
|
||||
- **G-16.2-1** (UAT Test 1, `.planning/debug/postgres-gate-field-types-conformance.md`): the postgres stage of the phase gate is red. Two 16.2 admin routes have no conformance case, the Journal SVG test still expects `mlmarkdown`, and both checks were hidden inside Docker-only test binaries.
|
||||
- **G-16.2-2** (UAT Test 2, `.planning/debug/category-content-not-on-site.md`): the save was correct, but the host Nuxt app's Nitro swr route cache (`'/**': { swr: 300 }`, Phase 16 decision D-07) kept serving the page rendered before the edit. User decision 2026-10-10: fix it in 16.2. A Journal post or category save in the admin clears the host's route-cache entries, so the site shows the edit on the next view.
|
||||
|
||||
## User decisions (binding, 2026-10-10)
|
||||
|
||||
- Exactly one plan, this one. The user overrode CLAUDE.md lean rule 3 ("unit tests are a separate last plan") for this gap closure. The tests are the final code task (Task 3), before the release tasks. The 2-3 tasks per plan guideline gives way to that decision, so this plan has six tasks.
|
||||
- G-16.2-2 is fixed inside 16.2, by a Journal save event, a token-protected Nuxt server route on 127.0.0.1, and host configuration for the URL and token.
|
||||
- G-16.2-1: two conformance cases, a Docker-free route-coverage check in the gate's go stage, the TestJournalSVGEmbed fix and the PATTERNS checklist line.
|
||||
- The plan ends with the full gate green, including postgres (this machine has Docker), then a release and a UAT deploy following 16.2-05. The UAT-touching steps are checkpoints, as in 16.2-05.
|
||||
|
||||
## Design decisions (planner, within the user decisions)
|
||||
|
||||
- **Where each part lives.**
|
||||
- The Go save event is in **sm-journal-plugin** (`classes/site_cache.go` plus the post and category Form hooks). The framework is unchanged, so no framework README or docs page changes.
|
||||
- The purge route is in **vue-journal-layer**, so every blog host that extends the layer gets it.
|
||||
- The host supplies the URL and the shared token through the server `.env` files: `go/.env` gets `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL` and `..._TOKEN`, and `nuxt/.env` gets `NUXT_JOURNAL_CACHE_PURGE_TOKEN`.
|
||||
- With neither set, nothing changes. Local development and the existing tests need no configuration.
|
||||
- **What is purged.** The route removes every entry of Nitro's route-cache group (`cache:nitro:routes:*`): pages, `_payload.json` and feeds. This is a superset of the decision's list (category and post pages, `/blog` lists and their payloads).
|
||||
- A precise per-page purge cannot be computed from one save. The host's header menu lists every category on every page. The home page, category pages, related posts and the author page all list posts.
|
||||
- Nitro keys entries as the first 16 word characters of the path plus a hash, so matching pages would mean mirroring Nitro internals.
|
||||
- At blog scale, a full purge only costs one fresh render per page on its next view.
|
||||
- **Ordering.** The purge runs through `lagoon.AfterCommit` on the cabana save transaction. If it ran before the commit, a concurrent view could re-cache the old content for the whole TTL. It never runs for a failed save. It runs synchronously before the save response, with a 2 s timeout on `context.WithoutCancel`, so the cache is already clear when the admin sees "saved". Errors are logged, never returned.
|
||||
- **The route is never cached itself.** Nitro wraps every handler whose route matches a host rule such as `'/**': { swr }` in its cached handler. That handler keys by path and does not skip POST (nitropack 2.13.4 `dist/runtime/internal/cache.mjs`), so without a rule a second purge within the TTL would get the first response from the cache. The layer adds `routeRules: { '/api/__journal__/**': { cache: false } }`.
|
||||
- **Browsers.** Nitro answers route-cached pages with `cache-control: s-maxage=<n>, stale-while-revalidate`. That carries no browser max-age, so browsers apply heuristic freshness from Last-Modified. h3's `handleCacheHeaders` answers a 304 with `public, max-age=<n>, s-maxage=<n>` and ends the response itself. After one revalidation, a browser may reuse its copy for the whole TTL without asking, and no server purge would reach it.
|
||||
- The layer's Nitro plugin rewrites every cache-control value that carries s-maxage to include `max-age=0`.
|
||||
- Browsers then revalidate each view, which is a cheap 304 while the page is unchanged. Shared caches keep s-maxage.
|
||||
- The rewrite works through a per-request wrapper of `res.setHeader`, because the 304 path ends the response before any beforeResponse hook runs.
|
||||
- **Guards.** The route is POST only. It answers 404 when the token is missing or shorter than 32 characters. It also answers 404 when the request carries `X-Forwarded-For`, `X-Real-IP` or `Forwarded`: the UAT vhost sets the first two on every proxied request, and the Go caller sends none. A missing or wrong bearer token gets 401, compared in constant time. The UAT vhost already refuses POST to Nuxt (`limit_except GET HEAD`, required by check-deploy.sh), so the route is reachable only on loopback.
|
||||
- **Scope.** Admin saves of posts and categories purge. The public write API (D-09), the import action and tags do not, because the decision names admin post and category saves.
|
||||
|
||||
## Multi-source coverage audit
|
||||
|
||||
| Source | Item | Covered by |
|
||||
|---|---|---|
|
||||
| GOAL | Imported HTML posts edited losslessly, visible on the site | Task 6 (Test 3 md5, Test 2 site view) |
|
||||
| REQ | ROADMAP lists none (TBD); the phase's decisions act as requirements | D-03, D-13, D-18, D-19 below |
|
||||
| CONTEXT | D-03 untouched re-save byte-identical | Tasks 3 (TestPostsAdminPurgesSiteCache untouched update), 6 (Test 3) |
|
||||
| CONTEXT | D-13 global field-type assets (GET /field-types) | Task 3 conformance case |
|
||||
| CONTEXT | D-18 field upload route | Task 3 conformance case |
|
||||
| CONTEXT | D-19 category content on the Blog Editor, visible on the site | Tasks 1, 2, 3, 6 |
|
||||
| GAP | G-16.2-1 conformance case GET /field-types | Task 3 |
|
||||
| GAP | G-16.2-1 conformance case for the field upload route | Task 3 |
|
||||
| GAP | G-16.2-1 TestJournalSVGEmbed expects mlblogeditor | Task 3 |
|
||||
| GAP | G-16.2-1 route-coverage check without Docker in the go stage | Task 3 |
|
||||
| GAP | G-16.2-1 PATTERNS checklist names openapi_conformance_test.go | Task 3 |
|
||||
| GAP | G-16.2-2 save clears the matching host page caches (pages, /blog lists, _payload.json) | Tasks 1, 2 (superset purge, see Design decisions) |
|
||||
| USER | Full gate incl. postgres green on this Docker host | Task 3 |
|
||||
| USER | Release (tag after v0.1.19, plugin/layer/host bumps) and UAT deploy per 16.2-05 | Tasks 4, 5 |
|
||||
| USER | UAT Tests 3 and 2 can be re-run | Task 6 |
|
||||
| RESEARCH | 16.2-RESEARCH.md has no item open for these gaps | none |
|
||||
|
||||
No item is missing. Deferred ideas from 16.2-CONTEXT.md are untouched.
|
||||
|
||||
## Release facts at planning time (2026-10-10)
|
||||
|
||||
- summercms.go: v0.1.19 is tagged and on origin (cf10ce8). UAT never ran it: the UAT binary was built from b5c70b7 at 12:37 UTC. The next tag is **v0.1.20**.
|
||||
- sm-journal-plugin: main 0efaa04 is on origin and requires v0.1.19.
|
||||
- vue-journal-layer: main 782aa4d is on origin. The latest tag is v0.2.0, so the next tag is **v0.3.0** (a new feature).
|
||||
- vue-grzybyfunkcjonalne-app: main 465286a is on origin and pins #v0.2.0.
|
||||
- Host sm-grzybyfunkcjonalne-app: master 0ad1004 is on origin, with journal gitlink 2c78211 (before the review fixes) and vue app gitlink 465286a.
|
||||
- Docker 29.7.2 answers on this machine.
|
||||
|
||||
<objective>
|
||||
Close G-16.2-1 (the phase gate's postgres stage is red) and G-16.2-2 (an admin save does not show on the site until the Nitro swr cache expires). Then run the full gate including postgres, release summercms v0.1.20 and vue-journal-layer v0.3.0 with the plugin and host bumps, deploy to UAT per 16.2-05, and re-run UAT Tests 3 and 2 (D-03, D-19).
|
||||
|
||||
Purpose: the phase cannot pass verification while its gate is red, and editors must see a Journal edit on the site at once.
|
||||
Output: the conformance and purge code and tests in four repositories, an updated gate, pushed tags and heads, a UAT release, and recorded UAT results.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-UAT.md
|
||||
@.planning/debug/postgres-gate-field-types-conformance.md
|
||||
@.planning/debug/category-content-not-on-site.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-PLAN.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md
|
||||
@../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
|
||||
Repositories (siblings of summercms.go; run commands from summercms.go):
|
||||
- summercms.go: framework tests, gate, planning docs.
|
||||
- ../sm-journal-plugin: the Go save event.
|
||||
- ../vue-journal-layer: the purge route, browser revalidation and the end-to-end check.
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app: the layer pin and README.
|
||||
- ../sm-grzybyfunkcjonalne-app: deploy files, docs, gitlinks and the UAT build.
|
||||
|
||||
The host builds the Journal plugin from its own submodule checkout (plugins/golem15/journal) and the framework from ../summercms.go through go.work.
|
||||
|
||||
Server facts (DEPLOY.md, 16.2-05-SUMMARY):
|
||||
- SSH alias `grzybyfunkcjonalne-uat`; install root `/srv/grzybyfunkcjonalne`, with `go/.env` and `nuxt/.env` owned by the deploy user, mode 0600.
|
||||
- The Nuxt rollback copy lives at `nuxt/.output.prev`. Delete the old copy before `cp -a`.
|
||||
- `sudo supervisorctl tail` fails on UAT, so log checks read `/var/log/supervisor/grzybyfunkcjonalne-go.log` from the restart's River `client_id=` anchor.
|
||||
- The UAT realm exempts requests from 127.0.0.1, so on-server curls resolve the host to loopback.
|
||||
|
||||
Helpers to reuse:
|
||||
- `newPostsEnv(t, extraEnv ...string)` and `adminAPI` (../sm-journal-plugin/admin_harness_test.go).
|
||||
- `lagoon.AfterCommit` (modules/lagoon/transaction.go) and `app.Publish`/`app.Lookup` (modules/backpack/app.go).
|
||||
- `startFixtureApi` and `startNuxtServer` (../vue-journal-layer/testing/).
|
||||
- The fail-closed detectors in scripts/check-phase16.2.sh.
|
||||
|
||||
Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits; stage by explicit path in every repository (another session may work in modules/).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a Journal category admin update purges the host Nuxt route cache after commit, one path only (sm-journal-plugin, vue-journal-layer)</name>
|
||||
<reversibility rating="costly">The route path /api/__journal__/cache/purge, the JSON body, the config keys golem15.journal.site_cache.purge_url/purge_token and the env name NUXT_JOURNAL_CACHE_PURGE_TOKEN become host configuration in every blog host; renaming them means editing each host's server .env files.</reversibility>
|
||||
<precondition>`docker info` exits 0 on this machine (the sm-journal-plugin root package harness starts PostgreSQL through testcontainers).</precondition>
|
||||
<files>../sm-journal-plugin/classes/site_cache.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/site_cache_admin_test.go, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/app/utils/siteCache.ts, ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts, ../vue-journal-layer/testing/fixture-api.mjs, ../vue-journal-layer/testing/purge-check.mjs, ../vue-journal-layer/.playground/nuxt.config.ts</files>
|
||||
<read_first>.planning/debug/category-content-not-on-site.md (Evidence and Resolution); ../sm-journal-plugin/plugin.go; ../sm-journal-plugin/controllers/categories.go; ../sm-journal-plugin/controllers/posts.go (db helper, existing Form hooks); ../sm-journal-plugin/admin_harness_test.go (newPostsEnv extraEnv, adminAPI, the pool); ../sm-journal-plugin/categories_admin_editor_test.go (a category PUT through the harness); modules/lagoon/transaction.go (Transaction, AfterCommit); modules/backpack/app.go (Publish, Lookup); modules/compass/env.go (SUMMER_ key mapping: `__` becomes a dot, parts lower-cased); ../vue-journal-layer/nuxt.config.ts; ../vue-journal-layer/server/routes/rss.xml.ts and ../vue-journal-layer/server/api/__sitemap__/journal.ts (server handler style, imports from app/utils); ../vue-journal-layer/app/utils/routes.ts (pure erasable TypeScript helper style); ../vue-journal-layer/testing/fixture-api.mjs; ../vue-journal-layer/testing/nuxt-server.mjs; ../vue-journal-layer/testing/smoke-ssr.mjs (CLI style); ../vue-journal-layer/.playground/nuxt.config.ts; the nitropack runtime in ../vue-journal-layer/node_modules (dist/runtime/internal/cache.mjs: the route cache key is cache:nitro:routes:_:<path>.<hash>.json and POST is not excluded)</read_first>
|
||||
<action>Wire the one path: a category admin update goes through an after-commit purge request to a layer route that clears the route cache. This serves the G-16.2-2 user decision and D-19. Every other save path is Task 2.
|
||||
|
||||
Contract (both repositories implement it; Task 2 documents it in both READMEs): a `POST <purge_url>` with `Authorization: Bearer <token>`, `Content-Type: application/json` and the body `{"kind":"category"|"post","slug":"<slug>"}`. The answer is 200 `{"purged":<n>}`.
|
||||
|
||||
sm-journal-plugin:
|
||||
|
||||
- `classes/site_cache.go` provides the following.
|
||||
- `type SiteCachePurger`.
|
||||
- `NewSiteCachePurger(purgeURL, token string) (*SiteCachePurger, error)`: both empty returns nil, nil (disabled). The tracer checks only that the URL parses with an http or https scheme and a host, and that the token is not empty. Task 2 adds the full rules.
|
||||
- `(*SiteCachePurger).Purge(ctx context.Context, kind, slug string)`:
|
||||
- A nil receiver does nothing.
|
||||
- It sends the contract request with a 2 s timeout on `context.WithoutCancel(ctx)` and reads at most 4 KiB of the answer.
|
||||
- It logs one slog line: Info `journal: site cache purged` with kind, slug, status and purged (when the answer parses), or Warn `journal: site cache purge failed` with kind, slug and the status or the error.
|
||||
- It never logs the token or the Authorization header, and it returns nothing.
|
||||
- `PurgeSiteCacheAfterCommit(ctx context.Context, db *gorm.DB, p *SiteCachePurger, kind, slug string)`: for a non-nil p, it registers p.Purge through `lagoon.AfterCommit(ctx, db, ...)`. The purge then runs only after the surrounding cabana save transaction commits, and never for a failed save.
|
||||
- `plugin.go` Boot reads `golem15.journal.site_cache.purge_url` and `golem15.journal.site_cache.purge_token` from `app.Config`. The env names are `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL` and `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN`. Boot builds the purger, returns a wrapped boot error for invalid configuration, and calls `app.Publish` with it when it is enabled.
|
||||
- `controllers/categories.go` gets a new FormAfterUpdate. It looks up `*classes.SiteCachePurger` through `c.app()` and `Lookup`, then calls `PurgeSiteCacheAfterCommit` with the tx from `cabana.TxFromContext(ctx)`, kind `category` and the saved slug.
|
||||
- New root-package test `site_cache_admin_test.go`, `TestCategoriesAdminPurgesSiteCache` (Docker):
|
||||
- An httptest receiver on 127.0.0.1 records each request. Inside its handler it reads the category's content through the harness pool.
|
||||
- Start with `newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL=<receiver>/api/__journal__/cache/purge", "SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN=<a 40-character test token>")`, or the harness's equivalent.
|
||||
- PUT an edited category content through `adminAPI`.
|
||||
- Assert exactly one POST, the path, the Bearer token and the JSON body (kind category, the slug).
|
||||
- Assert that the content read inside the receiver is the new content, which proves the purge ran after the commit.
|
||||
|
||||
vue-journal-layer:
|
||||
|
||||
- `nuxt.config.ts`:
|
||||
- `runtimeConfig.journal.cachePurgeToken: ''` (server-only; env `NUXT_JOURNAL_CACHE_PURGE_TOKEN`).
|
||||
- `routeRules: { '/api/__journal__/**': { cache: false } }`. This is required: Nitro wraps a handler matching a host's `'/**': { swr }` rule in a cached handler keyed by path that does not skip POST, so without it a second purge inside the TTL would be answered from the cache without running.
|
||||
- `app/utils/siteCache.ts` is pure erasable TypeScript that node --test can import, like routes.ts:
|
||||
- `MIN_PURGE_TOKEN_LENGTH = 32`.
|
||||
- `authorizePurge({ token, authorization, proxied })` returns `'ok' | 'disabled' | 'proxied' | 'unauthorized'`:
|
||||
- `disabled` when the token is shorter than 32 characters;
|
||||
- `proxied` when the request carried X-Forwarded-For, X-Real-IP or Forwarded (checked before the token);
|
||||
- otherwise the `Bearer` value is compared in constant time, over sha256 digests with node:crypto timingSafeEqual.
|
||||
- `ROUTE_CACHE_BASE = 'nitro:routes'`.
|
||||
- `purgeRouteCache(storage)` takes any object with getKeys(base) and removeItem(key), removes every key under ROUTE_CACHE_BASE and returns the count.
|
||||
- `parsePurgeEvent(body)` returns a kind (post, category or unknown) and a slug, with control characters removed and at most 200 characters.
|
||||
- `server/api/__journal__/cache/purge.post.ts`:
|
||||
- `disabled` or `proxied` answers 404; `unauthorized` answers 401.
|
||||
- Otherwise it runs `purgeRouteCache(useStorage('cache'))` and logs one console.info line: `[journal] cache purge: removed <n> route cache entries (<kind> <slug>)`.
|
||||
- It sets `cache-control: no-store` and returns `{ purged }`.
|
||||
- `testing/fixture-api.mjs`: add `reload()` to the returned object. It re-reads the JSON fixtures; existing behaviour is unchanged.
|
||||
- New `testing/purge-check.mjs` is plain .mjs with JSDoc, so it runs from node_modules like smoke-ssr.mjs. Arguments: `--output <.output> --fixtures <dir> --kind post|category --slug <slug> --path <page path>`. Steps:
|
||||
1. Copy the fixtures to a temp directory and start the fixture API on it.
|
||||
2. Start the built server with a random 48-hex-character `NUXT_JOURNAL_CACHE_PURGE_TOKEN`.
|
||||
3. GET the path (200).
|
||||
4. Append a unique marker paragraph to the entity in the temp fixture (post: content and content_html; category: content) and reload the API.
|
||||
5. GET again and require the marker to be absent. This proves the page is route-cached; if the marker shows, fail with a message about missing swr route rules.
|
||||
6. POST the purge with the token and the contract body. Require 200 and purged >= 1.
|
||||
7. GET again and require the marker.
|
||||
8. POST without a token (401), with a wrong token (401), and with the right token plus X-Forwarded-For (404).
|
||||
9. Print the last line as `purge-check: ok (<n> checks)`.
|
||||
- `.playground/nuxt.config.ts`: add `routeRules: { '/**': { swr: 300 } }` so the playground caches like a real host.
|
||||
|
||||
Commit as feat(...) in each repository, staged by explicit path. Task 2 adds the READMEs.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -timeout 15m -run '^TestCategoriesAdminPurgesSiteCache$' -v . 2>&1 | grep -- '^--- PASS: TestCategoriesAdminPurgesSiteCache'</automated>
|
||||
<fails_when>Non-zero exit or no PASS line: vet fails, the harness cannot start PostgreSQL, the PUT sends no purge or more than one, the request lacks the Bearer token or body, or the receiver read the old content (the purge ran before the commit).</fails_when>
|
||||
<automated>pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/purge-check.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --kind post --slug hello-acme --path /blog/hello-acme | tail -1 | grep '^purge-check: ok'</automated>
|
||||
<fails_when>Non-zero exit or the last line does not start with "purge-check: ok": the playground build fails, the page is not route-cached, the authorized purge does not answer 200 with purged of at least 1, the page still lacks the marker after the purge, or an unauthenticated, wrong-token or proxied purge is accepted.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func PurgeSiteCacheAfterCommit" ../sm-journal-plugin/classes/site_cache.go` and `grep -n "func TestCategoriesAdminPurgesSiteCache" ../sm-journal-plugin/site_cache_admin_test.go` each print one line.
|
||||
- `grep -n "cache: false" ../vue-journal-layer/nuxt.config.ts` and `grep -n "purgeRouteCache" ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts` each print at least one line.
|
||||
- Both verify commands pass.
|
||||
</acceptance_criteria>
|
||||
<done>One category admin update sends one authorized purge after its commit, and on a built swr site a cached page shows a content change on the first request after that purge; unauthenticated, wrong-token and proxied purges are refused.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Expansion: every admin post and category write purges, boot validates the config, browsers revalidate route-cached pages; READMEs, host app and deploy files</name>
|
||||
<files>../sm-journal-plugin/classes/site_cache.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/controllers/admin_registry.go, ../sm-journal-plugin/site_cache_admin_test.go, ../sm-journal-plugin/README.md, ../vue-journal-layer/app/utils/siteCache.ts, ../vue-journal-layer/server/plugins/journal-revalidate.ts, ../vue-journal-layer/testing/purge-check.mjs, ../vue-journal-layer/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/deploy/env.example, ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example, ../sm-grzybyfunkcjonalne-app/DEPLOY.md, ../sm-grzybyfunkcjonalne-app/README.md</files>
|
||||
<read_first>The Task 1 files; ../sm-journal-plugin/controllers/posts.go (FormAfterCreate, FormAfterUpdate, writeContentHTMLTranslations, FormBeforeDelete); ../sm-journal-plugin/controllers/admin_registry.go; ../sm-journal-plugin/README.md (section order: Overview, Features, Usage, API reference, CLI commands, Dependencies, Testing); the nitropack runtime (dist/runtime/internal/cache.mjs: the cache-control the cached handler writes; h3 handleCacheHeaders: the 304 answer and its cache-control); ../vue-journal-layer/README.md (Configuration, Testing); ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md (Configuration: routeRules bullet and the env table) and nuxt.config.ts (routeRules comment); ../sm-grzybyfunkcjonalne-app/deploy/env.example; ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example; ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Overview, First install, Release); ../sm-grzybyfunkcjonalne-app/README.md (Ports and environment); ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf (location / with limit_except GET HEAD and X-Forwarded-For)</read_first>
|
||||
<action>Expand from the proven slice (G-16.2-2, D-19).
|
||||
|
||||
sm-journal-plugin:
|
||||
|
||||
- Hooks:
|
||||
- posts.go: FormAfterCreate and FormAfterUpdate call `PurgeSiteCacheAfterCommit` with kind `post` and the post's slug, after writeContentHTMLTranslations succeeds. Add a new FormAfterDelete that does the same.
|
||||
- categories.go: add FormAfterCreate and FormAfterDelete (kind `category`).
|
||||
- An untouched update (D-03) still purges. That is harmless, because the stored bytes are unchanged.
|
||||
- Add compile-time assertions for the new hooks in admin_registry.go.
|
||||
- Tags, the posts import action and the public write API (D-09) do not purge. They are outside the decision, which names admin post and category saves.
|
||||
- `NewSiteCachePurger` rules. Boot fails loudly and the error names the key:
|
||||
- only one of url and token is set;
|
||||
- the URL does not parse with an http or https scheme and a host, or it has user info or a fragment;
|
||||
- an http URL whose host is not loopback (127.0.0.0/8, ::1 or localhost), because the bearer token would cross the network in clear;
|
||||
- a token shorter than 32 characters (the layer's minimum).
|
||||
When the purger is enabled, log one Info line at Boot with only the URL's host and path.
|
||||
- `site_cache_admin_test.go`: a `TestPostsAdminPurgesSiteCache` smoke test, in which an edited post PUT sends one purge with kind post after the commit.
|
||||
- `README.md`: a new "Configuration" section between Usage and API reference. It covers:
|
||||
- the two keys and their env names;
|
||||
- the request contract;
|
||||
- the after-commit timing and the 2 s timeout;
|
||||
- that a purge never fails a save;
|
||||
- which writes purge and which do not;
|
||||
- that the receiver is the host's Nuxt app built on vue-journal-layer.
|
||||
Use neutral names only. The Testing section names the new tests.
|
||||
|
||||
vue-journal-layer:
|
||||
|
||||
- `app/utils/siteCache.ts` gains `privateRevalidate(cacheControl: string): string`. For a value that contains `s-maxage`, it sets `max-age=0`: it replaces any `max-age=<n>` or prepends `max-age=0`. Other values are returned unchanged.
|
||||
- New `server/plugins/journal-revalidate.ts` is a Nitro plugin. Its request hook wraps that request's `event.node.res.setHeader`, so every cache-control value passes through privateRevalidate.
|
||||
- Why: Nitro's route cache answers 200 with `s-maxage=<n>, stale-while-revalidate`, which gives browsers no max-age, so they apply heuristic freshness from Last-Modified.
|
||||
- h3 answers a 304 with `public, max-age=<n>, s-maxage=<n>` and ends the response itself, so a beforeResponse hook cannot change it. After one 304, a browser could keep its copy for the whole TTL, past any purge.
|
||||
- Static assets carry no s-maxage and are untouched.
|
||||
- `testing/purge-check.mjs` adds these checks:
|
||||
- After the purge, the page's cache-control contains max-age=0 and s-maxage.
|
||||
- A GET with `If-None-Match: <the new ETag>` answers 304, and its cache-control contains max-age=0.
|
||||
- When `<path>/_payload.json` answered 200 before the change, it carries the marker after the purge.
|
||||
- A second purge after the page was re-cached also reports purged >= 1, which shows the route itself is never cached.
|
||||
- A second server start without the token answers 404 to a POST that carries any token.
|
||||
- `README.md`:
|
||||
- A runtime config row for `runtimeConfig.journal.cachePurgeToken` / `NUXT_JOURNAL_CACHE_PURGE_TOKEN`.
|
||||
- A "Site cache purge" section covering:
|
||||
- the route and the request contract;
|
||||
- the guards: POST only; 404 without a token of at least 32 characters or behind a proxy; 401 on a wrong token; never route-cached;
|
||||
- what it removes: every Nitro route-cache entry (pages, _payload.json, feeds), and why;
|
||||
- browser revalidation;
|
||||
- deployment: the reverse proxy must not forward POST to Nuxt; the purge clears the cache storage of the process that answers, so a multi-process server needs a shared cache storage driver.
|
||||
- Testing mentions purge-check.mjs.
|
||||
- Neutral names only.
|
||||
|
||||
Site app (../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app; commit locally, Task 5 pushes):
|
||||
|
||||
- In README.md, the routeRules bullet says that admin saves of Journal posts and categories purge the route cache. The env table gains a `NUXT_JOURNAL_CACHE_PURGE_TOKEN` row.
|
||||
- The routeRules comment in nuxt.config.ts says the same. The routeRules themselves do not change.
|
||||
- Commit as docs(...). The layer pin waits for the tag (Task 5).
|
||||
|
||||
Host (../sm-grzybyfunkcjonalne-app; commit locally, Task 5 pushes):
|
||||
|
||||
- `deploy/env.example`: add the lines `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL=http://127.0.0.1:3016/api/__journal__/cache/purge` and `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN=`, with a comment: the same value as NUXT_JOURNAL_CACHE_PURGE_TOKEN in nuxt/.env, generated on the server.
|
||||
- `deploy/nuxt.env.example`: add `NUXT_JOURNAL_CACHE_PURGE_TOKEN=` with the comment, and correct the header sentence that says the file holds no secrets.
|
||||
- `DEPLOY.md`:
|
||||
- First install: a bullet for the shared token.
|
||||
- Overview: the Go to Nuxt purge.
|
||||
- Release: replace "The Nuxt page caches (swr) expire by themselves." with how admin saves purge them and the on-server purge check from Task 5. Note that the vhost's `limit_except GET HEAD` keeps the route off the public path; check-deploy.sh already requires it.
|
||||
- `README.md`: the "Ports and environment" table lists the new variables.
|
||||
- Commit as docs(deploy): ...</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go -C ../sm-journal-plugin test -count=1 -timeout 15m -run '^(TestCategoriesAdminPurgesSiteCache|TestPostsAdminPurgesSiteCache)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: vet or an existing classes or controllers test fails, or fewer than two PASS lines (a post or category save path does not purge after commit).</fails_when>
|
||||
<automated>npm --prefix ../vue-journal-layer test 2>&1 | grep -E '^# fail 0$' && pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/purge-check.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --kind post --slug hello-acme --path /blog/hello-acme | tail -1 | grep '^purge-check: ok'</automated>
|
||||
<fails_when>Non-zero exit or a missing line: a layer unit test fails, or purge-check fails a check (purge, guards, max-age=0 on 200 and 304, payload marker, second purge, disabled server).</fails_when>
|
||||
<automated>S=$(mktemp -d) && rsync -a --exclude node_modules --exclude .output --exclude .nuxt ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/ "$S/app/" && printf 'overrides:\n vue-journal-layer: link:%s\n' "$(cd ../vue-journal-layer && pwd)" >> "$S/app/pnpm-workspace.yaml" && pnpm -C "$S/app" install --no-frozen-lockfile && pnpm -C "$S/app" build && node ../vue-journal-layer/testing/purge-check.mjs --output "$S/app/.output" --fixtures ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api --kind category --slug cordyceps --path /cordyceps | tail -1 | grep '^purge-check: ok'</automated>
|
||||
<fails_when>Non-zero exit or no "purge-check: ok": the host app, built in a scratch copy against the local layer (nothing in the real app checkout changes), does not purge its root-level category page /cordyceps under its own route rules.</fails_when>
|
||||
<automated>grep -c 'SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN' ../sm-grzybyfunkcjonalne-app/deploy/env.example ../sm-grzybyfunkcjonalne-app/DEPLOY.md | grep -c ':0$' | grep -qx 0 && grep -q 'NUXT_JOURNAL_CACHE_PURGE_TOKEN' ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example && grep -q 'NUXT_JOURNAL_CACHE_PURGE_TOKEN' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md && grep -q 'NUXT_JOURNAL_CACHE_PURGE_TOKEN' ../vue-journal-layer/README.md && grep -q 'site_cache.purge_url' ../sm-journal-plugin/README.md && echo docs-ok</automated>
|
||||
<fails_when>Non-zero exit or no docs-ok: a deploy template, DEPLOY.md, the app README, the layer README or the Journal README does not name its new setting.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- posts.go and categories.go each implement FormAfterCreate, FormAfterUpdate and FormAfterDelete (`grep -cE "func \(.*\) FormAfter(Create|Update|Delete)\(" ` prints 3 for each file), each reaching PurgeSiteCacheAfterCommit (`grep -c "PurgeSiteCacheAfterCommit"` prints at least 1 for each file; Task 3's Postgres tests prove all six paths).
|
||||
- `grep -n "privateRevalidate" ../vue-journal-layer/server/plugins/journal-revalidate.ts` prints at least one line.
|
||||
- All four verify commands pass; the real vue-grzybyfunkcjonalne-app checkout has no pnpm-workspace override and its package.json still pins #v0.2.0 (`git -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app diff --quiet -- package.json pnpm-lock.yaml pnpm-workspace.yaml`).
|
||||
</acceptance_criteria>
|
||||
<done>Every admin post and category write purges after its commit, misconfiguration fails at boot, route-cached pages tell browsers to revalidate, the host app purges its own category page in a scratch build, and the READMEs and deploy files describe the setting.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Tests: G-16.2-1 conformance cases, Docker-free inventory and the stale Journal assertion; full coverage of the purge; gate stages; full gate green including postgres</name>
|
||||
<precondition>`docker info` exits 0 on this machine.</precondition>
|
||||
<files>modules/cabana/openapi_conformance_test.go, scripts/check-phase16.2.sh, .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md, .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md, ../sm-journal-plugin/plugin_test.go, ../sm-journal-plugin/classes/site_cache_test.go, ../sm-journal-plugin/site_cache_admin_test.go, ../vue-journal-layer/tests/siteCache.test.ts</files>
|
||||
<read_first>.planning/debug/postgres-gate-field-types-conformance.md (Evidence and suggested_fix_direction); modules/cabana/openapi_conformance_test.go (conformCase, TestPhase10OpenAPIConformance with the coverage loop near line 400, conformEnv helpers upload/send/assertPluginAsset, newConformEnv, conformGadget, conformPlugin, conformFS and the gadget fields.yaml); modules/cabana/export_test.go (AdminAPIRoutes); modules/cabana/field_type.go (FieldTypeAssets, FieldTypesResponse, FieldUploadResponse, compileFieldTypes rules); modules/cabana/field_type_upload.go; modules/pact/capabilities.go (FormFieldType, FormFieldUpload, FormFieldUploadResult, HasFormFieldTypes); admin/openapi/admin.json (both routes); scripts/check-phase16.2.sh (all of it); ../sm-journal-plugin/plugin_test.go (TestJournalSVGEmbed); ../sm-journal-plugin/models/post/fields.yaml; the Task 1 and Task 2 Go and layer files; ../vue-journal-layer/tests/routes.test.ts (node --test style); .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md (line 24 and the field_type_upload.go section); .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md (Per-Task Verification Map)</read_first>
|
||||
<behavior>
|
||||
- Inventory (no database): every inventoried admin route has exactly one conformance case; a missing, extra or duplicate key fails with all offending keys listed in sorted order; the test passes under -short with no Docker.
|
||||
- GET /field-types: 200; the body decodes into cabana.FieldTypesResponse with unknown fields disallowed; admin.json documents cabana.FieldTypesResponse; the fixture type is listed with its element and a script URL that serves with ETag and the ?v= hash.
|
||||
- Field upload: a multipart part named file to the fixture field answers 201; the body decodes into cabana.Envelope[cabana.FieldUploadResponse]; admin.json documents cabana.Envelope-cabana_FieldUploadResponse; the existing create, update and show cases still pass with the plugin-typed field on the gadget form.
|
||||
- TestJournalSVGEmbed: post content is `type: mlblogeditor` with `htmlColumn: content_html`.
|
||||
- NewSiteCachePurger table: both empty gives disabled; only URL or only token, a bad scheme, no host, user info, a fragment, http to a non-loopback host and a 31-character token are each refused with the key in the error; https to any host, and http to 127.0.0.1, ::1 and localhost with a 32-character token are accepted.
|
||||
- Purge request: POST, the configured path, the Bearer token, the JSON content type and body; an Info log with purged from the answer; a 500 and a 401 answer each give one Warn line; a receiver slower than the timeout returns within about 2 s; a cancelled parent context still sends; no captured log line contains the token; a nil purger sends nothing.
|
||||
- Categories on Postgres: create, update and delete each send one purge with kind category after the commit; a failed update (validation 422) sends none.
|
||||
- Posts on Postgres: create, an untouched update (content and content_html still byte-identical, D-03) and delete each send one purge with kind post; with the receiver answering 500, and with no receiver listening, the save still answers 200 and the row is saved; a failed save sends none.
|
||||
- Layer (node --test, exact names): "purge auth: a missing or short token disables the route", "purge auth: a proxied request is refused before the token is checked", "purge auth: a missing or wrong bearer token is unauthorized", "purge removes every route cache entry and only those"; plus parsePurgeEvent cases (kinds, control characters, 200-character cap, non-object bodies) and privateRevalidate cases (s-maxage with and without max-age, the 304 form "public, max-age=300, s-maxage=300", a static "public, max-age=31536000, immutable" unchanged, an empty value unchanged).
|
||||
</behavior>
|
||||
<action>Write the tests in the behavior block, close G-16.2-1, extend the gate and run it in full.
|
||||
|
||||
summercms.go (G-16.2-1, per D-13 and D-18):
|
||||
|
||||
- Move the case list of TestPhase10OpenAPIConformance into a package-level `conformCases() []conformCase`. The closures already take the env as a parameter.
|
||||
- Add `checkConformInventory(t *testing.T, cases []conformCase)`. It reports every missing key in sorted order, plus every extra or duplicate key, in one failure.
|
||||
- The new `TestPhase10ConformanceInventory` calls it with no database. TestPhase10OpenAPIConformance calls it before newConformEnv, so the static check can no longer hide behind the testcontainers setup.
|
||||
- Add the two cases.
|
||||
- GET /field-types: status 200, ref `cabana.FieldTypesResponse`, decode `into[cabana.FieldTypesResponse]()`. The call asserts the fixture type is listed and checks its script with `e.assertPluginAsset`.
|
||||
- POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload: status 201, ref `cabana.Envelope-cabana_FieldUploadResponse`, decode `into[cabana.Envelope[cabana.FieldUploadResponse]]()`. Send a multipart part named `file` with the requireAjax header to the gadget's fixture field. Use an id the route accepts: 0 for a new record, or a gadget created earlier in the case list.
|
||||
- Fixture changes:
|
||||
- conformPlugin implements `pact.HasFormFieldTypes` with one type: an alias such as `conformnote`, an element under the `acme-conform-` widget tag prefix, a script already in conformFS, Decode returning the string, Load returning the stored value, and Upload returning a URL.
|
||||
- conformGadget gets a nullable `note` column, and the gadget fields.yaml gets a `note` field of that type.
|
||||
- The existing create, update and show cases now Decode and Load the note; keep them passing.
|
||||
- `scripts/check-phase16.2.sh`:
|
||||
- FRAMEWORK_RUN and FRAMEWORK_REQUIRE gain TestPhase10ConformanceInventory.
|
||||
- JOURNAL_REQUIRE gains TestSiteCachePurgeConfig and TestSiteCachePurgeRequest.
|
||||
- PG_FRAMEWORK_REQUIRE gains TestPhase10OpenAPIConformance.
|
||||
- PG_JOURNAL_REQUIRE gains TestCategoriesAdminPurgesSiteCache and TestPostsAdminPurgesSiteCache.
|
||||
- LAYER_REQUIRE gains the four layer test names.
|
||||
- Add a new stage `cache`. Wire it into usage, the case statement (cache and --cache), run_all after layer, and the self-test mode loop.
|
||||
- It runs `pnpm -C "$LAYER" run build:playground`.
|
||||
- It runs purge-check on the playground (post hello-acme at /blog/hello-acme) and fails unless the last line starts with "purge-check: ok".
|
||||
- Then it runs the acme SSR smoke against the same build: `node "$LAYER/testing/smoke-ssr.mjs" --output "$LAYER/.playground/.output" --fixtures "$LAYER/testing/fixtures/acme" --expect "$LAYER/testing/expect-acme.txt"`.
|
||||
- Update the header comment for the new stage and names.
|
||||
|
||||
sm-journal-plugin:
|
||||
|
||||
- In `plugin_test.go`, TestJournalSVGEmbed asserts `type: mlblogeditor` and `htmlColumn: content_html` for post content. This is the intended 16.2-03 change (D-08); no other assertion changes.
|
||||
- `classes/site_cache_test.go`: TestSiteCachePurgeConfig and TestSiteCachePurgeRequest. Capture logs with a slog handler on a buffer; use httptest.
|
||||
- Extend `site_cache_admin_test.go` as the behavior block lists. The receiver records requests and can answer 500. To test the "nothing listening" case, close it before the save.
|
||||
|
||||
vue-journal-layer:
|
||||
|
||||
- `tests/siteCache.test.ts` uses node --test with erasable TypeScript only and the exact names above. Use a fake storage whose keys include nitro:routes entries and other cache groups.
|
||||
|
||||
Docs (summercms.go, one separate docs(16.2) commit):
|
||||
|
||||
- 16.2-PATTERNS.md: name `modules/cabana/openapi_conformance_test.go` (one conformCase per new admin route; TestPhase10ConformanceInventory enforces it without Docker) in two places. First, the `admin_openapi.go` table row. Second, the sentence of the field_type_upload.go section that lists admin_openapi.go and phase09Routes.
|
||||
- 16.2-VALIDATION.md: add the 16.2-07-T1 to T6 rows to the per-task map, with their commands, and move the Docker-host suites from Manual-Only to automated.
|
||||
|
||||
Then run `PHASE162_REQUIRE_POSTGRES=1 bash scripts/check-phase16.2.sh --all`. If a suite exposes a production defect, fix it in a separate fix(...) commit with its own test, and name it in the SUMMARY. Commit tests per repository as test(...) commits, and the gate as "test(16.2-07): ...", staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -short -run '^TestPhase10ConformanceInventory$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestPhase10ConformanceInventory'</automated>
|
||||
<fails_when>No PASS line: the inventory test needs a database (it must pass under -short), or an inventoried route still has no conformance case.</fails_when>
|
||||
<automated>go test -count=1 -timeout 15m -run '^TestPhase10OpenAPIConformance$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestPhase10OpenAPIConformance'</automated>
|
||||
<fails_when>No PASS line: a case fails its status, its decode with unknown fields disallowed, or the admin.json ref, including the two new cases.</fails_when>
|
||||
<automated>L=$(mktemp) && { PHASE162_REQUIRE_POSTGRES=1 bash scripts/check-phase16.2.sh --all > "$L" 2>&1; grep -qx 'PASS postgres' "$L" && grep -qx 'PASS cache' "$L" && tail -1 "$L" | grep -qx 'Phase 16.2 gate passed'; }</automated>
|
||||
<fails_when>Non-zero exit: any stage fails (self-test, go, admin, editor, layer, cache, docs, postgres), a required test is missing by name, or the last line is not exactly "Phase 16.2 gate passed". This includes the UAT Test 1 suites and TestJournalSVGEmbed in the postgres stage.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestPhase10ConformanceInventory" modules/cabana/openapi_conformance_test.go` prints one line; `grep -c "TestPhase10ConformanceInventory" scripts/check-phase16.2.sh` prints at least 2 (run regex and required list).
|
||||
- `grep -n "fields/{field}/upload" modules/cabana/openapi_conformance_test.go` and `grep -n '"GET /field-types"' modules/cabana/openapi_conformance_test.go` each print at least one line.
|
||||
- `grep -n "openapi_conformance_test.go" .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md` prints at least two lines.
|
||||
- `grep -n "type: mlblogeditor" ../sm-journal-plugin/plugin_test.go` prints at least one line.
|
||||
- The three verify commands pass; the gate log (in the SUMMARY) shows PASS for every stage, including postgres and cache.
|
||||
</acceptance_criteria>
|
||||
<done>The gate's postgres stage is green on this Docker host, the route-coverage invariant is enforced without Docker, and the purge has full unit and Postgres coverage in the fail-closed gate.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Release gate: clean trees, tags chosen, UAT state read, a human approves the pushes, the tags and the UAT deploy (with the server-generated purge token)</name>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, Rollback); .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md (deviations: Nuxt rollback path, log anchor); .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-REVIEW-FIX.md and 16.2-SECURITY.md when present; the Task 3 gate log</read_first>
|
||||
<action>Do every automatable check, print a release sheet, then stop.
|
||||
|
||||
1. Concurrency. Each repository must be clean with nothing missing from origin:
|
||||
- summercms.go master: `git status --porcelain -- modules admin docs scripts cmd` prints nothing.
|
||||
- ../sm-journal-plugin main, ../vue-journal-layer main, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app main and ../sm-grzybyfunkcjonalne-app master: `git status --porcelain --untracked-files=no` prints nothing.
|
||||
- In every repository, after `git fetch origin --tags`, `git log --oneline HEAD..origin/<branch>` prints nothing. If any origin has commits this checkout lacks, stop and report. Do not merge or rebase.
|
||||
2. Tags:
|
||||
- summercms: propose the highest `v0.1.*` tag plus one, v0.1.20 unless someone tagged after v0.1.19. Confirm with `git ls-remote --tags origin` that it is absent.
|
||||
- vue-journal-layer: propose v0.3.0 and confirm it is absent on origin.
|
||||
3. Read-only on UAT:
|
||||
- The sha256 and mtime of `/srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne`.
|
||||
- The count, never the value, of lines starting with SUMMER_GOLEM15__JOURNAL__SITE_CACHE__ in go/.env and NUXT_JOURNAL_CACHE_PURGE_TOKEN in nuxt/.env.
|
||||
- The SELECTs of Task 6's posts and translations verify, compared with the 16.2-05 snapshot.
|
||||
- The category 2 row as id:slug:md5(content):updated_at.
|
||||
4. The release sheet:
|
||||
- the tags;
|
||||
- the commits to push per repository (`git log --oneline origin/<branch>..HEAD`, including planning-doc commits);
|
||||
- the Task 3 gate result;
|
||||
- every write on the server: the binary, Nuxt output, config/ and scripts/ uploads, the rollback copies, and two lines appended to go/.env and one to nuxt/.env, holding a token generated on the server that is never printed;
|
||||
- the steps of DEPLOY.md "Release";
|
||||
- the rollback: move go/bin/grzybyfunkcjonalne.prev and nuxt/.output.prev back and restart both programs. The appended .env lines are inert for the previous build;
|
||||
- any open high finding from the review or security reports. An open high finding allows only hold.
|
||||
|
||||
Then stop for the human.</action>
|
||||
<decision>Push the branches and the tags summercms v0.1.20 and vue-journal-layer v0.3.0, generate the purge token on the server, and deploy to UAT now?</decision>
|
||||
<context>A pushed tag is read by other repositories and the Go module proxy and cannot be moved, only superseded (v0.1.21, v0.3.1). The deploy writes the binary, the Nuxt output and two env files on the UAT server, next to the live Winter site, which it does not touch. Task 3 ran the full gate including the Docker suites on this machine.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Release and deploy now</name>
|
||||
<pros>UAT gets the v0.1.19 review fixes plus the purge; Tests 3 and 2 can be re-run at once; rollback is two moves and a restart</pros>
|
||||
<cons>Two tags are published; a later defect needs the next tags</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>UAT keeps the 12:37 build without the review fixes; Tests 2 and 3 stay open</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release" (with any tag change, for example "release v0.1.21") or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase16.2.sh --self-test && test -z "$(git status --porcelain -- modules admin docs scripts cmd)" && test -z "$(git ls-remote --tags origin refs/tags/v0.1.20)" && test -z "$(git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.3.0)" && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or no release-gate-ok: the gate self-test fails, summercms.go has uncommitted framework changes, or a proposed tag already exists on origin (then propose the next free tag and record it).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the release sheet and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human chose release with named tags, or hold (the plan stops).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Release and deploy: tags, pushes, layer pin, host bumps, build, UAT deploy with the server-generated purge token, smoke and an on-server purge check; snapshot for Task 6</name>
|
||||
<reversibility rating="one-way">Annotated tags summercms v0.1.20 and vue-journal-layer v0.3.0 are read by other repositories' go.mod and package.json; they can only be superseded.</reversibility>
|
||||
<precondition>Task 4 was answered "release" (RELEASE_TAG and LAYER_TAG set in the session, default v0.1.20 and v0.3.0) and `ssh grzybyfunkcjonalne-uat true` exits 0.</precondition>
|
||||
<files>../sm-journal-plugin/go.mod, ../vue-journal-layer/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke); ../sm-grzybyfunkcjonalne-app/scripts/build.sh; ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh; ../vue-journal-layer/scripts/check-git-install.sh; .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md (Deploy, Follow-up deploy, deviations); the Task 4 release sheet in this plan's SUMMARY</read_first>
|
||||
<action>Run the release in this order. Re-check before each push that `git log --oneline HEAD..origin/<branch>` prints nothing.
|
||||
|
||||
1. **Framework.** `git tag -a "$RELEASE_TAG"` on summercms.go master HEAD with the message "$RELEASE_TAG: conformance cases for the field types and field upload routes, Docker-free route inventory check, phase 16.2 gate with the site cache purge". Then `git push origin master` and `git push origin "$RELEASE_TAG"`.
|
||||
2. **Plugin.** `go -C ../sm-journal-plugin mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG`. The replace stays, so the require is nominal. Run `go -C ../sm-journal-plugin vet ./...`, commit go.mod only as "chore(deps): require summercms $RELEASE_TAG", then `git -C ../sm-journal-plugin push origin main`.
|
||||
3. **Layer.**
|
||||
- Set package.json version 0.3.0 and commit "chore: release v0.3.0".
|
||||
- `git -C ../vue-journal-layer tag -a v0.3.0` with the message "v0.3.0: token-protected site cache purge route (POST /api/__journal__/cache/purge) and browser revalidation of route-cached pages".
|
||||
- Push main and the tag.
|
||||
- `bash ../vue-journal-layer/scripts/check-git-install.sh v0.3.0`. Run it from a directory outside any vue-journal-layer directory.
|
||||
4. **Site app** (../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app).
|
||||
- Change the pin to `git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.3.0`. Run `pnpm install` (the lockfile must record the tag's commit), then `pnpm build` and `pnpm test`.
|
||||
- Run `node node_modules/vue-journal-layer/testing/purge-check.mjs --output .output --fixtures tests/fixtures/api --kind category --slug cordyceps --path /cordyceps`. It must end with "purge-check: ok".
|
||||
- Commit package.json and pnpm-lock.yaml as "chore(deps): vue-journal-layer v0.3.0 (site cache purge on Journal saves)" and push main together with the Task 2 docs commit.
|
||||
5. **Host** (../sm-grzybyfunkcjonalne-app).
|
||||
- In plugins/golem15/journal, fetch origin and check out the sm-journal-plugin main just pushed. In vue-grzybyfunkcjonalne-app, check out the main just pushed.
|
||||
- Commit both gitlinks as "chore: bump journal plugin and the site app (site cache purge on admin saves)".
|
||||
- Push master, which also pushes the Task 2 deploy-docs commit.
|
||||
6. **Build.** Run `bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh`, then `bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh`. Record the local sha256 of bin/grzybyfunkcjonalne.
|
||||
7. **Release on the server**, per DEPLOY.md "Release" with the 16.2-05 corrections.
|
||||
- Remove the old nuxt/.output.prev, then `cp -a nuxt/.output nuxt/.output.prev`.
|
||||
- rsync the binary to go/bin/grzybyfunkcjonalne.new, plus config/, scripts/ and build/nuxt/.output/, exactly as DEPLOY.md "Build and upload" lists. Never upload a local .env.
|
||||
- Compare the sha256 of the .new binary with the local one.
|
||||
- `cp -p bin/grzybyfunkcjonalne bin/grzybyfunkcjonalne.prev`, then move the .new binary into place.
|
||||
8. **Purge token, on the server, idempotent and never printed.**
|
||||
- If go/.env has no line starting with SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN=:
|
||||
- generate T on the server from 32 bytes of /dev/urandom, base64 with `+/` mapped to `-_` and `=` and newlines removed;
|
||||
- append `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL=http://127.0.0.1:3016/api/__journal__/cache/purge` and `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN=$T` to go/.env, and `NUXT_JOURNAL_CACHE_PURGE_TOKEN=$T` to nuxt/.env, with printf builtins.
|
||||
- If only one file has its line, stop and report. Never echo T.
|
||||
- Both files stay mode 0600.
|
||||
9. **Start.**
|
||||
- Run `bin/grzybyfunkcjonalne migrate`; it must exit 0.
|
||||
- Record RESTART_UTC as the server's `date -u +%Y-%m-%dT%H:%M:%SZ`.
|
||||
- `sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt`.
|
||||
- Record LOG_ANCHOR, the `client_id=` value of the River line the Go restart logged in /var/log/supervisor/grzybyfunkcjonalne-go.log.
|
||||
- Run the smoke (first verify).
|
||||
10. **Checks.** Run the deploy, token and purge verifies. On any failure: move go/bin/grzybyfunkcjonalne.prev and nuxt/.output.prev back, restart both programs and report.
|
||||
11. **Snapshot for Task 6** (read-only SELECTs, kept in the session and the SUMMARY).
|
||||
- The posts and translations SELECTs of Task 6's first and third verify. They must already equal the expected rows; if they differ, report it, because the data changed since 16.2-05.
|
||||
- posts 1 and 64 as id:updated_at.
|
||||
- category 2 as id:slug:md5(coalesce(content,'')):updated_at.</action>
|
||||
<verify>
|
||||
<automated>ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" > "$C/.curlrc"; cd /srv/grzybyfunkcjonalne/go && CURL_HOME="$C" scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl; rc=$?; rm -rf "$C"; exit $rc' | grep '^smoke: ok ('</automated>
|
||||
<fails_when>Non-zero exit or no line starting with "smoke: ok (": a smoke URL failed on the server.</fails_when>
|
||||
<automated>test "$(sha256sum ../sm-grzybyfunkcjonalne-app/bin/grzybyfunkcjonalne | cut -d' ' -f1)" = "$(ssh grzybyfunkcjonalne-uat 'sha256sum /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne' | cut -d' ' -f1)" && ssh grzybyfunkcjonalne-uat 'sudo supervisorctl status grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt' | grep -c RUNNING | grep -qx 2 && test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" && test -n "$(git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.3.0)" && grep -q 'vue-journal-layer.git#v0.3.0' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json && ssh grzybyfunkcjonalne-uat 'test -x /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne.prev && test -d /srv/grzybyfunkcjonalne/nuxt/.output.prev' && echo deploy-ok</automated>
|
||||
<fails_when>Non-zero exit or no deploy-ok: the server binary differs from the local build, fewer than two programs are RUNNING, a tag is missing on origin, the app does not pin v0.3.0, or a rollback copy is missing.</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat 'cd /srv/grzybyfunkcjonalne && test "$(grep -c "^SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN=" go/.env)" = 1 && test "$(grep -c "^NUXT_JOURNAL_CACHE_PURGE_TOKEN=" nuxt/.env)" = 1 && test "$(grep -c "^SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL=http://127.0.0.1:3016/api/__journal__/cache/purge$" go/.env)" = 1 && a=$(sed -n "s/^SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN=//p" go/.env) && b=$(sed -n "s/^NUXT_JOURNAL_CACHE_PURGE_TOKEN=//p" nuxt/.env) && test ${#a} -ge 32 && test "$a" = "$b" && echo token-match' | grep -qx token-match</automated>
|
||||
<fails_when>No token-match: a line is missing or duplicated, the URL differs, the token is shorter than 32 characters, or the two files hold different values. Only the word token-match crosses the connection.</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat 'cd /srv/grzybyfunkcjonalne && C=$(mktemp -d) && printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" > "$C/.curlrc" && CURL_HOME="$C" curl -s -o /dev/null https://uat.grzybyfunkcjonalne.pl/cordyceps && T=$(sed -n "s/^NUXT_JOURNAL_CACHE_PURGE_TOKEN=//p" nuxt/.env) && printf "Authorization: Bearer %s\n" "$T" | curl -s -o "$C/p.json" -w "%{http_code}\n" -X POST -H @- -H "Content-Type: application/json" --data "{\"kind\":\"category\",\"slug\":\"cordyceps\"}" http://127.0.0.1:3016/api/__journal__/cache/purge && grep -oE "\"purged\": *[0-9]+" "$C/p.json" | grep -oE "[0-9]+$" && CURL_HOME="$C" curl -s -o /dev/null -w "%{http_code}\n" -X POST https://uat.grzybyfunkcjonalne.pl/api/__journal__/cache/purge && curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:3016/api/__journal__/cache/purge; rm -rf "$C"' | paste -sd' ' | grep -Eq '^200 [1-9][0-9]* 403 401$' && echo purge-ok</automated>
|
||||
<fails_when>No purge-ok: the authorized loopback purge (token read on the server and passed to curl on stdin) does not answer 200 with purged of at least 1 after the category page was cached, a POST through nginx is not refused with 403, or a loopback POST without a token is not refused with 401.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-journal-plugin rev-parse main` equals its origin/main and its go.mod requires summercms at RELEASE_TAG; ../vue-journal-layer main equals origin/main and package.json says 0.3.0; check-git-install.sh v0.3.0 passed.
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal vue-grzybyfunkcjonalne-app` shows the pushed heads and the host master equals origin/master.
|
||||
- The four verify commands print their markers.
|
||||
- The SUMMARY records both tags, every pushed head, both sha256 values, RESTART_UTC, LOG_ANCHOR, the snapshot rows, and no credential or token.
|
||||
</acceptance_criteria>
|
||||
<done>UAT runs the released framework, plugin, layer and site from pushed tags and branches; the purge works on the server and is refused from outside; the pre-save snapshot is recorded.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 6: UAT re-run: Test 3 (untouched re-save of posts 1 and 64, D-03) and Test 2 (category edit visible on the next view, D-19)</name>
|
||||
<precondition>Task 5 printed deploy-ok, token-match and purge-ok; RESTART_UTC, LOG_ANCHOR and the snapshot are set in the session and recorded in the SUMMARY.</precondition>
|
||||
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-UAT.md (Tests 2 and 3); this plan's SUMMARY (snapshot, RESTART_UTC, LOG_ANCHOR)</read_first>
|
||||
<action>Print the steps for the human and stop. The executor never asks for, receives or stores a credential.
|
||||
|
||||
Human steps:
|
||||
|
||||
1. Open https://uat.grzybyfunkcjonalne.pl/cordyceps in the browser. This view caches the page, as in the original failure.
|
||||
2. In /gfadmin/golem15/journal/posts, open post 1 (10-faktow-na-temat-cordycepsu) and post 64 (ekstrakt-z-turkey-tail). On each, press "Zapisz" without touching the editor. No removal notice may appear (Test 3, D-03).
|
||||
3. Open /gfadmin/golem15/journal/categories/2, change one word of the content and save. No removal notice may appear (D-10, D-19).
|
||||
4. Go straight back to the /cordyceps tab and view it again, both by reload and by the site menu link. The change must show on that first view (Test 2).
|
||||
5. Reply with the phrase as it reads after your edit, and whether you want the word changed back.
|
||||
|
||||
After the reply, run the verify commands. Then fetch /cordyceps on the server, resolved to loopback, and count the reported phrase in it. The count must not be 0; record it in the SUMMARY. Mark Tests 2 and 3 for /msd-verify-work. If a check fails, report it and offer the rollback: move go/bin/grzybyfunkcjonalne.prev and nuxt/.output.prev back and restart both programs.</action>
|
||||
<instructions>
|
||||
Claude deployed v0.1.20 with the Journal site cache purge and vue-journal-layer v0.3.0, passed the smoke and an on-server purge check, and recorded a snapshot of posts 1 and 64 and category 2.
|
||||
The human logs in to the UAT admin (basic-auth realm plus an admin account), does steps 1 to 5 and replies.
|
||||
</instructions>
|
||||
<verification>Read-only psql on the server compares the posts with the 16.2-05 snapshot and checks updated_at; the Go log after the restart anchor shows successful purges, including one for the category; the category page served on the server is not older than the category's updated_at and contains the human's phrase.</verification>
|
||||
<resume-signal>Type "saved" with the phrase after the edit, or describe what went wrong.</resume-signal>
|
||||
<verify>
|
||||
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT id || ':' || md5(content) || ':' || md5(content_html) FROM golem15_journal_posts WHERE id IN (1, 64) ORDER BY id\"" | diff - <(printf '%s\n' 1:a026264105f900175b9770e637e5ec5c:a026264105f900175b9770e637e5ec5c 64:0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c) && ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT string_agg(id || ':' || (updated_at > '$RESTART_UTC'::timestamptz)::int, ' ' ORDER BY id) FROM golem15_journal_posts WHERE id IN (1, 64)\"" | grep -qx '1:1 64:1' && echo posts-unchanged</automated>
|
||||
<fails_when>No posts-unchanged: an untouched save changed content or content_html of post 1 or 64, or a post's updated_at did not move past RESTART_UTC (the save did not reach the database).</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT model_id || ':' || locale || ':' || md5(coalesce(attribute_data, '')) FROM golem15_translate_attributes WHERE model_type LIKE 'Golem15_Journal_Models_Post' AND model_id IN ('1', '64') ORDER BY model_id, locale\"" | grep -qx '64:en:862fc5b362cc9856e6736a2078dca3ba' && echo translations-unchanged</automated>
|
||||
<fails_when>No translations-unchanged: the untouched saves added, removed or changed a translation row of posts 1 or 64.</fails_when>
|
||||
<automated>S=$(mktemp -d) && ssh grzybyfunkcjonalne-uat "F=/var/log/supervisor/grzybyfunkcjonalne-go.log; grep -q '$LOG_ANCHOR' \$F && awk -v a='$LOG_ANCHOR' 'f{print} index(\$0,a){f=1}' \$F" > "$S/go.log" && grep 'journal: site cache purged' "$S/go.log" | grep 'kind=category' | grep -q 'status=200' && test "$(grep -c 'journal: site cache purge' "$S/go.log")" = "$(grep -c 'journal: site cache purged' "$S/go.log")" && grep -ciE 'panic|SQLSTATE' "$S/go.log" | grep -qx 0 && ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT (updated_at > '$RESTART_UTC'::timestamptz)::int FROM golem15_journal_categories WHERE id = 2\"" | grep -qx 1 && echo category-purged</automated>
|
||||
<fails_when>No category-purged: the restart anchor is gone from the log, there is no successful purge line for kind=category, any purge did not succeed, the Go log has an error line of those kinds after the restart, or category 2 was not saved after RESTART_UTC.</fails_when>
|
||||
<automated>LM=$(ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" > "$C/.curlrc"; CURL_HOME="$C" curl -s -o /dev/null -D - https://uat.grzybyfunkcjonalne.pl/cordyceps; rm -rf "$C"' | tr -d '\r' | sed -n 's/^last-modified: //Ip') && UA=$(ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT extract(epoch FROM updated_at)::bigint FROM golem15_journal_categories WHERE id = 2\"") && test -n "$LM" && test "$(date -d "$LM" +%s)" -ge "$UA" && echo category-fresh</automated>
|
||||
<fails_when>No category-fresh: the /cordyceps page served on the server carries no Last-Modified, or it was rendered before category 2 was saved (a stale cache entry survived the save).</fails_when>
|
||||
<human-check>Steps 1 to 4 above: no removal notice on any save, and the category edit shows on the first view of /cordyceps after the save, by reload and by the menu link. Reply "UAT approved" or list the issues.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- posts-unchanged, translations-unchanged, category-purged and category-fresh are printed, and the on-server page contains the human's phrase.
|
||||
- The SUMMARY records the human's replies, the phrase and its count, each check result, and whether the word was changed back. It lists Tests 2 and 3 as ready for /msd-verify-work to mark pass, and Test 1 as covered by the Task 3 gate log.
|
||||
</acceptance_criteria>
|
||||
<done>On the deployed build, untouched re-saves of posts 1 and 64 are byte-identical, and a category edit is visible on the next view of the site.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Go host → Nuxt (loopback HTTP) | the Journal plugin calls the purge route with a bearer token |
|
||||
| internet → nginx → Nuxt | visitors reach Nuxt only with GET/HEAD; nginx adds X-Forwarded-For and X-Real-IP |
|
||||
| server .env files → processes | the purge token lives only in go/.env and nuxt/.env (0600) |
|
||||
| executor → git origin | tags and branches pushed here are read by other repositories |
|
||||
| executor → UAT server (ssh) | the release runs next to the live Winter site |
|
||||
| human admin → UAT admin | the only path that writes post and category data in this plan |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-36 | Spoofing / Denial of Service | layer purge route | medium | mitigate | POST only; 404 unless a token of at least 32 characters is configured; 404 for any request with X-Forwarded-For, X-Real-IP or Forwarded (proxied); 401 for a missing or wrong bearer token, compared in constant time over sha256 digests; the UAT vhost refuses POST to Nuxt (limit_except GET HEAD, required by check-deploy.sh); proven by purge-check and the Task 5 on-server 403/401 checks |
|
||||
| T-16.2-37 | Information Disclosure | purge token | high | mitigate | Generated on the server from /dev/urandom and appended with printf builtins; compared on the server so only "token-match" crosses ssh; passed to curl on stdin (-H @-), never in argv or output; never logged by Go (TestSiteCachePurgeRequest checks captured logs) or by the layer; .env files stay 0600 and are never committed or rsynced |
|
||||
| T-16.2-38 | Tampering (stale content) | purge ordering | medium | mitigate | lagoon.AfterCommit runs the purge only after the save transaction commits and never for a failed save; the Postgres tests read the row inside the receiver to prove commit-before-purge |
|
||||
| T-16.2-39 | Denial of Service | admin save path | low | mitigate | 2 s timeout on context.WithoutCancel; errors and non-2xx answers are logged, never returned; tests cover a 500 answer and a closed receiver with the save still 200 |
|
||||
| T-16.2-40 | Information Disclosure | purge URL misconfiguration | medium | mitigate | Boot refuses an http URL to a non-loopback host (the bearer token would cross the network in clear), user info, fragments and short tokens; only one of URL and token set is a boot error |
|
||||
| T-16.2-41 | Tampering (log injection) | layer purge log line | low | mitigate | parsePurgeEvent strips control characters and caps the slug at 200 characters before logging |
|
||||
| T-16.2-42 | Tampering (cache poisoning of the purge response) | Nitro route cache | medium | mitigate | routeRules cache: false for /api/__journal__/**; cache-control no-store; purge-check requires a second purge after re-caching to report purged of at least 1 |
|
||||
| T-16.2-43 | Denial of Service | full route-cache purge | low | accept | Only authenticated loopback callers can purge; admin saves are rare; the next view of each page renders once from the local API |
|
||||
| T-16.2-44 | Repudiation (false-green gate) | phase gate | medium | mitigate | The inventory check runs without Docker in the go stage; the postgres stage requires TestPhase10OpenAPIConformance and the purge suites by name; the final run sets PHASE162_REQUIRE_POSTGRES=1 and checks the PASS postgres and PASS cache lines |
|
||||
| T-16.2-45 | Tampering / Repudiation | tags and pushes | high | mitigate | Clean trees and nothing behind origin, checked at a blocking-human gate and again before each push; absent tags confirmed on origin; binary sha256 compared; check-git-install proves the layer tag; every pushed head recorded |
|
||||
| T-16.2-46 | Tampering | UAT database and the Winter site | medium | mitigate | The executor runs only SELECTs; data changes only through human admin saves; the Winter vhost, php-fpm and PHP database are untouched; binary and Nuxt rollback copies are kept |
|
||||
| T-16.2-SC | Tampering | packages | high | mitigate | No new package: the Go purger uses net/http and lagoon, the layer uses node:crypto and Nitro built-ins; the app only moves its pin to the project's own layer tag and regenerates the lockfile; build.sh installs with --frozen-lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: TestCategoriesAdminPurgesSiteCache passes; purge-check on the playground ends "purge-check: ok".
|
||||
- Task 2: both Journal purge tests pass; layer tests and the playground purge-check pass; the host app purges /cordyceps in a scratch build; docs-ok.
|
||||
- Task 3: TestPhase10ConformanceInventory passes under -short; TestPhase10OpenAPIConformance passes; `PHASE162_REQUIRE_POSTGRES=1 bash scripts/check-phase16.2.sh --all` prints PASS postgres and PASS cache and ends "Phase 16.2 gate passed".
|
||||
- Task 4: release-gate-ok and a human "release".
|
||||
- Task 5: smoke ok, deploy-ok, token-match, purge-ok.
|
||||
- Task 6: posts-unchanged, translations-unchanged, category-purged, category-fresh, and the human's approval.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- G-16.2-1 closed: the gate's postgres stage is green on a Docker host, the conformance inventory is enforced without Docker, and the stale Journal assertion and the PATTERNS checklist are fixed.
|
||||
- G-16.2-2 closed: a Journal post or category save in the admin clears the host Nuxt route cache after commit, and browsers revalidate, so the site shows the edit on the next view.
|
||||
- Released (summercms v0.1.20, vue-journal-layer v0.3.0, plugin and host bumps) and deployed to UAT with rollback copies; UAT Tests 3 and 2 re-run with their results recorded for /msd-verify-work.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-SUMMARY.md` when done. Include:
|
||||
- the commits per repository and the full gate log summary;
|
||||
- the release sheet and the human replies;
|
||||
- both tags and every pushed head;
|
||||
- both sha256 values, RESTART_UTC, LOG_ANCHOR and the snapshot;
|
||||
- every check result, and the status of UAT Tests 1 to 3 for /msd-verify-work.
|
||||
</output>
|
||||
@@ -21,7 +21,7 @@ All paths are git-tracked source in their own repos (sm-journal-plugin, vue-jour
|
||||
| `modules/cabana/tx_context.go` (+withFieldWrites / FieldWritesFromContext) | utility | request-response | same file `withVirtualFields`/`VirtualFieldsFromContext` (36-60) | exact |
|
||||
| `modules/cabana/list_schema.go`, `http.go` `projectRow` (plugin column ListValue) | service | transform | `list_schema.go:22-24`, `http.go:1067` | self |
|
||||
| `modules/cabana/http.go` (Activate threading + route + field-types endpoint) | route | request-response | `http.go:74-121` (Activate), `:335-347` (file routes) | self |
|
||||
| `modules/cabana/admin_openapi.go` + phase09Routes test list | config | — | existing file-route entries in same file | self |
|
||||
| `modules/cabana/admin_openapi.go` + phase09Routes test list + `modules/cabana/openapi_conformance_test.go` (one conformCase per new admin route; TestPhase10ConformanceInventory enforces it without Docker) | config | — | existing file-route entries in same file | self |
|
||||
| `modules/cabana/README.md`, `modules/pact/README.md`, `docs/backend/...` + site nav | docs | — | Phase 10.1 widget sections in same READMEs / `docs/` partials-and-widgets page | role-match |
|
||||
| `admin/src/components/form/fields/PluginField.vue` (NEW) | component | event-driven | `admin/src/components/form/fields/WidgetField.vue` | exact |
|
||||
| `admin/src/components/form/fields/MLPluginField.vue` or MLField wiring | component | event-driven | `admin/src/components/form/fields/MLMarkdownField.vue` + `MLField.vue` | exact |
|
||||
@@ -106,7 +106,7 @@ func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
|
||||
ctx = withTx(ctx, tx)
|
||||
sc, err := fr.scope(ctx, tx, r)
|
||||
```
|
||||
Route registration follows `g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))`, so the new route is `g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload))`. Use `s.uploadBytes` for the cap, load the record through the `FormExtendQuery` scope when `id != 0`, map `pact.FieldError` to 422 on the field, and answer 201 `{data:{url}}`. Also add an entry to `admin_openapi.go` and `phase09Routes` and rerun `scripts/check-admin-openapi.sh`.
|
||||
Route registration follows `g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))`, so the new route is `g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload))`. Use `s.uploadBytes` for the cap, load the record through the `FormExtendQuery` scope when `id != 0`, map `pact.FieldError` to 422 on the field, and answer 201 `{data:{url}}`. Also add an entry to `admin_openapi.go` and `phase09Routes`, add a conformCase for the route to `modules/cabana/openapi_conformance_test.go` (one conformCase per new admin route; TestPhase10ConformanceInventory enforces it without Docker), and rerun `scripts/check-admin-openapi.sh`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,48 +1,89 @@
|
||||
---
|
||||
status: testing
|
||||
status: diagnosed
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
source: [16.2-VERIFICATION.md]
|
||||
started: 2026-10-10T14:54:46Z
|
||||
updated: 2026-10-10T14:54:46Z
|
||||
updated: 2026-10-10T15:27:52Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 1
|
||||
name: Postgres suites on a Docker host
|
||||
expected: |
|
||||
bash scripts/check-phase16.2.sh postgres passes, which closes WINDOWS 11, 12, 14-16, 20 and 21.
|
||||
awaiting: user response
|
||||
[testing paused — 1 items outstanding]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Postgres suites on a Docker host
|
||||
expected: bash scripts/check-phase16.2.sh postgres passes. This is the end-to-end proof for the CR-01 and WR-04 Load changes and for the category save.
|
||||
result: [pending]
|
||||
result: issue
|
||||
reported: "Run by Claude on this Docker host (2026-10-10 16:57): the postgres stage fails in ./modules/cabana: --- FAIL: TestPhase10OpenAPIConformance: openapi_conformance_test.go:418: admin route GET /field-types has no conformance case. The stage stops there, so the Journal admin harness suites (TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor) did not run."
|
||||
severity: major
|
||||
|
||||
### 2. D-19 category content on UAT (WINDOWS 18)
|
||||
expected: Change one word in a category body and save. No removal notice appears, and the site shows the change.
|
||||
result: [pending]
|
||||
result: issue
|
||||
reported: "something is wrong. Updated content in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/categories/2 (\"Te grzyby, szczególnie\" changed into \"Te grzyby, a szczególnie\") and i see the change in backend even after refresh but not on frontend"
|
||||
severity: major
|
||||
|
||||
### 3. Untouched re-save after the next deploy (with the CR-01/WR-04 fixes)
|
||||
expected: Re-save posts 1 and 64 untouched. The md5 SELECT is unchanged (1: a026264105f900175b9770e637e5ec5c both columns; 64: 0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c) and updated_at moves.
|
||||
result: [pending]
|
||||
result: blocked
|
||||
blocked_by: release-build
|
||||
reason: "User: posts resaved. Claude checked UAT read-only at 15:05 UTC: md5 of posts 1 and 64 equal the snapshot and updated_at moved (15:05:09 / 15:05:18 UTC). But the UAT binary /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne dates from 12:27 UTC, before v0.1.19 was tagged, so the CR-01/WR-04 fixes are not deployed yet. Re-run after the v0.1.19 deploy."
|
||||
|
||||
### 4. Preview pane in a real browser (quick 261010-iwu)
|
||||
expected: 50/50 split; the pane wraps below the editor when the element is narrower than about 720px; a <script> in a raw HTML block stays inert; YouTube thumbnails load; relative /storage images resolve.
|
||||
result: [pending]
|
||||
result: pass
|
||||
|
||||
### 5. Accept the sandboxed preview iframe as an exception to plan 02's "no iframe in the admin"
|
||||
expected: The human accepts the empty-sandbox srcdoc preview, or asks for a change.
|
||||
result: [pending]
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 5
|
||||
passed: 0
|
||||
issues: 0
|
||||
pending: 5
|
||||
passed: 2
|
||||
issues: 2
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
blocked: 1
|
||||
|
||||
## Gaps
|
||||
|
||||
- gap_id: G-16.2-1
|
||||
truth: "bash scripts/check-phase16.2.sh postgres passes on a Docker host"
|
||||
status: failed
|
||||
reason: "Observed by Claude running the postgres stage: TestPhase10OpenAPIConformance fails: admin route GET /field-types (added in 16.2) has no conformance case in modules/cabana/openapi_conformance_test.go; the Journal Postgres suites never ran because the stage aborted"
|
||||
severity: major
|
||||
test: 1
|
||||
root_cause: "Two 16.2 test defects, hidden because both checks live in Docker-gated packages and the 16.2 execution host had no Docker: (1) TestPhase10OpenAPIConformance has no case for the two routes plan 16.2-01 added to phase09Routes/admin.json, GET /field-types and POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload (map order decides which is reported); (2) sm-journal-plugin TestJournalSVGEmbed still asserts post content type mlmarkdown, but 16.2-03 changed it to mlblogeditor. The gate's go stage (FRAMEWORK_RUN) never runs the route check. All four Journal Postgres suites named in Test 1 and the rest of cabana pass on a Docker host."
|
||||
artifacts:
|
||||
- path: "modules/cabana/openapi_conformance_test.go"
|
||||
issue: "no conformance case for GET /field-types or the field upload route; conformPlugin registers no plugin field type; route-key check runs after the testcontainers setup"
|
||||
- path: "../sm-journal-plugin/plugin_test.go"
|
||||
issue: "TestJournalSVGEmbed lines 122-123 expect type: mlmarkdown"
|
||||
- path: "scripts/check-phase16.2.sh"
|
||||
issue: "FRAMEWORK_RUN does not include the route-coverage check"
|
||||
- path: ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md"
|
||||
issue: "new-route checklist omits openapi_conformance_test.go"
|
||||
missing:
|
||||
- "Conformance case for GET /field-types (200, cabana.FieldTypesResponse), with conformPlugin implementing pact.HasFormFieldTypes"
|
||||
- "Conformance case for the field upload route (201, cabana.Envelope-cabana_FieldUploadResponse, multipart file)"
|
||||
- "TestJournalSVGEmbed expects type: mlblogeditor"
|
||||
- "Route-coverage check runnable without Docker and included in the gate's go stage"
|
||||
debug_session: .planning/debug/postgres-gate-field-types-conformance.md
|
||||
|
||||
- gap_id: G-16.2-2
|
||||
truth: "Changing one word in a category body and saving shows no removal notice, and the site shows the change"
|
||||
status: failed
|
||||
reason: "User reported: something is wrong. Updated content in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/categories/2 (\"Te grzyby, szczególnie\" changed into \"Te grzyby, a szczególnie\") and i see the change in backend even after refresh but not on frontend"
|
||||
severity: major
|
||||
test: 2
|
||||
root_cause: "Not a 16.2 save defect. DB row (category 2, content) and the Journal API both hold the new text. The public page was served from the host Nuxt app's Nitro swr route cache (nuxt.config.ts routeRules '/**': { swr: 300 }, per Phase 16 D-07): /cordyceps was cached at 14:59:08 UTC, 40 s before the first save, and all post-save views fell inside the 300 s fresh window. Nothing purges the cache on admin save; after expiry the first view still gets stale content and triggers revalidation. At 15:25 UTC the page served the new text with no intervention. The UAT expectation omitted the cache window."
|
||||
artifacts:
|
||||
- path: "sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts"
|
||||
issue: "swr 300 / 120 route cache with no purge path on admin save (by design, Phase 16 D-07)"
|
||||
- path: ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-UAT.md"
|
||||
issue: "Test 2 expectation does not account for the swr TTL"
|
||||
missing:
|
||||
- "User decision 2026-10-10: fix in 16.2. A Journal post/category save clears the matching host Nuxt page cache entries (pages, /blog lists, _payload.json) so the site shows the edit on the next view"
|
||||
debug_session: .planning/debug/category-content-not-on-site.md
|
||||
|
||||
@@ -61,13 +61,21 @@ revised: "2026-10-10"
|
||||
| 16.2-03-T1, 16.2-06-T2 | 03, 06 | 2, 4 | D-02 / D-14 | — | Posts hooks: default write copied to content_html, non-default writes through the TranslationWriter, nothing for untouched content or without a writer | unit | `go -C ../sm-journal-plugin test -count=1 -run '^TestPostsEditorHooks$' ./controllers/` | ✅ | ✅ green |
|
||||
| 16.2-02-T2, 16.2-02-T3, 16.2-06-T1, 16.2-06-T3 | 02, 06 | 1, 4 | D-03 / D-04 | XSS | Element returns an untouched value byte for byte through Source, tabs, preview and reattach; raw HTML cards are escaped text; every raw tag rule; figures; links limited to http, https, mailto, relative; block reuse after edit, deletion and insertion | vitest | `npm --prefix ../sm-journal-plugin test` | ✅ | ✅ green |
|
||||
| 16.2-02-T3 | 02 | 1 | D-05 | — | Committed editor corpus equals the editor's current output | vitest | `npm --prefix ../sm-journal-plugin test -- tests/blogeditor/corpus.test.ts` | ✅ | ✅ green |
|
||||
| 16.2-03-T1, 16.2-06-T1 | 03, 06 | 2, 4 | todo resave / D-03 | — | Untouched GET-then-PUT of an HTML and a markdown post byte-identical with no notices and no translated content added; a clean edited save stores equal content and content_html per locale | integration (Postgres) | `go -C ../sm-journal-plugin test -count=1 -run TestPostsAdminResaveKeepsHTML .` | ✅ | ⚠️ Docker host (manual) |
|
||||
| 16.2-03-T1 | 03 | 2 | D-10 / D-02 | XSS | Admin post save strips unsafe markup in both columns per locale and reports it (no 422) | integration (Postgres) | `go -C ../sm-journal-plugin test -count=1 -run TestPostsAdminSaveSanitizesHTML .` | ✅ | ⚠️ Docker host (manual) |
|
||||
| 16.2-03-T2, 16.2-06-T2 | 03, 06 | 2, 4 | D-19 / D-10 | XSS | Category content: untouched saves keep imported markup and NULL, clean edit as sent, unsafe edit stripped with notices (no 422), blank to NULL, editor upload under journal/categories | integration (Postgres) | `go -C ../sm-journal-plugin test -count=1 -run '^(TestCategoriesAdminContent\|TestCategoriesAdminBlogEditor)$' .` | ✅ | ⚠️ Docker host (manual) |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-12 / D-18 | upload | Real-database create, show, untouched and changed update, refused update rolls back, upload in and out of the FormExtendQuery scope | integration (Postgres) | `go test -count=1 -run '^TestFieldTypeSavePostgres$' ./modules/cabana` | ✅ | ⚠️ Docker host (manual) |
|
||||
| 16.2-03-T1, 16.2-06-T1 | 03, 06 | 2, 4 | todo resave / D-03 | — | Untouched GET-then-PUT of an HTML and a markdown post byte-identical with no notices and no translated content added; a clean edited save stores equal content and content_html per locale | integration (Postgres) | `go -C ../sm-journal-plugin test -count=1 -run TestPostsAdminResaveKeepsHTML .` | ✅ | ✅ green (Docker host, gate postgres stage, 16.2-07) |
|
||||
| 16.2-03-T1 | 03 | 2 | D-10 / D-02 | XSS | Admin post save strips unsafe markup in both columns per locale and reports it (no 422) | integration (Postgres) | `go -C ../sm-journal-plugin test -count=1 -run TestPostsAdminSaveSanitizesHTML .` | ✅ | ✅ green (Docker host, gate postgres stage, 16.2-07) |
|
||||
| 16.2-03-T2, 16.2-06-T2 | 03, 06 | 2, 4 | D-19 / D-10 | XSS | Category content: untouched saves keep imported markup and NULL, clean edit as sent, unsafe edit stripped with notices (no 422), blank to NULL, editor upload under journal/categories | integration (Postgres) | `go -C ../sm-journal-plugin test -count=1 -run '^(TestCategoriesAdminContent\|TestCategoriesAdminBlogEditor)$' .` | ✅ | ✅ green (Docker host, gate postgres stage, 16.2-07) |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-12 / D-18 | upload | Real-database create, show, untouched and changed update, refused update rolls back, upload in and out of the FormExtendQuery scope | integration (Postgres) | `go test -count=1 -run '^TestFieldTypeSavePostgres$' ./modules/cabana` | ✅ | ✅ green (Docker host, gate postgres stage, 16.2-07) |
|
||||
| 16.2-04-T1, 16.2-06-T3 | 04, 06 | 2, 4 | D-17 / D-05 | iframe | Embed allowlist and id regex; sanitize first, expand last at each sink, never in payload transforms; stored iframe stripped; corpus safe under the mirror; Go negatives stripped the same way; corpus copies identical | node:test + cmp | `npm --prefix ../vue-journal-layer test && cmp ../vue-journal-layer/tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-06-T3 | 01, 06 | 1, 4 | docs | — | README/docs identifiers exist; plugin dist current | unit + script | `go test -count=1 ./cmd/summer -run TestDocsTree && bash ../sm-journal-plugin/scripts/check-dist.sh` | ✅ | ✅ green |
|
||||
| 16.2-06-T3 | 06 | 4 | all | T-16.2-29 | Phase gate: every runnable stage fail-closed, detectors self-tested | script | `bash scripts/check-phase16.2.sh --self-test && bash scripts/check-phase16.2.sh --all` | ✅ | ✅ green |
|
||||
| 16.2-07-T1 | 07 | 5 | D-19 | T-16.2-36, T-16.2-38 | Tracer: a category admin update sends one authorized purge after its commit; on a built swr site a cached page shows a change on the first request after the purge; unauthenticated, wrong-token and proxied purges refused | integration (Postgres) + e2e | `go -C ../sm-journal-plugin test -count=1 -run '^TestCategoriesAdminPurgesSiteCache$' . && pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/purge-check.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --kind post --slug hello-acme --path /blog/hello-acme` | ✅ | ✅ green |
|
||||
| 16.2-07-T2 | 07 | 5 | D-19 | T-16.2-37, T-16.2-40, T-16.2-42 | Every admin post and category write purges after commit; boot refuses bad purge config; route-cached pages and 304s carry max-age=0; second purge after re-cache runs; payload purged; no-token server 404 | integration (Postgres) + e2e | `go -C ../sm-journal-plugin test -count=1 -run '^(TestCategoriesAdminPurgesSiteCache\|TestPostsAdminPurgesSiteCache)$' . && bash scripts/check-phase16.2.sh cache` | ✅ | ✅ green |
|
||||
| 16.2-07-T3 | 07 | 5 | D-13, D-18 | T-16.2-44 | Every inventoried admin route has a conformance case, checked without Docker; GET /field-types and the field upload route decode into their documented types with unknown fields disallowed and match admin.json; TestJournalSVGEmbed expects mlblogeditor | unit + integration (Postgres) | `go test -count=1 -short -run '^TestPhase10ConformanceInventory$' ./modules/cabana && go test -count=1 -run '^TestPhase10OpenAPIConformance$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-07-T3 | 07 | 5 | D-19 | T-16.2-37, T-16.2-39, T-16.2-41 | Purger config table, request contract, Warn on 500/401, 2 s timeout, cancelled parent context, no token in logs; layer auth, purge, body parsing and revalidation | unit + node:test | `go -C ../sm-journal-plugin test -count=1 -run '^(TestSiteCachePurgeConfig\|TestSiteCachePurgeRequest)$' ./classes/ && npm --prefix ../vue-journal-layer test` | ✅ | ✅ green |
|
||||
| 16.2-07-T3 | 07 | 5 | all | T-16.2-44 | Full gate including the Docker suites and the cache stage | script | `PHASE162_REQUIRE_POSTGRES=1 bash scripts/check-phase16.2.sh --all` | ✅ | ✅ green |
|
||||
| 16.2-07-T4 | 07 | 5 | release | T-16.2-45 | Release gate: clean trees, nothing behind origin, proposed tags absent on origin, human decision | script + human | `bash scripts/check-phase16.2.sh --self-test && test -z "$(git status --porcelain -- modules admin docs scripts cmd)" && test -z "$(git ls-remote --tags origin refs/tags/v0.1.20)"` | ✅ | ⬜ pending |
|
||||
| 16.2-07-T5 | 07 | 5 | release | T-16.2-36, T-16.2-37, T-16.2-45 | UAT deploy: smoke, binary sha256 equal, both programs running, tokens equal and never printed, on-server purge 200 with purged >= 1, nginx POST 403, no-token 401 | script (ssh) | Task 5 verify commands in 16.2-07-PLAN.md (smoke ok, deploy-ok, token-match, purge-ok) | ✅ | ⬜ pending |
|
||||
| 16.2-07-T6 | 07 | 5 | D-03, D-19 | T-16.2-46 | UAT Test 3 untouched re-saves byte-identical; UAT Test 2 category edit visible on the next view after a logged purge | psql (read-only) + human | Task 6 verify commands in 16.2-07-PLAN.md (posts-unchanged, translations-unchanged, category-purged, category-fresh) | ✅ | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky or manual (Docker host)*
|
||||
|
||||
@@ -88,7 +96,6 @@ revised: "2026-10-10"
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Testcontainers Postgres suites | todo resave, D-03, D-10, D-12, D-18, D-19 | No Docker on the dev machine | On a Docker host: `bash scripts/check-phase16.2.sh postgres` (cabana `./modules/cabana` incl. TestFieldTypeSavePostgres and TestListRelationSearchPostgres; sm-journal-plugin `./...` incl. TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor, TestJournalSVGEmbed) |
|
||||
| Editing imported posts on UAT | Phase goal | Real browser editing of live posts | Open an imported post, save untouched (no notice, md5 unchanged), then edit and save; compare columns |
|
||||
| Strip and report on UAT | D-10 (amended) | Needs the admin session and a real save | Add a harmless style block and onclick in the source tab of an unpublished test post, save: it succeeds, the notice names both, the stored columns are equal and contain neither (plan 05 Task 3) |
|
||||
|
||||
@@ -100,7 +107,7 @@ revised: "2026-10-10"
|
||||
- [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 (the gate's runnable stages; the Docker suites are the manual row)
|
||||
- [x] Feedback latency < 120s for the Docker-free stages; the Docker suites run in the gate's postgres stage on a Docker host (`PHASE162_REQUIRE_POSTGRES=1 bash scripts/check-phase16.2.sh --all`, 16.2-07)
|
||||
- [x] nyquist_compliant set to true in the frontmatter
|
||||
|
||||
**Approval:** automated map complete (plan 06, 2026-10-10); the Docker-host suites and the UAT rows above stay manual
|
||||
**Approval:** automated map complete (plan 06, 2026-10-10); the Docker-host suites are automated in the gate's postgres stage since plan 07 (2026-10-10); the UAT rows above stay manual
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
@@ -126,11 +128,107 @@ func (stubMLSchemaController) ID() string { return "acme.demo.widgets" }
|
||||
func (stubMLSchemaController) ModelName() string { return "Widget" }
|
||||
func (stubMLSchemaController) ConfigDir() string { return "controllers/widgets" }
|
||||
|
||||
// TestPhase10ConformanceInventory checks without a database that every
|
||||
// inventoried admin route has exactly one conformance case, so a new admin
|
||||
// route without a case fails on any host (G-16.2-1).
|
||||
func TestPhase10ConformanceInventory(t *testing.T) {
|
||||
checkConformInventory(t, conformCases())
|
||||
}
|
||||
|
||||
// checkConformInventory fails once, listing in sorted order every
|
||||
// inventoried admin route without a conformance case, every case that names
|
||||
// no inventoried route and every duplicate case.
|
||||
func checkConformInventory(t *testing.T, cases []conformCase) {
|
||||
t.Helper()
|
||||
inventory := map[string]bool{}
|
||||
for _, key := range cabana.AdminAPIRoutes() {
|
||||
inventory[key] = true
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for _, tc := range cases {
|
||||
seen[tc.key]++
|
||||
}
|
||||
var missing, extra, duplicate []string
|
||||
for key := range inventory {
|
||||
if seen[key] == 0 {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
for key, n := range seen {
|
||||
if !inventory[key] {
|
||||
extra = append(extra, key)
|
||||
}
|
||||
if n > 1 {
|
||||
duplicate = append(duplicate, key)
|
||||
}
|
||||
}
|
||||
if len(missing)+len(extra)+len(duplicate) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
sort.Strings(duplicate)
|
||||
var b strings.Builder
|
||||
for _, group := range []struct {
|
||||
title string
|
||||
keys []string
|
||||
}{
|
||||
{"admin routes without a conformance case (add one to conformCases)", missing},
|
||||
{"conformance cases that name no inventoried admin route", extra},
|
||||
{"duplicate conformance cases", duplicate},
|
||||
} {
|
||||
if len(group.keys) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%s:", group.title)
|
||||
for _, key := range group.keys {
|
||||
fmt.Fprintf(&b, "\n %s", key)
|
||||
}
|
||||
}
|
||||
t.Fatalf("conformance inventory does not match the admin routes:%s", b.String())
|
||||
}
|
||||
|
||||
func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
cases := conformCases()
|
||||
checkConformInventory(t, cases)
|
||||
env := newConformEnv(t)
|
||||
spec := conformSpec(t)
|
||||
|
||||
cases := []conformCase{
|
||||
for _, tc := range cases {
|
||||
ok := t.Run(tc.key, func(t *testing.T) {
|
||||
rec := tc.call(t, env)
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
|
||||
}
|
||||
method, path, _ := strings.Cut(tc.key, " ")
|
||||
if tc.raw != nil {
|
||||
tc.raw(t, rec)
|
||||
if !spec.binary(path, strings.ToLower(method), fmt.Sprint(tc.status)) {
|
||||
t.Fatalf("admin.json does not document %s %d as binary", tc.key, tc.status)
|
||||
}
|
||||
return
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := tc.decode(dec); err != nil {
|
||||
t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status))
|
||||
if got != tc.ref {
|
||||
t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref)
|
||||
}
|
||||
})
|
||||
if !ok {
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// conformCases is the conformance case of every inventoried admin route, in
|
||||
// the order TestPhase10OpenAPIConformance calls them: later cases use the
|
||||
// records earlier ones create.
|
||||
func conformCases() []conformCase {
|
||||
return []conformCase{
|
||||
{"POST /auth/login", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false)
|
||||
}, into[cabana.Envelope[cabana.AdminLoginData]](), nil},
|
||||
@@ -191,6 +289,45 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
{"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true)
|
||||
}, into[cabana.Envelope[[]cabana.FilterOption]](), nil},
|
||||
{"GET /field-types", 200, "cabana.FieldTypesResponse", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodGet, "/field-types", nil, true)
|
||||
var body cabana.FieldTypesResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("field types: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
var found *cabana.FieldTypeAssets
|
||||
for i := range body.Data {
|
||||
if body.Data[i].Name == conformNoteType {
|
||||
found = &body.Data[i]
|
||||
}
|
||||
}
|
||||
if found == nil || found.Element != conformNoteElement || found.Translatable || len(found.Scripts) != 1 || len(found.Styles) != 0 {
|
||||
t.Fatalf("field types = %s, want %s with its element and one script", rec.Body.String(), conformNoteType)
|
||||
}
|
||||
e.assertPluginAsset(t, found.Scripts[0], "text/javascript; charset=utf-8")
|
||||
return rec
|
||||
}, into[cabana.FieldTypesResponse](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", 201, "cabana.Envelope-cabana_FieldUploadResponse", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile("file", "note.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write([]byte("conform note " + e.stamp)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// {id} 0 is the create form: Upload receives no record.
|
||||
rec := e.sendWith(t, http.MethodPost, "/acme/conform/gadgets/0/fields/note/upload", buf.Bytes(), mw.FormDataContentType(), map[string]string{"X-Requested-With": "XMLHttpRequest"})
|
||||
var body cabana.Envelope[cabana.FieldUploadResponse]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.URL != fmt.Sprintf("/media/conform/note.txt?bytes=%d&record=nil", len("conform note "+e.stamp)) {
|
||||
t.Fatalf("field upload body = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.FieldUploadResponse]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey)
|
||||
e.photoID = dataID(t, rec.Body.Bytes())
|
||||
@@ -212,8 +349,13 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
return e.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", e.photoID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
|
||||
}, into[cabana.Envelope[cabana.FileMutationResult]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID, "released_on": "2026-10-02", "starts_at": "2026-10-02T12:30:00+02:00"}, true)
|
||||
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID, "released_on": "2026-10-02", "starts_at": "2026-10-02T12:30:00+02:00", "note": " note-" + e.stamp + " "}, true)
|
||||
e.gadgetID = dataID(t, rec.Body.Bytes())
|
||||
// Decode trimmed the note before it was stored.
|
||||
var stored conformGadget
|
||||
if err := e.db.First(&stored, e.gadgetID).Error; err != nil || stored.Note == nil || *stored.Note != "note-"+e.stamp {
|
||||
t.Fatalf("stored note = %#v (%v), want the decoded value", stored.Note, err)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
{"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
@@ -259,10 +401,14 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true)
|
||||
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
|
||||
rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
|
||||
if !strings.Contains(rec.Body.String(), `"note":"loaded:note-`+e.stamp+`"`) {
|
||||
t.Fatalf("show does not serve the note through Load: %s", rec.Body.String())
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
{"PUT /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil, "released_on": "2026-10-03", "starts_at": nil}, true)
|
||||
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil, "released_on": "2026-10-03", "starts_at": nil, "note": "note-" + e.stamp + "-renamed"}, true)
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/candidates?search=%s", e.gadgetID, e.stamp), nil, true)
|
||||
@@ -398,55 +544,6 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
|
||||
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
|
||||
}
|
||||
|
||||
inventory := map[string]bool{}
|
||||
for _, key := range cabana.AdminAPIRoutes() {
|
||||
inventory[key] = true
|
||||
}
|
||||
covered := map[string]bool{}
|
||||
for _, tc := range cases {
|
||||
if !inventory[tc.key] {
|
||||
t.Fatalf("conformance case %s is not an inventoried admin route", tc.key)
|
||||
}
|
||||
if covered[tc.key] {
|
||||
t.Fatalf("duplicate conformance case %s", tc.key)
|
||||
}
|
||||
covered[tc.key] = true
|
||||
}
|
||||
for key := range inventory {
|
||||
if !covered[key] {
|
||||
t.Fatalf("admin route %s has no conformance case", key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
ok := t.Run(tc.key, func(t *testing.T) {
|
||||
rec := tc.call(t, env)
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
|
||||
}
|
||||
method, path, _ := strings.Cut(tc.key, " ")
|
||||
if tc.raw != nil {
|
||||
tc.raw(t, rec)
|
||||
if !spec.binary(path, strings.ToLower(method), fmt.Sprint(tc.status)) {
|
||||
t.Fatalf("admin.json does not document %s %d as binary", tc.key, tc.status)
|
||||
}
|
||||
return
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := tc.decode(dec); err != nil {
|
||||
t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status))
|
||||
if got != tc.ref {
|
||||
t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref)
|
||||
}
|
||||
})
|
||||
if !ok {
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type conformSpecDoc struct {
|
||||
@@ -734,12 +831,14 @@ type conformGadget struct {
|
||||
Active bool `gorm:"column:active"`
|
||||
GroupID *uint `gorm:"column:group_id"`
|
||||
// ReleasedOn and StartsAt are the date and datetime datepicker columns.
|
||||
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
|
||||
StartsAt *time.Time `gorm:"column:starts_at"`
|
||||
Members []conformMember `gorm:"-"`
|
||||
Parts []conformPart `gorm:"-"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
|
||||
StartsAt *time.Time `gorm:"column:starts_at"`
|
||||
// Note is the plugin-typed field (conformNoteType).
|
||||
Note *string `gorm:"column:note"`
|
||||
Members []conformMember `gorm:"-"`
|
||||
Parts []conformPart `gorm:"-"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (conformGadget) TableName() string { return "cabana_conform_gadgets" }
|
||||
@@ -748,7 +847,7 @@ func (conformGadget) AttachRelations() []attach.Relation {
|
||||
return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}}
|
||||
}
|
||||
func (conformGadget) Fillable() []string {
|
||||
return []string{"name", "active", "released_on", "starts_at"}
|
||||
return []string{"name", "active", "released_on", "starts_at", "note"}
|
||||
}
|
||||
func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
func (conformGadget) FilterScopes() []string { return []string{"filterByGroup"} }
|
||||
@@ -842,6 +941,55 @@ func (conformPlugin) Settings() []pact.SettingsItem {
|
||||
}
|
||||
func (conformPlugin) AdminFS() fs.FS { return conformFS() }
|
||||
|
||||
const (
|
||||
// conformNoteType is the fixture plugin's field type alias.
|
||||
conformNoteType = "conformnote"
|
||||
// conformNoteElement is its custom element, under the acme-conform- prefix.
|
||||
conformNoteElement = "acme-conform-note"
|
||||
)
|
||||
|
||||
// FormFieldTypes registers one plugin field type, so GET /field-types lists
|
||||
// it and the gadget form's note field can upload: Decode trims the value,
|
||||
// Load prefixes "loaded:", Upload answers a URL naming the bytes it read.
|
||||
func (conformPlugin) FormFieldTypes() []pact.FormFieldType {
|
||||
return []pact.FormFieldType{{
|
||||
Name: conformNoteType,
|
||||
Element: conformNoteElement,
|
||||
Scripts: []string{"assets/js/lookup.js"},
|
||||
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
text, ok := in.Value.(string)
|
||||
if !ok {
|
||||
return pact.FormFieldDecoded{Value: in.Value}, nil
|
||||
}
|
||||
return pact.FormFieldDecoded{Value: strings.TrimSpace(text)}, nil
|
||||
},
|
||||
Load: func(_ context.Context, in pact.FormFieldValue) (any, error) {
|
||||
switch v := in.Value.(type) {
|
||||
case string:
|
||||
return "loaded:" + v, nil
|
||||
case *string:
|
||||
if v != nil {
|
||||
return "loaded:" + *v, nil
|
||||
}
|
||||
}
|
||||
return in.Value, nil
|
||||
},
|
||||
Upload: func(_ context.Context, in pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
|
||||
body, err := io.ReadAll(in.Body)
|
||||
if err != nil {
|
||||
return pact.FormFieldUploadResult{}, err
|
||||
}
|
||||
record := "nil"
|
||||
if in.Record != nil {
|
||||
record = "set"
|
||||
}
|
||||
return pact.FormFieldUploadResult{URL: fmt.Sprintf("/media/conform/%s?bytes=%d&record=%s", in.Filename, len(body), record)}, nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
var _ pact.HasFormFieldTypes = conformPlugin{}
|
||||
|
||||
type conformController struct{ stamp string }
|
||||
|
||||
func (conformController) ID() string { return "acme.conform.gadgets" }
|
||||
@@ -1109,6 +1257,9 @@ parts:
|
||||
fileTypes: [pdf, png, svg, txt]
|
||||
useCaption: true
|
||||
context: update
|
||||
note:
|
||||
label: Note
|
||||
type: conformnote
|
||||
`),
|
||||
"models/member/pivot_fields.yaml": file(`fields:
|
||||
pivot[note]:
|
||||
|
||||
@@ -5,21 +5,34 @@
|
||||
#
|
||||
# Stages:
|
||||
# go go vet in both Go repositories; the framework's field type tests
|
||||
# and the Journal classes and controllers tests (including the
|
||||
# editor-corpus policy cross-check); the 16.1 policy test files
|
||||
# must be unchanged since c60abc6.
|
||||
# and the Docker-free admin route conformance inventory
|
||||
# (TestPhase10ConformanceInventory: every inventoried admin route
|
||||
# has a conformance case); the Journal classes and controllers
|
||||
# tests (including the editor-corpus policy cross-check and the
|
||||
# site cache purger, TestSiteCachePurgeConfig and
|
||||
# TestSiteCachePurgeRequest); the 16.1 policy test files must be
|
||||
# unchanged since c60abc6.
|
||||
# admin the admin SPA typecheck and full vitest suite.
|
||||
# editor the Blog Editor typecheck and full vitest suite (smoke, corpus,
|
||||
# preview, element, schema, serializer, embed).
|
||||
# layer the vue-journal-layer node --test suite and the editor corpus
|
||||
# copy, which must equal the plugin's byte for byte (cmp).
|
||||
# layer the vue-journal-layer node --test suite (including the site
|
||||
# cache purge tests) and the editor corpus copy, which must equal
|
||||
# the plugin's byte for byte (cmp).
|
||||
# cache builds the layer playground (routeRules swr) and runs
|
||||
# testing/purge-check.mjs on it (post hello-acme at
|
||||
# /blog/hello-acme: a cached page shows a change on the first
|
||||
# request after an authorized purge, guards refuse the rest), then
|
||||
# the acme SSR smoke against the same build.
|
||||
# docs TestDocsTree, the admin OpenAPI and dist drift gates and the
|
||||
# plugin's assets/dist drift gate.
|
||||
# postgres Docker host only: the testcontainers suites (cabana, including
|
||||
# TestFieldTypeSavePostgres; the Journal admin harness, including
|
||||
# TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML,
|
||||
# TestCategoriesAdminContent and TestCategoriesAdminBlogEditor).
|
||||
# Without a reachable Docker daemon this stage fails.
|
||||
# TestFieldTypeSavePostgres and TestPhase10OpenAPIConformance; the
|
||||
# Journal admin harness, including TestPostsAdminResaveKeepsHTML,
|
||||
# TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent,
|
||||
# TestCategoriesAdminBlogEditor, TestJournalSVGEmbed and the site
|
||||
# cache purge suites TestCategoriesAdminPurgesSiteCache and
|
||||
# TestPostsAdminPurgesSiteCache). Without a reachable Docker daemon
|
||||
# this stage fails.
|
||||
#
|
||||
# Every stage exits non-zero on a failing command; a go test -json run that
|
||||
# fails, skips, matches zero tests, prints "no tests to run" or misses a named
|
||||
@@ -29,7 +42,7 @@
|
||||
# test. --self-test proves the detectors and the corpus comparison fail on
|
||||
# planted output.
|
||||
#
|
||||
# --all runs self-test, go, admin, editor, layer and docs, then postgres when
|
||||
# --all runs self-test, go, admin, editor, layer, cache and docs, then postgres when
|
||||
# a Docker daemon answers. When none does, --all says so loudly (NOT RUN
|
||||
# postgres) and the Docker suites stay a manual step for a Docker host:
|
||||
# bash scripts/check-phase16.2.sh postgres
|
||||
@@ -48,7 +61,7 @@ LAYER="${LAYER:-$ROOT/../vue-journal-layer}"
|
||||
POLICY_BASE="c60abc6"
|
||||
|
||||
# Framework tests (modules/pact, modules/cabana) that must pass by name.
|
||||
FRAMEWORK_RUN='^(TestFormFieldErrorMessage|TestFormFieldTypeContract|TestFieldTypeRegistry|TestFieldTypeOptions|TestFieldTypeCompile|TestFieldTypeDecode|TestFieldTypeNotices|TestFieldTypeUntouched|TestFieldTypeLoad|TestFieldTypeListValue|TestFieldTypeWrites|TestFieldTypesRoute|TestFieldUpload|TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase10CSRF|TestPhase09ContractInventory|TestPhase09PermissionMatrix|ExampleCompileFormWithTypes)$'
|
||||
FRAMEWORK_RUN='^(TestFormFieldErrorMessage|TestFormFieldTypeContract|TestFieldTypeRegistry|TestFieldTypeOptions|TestFieldTypeCompile|TestFieldTypeDecode|TestFieldTypeNotices|TestFieldTypeUntouched|TestFieldTypeLoad|TestFieldTypeListValue|TestFieldTypeWrites|TestFieldTypesRoute|TestFieldUpload|TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase10CSRF|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|ExampleCompileFormWithTypes)$'
|
||||
FRAMEWORK_REQUIRE=(
|
||||
TestFormFieldErrorMessage TestFormFieldTypeContract
|
||||
TestFieldTypeRegistry TestFieldTypeOptions TestFieldTypeCompile TestFieldTypeDecode
|
||||
@@ -56,6 +69,7 @@ FRAMEWORK_REQUIRE=(
|
||||
TestFieldTypeWrites TestFieldTypesRoute TestFieldUpload
|
||||
TestFieldTypeTracerSmoke TestFieldTypeExpansionSmoke
|
||||
TestPhase10CSRF TestPhase09ContractInventory TestPhase09PermissionMatrix
|
||||
TestPhase10ConformanceInventory
|
||||
ExampleCompileFormWithTypes
|
||||
)
|
||||
# Journal classes and controllers tests that must pass by name (the packages
|
||||
@@ -68,13 +82,15 @@ JOURNAL_REQUIRE=(
|
||||
TestMediaObjectPath TestMediaFolderPatternRejectsDotDot
|
||||
TestPostsEditorHooks TestBlogEditorTracerSmoke TestBlogEditorUploadSmoke
|
||||
TestWriteMediaStoreError
|
||||
TestSiteCachePurgeConfig TestSiteCachePurgeRequest
|
||||
)
|
||||
# Docker-host suites.
|
||||
PG_FRAMEWORK_REQUIRE=(TestFieldTypeSavePostgres TestListRelationSearchPostgres)
|
||||
PG_FRAMEWORK_REQUIRE=(TestFieldTypeSavePostgres TestListRelationSearchPostgres TestPhase10OpenAPIConformance)
|
||||
PG_JOURNAL_REQUIRE=(
|
||||
TestPostsFormCompiles TestPostsAdminCreateSmoke TestPostsAdminResaveKeepsHTML
|
||||
TestPostsAdminSaveSanitizesHTML TestCategoriesAdminContent TestCategoriesAdminBlogEditor
|
||||
TestJournalSVGEmbed
|
||||
TestCategoriesAdminPurgesSiteCache TestPostsAdminPurgesSiteCache
|
||||
)
|
||||
|
||||
ADMIN_FILES=(
|
||||
@@ -107,14 +123,18 @@ LAYER_REQUIRE=(
|
||||
"the sinks expand after sanitizing and the payload transforms never expand"
|
||||
"the Go policy negatives are stripped the same way here"
|
||||
"the editor corpus has the entries the mirror rules are for"
|
||||
"purge auth: a missing or short token disables the route"
|
||||
"purge auth: a proxied request is refused before the token is checked"
|
||||
"purge auth: a missing or wrong bearer token is unauthorized"
|
||||
"purge removes every route cache entry and only those"
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage:
|
||||
check-phase16.2.sh --self-test
|
||||
check-phase16.2.sh go | admin | editor | layer | docs | postgres
|
||||
check-phase16.2.sh --go | --admin | --editor | --layer | --docs | --postgres
|
||||
check-phase16.2.sh go | admin | editor | layer | cache | docs | postgres
|
||||
check-phase16.2.sh --go | --admin | --editor | --layer | --cache | --docs | --postgres
|
||||
check-phase16.2.sh --all
|
||||
EOF
|
||||
exit 2
|
||||
@@ -434,7 +454,7 @@ ok 1 - a
|
||||
rm -f "$a" "$b"
|
||||
|
||||
local mode
|
||||
for mode in self-test go admin editor layer docs postgres all; do
|
||||
for mode in self-test go admin editor layer cache docs postgres all; do
|
||||
grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || {
|
||||
echo "refuse: missing mode --$mode" >&2
|
||||
return 1
|
||||
@@ -484,6 +504,28 @@ run_layer() {
|
||||
echo "phase16.2 layer passed"
|
||||
}
|
||||
|
||||
# run_cache proves the site cache purge end to end on the layer playground,
|
||||
# whose routeRules cache every page, then runs the acme SSR smoke against the
|
||||
# same build.
|
||||
run_cache() {
|
||||
require_dir "$LAYER" "layer repository"
|
||||
local log rc=0
|
||||
pnpm -C "$LAYER" run build:playground >/dev/null
|
||||
log="$(mktemp)"
|
||||
node "$LAYER/testing/purge-check.mjs" --output "$LAYER/.playground/.output" --fixtures "$LAYER/testing/fixtures/acme" \
|
||||
--kind post --slug hello-acme --path /blog/hello-acme >"$log" 2>&1 || rc=$?
|
||||
if [[ "$rc" -ne 0 ]] || ! tail -n 1 "$log" | grep -q '^purge-check: ok'; then
|
||||
cat "$log" >&2
|
||||
rm -f "$log"
|
||||
echo "refuse: purge-check on the layer playground (exit $rc)" >&2
|
||||
return 1
|
||||
fi
|
||||
tail -n 1 "$log"
|
||||
rm -f "$log"
|
||||
node "$LAYER/testing/smoke-ssr.mjs" --output "$LAYER/.playground/.output" --fixtures "$LAYER/testing/fixtures/acme" --expect "$LAYER/testing/expect-acme.txt"
|
||||
echo "phase16.2 cache passed"
|
||||
}
|
||||
|
||||
run_docs() {
|
||||
require_dir "$JOURNAL" "Journal plugin"
|
||||
REQUIRE_TESTS="TestDocsTree" go_json "$ROOT" -count=1 -run '^TestDocsTree$' ./cmd/summer
|
||||
@@ -510,7 +552,7 @@ run_postgres() {
|
||||
|
||||
run_all() {
|
||||
local stage
|
||||
for stage in self-test go admin editor layer docs; do
|
||||
for stage in self-test go admin editor layer cache docs; do
|
||||
if bash "${BASH_SOURCE[0]}" "--$stage"; then
|
||||
echo "PASS $stage"
|
||||
else
|
||||
@@ -532,9 +574,11 @@ run_all() {
|
||||
cat >&2 <<'EOF'
|
||||
************************************************************************
|
||||
NOT RUN postgres: no reachable Docker daemon on this host.
|
||||
The testcontainers suites (TestFieldTypeSavePostgres, the Journal admin
|
||||
harness incl. TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML,
|
||||
TestCategoriesAdminContent, TestCategoriesAdminBlogEditor) did NOT run.
|
||||
The testcontainers suites (TestFieldTypeSavePostgres,
|
||||
TestPhase10OpenAPIConformance, the Journal admin harness incl.
|
||||
TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML,
|
||||
TestCategoriesAdminContent, TestCategoriesAdminBlogEditor, TestJournalSVGEmbed
|
||||
and the site cache purge suites) did NOT run.
|
||||
Run on a Docker host before /msd-verify-work:
|
||||
bash scripts/check-phase16.2.sh postgres
|
||||
************************************************************************
|
||||
@@ -550,6 +594,7 @@ case "${1:---all}" in
|
||||
--admin | admin) run_admin ;;
|
||||
--editor | editor) run_editor ;;
|
||||
--layer | layer) run_layer ;;
|
||||
--cache | cache) run_cache ;;
|
||||
--docs | docs) run_docs ;;
|
||||
--postgres | postgres) run_postgres ;;
|
||||
--all | all) run_all ;;
|
||||
|
||||
Reference in New Issue
Block a user