docs(16.2): create gap closure plan 07 for G-16.2-1 and G-16.2-2
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user