Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7554241e9d | ||
|
|
fa25b4e000 | ||
|
|
b4cc6f88e4 | ||
|
|
52eef9b43b | ||
|
|
0b09acdb0c | ||
|
|
4a9b0896b1 | ||
|
|
b040ee3a74 | ||
|
|
a586357a34 | ||
|
|
5707f97089 | ||
|
|
9b3e630b6b | ||
|
|
efcef5d501 | ||
|
|
f15b38e2ff | ||
|
|
840af3e922 | ||
|
|
6d2e3ba8bb | ||
|
|
4a94380b5f | ||
|
|
9fa714ff17 | ||
|
|
4abac02d2d | ||
|
|
c05ad09dc2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ go.work.sum
|
||||
.gsd/
|
||||
.planning/research/.cache/
|
||||
.planning/milestone.lock
|
||||
temp
|
||||
|
||||
@@ -5,10 +5,10 @@ current_phase: 15
|
||||
current_phase_name: Journal plugin
|
||||
status: executing
|
||||
stopped_at: Completed 15-04-PLAN.md
|
||||
last_updated: "2026-10-06T17:22:19Z"
|
||||
last_activity: 2026-10-06
|
||||
last_updated: "2026-10-07T16:43:18.298Z"
|
||||
last_activity: 2026-10-07
|
||||
last_activity_desc: Completed quick task 261006-s0v (remove ML copy-from locale UI)
|
||||
state_head: 7307b36baaad660c84b5b365f2dac17ca7313941
|
||||
state_head: fa25b4e000fac864b949be6daa4f800a4511a458
|
||||
progress:
|
||||
total_phases: 26
|
||||
completed_phases: 12
|
||||
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
Phase: 15 (Journal plugin) — EXECUTING
|
||||
Plan: 4 of 4
|
||||
Status: Ready for phase verification
|
||||
Last activity: 2026-10-06 — Completed quick task 261006-tnp: Add mltextarea field type
|
||||
Last activity: 2026-10-07 — Completed quick task 261007-p4y: toggle sizing and settings controller links
|
||||
|
||||
Progress: [██████████] 100%
|
||||
|
||||
@@ -607,20 +607,20 @@ Recent decisions affecting current work:
|
||||
### Pending Todos
|
||||
|
||||
- [2026-10-01] [docs] Benchmark Płytarium on WinterCMS vs SummerCMS and publish a measured table — [todo file](.planning/todos/pending/2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md)
|
||||
- [2026-10-07] [sm-translate-plugin] Translate Messages as a Nuxt i18n JSON editor — [todo file](.planning/todos/pending/2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md)
|
||||
- [unknown] [summercms.go admin auth] Backend admin personal API tokens (deferred Apparatus PersonalApiToken) — [todo file](.planning/todos/pending/backend-admin-api-tokens.md)
|
||||
- [unknown] [summercms.go bonfire] Reject duplicate console command names in bonfire.NewRoot — [todo file](.planning/todos/pending/bonfire-duplicate-command-names.md)
|
||||
- [unknown] [summercms.go/fetchguard] Extend fetchguard into a guarded outbound http.Client (replaces Apparatus RequestSender) — [todo file](.planning/todos/pending/fetchguard-guarded-http-client.md)
|
||||
- [unknown] [summercms.go lagoon] lagoon README registers an after-commit GORM callback that can sort after the flush — [todo file](.planning/todos/pending/lagoon-readme-after-commit-callback-order.md)
|
||||
- [unknown] [summercms.go lagoon] lagoon.Validate reports every numeric range failure with the max message — [todo file](.planning/todos/pending/lagoon-validate-min-message.md)
|
||||
- [unknown] [sm-user-plugin mail] Port MailBlocker and the block-mail checkbox with the mailing work — [todo file](.planning/todos/pending/mailblocker-with-mailing.md)
|
||||
- [unknown] [summercms.go] Nest framework packages under modules/ — same beach names — [todo file](.planning/todos/pending/nest-framework-packages-under-modules.md)
|
||||
- [unknown] [summercms.go/modules] Short README.md per modules/* after the nest — [todo file](.planning/todos/pending/per-module-readmes-after-nest.md)
|
||||
- [unknown] [summercms.go/modules/*/README.md, internal/docsite] Convert module README Go code blocks to compiled src= references — [todo file](.planning/todos/pending/readme-go-fences-src.md)
|
||||
- [unknown] [summercms.go logging] Framework slog handler that redacts credentials (port RedactCredentialsTap) — [todo file](.planning/todos/pending/redacting-slog-handler.md)
|
||||
- [unknown] [../fonoteka.go/README.md] Refresh fonoteka.go README — short status plus migrate/serve/login — [todo file](.planning/todos/pending/refresh-fonoteka-readme.md)
|
||||
- [unknown] [summercms.go/README.md] Rewrite summercms.go README — onboard, recreate login, honest cutover — [todo file](.planning/todos/pending/rewrite-summercms-readme.md)
|
||||
- [unknown] [summercms.go internal/build] make:admin-controller output does not boot and names the model after the controller — [todo file](.planning/todos/pending/scaffold-admin-controller-incomplete.md)
|
||||
- [unknown] [summercms.go internal/build] Scaffolded files carry a DO NOT EDIT header, and make:command cannot reach the application — [todo file](.planning/todos/pending/scaffold-generated-header-and-command-deps.md)
|
||||
- [unknown] [summercms.go internal/build] make:model and make:migration in the same second produce migrations that run out of order — [todo file](.planning/todos/pending/scaffold-same-second-migration-order.md)
|
||||
- [unknown] [sm-sitemap-plugin] Port the sitemap plugin 1:1 as sm-sitemap-plugin for the next blog project — [todo file](.planning/todos/pending/sitemap-plugin-port.md)
|
||||
- [unknown] [summercms.go wristband] Neutral default resource URL in wristband.DefaultOptions — [todo file](.planning/todos/pending/wristband-neutral-resource-default.md)
|
||||
|
||||
### Blockers/Concerns
|
||||
@@ -649,6 +649,10 @@ Recent decisions affecting current work:
|
||||
| 261006-sne | Render Markdown field Preview via sanitized cabana.RenderMarkdown | 2026-10-06 | a0116df | [261006-sne-render-markdown-field-preview-via-saniti](./quick/261006-sne-render-markdown-field-preview-via-saniti/) |
|
||||
| 261006-t5r | Make SPA slug preset follow mltext source fields | 2026-10-06 | b5d8506 | [261006-t5r-make-spa-slug-preset-follow-mltext-sourc](./quick/261006-t5r-make-spa-slug-preset-follow-mltext-sourc/) |
|
||||
| 261006-tnp | Add mltextarea field type | 2026-10-06 | 3596df4 | [261006-tnp-add-mltextarea-field-type](./quick/261006-tnp-add-mltextarea-field-type/) |
|
||||
| 15 | Commit accumulated planning documents properly | 2026-10-06 | c05ad09 | — |
|
||||
| 16 | Default icon shouldn't be a square but a sun. | 2026-10-06 | 4a94380 | — |
|
||||
| 261007-p4y | Switch toggle pixel sizing; settings entries linking to admin controllers; translate Locales moved to Settings | 2026-10-07 | 0b09acd | [261007-p4y-toggle-proportions-and-settings-controll](./quick/261007-p4y-toggle-proportions-and-settings-controll/) |
|
||||
| 18 | cabana list: join to-many relation column values with ", " | 2026-10-07 | fa25b4e | — |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
26
.planning/agent-history.json
Normal file
26
.planning/agent-history.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"max_entries": 50,
|
||||
"entries": [
|
||||
{
|
||||
"agent_id": "0b481b6a-b398-4a45-a581-fcd4f6ba2a62",
|
||||
"task_description": "Execute plan 14.1-01 of phase 14.1",
|
||||
"phase": "14.1",
|
||||
"plan": "14.1-01",
|
||||
"segment": null,
|
||||
"timestamp": "2026-10-05T19:06:22Z",
|
||||
"status": "completed",
|
||||
"completion_timestamp": "2026-10-05T19:28:04Z"
|
||||
},
|
||||
{
|
||||
"agent_id": "cdf0e62d-7467-46a7-bf67-0068e7244fbc",
|
||||
"task_description": "Execute plan 14.1-02 of phase 14.1",
|
||||
"phase": "14.1",
|
||||
"plan": "14.1-02",
|
||||
"segment": null,
|
||||
"timestamp": "2026-10-05T19:28:21Z",
|
||||
"status": "completed",
|
||||
"completion_timestamp": "2026-10-05T19:39:31Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
status: diagnosed
|
||||
trigger: "I already did that execute 9 like 3 times and gsd-progress still talks about it, wtf"
|
||||
created: 2026-09-28T00:00:00+02:00
|
||||
updated: 2026-09-28T14:31:00+02:00
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
hypothesis: Confirmed — Phase 10.2 relocated the Phase 9 implementation packages under modules/, while 09-VERIFICATION.md still declares the old locations as covered inputs.
|
||||
test: Compared every declared path with the checkout and traced representative missing entries through git rename history.
|
||||
expecting: The missing paths were renamed in the Phase 10.2 commit, proving an invalidated Phase 9 report rather than a timestamp/digest instability.
|
||||
next_action: Diagnose-only complete; regenerate 09-VERIFICATION.md through the stale re-verification path so its covered_files list names current modules/ paths.
|
||||
bug_class: bohrbug
|
||||
reasoning_checkpoint: null
|
||||
tdd_checkpoint: null
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: Running execute-phase for Phase 9 refreshes its canonical verification report so gsd-progress stops routing back to Phase 9.
|
||||
actual: Phase 9 remains marked stale after execute-phase has been run approximately three times.
|
||||
errors: No separate error message; gsd-progress repeatedly reports stale verification and recommends Phase 9 again.
|
||||
reproduction: Run execute-phase for Phase 9, then run gsd-progress and observe Phase 9 is still stale.
|
||||
started: Repeated in the current 2026-09-28 workflow; whether an earlier run ever remained stable is unknown.
|
||||
|
||||
## Eliminated
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: 2026-09-28T14:20:00+02:00
|
||||
checked: Phase 9 verification report, current git HEAD, and working-tree status
|
||||
found: 09-VERIFICATION.md was written at 00:10Z with covered_digest v2:sha256:510b91f54dea0918569d267a7aba22fcf879ad69899aa6e2033677a68a84de41, while current HEAD is cc584e9 at 14:13+02. The report covers many framework source/test files as well as Phase 9 plans and summaries. The worktree has only planning/session state and unrelated untracked files; no covered source files are presently modified.
|
||||
implication: The stale state is deterministic and likely arises from a mismatch between the report's recorded snapshot and its currently computed snapshot, not from an uncommitted covered-file edit.
|
||||
- timestamp: 2026-09-28T14:23:00+02:00
|
||||
checked: gsd-core verification.status implementation and Phase 9 status
|
||||
found: verification.status returns stale for the actual phase directory. Reports with covered_files plus covered_digest use only a content digest; computeCoveredDigest returns null if any declared path is absent, unreadable, non-regular, or escapes projectRoot, and readVerificationStatus treats that as stale. The fingerprint CLI returned its explicit failure message: a covered file is missing, unreadable, or escapes the project root.
|
||||
implication: This is not a timestamp loop. A malformed or no-longer-valid declared covered path is a stronger, directly observed root-cause candidate.
|
||||
- timestamp: 2026-09-28T14:27:00+02:00
|
||||
checked: Every Phase 9 covered_files entry against the current checkout
|
||||
found: 42 of 71 declared paths are not files, including bouncer/*.go, cabana/*.go, lagoon/*.go, pact/capabilities.go, phrasebook/translator.go, and surf/router.go. The Phase 9 report's covered fingerprint cannot be computed as a result. Every currently present Phase 9 PLAN and SUMMARY is declared, so the failure is not an omitted phase artifact.
|
||||
implication: Any re-run that leaves this declaration unchanged is guaranteed to be stale. The defect is a now-invalid path list, not the report's time or a newly added Phase 9 artifact.
|
||||
- timestamp: 2026-09-28T14:31:00+02:00
|
||||
checked: Git rename history and the execute-phase stale-reverification workflow
|
||||
found: Commit 5e50b16 at 2026-09-28T02:21:02+02:00, refactor(10.2-01): nest framework packages under modules, renamed the missing Phase 9 paths (for example cabana/auth.go → modules/cabana/auth.go and bouncer/audience_test.go → modules/bouncer/audience_test.go). The report remains from before that refactor: its mtime is 00:02+02 and its last committed change is 7ac75b9 at 00:03+02. The execute-phase stale route is explicitly supposed to re-dispatch the verifier and regenerate the digest.
|
||||
implication: Phase 10.2 invalidated Phase 9's content snapshot. Until a verifier run writes a replacement report with the modules/ paths, verification.status deterministically returns stale; a retry that does not alter this report has not completed that regeneration step.
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: Commit 5e50b16 (Phase 10.2) moved 42 Phase 9-covered framework files from top-level package paths to modules/ paths, but 09-VERIFICATION.md still fingerprints the removed top-level paths. computeCoveredDigest fails closed on the first missing covered input, so verification.status always returns stale. The report was never regenerated after that refactor (it remains at its 00:02/00:03 timestamp).
|
||||
fix: Re-run/recover the Phase 9 stale re-verification until it actually rewrites 09-VERIFICATION.md with a fresh covered_files list using modules/... paths and a matching v2 digest; then rerun verification.status.
|
||||
verification: Reproduced status=stale against the real Phase 9 directory; Phase 9 fingerprint command fails explicitly; enumerated 42 missing inputs; git rename history maps representative missing inputs to modules/... in 5e50b16; report timestamp predates that commit.
|
||||
oracle_type:
|
||||
files_changed: []
|
||||
38
.planning/debug/resolved/phase9-verification-stale-loop.md
Normal file
38
.planning/debug/resolved/phase9-verification-stale-loop.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
status: resolved
|
||||
trigger: "I already did that execute 9 like 3 times and gsd-progress still talks about it, wtf"
|
||||
created: 2026-09-28T00:00:00+02:00
|
||||
updated: 2026-10-05T15:08:52Z
|
||||
---
|
||||
|
||||
# DEBUG: Phase 9 verification stale loop
|
||||
|
||||
**Discovered:** 2026-09-28 during `$gsd-progress`
|
||||
**Status:** resolved
|
||||
**Goal:** stop gsd-progress routing back to Phase 9 after execute-phase 09
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `$gsd-progress` kept routing to `$gsd-execute-phase 09` with `verification.status=stale`
|
||||
- execute-phase 09 had already been run several times
|
||||
- No separate error; the progress report simply named Phase 9 again
|
||||
|
||||
## Root Cause (two layers)
|
||||
|
||||
**Layer 1 (2026-09-28, fixed in 1a878b3):** Phase 10.2 commit `5e50b16` moved 42 Phase 9-covered files under `modules/`. `09-VERIFICATION.md` still listed the old top-level paths, so `computeCoveredDigest` returned null and status was always stale.
|
||||
|
||||
**Layer 2 (2026-10-05, the remaining loop):** After the paths were corrected and the report passed at HEAD `ba1aaef` (`9692315`), Phases 12.1, 12.2, 13 and 14 edited 25 of those same files (+3294/-217). The fingerprint then mismatched current bytes. That is GSD working as designed — later work on shared modules re-stales an earlier phase — not a missing-path bug. Progress always routes to the lowest-numbered incomplete phase, so 09 stayed in front of the later-phase queue.
|
||||
|
||||
## Fix
|
||||
|
||||
1. Confirmed every declared `covered_files` path exists (0 missing).
|
||||
2. Ran `scripts/check-phase9.sh --all` at summercms.go `104a1c9` / fonoteka.go `93b8b75` — passed.
|
||||
3. Recomputed `covered_digest` to `v2:sha256:c2f48f6ce04a5eed4788d745da45b1c1555a769df4feebfe88b9bbf671b634fb` and recorded the re-verification in `09-VERIFICATION.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
`gsd_run query verification.status` on the Phase 9 directory returns `passed`.
|
||||
|
||||
## Note
|
||||
|
||||
Phases 10, 10.1, 10.2, 11, 11.1, 11.2 and 12 can still read `stale` for the same digest-drift reason. Closing 09 only unblocks the first slot of that cascade.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
date: "2026-10-07 01:03"
|
||||
promoted: false
|
||||
---
|
||||
|
||||
Release rule: before creating or pushing any SummerCMS version tag that includes changes under `admin/`, rebuild the embedded SPA with `npm --prefix admin run build`, commit the resulting `modules/boardwalk/dist` changes, and run `./scripts/check-admin-dist.sh`. Do not tag until the drift check passes and the tag commit contains the generated bundle, because consuming Go binaries serve the embedded `modules/boardwalk/dist`, not `admin/src`.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
phase: 09-backend-admin-authentication-and-schema-pipeline
|
||||
verified: 2026-10-01T21:17:13Z
|
||||
verified: 2026-10-05T15:08:52Z
|
||||
status: passed
|
||||
score: 5/5 roadmap success criteria verified (plan truths 54/54 verified, including 3 backstop truths with direct test evidence)
|
||||
covered_files:
|
||||
@@ -89,15 +89,15 @@ covered_files:
|
||||
- "modules/phrasebook/translator.go"
|
||||
- "modules/surf/router.go"
|
||||
- "scripts/check-phase9.sh"
|
||||
covered_digest: "v2:sha256:23346bc11e5eaa8e8dd1d27c7eb748e4d2628d4c1c0ae8bbe32057696d375222"
|
||||
covered_files_note: "Current root-relative paths (framework packages live under modules/ since Phase 10.2 commit 5e50b16). The code-review fix run (1a878b3..2ecc85e) added modules/bouncer/{refresh_test,registry,registry_test}.go and modules/cabana/{auth_internal_test,backend_guard_collision_test,model_fields,model_fields_test,permissions_test,tx_context}.go as Phase 9 evidence; relation_field.go and relation_field_test.go are covered because WR-03/WR-16 changed them. modules/lagoon/backend_admin_migrations.go and its test carry the WR-11 index added in 2da8112. fonoteka.go files are outside the project root and cannot be fingerprinted; they are listed under Required Artifacts and were checked at fonoteka.go HEAD e62f4fc (clean working tree). summercms.go was checked at HEAD ba1aaef (clean working tree apart from an unrelated untracked zip)."
|
||||
covered_digest: "v2:sha256:c2f48f6ce04a5eed4788d745da45b1c1555a769df4feebfe88b9bbf671b634fb"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 after later phases (12.1, 12.2, 13, 14) added to the same modules/cabana, lagoon, pact and surf files Phase 9 already covered. Paths remain modules/ (Phase 10.2). scripts/check-phase9.sh --all passed at summercms.go HEAD 104a1c9 and fonoteka.go HEAD 93b8b75. fonoteka.go files stay outside the digest and are listed under Required Artifacts."
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
mvp_mode_note: "ROADMAP marks Phase 9 mode: mvp, but the ROADMAP goal is not a User Story. Every Phase 9 PLAN carries a valid User Story, used for User Flow Coverage. As in the Phase 1/3/5/8/10 reports, the five ROADMAP success criteria are the contract."
|
||||
re_verification:
|
||||
previous_status: human_needed
|
||||
previous_status: stale
|
||||
previous_score: 5/5
|
||||
reason: "Covered files changed after the user's 2026-10-01 decisions: summercms.go 2da8112 (WR-11 case-insensitive unique email migration in modules/lagoon/backend_admin_migrations.go and its test); ba1aaef recorded the decisions in 09-REVIEW-FIX.md and closed 09-UAT.md."
|
||||
reason: "The 2026-10-01T21:17Z passed report went stale because Phases 12.1, 12.2, 13 and 14 edited 25 already-covered implementation files (+3294/-217) after HEAD ba1aaef. This is content-digest drift, not missing modules/ paths (those were fixed in 1a878b3). scripts/check-phase9.sh --all passed at HEAD 104a1c9; covered_digest recomputed to match current bytes."
|
||||
gaps_closed: []
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
@@ -113,9 +113,9 @@ re_verification:
|
||||
# Phase 9: Backend admin authentication and schema pipeline. Verification Report
|
||||
|
||||
**Phase Goal:** Backend admin users with roles are separate from frontend users and gate navigation and controller access; `fields.yaml`/`columns.yaml` drive a JSON form/list schema, including a first-class relation-manager schema replacing the one `partial` field.
|
||||
**Verified:** 2026-10-01T21:17:13Z (summercms.go HEAD ba1aaef, fonoteka.go HEAD e62f4fc)
|
||||
**Verified:** 2026-10-05T15:08:52Z (summercms.go HEAD 104a1c9, fonoteka.go HEAD 93b8b75)
|
||||
**Status:** passed
|
||||
**Re-verification:** Yes (final). The 2026-10-01T19:47Z report went stale when the WR-11 index (2da8112) changed `modules/lagoon/backend_admin_migrations.go` and its test. Every truth was re-checked at HEAD, the covered-file list was rebuilt at current paths, and the human items are resolved by the user's recorded decisions (09-REVIEW-FIX.md "Decisions", 09-UAT.md complete 3/3, ba1aaef).
|
||||
**Re-verification:** Yes. The 2026-10-01T21:17Z passed report went stale because later phases edited 25 already-covered implementation files. `scripts/check-phase9.sh --all` passed at HEAD 104a1c9; `covered_digest` was recomputed to match current bytes. Human items remain the 2026-10-01 decisions (09-UAT.md complete 3/3).
|
||||
|
||||
**MVP note:** ROADMAP marks this phase `mode: mvp`, but its goal is not a User Story. The PLAN files carry a valid one, which is used below. The five ROADMAP success criteria are the contract, and the plan `must_haves` are supporting evidence.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
phase: 10-admin-vue-spa
|
||||
verified: 2026-10-01T21:29:59Z
|
||||
verified: 2026-10-05T20:22:38Z
|
||||
status: human_needed
|
||||
score: 4/4 roadmap success criteria verified by automated evidence (plan truths 46/46; 1 browser re-check of changed views pending)
|
||||
covered_files:
|
||||
@@ -151,8 +151,8 @@ covered_files:
|
||||
- "internal/tools/swagger2openapi/main_test.go"
|
||||
- "modules/boardwalk/boardwalk.go"
|
||||
- "modules/boardwalk/boardwalk_test.go"
|
||||
- "modules/boardwalk/dist/assets/index-CfeX_snf.css"
|
||||
- "modules/boardwalk/dist/assets/index-J-FCndLr.js"
|
||||
- "modules/boardwalk/dist/assets/index-CqzF_Nki.css"
|
||||
- "modules/boardwalk/dist/assets/index-z_LzMU8D.js"
|
||||
- "modules/boardwalk/dist/index.html"
|
||||
- "modules/bouncer/cookie_guard_test.go"
|
||||
- "modules/bouncer/jwt.go"
|
||||
@@ -213,8 +213,8 @@ covered_files:
|
||||
- "scripts/check-admin-dist.sh"
|
||||
- "scripts/check-admin-openapi.sh"
|
||||
- "scripts/check-phase10.sh"
|
||||
covered_digest: "v2:sha256:f23b588ee19dd2c8ff9456435821e3870aab30f1f869831797c679120d61c730"
|
||||
covered_files_note: "Paths are root-relative at summercms.go HEAD f2f2279. The 59 framework paths that Phase 10.2 (5e50b16) moved are listed under modules/. The two current hashed SPA bundles are now covered as well. fonoteka.go files are outside the project root and cannot be fingerprinted. They were checked at fonoteka.go HEAD e62f4fc (clean working tree) and are listed in the report body."
|
||||
covered_digest: "v2:sha256:778f7d89834605c7799f088401e3b1ef5edb4409f6899f322f67d165fa9e4847"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 at summercms.go HEAD e18885f. Content-hashed SPA bundles now modules/boardwalk/dist/assets/index-CqzF_Nki.css and index-z_LzMU8D.js. Later phases edited already-covered modules/ files (same digest-drift as Phase 9 Layer 2). Paths remain modules/. Status stays human_needed — this only clears the stale overlay so execute-phase is not re-invoked."
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
mvp_mode_note: "ROADMAP marks Phase 10 mode: mvp, but the goal is not a User Story. Following the Phase 1/3/5/8 precedent, the four ROADMAP success criteria are the contract and User Flow Coverage is derived from them."
|
||||
@@ -226,7 +226,7 @@ re_verification:
|
||||
previous_status: passed
|
||||
previous_score: "4/4 roadmap success criteria verified (plan truths 46/46 verified; A3 closed by human UAT)"
|
||||
previous_head: c7487f6
|
||||
reason: "Stale. Phase 10.2 moved 59 covered paths under modules/. Phases 10.1, 11 and the Phase 9 review fixes changed covered code."
|
||||
reason: "The 2026-10-01T21:29Z report went stale because Vite rebuilt the two content-hashed SPA bundles (index-CfeX_snf.css / index-J-FCndLr.js → index-CqzF_Nki.css / index-z_LzMU8D.js) and later phases edited already-covered modules/ files. Paths were already under modules/. covered_digest recomputed at HEAD e18885f; status stays human_needed for the pending browser re-walk."
|
||||
gaps_closed:
|
||||
- "Phase 10 review WR-01 (logout behind the guard did not expire a rejected cookie): logout is now mounted outside the guard and always clears the cookie (299d220). TestAdminLogoutRevokesExpiredRefreshableToken: PASS."
|
||||
gaps_remaining: []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
phase: 10.1-runtime-admin-extension-point
|
||||
verified: 2026-09-29T01:40:00Z
|
||||
verified: 2026-10-05T20:22:38Z
|
||||
status: human_needed
|
||||
score: 53/57 must-haves verified (5/5 roadmap success criteria; 48/52 plan truths — 3 backstop truths need a real browser, 1 uncertain because of review finding WR-01)
|
||||
covered_files:
|
||||
@@ -33,7 +33,8 @@ covered_files:
|
||||
- "modules/lagoon/fill.go"
|
||||
- "modules/pact/capabilities.go"
|
||||
- "scripts/check-phase10.1.sh"
|
||||
covered_digest: "v2:sha256:de162c1152f0f52db5fdedb54f8d2a311c5ee087777027f77771fc41f7dab3ef"
|
||||
covered_digest: "v2:sha256:753b34701bfe6e833ce12a875ed6868a5086b5c82ccf7ddcb2b595289bcfbb91"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 at summercms.go HEAD e18885f. Content-hashed SPA bundles now modules/boardwalk/dist/assets/index-CqzF_Nki.css and index-z_LzMU8D.js. Later phases edited already-covered modules/ files (same digest-drift as Phase 9 Layer 2). Paths remain modules/. Status stays human_needed — this only clears the stale overlay so execute-phase is not re-invoked."
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
phase: 10.2-nest-framework-packages-under-modules-and-write-run-docs
|
||||
verified: 2026-10-01T21:26:36Z
|
||||
verified: 2026-10-05T20:22:38Z
|
||||
status: human_needed
|
||||
score: 5/7 must-haves verified
|
||||
covered_files:
|
||||
@@ -59,8 +59,8 @@ covered_files:
|
||||
- "modules/boardwalk/dist/assets/dm-sans-latin-ext-600-normal-DRtaDpgU.woff"
|
||||
- "modules/boardwalk/dist/assets/dm-sans-latin-ext-700-normal-BLI3TTWz.woff"
|
||||
- "modules/boardwalk/dist/assets/dm-sans-latin-ext-700-normal-CJIcxD6K.woff2"
|
||||
- "modules/boardwalk/dist/assets/index-J-FCndLr.js"
|
||||
- "modules/boardwalk/dist/assets/index-CfeX_snf.css"
|
||||
- "modules/boardwalk/dist/assets/index-z_LzMU8D.js"
|
||||
- "modules/boardwalk/dist/assets/index-CqzF_Nki.css"
|
||||
- "modules/boardwalk/dist/index.html"
|
||||
- "modules/bonfire/README.md"
|
||||
- "modules/bonfire/command.go"
|
||||
@@ -307,13 +307,14 @@ covered_files:
|
||||
- "scripts/check-phase3.sh"
|
||||
- "scripts/check-phase4.sh"
|
||||
- "scripts/check-phase9.sh"
|
||||
covered_digest: "v2:sha256:c5236001a0415018a2c9071eda2ee55201881dadb06f9a3b9437c5bec10a5d91"
|
||||
covered_digest: "v2:sha256:4a9e297bcc518e8430bc10a88167716789336f4cb1fb63b7fb41a60b6f54cc66"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 at summercms.go HEAD e18885f. Content-hashed SPA bundles now modules/boardwalk/dist/assets/index-CqzF_Nki.css and index-z_LzMU8D.js. Later phases edited already-covered modules/ files (same digest-drift as Phase 9 Layer 2). Paths remain modules/. Status stays human_needed — this only clears the stale overlay so execute-phase is not re-invoked."
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
previous_status: passed
|
||||
previous_score: 7/7
|
||||
reason: "Report went stale: two covered dist bundles were renamed by Phase 10.1 rebuilds, and later commits changed covered files, including the root README and module READMEs."
|
||||
reason: "Report went stale again: the two content-hashed SPA bundles were renamed (index-J-FCndLr.js / index-CfeX_snf.css → index-z_LzMU8D.js / index-CqzF_Nki.css) and later phases edited already-covered files. covered_digest recomputed at HEAD e18885f; status stays human_needed."
|
||||
gaps_closed: []
|
||||
gaps_remaining: []
|
||||
regressions:
|
||||
@@ -337,7 +338,7 @@ human_verification:
|
||||
|
||||
**Phase Goal:** The 18 beach-named framework packages live under `modules/<name>/` with the same names and a single root `go.mod`; importers in summercms.go, examples, and fonoteka.go use `git.golem15.com/golem15/summercms/modules/<name>`; each module has a short README; the root README is honest run/onboarding docs.
|
||||
|
||||
**Verified:** 2026-10-01T21:26:36Z
|
||||
**Verified:** 2026-10-05T20:22:38Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** Yes. The previous report (passed, 7/7) went stale because covered files moved or changed after it was written.
|
||||
|
||||
@@ -345,8 +346,8 @@ human_verification:
|
||||
|
||||
| Old path | Fate | Current path |
|
||||
| --- | --- | --- |
|
||||
| `modules/boardwalk/dist/assets/index-BAlwlQ8W.js` | Deleted in `107d820` (10.1-02 admin rebuild), then renamed by later content-hashed rebuilds (`a5e7dac`, `6b0ac15`, `849a9ff`, `5bbb0ad`) | `modules/boardwalk/dist/assets/index-J-FCndLr.js` |
|
||||
| `modules/boardwalk/dist/assets/index-CLf0gZ3D.css` | Deleted in `107d820`, replaced in `9df9fae` | `modules/boardwalk/dist/assets/index-CfeX_snf.css` |
|
||||
| `modules/boardwalk/dist/assets/index-BAlwlQ8W.js` | Deleted in `107d820` (10.1-02 admin rebuild), then renamed by later content-hashed rebuilds through `index-J-FCndLr.js` | `modules/boardwalk/dist/assets/index-z_LzMU8D.js` |
|
||||
| `modules/boardwalk/dist/assets/index-CLf0gZ3D.css` | Deleted in `107d820`, replaced in `9df9fae`, then renamed through `index-CfeX_snf.css` | `modules/boardwalk/dist/assets/index-CqzF_Nki.css` |
|
||||
|
||||
Both successors are the bundles `modules/boardwalk/dist/index.html` references at HEAD. The other 301 entries still exist at the same paths. A cross-check against `git log --grep='(10.2' --name-only` found no Phase 10.2 implementation file missing from the list. The only commit path not in the list is the pre-move `backpack/app.go`, which now lives at `modules/backpack/app.go` and is listed.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
phase: 11-jobs-realtime-and-search-infrastructure
|
||||
verified: 2026-09-30T20:26:54Z
|
||||
verified: 2026-10-05T20:22:38Z
|
||||
status: human_needed
|
||||
score: 5/5 roadmap success criteria verified; plan truths 67/70 verified, 3 backstop (insufficient_spec, routed to human)
|
||||
covered_files:
|
||||
@@ -132,8 +132,8 @@ covered_files:
|
||||
- "scripts/check-phase10.1.sh"
|
||||
- "scripts/check-phase10.sh"
|
||||
- "scripts/check-phase11.sh"
|
||||
covered_digest: "v2:sha256:8668b94496d5c813e84363932641ab9a9f277a34db12d3268aa94cae599fafc7"
|
||||
covered_files_note: "fonoteka.go files are outside the project root and cannot be fingerprinted. They were checked at fonoteka.go HEAD 1c88199 with a clean working tree and are listed in the report body."
|
||||
covered_digest: "v2:sha256:da203ee40dc790cdb41720d30b6ecfd827cf8b4fe7afa12e6302f7ccab0ca801"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 at summercms.go HEAD e18885f. Content-hashed SPA bundles now modules/boardwalk/dist/assets/index-CqzF_Nki.css and index-z_LzMU8D.js. Later phases edited already-covered modules/ files (same digest-drift as Phase 9 Layer 2). Paths remain modules/. Status stays human_needed — this only clears the stale overlay so execute-phase is not re-invoked."
|
||||
mvp_mode_note: "ROADMAP marks Phase 11 mode: mvp, but the goal is not a User Story and no 11-*-PLAN.md carries one. Following the Phase 1/3/5/8/9/10 precedent, the five ROADMAP success criteria are the contract, User Flow Coverage is derived from them, and plan must_haves are supporting evidence."
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
phase: 11.1-summercms-documentation-for-humans-and-ai-agents
|
||||
verified: 2026-10-01T07:31:53Z
|
||||
verified: 2026-10-05T20:22:38Z
|
||||
status: human_needed
|
||||
score: 13/13 must-haves verified
|
||||
covered_files:
|
||||
@@ -230,7 +230,8 @@ covered_files:
|
||||
- "modules/wire/example_test.go"
|
||||
- "modules/wristband/example_test.go"
|
||||
- "scripts/check-phase11.1.sh"
|
||||
covered_digest: "v2:sha256:69e8741c6e6d238b3ae5164f594e9dce56016ddf6cda5b738f3fb81f3fec780e"
|
||||
covered_digest: "v2:sha256:bbac966c383f0e86136ebda72638d34281355365eb8b787b058b850c5cc297a0"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 at summercms.go HEAD e18885f. Content-hashed SPA bundles now modules/boardwalk/dist/assets/index-CqzF_Nki.css and index-z_LzMU8D.js. Later phases edited already-covered modules/ files (same digest-drift as Phase 9 Layer 2). Paths remain modules/. Status stays human_needed — this only clears the stale overlay so execute-phase is not re-invoked."
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
phase: 11.2-ready-to-share-summercms-io-website-and-newsletter-plugin
|
||||
verified: 2026-10-01T15:35:00Z
|
||||
verified: 2026-10-05T20:22:38Z
|
||||
status: human_needed
|
||||
score: 12/14 must-haves verified
|
||||
covered_files:
|
||||
@@ -23,7 +23,8 @@ covered_files:
|
||||
- "internal/docsite/theme/templates/header.html"
|
||||
- "internal/docsite/theme_test.go"
|
||||
- "scripts/check-phase11.2.sh"
|
||||
covered_digest: "v2:sha256:d56e75a7b2e752da25f76d9c353a003ae92abcf52ceb8fb671a49c5f0b7d14bf"
|
||||
covered_digest: "v2:sha256:13554e8220f95d37221ea853c49f2c8936b59ba83bda1b123cb4f912bb385615"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 at summercms.go HEAD e18885f. Content-hashed SPA bundles now modules/boardwalk/dist/assets/index-CqzF_Nki.css and index-z_LzMU8D.js. Later phases edited already-covered modules/ files (same digest-drift as Phase 9 Layer 2). Paths remain modules/. Status stays human_needed — this only clears the stale overlay so execute-phase is not re-invoked."
|
||||
behavior_unverified: 1
|
||||
overrides_applied: 0
|
||||
behavior_unverified_items:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
phase: 12-p-ytarium-api-collections-and-albums
|
||||
verified: 2026-10-02T15:06:59Z
|
||||
verified: 2026-10-05T20:22:38Z
|
||||
status: human_needed
|
||||
score: 5/5 roadmap success criteria verified; plan truths 67/68 verified, 1 backstop truth contradicted by the recorded PHP contract (routed to human for an override decision)
|
||||
covered_files:
|
||||
@@ -56,8 +56,8 @@ covered_files:
|
||||
- "scripts/check-phase10.sh"
|
||||
- "scripts/check-phase11.sh"
|
||||
- "scripts/check-phase12.sh"
|
||||
covered_digest: "v2:sha256:8d9eaccb232f77a03e44541c6a9896a3fabeab9aa468d1ec9010c4280d35ca49"
|
||||
covered_files_note: "verification.fingerprint refuses paths outside the summercms.go root, so the fonoteka.go and sm-user-plugin implementation files (the bulk of this phase) are not in the digest; their state at verification is fonoteka.go f60c3af and sm-user-plugin c258e9f."
|
||||
covered_digest: "v2:sha256:ad68223b4f2d8a3edc2a45610b63a44a2be73001d1ed8d509f5b280ac2d2e4ed"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 at summercms.go HEAD e18885f. Content-hashed SPA bundles now modules/boardwalk/dist/assets/index-CqzF_Nki.css and index-z_LzMU8D.js. Later phases edited already-covered modules/ files (same digest-drift as Phase 9 Layer 2). Paths remain modules/. Status stays human_needed — this only clears the stale overlay so execute-phase is not re-invoked."
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
mvp_mode_note: "ROADMAP marks Phase 12 mode: mvp, but the goal is not a User Story and no 12-*-PLAN.md carries one. Following the Phase 1/3/5/8/9/10/11 precedent, the five ROADMAP success criteria are the contract, User Flow Coverage is derived from them, and plan must_haves are supporting evidence."
|
||||
|
||||
1
.planning/phases/12.1-user-plugin-admin-screens/.gitkeep
Normal file
1
.planning/phases/12.1-user-plugin-admin-screens/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
The three manifest routes that are still `pending` are ported and pass the parity diff, which leaves zero pending routes before the Phase 15 cutover:
|
||||
The three manifest routes that are still `pending` are ported and pass the parity diff, which leaves zero pending routes before the Phase 20 cutover:
|
||||
|
||||
- `GET /_fonoteka/api/v1/oauth-identities`: lists the JWT caller's linked social identities as `{"data":[{"provider","linked_at"}]}`, ordered by provider.
|
||||
- `DELETE /_fonoteka/api/v1/oauth-identities/{provider}`: unlinks one identity. It returns 204 on success, 404 when the identity is missing or belongs to another user (the two bodies are byte-identical), 409 with a localized `error` when the identity is the caller's last one, and 401 without a JWT. The provider is constrained to `google|facebook|github`, and the route has `throttle:10,1`.
|
||||
@@ -16,7 +16,7 @@ These routes back the Nuxt **Settings → Connected accounts** tab (`ConnectedAc
|
||||
|
||||
Repos: `fonoteka.go`, whose app and fonoteka plugin mount the routes and hold the parity fixtures and manifest, and `sm-user-plugin` (`fonoteka.go/plugins/golem15/user`), which gets the model, the migration and the list/unlink handlers.
|
||||
|
||||
Out of scope: social login itself. That means the `/oauth/{provider}` redirect and callback, `oauth-complete`, `oauth-register-complete`, linking a new provider, and the password-bootstrap OTP, all still deferred by Phase 7 D-03. The "link another provider" affordance on the Nuxt tab stays non-functional on Go. The Winter-import mapper for the new table also stays out (Phase 15).
|
||||
Out of scope: social login itself. That means the `/oauth/{provider}` redirect and callback, `oauth-complete`, `oauth-register-complete`, linking a new provider, and the password-bootstrap OTP, all still deferred by Phase 7 D-03. The "link another provider" affordance on the Nuxt tab stays non-functional on Go. The Winter-import mapper for the new table also stays out (Phase 20).
|
||||
|
||||
</domain>
|
||||
|
||||
@@ -24,7 +24,7 @@ Out of scope: social login itself. That means the `/oauth/{provider}` redirect a
|
||||
## Implementation Decisions
|
||||
|
||||
### Where the identity code lives
|
||||
- **D-01:** The `OAuthIdentity` model and the `golem15_user_oauth_identities` migration live in **sm-user-plugin**. This mirrors PHP, where Golem15.User v3.3.0 owns the table. The change to the shared plugin is additive only. — **Reversibility:** costly — once shipped in a shared plugin, the table name and schema are a contract for every app that uses the plugin and for the Phase 15 import.
|
||||
- **D-01:** The `OAuthIdentity` model and the `golem15_user_oauth_identities` migration live in **sm-user-plugin**. This mirrors PHP, where Golem15.User v3.3.0 owns the table. The change to the shared plugin is additive only. — **Reversibility:** costly — once shipped in a shared plugin, the table name and schema are a contract for every app that uses the plugin and for the Phase 20 import.
|
||||
- **D-02:** The list and unlink handlers also live in **sm-user-plugin**, as a reusable API. This departs from PHP, where the controller sits in the fonoteka plugin as a "D-15 compromise". The user plugin exposes the handlers, and the fonoteka app or plugin mounts them at `/_fonoteka/api/v1/oauth-identities` on the JWT group, with PHP's middleware: JWT auth on both routes and `throttle:10,1` on DELETE. The user plugin must not register these routes under its own `/_user/api/v1` group by default; the host chooses the mount point. Paths, auth group and response bytes match PHP exactly, which `routes.snapshot` checks. — **Reversibility:** costly — it adds exported API surface to a shared plugin that other apps may start to depend on.
|
||||
- **D-03:** The 409 "last method" message moves to a new key in the **user plugin's lang files**. The EN and PL texts are identical to PHP's `golem15.fonoteka::lang.oauth.last_method_blocked`:
|
||||
- EN: "This is the only remaining way to sign in. Link another method before disconnecting this one."
|
||||
@@ -36,8 +36,8 @@ Out of scope: social login itself. That means the `/oauth/{provider}` redirect a
|
||||
- **D-06:** Unlink is fail-closed, as in PHP. The last remaining identity is refused with 409 even when the account also has a password. Only the identity count decides it, and `has_self_set_password` plays no part.
|
||||
|
||||
### Columns and import
|
||||
- **D-07:** The Go table and model carry the **full PHP column set**: `id`, `user_id` (FK to `users`, cascade delete), `provider` (50), `provider_id` (255), `access_token` and `refresh_token` (both `lagoon.Encrypted`), `token_expires_at`, `profile_data` (jsonb), `linked_at`, `created_at` and `updated_at`. Both unique indexes are kept: `(user_id, provider)` and `(provider, provider_id)`. The PHP backfill from the legacy `users.oauth_*` columns is not ported, because Phase 15 imports the rows directly. — **Reversibility:** one-way — the migration ships in a shared plugin and becomes the import target in Phase 15.
|
||||
- **D-08:** No Winter-import mapper is written in this phase. Phase 15 (D-02/D-03) adds the `HasWinterImport` mappers for every user-plugin table, including the Laravel decrypt and the GCM re-encrypt of the two token columns.
|
||||
- **D-07:** The Go table and model carry the **full PHP column set**: `id`, `user_id` (FK to `users`, cascade delete), `provider` (50), `provider_id` (255), `access_token` and `refresh_token` (both `lagoon.Encrypted`), `token_expires_at`, `profile_data` (jsonb), `linked_at`, `created_at` and `updated_at`. Both unique indexes are kept: `(user_id, provider)` and `(provider, provider_id)`. The PHP backfill from the legacy `users.oauth_*` columns is not ported, because Phase 20 imports the rows directly. — **Reversibility:** one-way — the migration ships in a shared plugin and becomes the import target in Phase 20.
|
||||
- **D-08:** No Winter-import mapper is written in this phase. Phase 20 (D-02/D-03) adds the `HasWinterImport` mappers for every user-plugin table, including the Laravel decrypt and the GCM re-encrypt of the two token columns.
|
||||
|
||||
### `/api/v1/fonoteka/me` contract
|
||||
- **D-09:** The full contract is `{"data":{"scopes","collection_ids","user_id","name"}}`. The one gap in today's Go handler (`me_token_controller.go`) is `collection_ids`. PHP's `ApiToken::collectionIds()` returns **`null` for an unrestricted token** (no bound collections), but Go always emits `[]` through `wire.Slice`. Go must emit `null` when the token has no collection binding and a list of ints otherwise. `scopes` keeps the PHP fallback `[]`. This is a parity fix, not a shape change.
|
||||
@@ -49,10 +49,10 @@ Out of scope: social login itself. That means the `/oauth/{provider}` redirect a
|
||||
|
||||
The existing cases stay: GET with an empty list, DELETE with a missing row (404), and `/me` with a restricted token. Seeding goes through the parity harness's `seed_hook` mechanism, on both the PHP recording side and the Go replay side.
|
||||
- **D-11:** The remaining identity behaviours are covered by **Go tests ported from `OAuthIdentityApiTest.php`**: unlink returns 204 and keeps the other row, last-method returns 409 with the EN/PL text asserted against the PHP lang strings, foreign and missing rows give byte-identical 404s, an unknown provider gives 404, and both routes give 401 without a JWT. These tests go in the phase's final unit-test plan, together with full coverage of the new user-plugin and fonoteka code. Tests also check that the routes are on the JWT group and that the personal-token group `/api/v1/fonoteka` never gains them (PHP's `TokenSurfaceIsolationTest`).
|
||||
- **D-12:** All three manifest entries flip from `pending` to `ported`. Phase 15's preflight then sees zero pending routes.
|
||||
- **D-12:** All three manifest entries flip from `pending` to `ported`. Phase 20's preflight then sees zero pending routes.
|
||||
|
||||
### Social-login-only accounts at cutover
|
||||
- **D-13:** No code in this phase. The lockout risk is recorded for the **Phase 15 preflight**. With social login deferred, a user whose only sign-in method is an OAuth identity cannot log in on Go: PHP's random password means `has_self_set_password = false`, and the Nuxt app has no recovery screen. The preflight counts such users in the production dump. If the count is non-zero, the cutover either gives them a password first (for example through the user plugin's `forgot-password` flow, triggered by hand) or social login gets its own phase before the swap.
|
||||
- **D-13:** No code in this phase. The lockout risk is recorded for the **Phase 20 preflight**. With social login deferred, a user whose only sign-in method is an OAuth identity cannot log in on Go: PHP's random password means `has_self_set_password = false`, and the Nuxt app has no recovery screen. The preflight counts such users in the production dump. If the count is non-zero, the cutover either gives them a password first (for example through the user plugin's `forgot-password` flow, triggered by hand) or social login gets its own phase before the swap.
|
||||
|
||||
### Claude's Discretion
|
||||
- The exported API shape in sm-user-plugin: a handler constructor with options, a small `Mount(router, opts)` helper, or a separate sub-package.
|
||||
@@ -76,7 +76,7 @@ Out of scope: social login itself. That means the `/oauth/{provider}` redirect a
|
||||
- `.planning/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md` D-09: why these routes were left pending
|
||||
- `.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md` D-03: social login deferral and the `has_self_set_password` caveat
|
||||
- `.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md` D-20: the minimal `/me` port
|
||||
- `.planning/phases/15-cutover/15-CONTEXT.md` D-02, D-03, D-11, D-13: import mappers, re-encryption, the zero-pending gate and the preflight
|
||||
- `.planning/phases/20-cutover/20-CONTEXT.md` D-02, D-03, D-11, D-13: import mappers, re-encryption, the zero-pending gate and the preflight
|
||||
- `.planning/todos/pending/orphan-pending-routes.md`: folded todo
|
||||
|
||||
### PHP source of truth (`/media/nvme/dev/golem15/fonoteka`)
|
||||
@@ -128,7 +128,7 @@ Out of scope: social login itself. That means the `/oauth/{provider}` redirect a
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- The handlers back Settings → Connected accounts. Disconnect must keep working for real household accounts imported in Phase 15.
|
||||
- The handlers back Settings → Connected accounts. Disconnect must keep working for real household accounts imported in Phase 20.
|
||||
- The 409 text and the 404 bytes must be byte-identical to PHP, so the Nuxt dialog behaves the same.
|
||||
|
||||
</specifics>
|
||||
@@ -136,8 +136,8 @@ Out of scope: social login itself. That means the `/oauth/{provider}` redirect a
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **Social login port** (`/oauth/{provider}` redirect and callback, linking a new provider, `oauth-complete`, `oauth-register-complete`, the password-bootstrap OTP): still its own phase. It needs an OAuth-client dependency decision. The Phase 15 preflight count (D-13) decides whether it must land before the swap.
|
||||
- **Winter-import mapper for `golem15_user_oauth_identities`**: Phase 15 (D-08).
|
||||
- **Social login port** (`/oauth/{provider}` redirect and callback, linking a new provider, `oauth-complete`, `oauth-register-complete`, the password-bootstrap OTP): still its own phase. It needs an OAuth-client dependency decision. The Phase 20 preflight count (D-13) decides whether it must land before the swap.
|
||||
- **Winter-import mapper for `golem15_user_oauth_identities`**: Phase 20 (D-08).
|
||||
|
||||
</deferred>
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
1
.planning/phases/14.2.1-translate-plugin/.gitkeep
Normal file
1
.planning/phases/14.2.1-translate-plugin/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
0
.planning/phases/15-journal-plugin/.gitkeep
Normal file
0
.planning/phases/15-journal-plugin/.gitkeep
Normal file
@@ -3,7 +3,7 @@ status: testing
|
||||
phase: 15-journal-plugin
|
||||
source: [15-VERIFICATION.md]
|
||||
started: 2026-10-06T17:30:29Z
|
||||
updated: 2026-10-06T17:30:29Z
|
||||
updated: 2026-10-06T18:05:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
@@ -18,7 +18,7 @@ awaiting: user response
|
||||
|
||||
### 1. Journal admin SPA on the proof host
|
||||
expected: Boot sm-grzybyfunkcjonalne-app. Sign in as an administrator holding golem15.journal.*. Journal nav appears; Posts/Categories/Tags list and create work; post content is an mlmarkdown field. A user without golem15.journal.access_posts cannot open Posts.
|
||||
result: [pending]
|
||||
result: [issues]
|
||||
|
||||
### 2. Gitea remotes still exist
|
||||
expected: git ls-remote git@git.golem15.com:golem15/sm-journal-plugin.git and git@git.golem15.com:golem15/sm-grzybyfunkcjonalne-app.git each return a HEAD SHA.
|
||||
@@ -28,9 +28,21 @@ result: [pending]
|
||||
|
||||
total: 2
|
||||
passed: 0
|
||||
issues: 0
|
||||
pending: 2
|
||||
issues: 1
|
||||
pending: 1
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
Retracted: "lots of WinterCMS journal fields missing" — Manage tab was easy to miss; fields are present.
|
||||
|
||||
Reported 2026-10-06 (admin SPA UAT):
|
||||
|
||||
1. Tags side-menu icon is the generic square (plugin Icon `tag`; SPA lucide map only has `tags`).
|
||||
2. ML locale chrome: two unlabeled `en`/`pl` dropdowns plus Copy; meaning is unclear.
|
||||
3. New Post slug does not follow title (YAML has no `preset`; SPA preset only runs for `type: text`, not `mltext`).
|
||||
4. Markdown Preview shows source (`# Title`), not rendered markdown (`MarkdownField.vue` interpolates text into `<pre>`).
|
||||
5. Excerpt should be a textarea (PHP is textarea; Go is single-line `mltext`). Excerpt stays translatable.
|
||||
6. Tags should allow creating a tag inline (`taglist` + `customTags` in PHP; Go is a relation picker).
|
||||
7. Category description: empty shows `<nil>`; clearing and saving stores a Go pointer dump (`0x…`). `hostScalarString` uses `fmt.Sprint` on `*string`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Phase 15: Cutover - Context
|
||||
# Phase 20: Płytarium cutover - Context
|
||||
|
||||
**Gathered:** 2026-10-04
|
||||
**Status:** Ready for planning
|
||||
@@ -52,7 +52,7 @@ Not in scope: anything on the PHP side after the swap. The user handles the PHP
|
||||
### "All routes green" gate
|
||||
- **D-11:** The acceptance set is every route in `fonoteka.go/parity/manifest.yaml`: 175 entries today, covering the fonoteka, user, oauth, realtime and feedback routes. Every entry must be `ported`, with passing recorded cases, zero `pending` entries, and `routes.snapshot` matching PHP on path, method and auth group. API-09, QA-05 and the ROADMAP success criteria are reworded at plan time from "154 routes" to "every manifest route".
|
||||
- **D-12:** Fixtures: the whole corpus is re-recorded from the current PHP backend (`summer parity:record`) shortly before the freeze, and the Go replay must be green on it. A drift report lists every fixture whose PHP response changed since it was committed, and each drift is resolved (a Go fix, or an accepted normalizer change) before the swap. In addition, a **read-only smoke diff runs on the rehearsal import**. A scripted set of authenticated GET requests (as real users, via minted JWTs and personal tokens) is replayed against PHP on the restored MariaDB and against Go on the Postgres imported from the same dump, and both are diffed with the tide normalizer. The smoke diff runs locally on the dump only.
|
||||
- **D-13 (prerequisites):** Phase 14.1 (the 3 pending routes) and Phase 12.1 (the user admin screens) are hard prerequisites, and 12.1 is added to Phase 15's depends-on in ROADMAP. Phase 14's open UAT items must also be closed: the CR-01 decision record, and the WR-02/WR-05 dispositions, which `fonoteka.go` commits suggest are already fixed. Phase 15's first plan opens with a preflight gate that fails unless all of this holds and the manifest has zero pending routes. Planning may start before the prerequisites close.
|
||||
- **D-13 (prerequisites):** Phase 14.1 (the 3 pending routes), Phase 12.1 (the user admin screens) and Phase 14.2 (local Nuxt against Go) are hard prerequisites, and they are on Phase 20's depends-on in ROADMAP. Phase 14's open UAT items must also be closed: the CR-01 decision record, and the WR-02/WR-05 dispositions, which `fonoteka.go` commits suggest are already fixed. Phase 20's first plan opens with a preflight gate that fails unless all of this holds and the manifest has zero pending routes. Planning may start before the prerequisites close.
|
||||
|
||||
### Daily-use acceptance
|
||||
- **D-14:** The soak is 7 days of normal household use on Go with no rollback-worthy issue. During it, the wishlist digest and the notification prune schedules each run at least once, and at least one CSV import and one Discogs match complete. The end of the soak is the phase sign-off and closes the rollback window.
|
||||
@@ -85,7 +85,7 @@ Not in scope: anything on the PHP side after the swap. The user handles the PHP
|
||||
### Prior decisions this phase builds on
|
||||
- `.planning/PROJECT.md`: Postgres only, the out-of-scope MySQL support, the core value.
|
||||
- `.planning/REQUIREMENTS.md`: API-09 and QA-05, which are reworded per D-11.
|
||||
- `.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md`: D-10 (Laravel decrypt is import-only), D-14/D-17 (`system_files` shape and thumb reuse), and the cutover import deferred to Phase 15.
|
||||
- `.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md`: D-10 (Laravel decrypt is import-only), D-14/D-17 (`system_files` shape and thumb reuse), and the cutover import deferred to Phase 20.
|
||||
- `.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md`: D-06 (wire-compatible JWTs, blacklist not migrated) and D-15 (reset codes).
|
||||
- `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md`: D-01 (Winter-shaped `backend_users`, copied straight in at cutover) and WR-17 (user-level permissions).
|
||||
- `.planning/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md`: D-18 (golem/feedback settings importers), D-20 (`GOLEM15_SSRF_ALLOWED_HOSTS`), and D-09 (the routes now in 14.1).
|
||||
@@ -151,5 +151,5 @@ Not in scope: anything on the PHP side after the swap. The user handles the PHP
|
||||
|
||||
---
|
||||
|
||||
*Phase: 15-cutover*
|
||||
*Phase: 20-cutover*
|
||||
*Context gathered: 2026-10-04*
|
||||
@@ -1,10 +1,10 @@
|
||||
# Phase 15: Cutover - Discussion Log
|
||||
# Phase 20: Płytarium cutover - 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-04
|
||||
**Phase:** 15-cutover
|
||||
**Phase:** 20-cutover
|
||||
**Areas discussed:** MariaDB → Postgres data move, production switch and rollback, the "all routes green" gate, daily-use acceptance
|
||||
|
||||
Before the discussion started, the scout found that production runs on MariaDB (with a Redis queue), not Postgres. It also found that the parity manifest holds 175 routes (172 ported, 3 pending), not 154, and that Phases 14.1 and 12.1 have not started.
|
||||
@@ -0,0 +1,271 @@
|
||||
---
|
||||
phase: quick-261007-p4y
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- admin/src/components/form/fields/SwitchField.vue
|
||||
- modules/boardwalk/dist/
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/navigation.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/settings_link_test.go
|
||||
- modules/cabana/README.md
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/tests/fixtures/settings.json
|
||||
- admin/src/state/useSettings.ts
|
||||
- admin/src/views/SettingsIndexView.vue
|
||||
- admin/src/components/shell/PluginRail.vue
|
||||
- admin/src/components/shell/Breadcrumbs.vue
|
||||
- admin/src/app/pageTitle.ts
|
||||
- admin/tests/state/useSettings.test.ts
|
||||
- admin/tests/form/Settings.test.ts
|
||||
- admin/tests/shell/PluginRail.test.ts
|
||||
- admin/tests/shell/Breadcrumbs.test.ts
|
||||
- admin/tests/app/pageTitle.test.ts
|
||||
- docs/backend/settings.md
|
||||
- docs/setup/coming-from-wintercms.md
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_navigation.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_settings.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/plugin_test.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/locales_admin_test.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/README.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261007-p4y]
|
||||
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The admin switch track is 44px by 26px and its knob 20px whatever the root font size (html is 14px), so the knob travels 18px inside a 3px inset on both ends; the embedded modules/boardwalk/dist matches a fresh build of that change"
|
||||
- "A pact.SettingsItem with Controller set compiles without Form, NewModel, Model or an AdminFS and is not a singleton; Controller combined with Form, NewModel or Model, or Controller naming an admin controller no plugin registered, stops start-up with a cabana error; settings codes stay unique across singleton and link entries"
|
||||
- "GET /settings returns a controller-link entry with `controller` set to its controller ID and `model` empty; every singleton entry carries `controller: \"\"`; a link entry is listed only when the principal passes the item's Permissions AND can open the controller (Registry.canOpen); singleton filtering is unchanged"
|
||||
- "GET /settings/{code}/schema, GET /settings/{code} and PUT /settings/{code} answer 404 not_found for a link entry's code; singleton settings endpoints behave exactly as before"
|
||||
- "In the admin SPA a settings index card for a link entry navigates to that controller's route (/vendor/plugin/controller, the same derivation navigation uses); a singleton card still goes to /settings/<encoded code>. On a link controller's pages with no main-navigation entry the rail marks Settings current, breadcrumbs read Settings > <entry label> (plus the record title on record and create routes), and the page title uses the entry label"
|
||||
- "The translate plugin registers no main navigation; its Locales screen appears on the Settings page under category Translate (golem15.translate::lang.plugin.name) as 'Manage languages' (golem15.translate::lang.locale.title), order 550, gated by golem15.translate.manage_locales, linking to golem15.translate.locales, exactly as the PHP plugin's registerSettings entry"
|
||||
- "admin/openapi/admin.json and admin/src/api/schema.d.ts match a fresh generation; the pact and cabana READMEs and docs/backend/settings.md describe the Controller link; TestDocsTree, go vet ./... and go test ./... are green in summercms.go and in sm-translate-plugin; existing keyed pact.SettingsItem literals in other plugins compile unchanged"
|
||||
artifacts:
|
||||
- path: modules/pact/capabilities.go
|
||||
provides: "SettingsItem.Controller string (additive field), documented as WinterCMS registerSettings 'url' => Backend::url(...)"
|
||||
- path: modules/cabana/settings.go
|
||||
provides: "compileSettingLink: exclusivity check (no Form, NewModel or Model) returning a CompiledSetting with nil Form and Writable"
|
||||
- path: modules/cabana/registry.go
|
||||
provides: "compileContributions link branch (no AdminFS requirement) and post-collection unknown-controller validation for link entries"
|
||||
- path: modules/cabana/navigation.go
|
||||
provides: "SettingsEntry.Controller json:\"controller\"; Metadata filters link entries through Allows and canOpen"
|
||||
- path: modules/cabana/contracts.go
|
||||
provides: "Registry.Setting returns false for link entries so every singleton endpoint answers 404"
|
||||
- path: modules/cabana/settings_link_test.go
|
||||
provides: "Compile validation, metadata output and permission filtering, and singleton-endpoint 404 tests for link entries"
|
||||
- path: admin/src/state/useSettings.ts
|
||||
provides: "settingsPath(entry) and settingsLinkFor(controllerId)"
|
||||
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_settings.go
|
||||
provides: "Plugin.Settings() returning the single locales link entry"
|
||||
key_links:
|
||||
- from: "pact.SettingsItem.Controller"
|
||||
to: "cabana compileContributions link branch + unknown-controller check"
|
||||
via: "reg.settings map shared with singletons (one code namespace)"
|
||||
- from: "cabana.Registry.Metadata"
|
||||
to: "SettingsIndexView RouterLink"
|
||||
via: "SettingsEntry.controller -> admin.json -> schema.d.ts -> useSettings.settingsPath"
|
||||
- from: "cabana.Registry.Setting"
|
||||
to: "protectSetting in modules/cabana/http.go"
|
||||
via: "link code is not found -> 404 not_found before any Form access"
|
||||
- from: "translate Plugin.Settings()"
|
||||
to: "GET /settings in the translate admin harness"
|
||||
via: "cabana registry boot with the plugin's controllers"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Two user-facing fixes in one quick task.
|
||||
|
||||
1. The admin toggle (SwitchField) looks too tall for its width. Root cause: admin/src/styles/main.css sets the html font size to 14px, so the Tailwind v4 rem utilities w-11 (38.5px) and size-5 (17.5px) shrink against the fixed h-[26px] track and translate-x-[18px] travel. The fix (w-[44px] and size-[20px]) is ALREADY APPLIED, uncommitted, in admin/src/components/form/fields/SwitchField.vue. It needs its test check, a dist rebuild and its own commit.
|
||||
|
||||
2. WinterCMS lets registerSettings point an entry at a backend controller ('url' => Backend::url('golem15/translate/locales')), so the translate plugin's Locales screen lives on the Settings page, not in the main navigation. SummerCMS core only supports singleton-model settings, so the Go translate plugin registers a main navigation item instead. This task adds an additive `Controller` field to pact.SettingsItem with registry validation, metadata output, permission filtering and singleton-endpoint 404s, plus the SPA card link and Settings-aware shell. The translate plugin then switches from Navigation() to Settings().
|
||||
|
||||
Purpose: restore the WinterCMS admin shape (Locales under Settings) without breaking the pact contract that many plugins and apps depend on, and fix a visibly wrong control.
|
||||
Output: three summercms.go commits (switch fix with dist; Go contract with OpenAPI, READMEs and docs; SPA with tests and dist) and one sm-translate-plugin commit.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
|
||||
Facts established during planning (do not re-derive):
|
||||
- The SPA builds into modules/boardwalk/dist (admin/vite.config.ts outDir, emptyOutDir). Rebuild with `npm --prefix admin run build` (vue-tsc then vite build). `scripts/check-admin-dist.sh` fails when dist differs from a fresh build. Stage dist with `git add -A modules/boardwalk/dist` so hashed asset renames and deletions are captured (see commits f15b38e and a586357).
|
||||
- No admin test asserts the switch's w-11 or size-5 classes. tests/form/fields.test.ts and tests/form/registry.test.ts mount SwitchField behaviourally.
|
||||
- `scripts/check-admin-openapi.sh` (no argument) regenerates admin/openapi/admin.json and admin/src/api/schema.d.ts from the cabana swag annotations. `--check` fails on drift. Every SettingsEntry property is currently `required` in admin.json, so a non-omitempty `json:"controller"` becomes a required string. That matches NavigationEntry.Controller, which is also always present.
|
||||
- admin/tests/fixtures/typed.ts types settings.json as the generated SettingsEntry. Once `controller` is required, every fixture entry needs `"controller": ""` or `npm --prefix admin run typecheck` fails. Build link entries inline in tests rather than adding them to the shared fixture (existing tests count its entries).
|
||||
- The registry builds `reg.byID` (compiled controllers) before compileContributions runs, so link validation can check `reg.byID` the same way validateNavigation does (modules/cabana/registry.go ~line 322).
|
||||
- protectSetting (modules/cabana/http.go ~line 501) resolves the code only through Registry.Setting, so Registry.Setting returning false for a link code turns all three singleton endpoints into 404s. http.go needs no edit.
|
||||
- Reusable Go fixtures: modules/cabana/metadata_settings_test.go has contributionPlugin, metadataController (requires acme.demo.access), metadataRegistry() (byID has acme.demo.widgets) and metadataPlugin(). HTTP-level settings calls exist in modules/cabana/openapi_conformance_test.go (conformEnv, "GET /settings") and modules/cabana/security_coverage_test.go. Reuse whichever harness mounts the admin API with a backend principal.
|
||||
- Settings category convention: every Go settings item uses its own plugin phrase key as Category (feedback and fonoteka use lang.plugin.tab, journal uses lang.journal.menu_label). Core has no "System" category key. The PHP reference examples/golem15-wintercms-starter/plugins/golem15/translate/Plugin.php registerSettings uses category 'golem15.translate::lang.plugin.name', label 'golem15.translate::lang.locale.title', description 'golem15.translate::lang.plugin.description', order 550 and permission golem15.translate.manage_locales, with no keywords. All three keys already exist in sm-translate-plugin/lang/en and lang/pl. Per the API-parity rule, follow the PHP reference and add no core category constant.
|
||||
- sm-translate-plugin is a separate git repo whose go.mod has `replace git.golem15.com/golem15/summercms => ../summercms.go` (and a go.work with the same replace), so it builds against the local framework. Do not change its go.mod require version. Its admin harness (admin_harness_test.go: newAdminEnv, e.admin(t, permissions...), e.expect(...), adminAPI("/settings")) boots cabana with the plugin and Postgres via testcontainers.
|
||||
- Consumers of the translate plugin (sm-grzybyfunkcjonalne-app, sm-summercmsio-app) pin it as a submodule. Bumping them is OUT of scope.
|
||||
|
||||
Commit rules: one logical change per commit; planning docs never go in a code commit; NO co-author trailers (the user's global CLAUDE.md overrides any attribution reminder); `go vet ./...` and `go test ./...` green at every commit. Do not commit anything under .planning (the orchestrator does).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Commit the pre-applied switch proportion fix with its rebuilt dist</name>
|
||||
<files>admin/src/components/form/fields/SwitchField.vue, modules/boardwalk/dist/</files>
|
||||
<read_first>admin/src/components/form/fields/SwitchField.vue, scripts/check-admin-dist.sh</read_first>
|
||||
<action>
|
||||
This change is already in the working tree. It must land first, alone, so the later SPA commit's dist rebuild does not mix in an unrelated fix.
|
||||
|
||||
1. Run `git status --short` in summercms.go. The only modified file must be admin/src/components/form/fields/SwitchField.vue. If anything else is dirty, stop and report it rather than building it into dist.
|
||||
2. Confirm the track uses w-[44px] with h-[26px], and the knob uses size-[20px] with top-[3px], left-[3px] and translate-x-[18px]. The geometry is 3 + 20 + 18 + 3 = 44, so it is symmetric. Leave the colour, aria and transition classes untouched.
|
||||
3. Grep admin/tests for any assertion on w-11 or size-5 tied to the switch. Planning found none. If one exists, update it to the pixel classes.
|
||||
4. Run the switch tests, then `npm --prefix admin run build`, then `scripts/check-admin-dist.sh`.
|
||||
5. Stage SwitchField.vue and `git add -A modules/boardwalk/dist`. Commit as `fix(admin): size the switch toggle in pixels at the 14px root` with a body explaining that the rem utilities shrank the track and knob under html font-size 14px. Add no co-author trailer.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && grep -q 'w-\[44px\]' admin/src/components/form/fields/SwitchField.vue && grep -q 'size-\[20px\]' admin/src/components/form/fields/SwitchField.vue && npm --prefix admin test -- tests/form/fields.test.ts tests/form/registry.test.ts && scripts/check-admin-dist.sh && go test ./modules/boardwalk -count=1 && test -z "$(git status --short)"</automated>
|
||||
</verify>
|
||||
<done>SwitchField uses pixel width and knob sizes, the switch tests pass, dist matches a fresh build, boardwalk tests pass, and one commit holds SwitchField.vue plus modules/boardwalk/dist with no co-author trailer. The working tree is clean afterwards.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 2: Settings entries that link to an admin controller, end to end (pact, cabana, OpenAPI, SPA, dist, docs)</name>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/settings.go, modules/cabana/registry.go, modules/cabana/navigation.go, modules/cabana/contracts.go, modules/cabana/settings_link_test.go, modules/cabana/README.md, docs/backend/settings.md, docs/setup/coming-from-wintercms.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/tests/fixtures/settings.json, admin/src/state/useSettings.ts, admin/src/views/SettingsIndexView.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/shell/Breadcrumbs.vue, admin/src/app/pageTitle.ts, admin/tests/state/useSettings.test.ts, admin/tests/form/Settings.test.ts, admin/tests/shell/PluginRail.test.ts, admin/tests/shell/Breadcrumbs.test.ts, admin/tests/app/pageTitle.test.ts, modules/boardwalk/dist/</files>
|
||||
<read_first>modules/pact/capabilities.go (SettingsItem, NavigationItem), modules/cabana/registry.go (compileContributions, validateNavigation), modules/cabana/settings.go (CompiledSetting, compileSetting), modules/cabana/navigation.go (SettingsEntry, Metadata, canOpen), modules/cabana/contracts.go (Registry.Setting), modules/cabana/metadata_settings_test.go (fixtures), admin/src/state/useSettings.ts, admin/src/state/useNavigation.ts, admin/src/app/controllerRoutes.ts, admin/src/views/SettingsIndexView.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/shell/Breadcrumbs.vue, admin/src/app/pageTitle.ts, admin/tests/form/Settings.test.ts, admin/tests/shell/PluginRail.test.ts, admin/tests/shell/Breadcrumbs.test.ts, admin/tests/app/pageTitle.test.ts, docs/backend/settings.md</read_first>
|
||||
<behavior>
|
||||
- Go: a link item `{Code: "locales", Controller: "acme.demo.widgets", Permissions: ["acme.demo.manage_settings"]}` compiles from a plugin with no AdminFS. Registry.Setting("locales") returns false.
|
||||
- Go: Controller plus Form, plus NewModel, or plus Model each fail compileContributions with an error naming the setting code. Controller "acme.demo.missing" fails with "cabana: setting locales references unknown controller acme.demo.missing". A link code equal to a singleton code fails with "duplicate setting".
|
||||
- Go: Metadata for a developer holding acme.demo.access and acme.demo.manage_settings lists the link entry with Controller "acme.demo.widgets" and Model "", and the singleton entry with Controller "".
|
||||
- Go: a principal granted only acme.demo.manage_settings (it passes the item but cannot open the controller, which requires acme.demo.access) does not see the link entry, but does see the singleton.
|
||||
- Go: GET /settings/locales/schema, GET /settings/locales and PUT /settings/locales answer 404 not_found for an allowed backend principal. GET /settings JSON has a "controller" key on every entry.
|
||||
- SPA: settingsPath returns /settings/<encoded code> for a singleton, /acme/demo/locales for controller acme.demo.locales, and /settings for a malformed controller ID. settingsLinkFor('acme.demo.locales') finds the link entry; settingsLinkFor('') and null find nothing (singleton entries with an empty controller never match).
|
||||
- SPA: the settings index card for a link entry has an href ending in /acme/demo/locales. Singleton cards are unchanged.
|
||||
- SPA: on route /acme/demo/locales with no navigation entry for acme.demo, data-rail-settings has aria-current="page" and no rail item is current. Breadcrumbs are Settings (link to /settings) > entry label (current), the record route adds the record title, and the document title starts with the entry label. On a navigation controller route, Settings is not current.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the failing Go tests and SPA tests from the behavior block first, then implement. This tracer wires one settings link through every layer. The behaviour-preserving rule is absolute: keyed pact.SettingsItem literals elsewhere must compile and behave unchanged, and singleton settings must not change.
|
||||
|
||||
Go contract, commit A:
|
||||
|
||||
- modules/pact/capabilities.go: add `Controller string` to SettingsItem, after Permissions and before Form, with no json tag (like the other exported fields). Its doc comment says: when set, the entry is a link to that admin controller ID (vendor.plugin.controller), the equivalent of WinterCMS registerSettings 'url' => Backend::url(...). A link entry declares no Model, Form or NewModel and is not a singleton. Extend the SettingsItem doc comment to say an item is either a singleton form or a controller link.
|
||||
- modules/cabana/settings.go: add `compileSettingLink(pluginID string, item pact.SettingsItem) (*CompiledSetting, error)`. It errors when item.Form != "" or item.NewModel != nil, with message "cabana: setting %s links controller %s and declares a singleton form; declare one". It errors when item.Model != "", with message "cabana: setting %s links controller %s and names a model". Otherwise it returns &CompiledSetting{PluginID, Item} with Form and Writable nil. Rejecting Model is a planning discretion choice: a link has no model, so the SPA never sees a misleading model string. Update the CompiledSetting doc comment to say Form and Writable are nil for a link entry.
|
||||
- modules/cabana/registry.go compileContributions: in the HasSettings loop, keep the identifier and duplicate checks first. If item.Controller != "", call compileSettingLink, store the result in reg.settings[item.Code] and continue. This skips the AdminFS requirement and compileSetting. Otherwise keep the existing path unchanged. In the post-collection loop over reg.settings, after validatePermissions, a link entry whose controller is not in reg.byID returns "cabana: setting %s references unknown controller %s", mirroring validateNavigation.
|
||||
- modules/cabana/contracts.go Registry.Setting: return (nil, false) when the stored setting's Item.Controller is non-empty. Update the doc comment: link entries are not singletons and are not returned. This alone makes protectSetting answer 404 for all three singleton endpoints. Leave http.go unchanged.
|
||||
- modules/cabana/navigation.go: add `Controller string` with tag `json:"controller"` (not omitempty, matching NavigationEntry) to SettingsEntry, after Model, with a doc comment ("the admin controller a link entry opens; empty for a singleton settings form"). In Metadata's settings loop, skip an item unless Allows(principal, item.Permissions) holds and also either item.Controller is empty or r.canOpen(principal, item.Controller) is true. Populate Controller in the appended entry.
|
||||
- modules/cabana/settings_link_test.go (new, package cabana): implement the Go behavior cases, reusing contributionPlugin, metadataRegistry, metadataPlugin and metadataController from metadata_settings_test.go. Add a plugin with nil fsys for the no-AdminFS case. For the 404 and JSON-shape cases, reuse the existing admin HTTP harness (see the Context facts) instead of writing a new one.
|
||||
- Run `scripts/check-admin-openapi.sh` with no argument to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts. Confirm the only diff is the new controller property, which is required on cabana.SettingsEntry.
|
||||
- admin/tests/fixtures/settings.json: add `"controller": ""` to every existing list entry so `npm --prefix admin run typecheck` stays green against the regenerated schema.d.ts.
|
||||
- Docs in the same commit, per the CLAUDE.md documentation rule:
|
||||
- modules/pact/README.md: extend the SettingsItem row and the Features bullet to cover the controller link.
|
||||
- modules/cabana/README.md: extend the Features bullet on settings, covering link entries, their validation, the 404 on singleton endpoints and the canOpen filtering. Update any Registry.Setting or SettingsEntry rows.
|
||||
- docs/backend/settings.md: add a section "Linking a settings entry to an admin controller". Explain the WinterCMS 'url' equivalent and the Controller field, that a link entry declares no Model, Form or NewModel, the start-up validation, the permission plus controller-access filtering and how the SPA opens the controller, with a short Go example using neutral names such as acme.blog. A plain go fence without src= is fine unless TestDocsTree requires otherwise.
|
||||
- docs/setup/coming-from-wintercms.md: extend the registerSettings row to mention 'url' mapping to the Controller field.
|
||||
- Name no consuming application. If TestDocsTree rejects a field selector such as pact.SettingsItem.Controller, write "the `Controller` field of `pact.SettingsItem`" instead.
|
||||
- Commit A, Go plus OpenAPI plus fixture plus docs: `feat(cabana): settings entries that link to an admin controller`. Add no co-author trailer.
|
||||
|
||||
SPA, commit B:
|
||||
|
||||
- admin/src/state/useSettings.ts: import controllerPath from ../app/controllerRoutes. Export `settingsPath(entry: SettingsEntry): string`. When entry.controller is non-empty it returns controllerPath(entry.controller), falling back to '/settings' when that is null. Otherwise it returns `/settings/` plus encodeURIComponent(entry.code). Export `settingsLinkFor(controllerId: string | null): SettingsEntry | null`. It returns null for null or an empty string, and otherwise the first entry whose non-empty controller equals the ID. Add both to useSettings().
|
||||
- admin/src/views/SettingsIndexView.vue: bind the card RouterLink `:to` to settingsPath(entry). Keep data-settings. Update the header comment.
|
||||
- admin/src/components/shell/PluginRail.vue: inSettings is also true when active is null and settingsLinkFor(controllerIdFromPath(route.path)) is non-null. A controller that also has a navigation entry keeps its plugin highlight. Update the comment.
|
||||
- admin/src/components/shell/Breadcrumbs.vue: when activeEntry is null, look up settingsLinkFor(controllerIdFromPath(route.path)). If an entry is found, the crumbs are the Settings label (t('backend::lang.nav.settings'), to /settings), then the entry label (to its controller path), then the record title on record and create routes when non-empty, using the same rule as the plugin branch. Otherwise return [] as today.
|
||||
- admin/src/app/pageTitle.ts: controllerLabel falls back to settingsLinkFor(controller)?.label after the section and plugin labels.
|
||||
- Tests: implement the SPA behavior cases in useSettings.test.ts, Settings.test.ts (index), PluginRail.test.ts, Breadcrumbs.test.ts and pageTitle.test.ts. Build link entries inline (for example `setSettings([...settingsFixture.list.data, linkEntry])` with controller 'acme.demo.locales' and a vendor or plugin absent from the navigation fixture). Do not change existing assertions.
|
||||
- `npm --prefix admin run build`, then `scripts/check-admin-dist.sh`.
|
||||
- Commit B, SPA plus tests plus dist (`git add -A modules/boardwalk/dist`): `feat(admin): open controller-link settings entries from the settings index`. Add no co-author trailer.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && test -z "$(gofmt -l modules/pact modules/cabana)" && go vet ./modules/pact ./modules/cabana && go test ./modules/cabana -run 'TestSettingsLink' -count=1 -v && go test ./modules/pact ./modules/cabana ./modules/boardwalk -count=1 && scripts/check-admin-openapi.sh --check && grep -q '"controller"' admin/openapi/admin.json && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1</automated>
|
||||
</verify>
|
||||
<done>Every Go and SPA behavior case failed before the change and passes after it. Existing metadata, settings-singleton, conformance, security-coverage and SPA tests pass unchanged apart from the fixture's added controller key. admin.json and schema.d.ts match a fresh generation. Dist matches a fresh build. TestDocsTree passes. Two commits exist (feat(cabana) with docs, feat(admin) with dist), neither with a co-author trailer.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Translate plugin lists Locales on the Settings page instead of the main navigation</name>
|
||||
<files>/media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_navigation.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_settings.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/plugin_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/locales_admin_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/README.md</files>
|
||||
<read_first>/media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_navigation.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_harness_test.go (newAdminEnv, admin, expect, adminAPI), /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/locales_admin_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/README.md, /media/nvme/dev/golem15/summercms.io/summercms/examples/golem15-wintercms-starter/plugins/golem15/translate/Plugin.php (registerSettings)</read_first>
|
||||
<behavior>
|
||||
- Plugin.Settings() returns exactly one item. Its fields are: Code "locales", Label "golem15.translate::lang.locale.title", Description "golem15.translate::lang.plugin.description", Category "golem15.translate::lang.plugin.name", Icon "languages", Order 550, Permissions [controllers.PermissionManageLocales], Controller "golem15.translate.locales". Model and Form are empty, NewModel is nil and there are no Keywords.
|
||||
- *Plugin does not implement pact.HasNavigation, and does implement pact.HasSettings.
|
||||
- Harness, with Postgres: an admin holding golem15.translate.manage_locales gets a GET /settings entry with code locales, controller golem15.translate.locales, model "" and a translated label (English "Manage languages"). GET /navigation contains no entry with code translate. GET /settings/locales and GET /settings/locales/schema answer 404.
|
||||
- Harness: an admin without manage_locales gets no locales entry from GET /settings.
|
||||
</behavior>
|
||||
<action>
|
||||
Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin, a separate git repo. Use absolute paths. Run `pwd` if a path error appears. Start with `git status --short` and confirm the repo is clean. It builds against the local framework through the go.mod and go.work replace, so the Task 2 Controller field is visible. Do not change the go.mod require version.
|
||||
|
||||
1. Write the failing tests first.
|
||||
- In plugin_test.go, add a test asserting the exact Settings() item from the behavior block, compared with reflect.DeepEqual against an expected pact.SettingsItem (NewModel nil on both sides). Assert the interface facts with type assertions on any(&Plugin{}).
|
||||
- In locales_admin_test.go, add a harness test covering the GET /settings, GET /navigation and 404 cases using newAdminEnv, e.admin and e.expect.
|
||||
2. Create admin_settings.go (package translate). It defines `func (*Plugin) Settings() []pact.SettingsItem` returning the single item, using controllers.PermissionManageLocales. Its doc comment says it replaces registerSettings and that Locales opens from the Settings page, as the PHP plugin's 'url' => Backend::url('golem15/translate/locales') entry did. The values follow the PHP reference exactly: category is the plugin name key, and there are no keywords. Planning chose the reference over a core "System" category because core has none and every other Go settings item uses its own plugin phrase key.
|
||||
3. Remove admin_navigation.go with `git rm`. In admin.go's interface-assertion block, replace the navigation assertion with `_ pact.HasSettings = (*Plugin)(nil)`.
|
||||
4. All three lang keys already exist in lang/en/lang.yaml and lang/pl/lang.yaml (locale.title, plugin.description, plugin.name). Add no lang changes.
|
||||
5. README.md:
|
||||
- Extend the "Locales admin" Features bullet: Locales opens from the Settings page (category Translate) through a settings entry linking to the controller, not from the main navigation, matching the PHP plugin.
|
||||
- Add an API reference row `translate.Plugin.Settings`: one settings entry, locales, linking to controller golem15.translate.locales and gated by golem15.translate.manage_locales.
|
||||
- Note the framework requirement: the entry uses the Controller field of pact.SettingsItem, so the plugin needs a framework release that has it. Do not edit go.mod.
|
||||
6. Run gofmt, go vet ./... and go test ./... -count=1. The harness tests need Docker for testcontainers.
|
||||
7. Commit in sm-translate-plugin as `feat(settings): open Locales from the Settings page`. Add no co-author trailer.
|
||||
8. Back in summercms.go, run the full `go vet ./...` and `go test ./... -count=1` once more to confirm both repos are green. Leave the consuming apps' translate submodules unbumped and note that in the SUMMARY as a follow-up.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin && test ! -e admin_navigation.go && test -z "$(gofmt -l .)" && go vet ./... && go test ./... -count=1 && test -z "$(git status --short)" && cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./... -count=1</automated>
|
||||
</verify>
|
||||
<done>The translate plugin implements HasSettings and not HasNavigation. Settings() returns the single locales link entry matching the PHP registerSettings values. The harness proves the entry appears on /settings for manage_locales admins only, is absent from /navigation, and that the singleton endpoints 404 for it. The README documents it. One sm-translate-plugin commit with no co-author trailer. go vet and go test are green in both repos.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| backend principal -> GET /settings | Metadata decides which settings entries, and which controller IDs, a principal may learn about |
|
||||
| backend principal -> /settings/{code} endpoints | Singleton read and write endpoints keyed by a client-supplied code |
|
||||
| plugin registration -> cabana registry | Compiled plugins declare settings entries that the registry trusts at boot |
|
||||
| server metadata -> SPA router | The SPA turns a server-sent controller ID into a client route |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-quick-261007-p4y-01 | Information disclosure | cabana Registry.Metadata settings loop | medium | mitigate | A link entry is listed only when Allows(principal, item.Permissions) and r.canOpen(principal, item.Controller) both hold, so a principal never learns a controller ID it cannot open; covered by the restricted-principal case in settings_link_test.go |
|
||||
| T-quick-261007-p4y-02 | Tampering / Denial of service | protectSetting via Registry.Setting | medium | mitigate | Registry.Setting returns false for link entries, so GET/PUT on a link code answer 404 before any nil Form or model factory is touched (no panic, no write); covered by the three 404 cases |
|
||||
| T-quick-261007-p4y-03 | Spoofing / Tampering | compileContributions link branch | low | mitigate | Boot fails when a link names an unregistered controller, combines Controller with Form, NewModel or Model, or reuses a settings code; there is no runtime path to a dangling or ambiguous entry |
|
||||
| T-quick-261007-p4y-04 | Tampering | SPA settingsPath | low | mitigate | The route comes only from controllerPath, which accepts exactly three [A-Za-z0-9_-] segments and otherwise falls back to /settings; no server string is interpolated into a URL unchecked |
|
||||
| T-quick-261007-p4y-05 | Elevation of privilege | controller pages opened from Settings | low | accept | Opening a controller still goes through the existing controller guard (requiredOf) on every API call; the Settings link adds no access path |
|
||||
| T-quick-261007-p4y-SC | Tampering | npm/go installs | low | accept | No new npm or Go dependency; the work uses the committed lockfile, the existing Vitest setup and the pinned swag in check-admin-openapi.sh |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- summercms.go git log shows three new commits in order: fix(admin) switch with dist, feat(cabana) with OpenAPI, fixture, READMEs and docs, and feat(admin) with tests and dist. None has a co-author trailer and none touches .planning.
|
||||
- sm-translate-plugin git log shows one new commit with no co-author trailer; admin_navigation.go is removed.
|
||||
- scripts/check-admin-openapi.sh --check, scripts/check-admin-dist.sh and go test ./cmd/summer -run TestDocsTree pass.
|
||||
- go vet ./... and go test ./... pass in both repos; npm --prefix admin test and typecheck pass.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The toggle renders a 44x26 track with a 20px knob and symmetric 3px insets.
|
||||
- A plugin can declare a pact.SettingsItem that links to its admin controller. The Settings page shows it, permission- and controller-access-filtered, and opens the controller. The shell treats that controller as part of Settings. Singleton endpoints refuse it with 404. Existing singleton settings and other plugins are unaffected.
|
||||
- The translate plugin's Locales screen is reached from Settings under Translate, as in WinterCMS, and no longer from the main navigation.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261007-p4y-toggle-proportions-and-settings-controll/261007-p4y-SUMMARY.md` when done. Record:
|
||||
- the discretion choices: Model rejected on link entries; category follows the PHP reference rather than a core "System" constant;
|
||||
- the follow-up that consuming apps must bump their translate submodule and need a framework tag that includes the Controller field.
|
||||
</output>
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
phase: quick-261007-p4y
|
||||
plan: 01
|
||||
status: complete
|
||||
subsystem: admin, cabana, pact, sm-translate-plugin
|
||||
tags: [admin-spa, settings, pact, cabana, translate, parity]
|
||||
requires: []
|
||||
provides:
|
||||
- "pact.SettingsItem.Controller: a settings entry that links to an admin controller"
|
||||
- "cabana link compile/validation, metadata filtering (Allows + canOpen), singleton-endpoint 404s"
|
||||
- "SPA settingsPath / settingsLinkFor; Settings-aware rail, breadcrumbs and page title"
|
||||
- "translate plugin: Locales on the Settings page instead of the main navigation"
|
||||
affects: [sm-grzybyfunkcjonalne-app, sm-summercmsio-app]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Settings entries share one code namespace across singleton and link entries"
|
||||
- "Registry.Setting hides link entries so protectSetting answers 404 with no http.go change"
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/settings_link_test.go
|
||||
- modules/cabana/example_settings_test.go
|
||||
- ../sm-translate-plugin/admin_settings.go
|
||||
modified:
|
||||
- admin/src/components/form/fields/SwitchField.vue
|
||||
- modules/pact/capabilities.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/navigation.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/example_test.go
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/tests/fixtures/settings.json
|
||||
- admin/src/state/useSettings.ts
|
||||
- admin/src/views/SettingsIndexView.vue
|
||||
- admin/src/components/shell/PluginRail.vue
|
||||
- admin/src/components/shell/Breadcrumbs.vue
|
||||
- admin/src/app/pageTitle.ts
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/settings.md
|
||||
- docs/setup/coming-from-wintercms.md
|
||||
- modules/boardwalk/dist/
|
||||
- ../sm-translate-plugin/admin.go
|
||||
- ../sm-translate-plugin/plugin_test.go
|
||||
- ../sm-translate-plugin/locales_admin_test.go
|
||||
- ../sm-translate-plugin/README.md
|
||||
deleted:
|
||||
- ../sm-translate-plugin/admin_navigation.go
|
||||
decisions:
|
||||
- "A settings link entry rejects Model as well as Form/NewModel, so the SPA never sees a misleading model string"
|
||||
- "Translate's settings category follows the PHP reference (golem15.translate::lang.plugin.name); no core System category constant added"
|
||||
- "The docs Go example for the link entry lives in its own file (example_settings_test.go) because example_controller_test.go is embedded whole in admin-controllers.md"
|
||||
metrics:
|
||||
duration: "13 min"
|
||||
completed: 2026-10-07
|
||||
estimate:
|
||||
tokens: 140000
|
||||
tasks: 3
|
||||
actuals:
|
||||
tokens: 15600
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: a586357a346a66111d37bd8c1d564c769a412715
|
||||
plan_head_after: 0b09acdb0cca4955a2dcba2e9a2708a06b032104
|
||||
---
|
||||
|
||||
# Quick 261007-p4y: Toggle proportions and Settings controller links Summary
|
||||
|
||||
The admin switch now has pixel-based proportions: a 44x26 track and a 20px knob. Settings entries can now link to an admin controller through an additive `pact.SettingsItem.Controller` field, covered from start-up validation through to the Settings page. The translate plugin now lists Locales on the Settings page, under the Translate category, as WinterCMS did.
|
||||
|
||||
## Commits
|
||||
|
||||
summercms.go (3 commits, measured `git rev-list --count a586357..HEAD` = 3):
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | b040ee3 | fix(admin): size the switch toggle in pixels at the 14px root |
|
||||
| 2A | 4a9b089 | feat(cabana): settings entries that link to an admin controller |
|
||||
| 2B | 0b09acd | feat(admin): open controller-link settings entries from the settings index |
|
||||
|
||||
sm-translate-plugin (separate repo, 1 commit):
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 3 | bde3f1a | feat(settings): open Locales from the Settings page |
|
||||
|
||||
No commit has a co-author trailer. No commit touches `.planning`. Nothing was pushed.
|
||||
|
||||
## What was built
|
||||
|
||||
- **Switch:** `w-[44px]` and `size-[20px]` give 3 + 20 + 18 + 3 = 44, with a symmetric inset. Dist was rebuilt and committed on its own.
|
||||
- **pact:** `SettingsItem.Controller` is a new additive field placed before `Form`. Keyed literals in other code compile unchanged, and a grep of the meta repo found no unkeyed ones.
|
||||
- **cabana:**
|
||||
- `compileSettingLink` rejects a link that also sets Form, NewModel or Model.
|
||||
- The link branch in `compileContributions` skips the AdminFS requirement.
|
||||
- After every plugin is collected, start-up fails on an unknown controller with `cabana: setting <code> references unknown controller <id>`.
|
||||
- `Registry.Setting` hides link entries, so all three singleton endpoints answer 404.
|
||||
- `SettingsEntry.Controller` is serialised as `json:"controller"` and is always present.
|
||||
- Metadata lists a link entry only when the principal passes `Allows(item.Permissions)` and `canOpen(controller)`.
|
||||
- **OpenAPI:** `controller` is a required string on `cabana.SettingsEntry` in admin.json and schema.d.ts. The settings fixture entries now carry `"controller": ""`.
|
||||
- **SPA:**
|
||||
- Index cards link through `settingsPath`.
|
||||
- The rail marks Settings current on a linked controller that has no navigation entry. A linked controller that does have a navigation entry keeps its plugin highlighted.
|
||||
- Breadcrumbs on such a page read `Ustawienia > <label> [> record]`.
|
||||
- The page title falls back to the entry label.
|
||||
- **Translate:** `Plugin.Settings()` returns the single locales link entry, with values taken from the PHP registerSettings reference. `admin_navigation.go` is removed, and `HasNavigation` is replaced by `HasSettings` in the assertion block.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
- Go RED: `settings_link_test.go` failed to build (`unknown field Controller in struct literal of type pact.SettingsItem`). After the change it passes: 4 tests plus 9 subtests.
|
||||
- SPA RED: 6 new cases failed across useSettings, Settings, PluginRail, Breadcrumbs and pageTitle. After the change it passes: all 1083 tests in 74 files.
|
||||
- Translate RED: the build failed (`p.Settings undefined`). After the change it passes, and the harness test ran against Postgres via testcontainers.
|
||||
|
||||
## Verification
|
||||
|
||||
- summercms.go: `go vet ./...` and `go test ./... -count=1` pass. So do `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh`, `go test ./cmd/summer -run TestDocsTree`, `npm --prefix admin run typecheck` and `npm --prefix admin test`.
|
||||
- sm-translate-plugin: `gofmt -l .` is clean, and `go vet ./...` and `go test ./... -count=1` pass.
|
||||
- Both working trees are clean, apart from the orchestrator-owned `.planning/quick/...` directory.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] The docs snippet needed a `src=` reference and a shorter description**
|
||||
- **Found during:** Task 2, commit A
|
||||
- **Issue:** TestDocsTree rejects a Go fence without `src=` and a frontmatter description over 160 characters. Adding the example to `example_controller_test.go` changed a whole-file snippet in `docs/backend/admin-controllers.md`.
|
||||
- **Fix:** I added `modules/cabana/example_settings_test.go`, which holds `LinkedBlogPlugin` and a `docs:start settings-link` region. `TestDocsSettingsLink` in `example_test.go` activates the plugin through `cabana.Activate`, which proves the link compiles end to end. The settings.md description is shortened.
|
||||
- **Commit:** 4a9b089
|
||||
|
||||
**2. [Process] The admin-behaviour paragraph moved to commit B**
|
||||
- The settings.md paragraph about the rail and breadcrumbs went into the SPA commit (0b09acd) rather than commit A, so each commit's docs match its code.
|
||||
|
||||
**3. [Test fixture choice] Rail, breadcrumb and title tests use `acme.lang.locales`**
|
||||
- The plan suggested `acme.demo.locales`, but `acme.demo` is in the navigation fixture, which would make the plugin highlight win. These tests use a plugin that is absent from navigation, `acme.lang.locales`. The path tests in useSettings still use `acme.demo.locales`.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Consuming apps (sm-grzybyfunkcjonalne-app, sm-summercmsio-app) must bump their translate submodule to bde3f1a or later. They also need a framework tag that includes `pact.SettingsItem.Controller` (4a9b089 or later). Neither bump is done here, because it was out of scope.
|
||||
- The PHP plugin also registers a `messages` settings link. There is no Messages admin controller in the Go port yet, so only `locales` is registered.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. All new surface is covered by the plan's threat register: T-01 has the metadata filter tests, T-02 the 404 tests, T-03 the compile validation tests, and T-04 the settingsPath fallback test.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/cabana/settings_link_test.go, modules/cabana/example_settings_test.go, ../sm-translate-plugin/admin_settings.go
|
||||
- MISSING (intended): ../sm-translate-plugin/admin_navigation.go
|
||||
- FOUND commits: b040ee3, 4a9b089, 0b09acd (summercms.go); bde3f1a (sm-translate-plugin)
|
||||
281
.planning/tmp/14.2.1-verify-command-paths.json
Normal file
281
.planning/tmp/14.2.1-verify-command-paths.json
Normal file
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"commands": [
|
||||
{
|
||||
"command": "git ls-remote git@git.golem15.com:golem15/sm-translate-plugin.git",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-01-PLAN.md",
|
||||
"task": "Task 1: Create the public Gitea remote and authorize local repository setup"
|
||||
},
|
||||
{
|
||||
"command": "test -f /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/models/Locale.php && git -C /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate rev-parse HEAD",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-01-PLAN.md",
|
||||
"task": "Task 2: Confirm the one-way table contract"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./... -short -count=1 && go vet ./modules/surf/... && go test ./modules/surf -short -count=1",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-01-PLAN.md",
|
||||
"task": "Task 3: Resolve one URL-prefixed request through the plugin, enabled Locale row, surf, and context"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin test ./classes -count=1 -v -run '^(TestFixtureTranslatableSaveRead)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-02-PLAN.md",
|
||||
"task": "Task 1: Save one fixture title in en and pl, then read pl through the exported API"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./classes -count=1 -v -run '^(TestFixtureTranslatableSaveRead|TestFixtureTranslatedIndexSmoke)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-02-PLAN.md",
|
||||
"task": "Task 2: Add indexed locale lookup, WithLocale query scope, and default fallback"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./... -short -count=1 -v -run '^(TestLocalesAdminSmoke|TestLocalesAdminForbiddenSmoke)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-02-PLAN.md",
|
||||
"task": "Task 3: Manage Locales through permissioned YAML CRUD and separate phrasebook catalogs"
|
||||
},
|
||||
{
|
||||
"command": "go test ./modules/cabana -count=1 -v -run '^(TestMLNestedSaveSmoke)$' && npm --prefix admin test -- --run admin/tests/form/MLFields.test.ts",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": "prefix",
|
||||
"rawTarget": "admin",
|
||||
"target": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin",
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-03-PLAN.md",
|
||||
"task": "Task 1: Submit one mltext map through cabana and persist default plus Polish values"
|
||||
},
|
||||
{
|
||||
"command": "git -C ../sm-grzybyfunkcjonalne-app rev-parse --is-inside-work-tree && git -C ../sm-grzybyfunkcjonalne-app remote get-url origin",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-03-PLAN.md",
|
||||
"task": "Task 2: Confirm the one-way proof-host submodule layout"
|
||||
},
|
||||
{
|
||||
"command": "go test ./cmd/summer -count=1 -run 'TestDocsTree' && go run ./cmd/summer docs:build --check && npm --prefix admin run typecheck && npm --prefix admin test -- --run admin/tests/form/registry.test.ts admin/tests/form/MLFields.test.ts && npm --prefix admin run build && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslate)$'",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": "prefix",
|
||||
"rawTarget": "admin",
|
||||
"target": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin",
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-03-PLAN.md",
|
||||
"task": "Task 3: Complete docs/OpenAPI/TS/dist and boot the user+translate proof host"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin test ./... -count=1 -v -run '^(TestTranslateEndToEnd)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslate)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-04-PLAN.md",
|
||||
"task": "Task 1: Prove migration → activation → resolver → Locales admin → ML save → WithLocale read end to end"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin test ./... -count=1 -race && go test ./modules/surf ./modules/cabana -count=1 -race && npm --prefix admin test -- --run admin/tests/form/registry.test.ts admin/tests/form/MLFields.test.ts admin/tests/form/MarkdownField.test.ts",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": "prefix",
|
||||
"rawTarget": "admin",
|
||||
"target": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin",
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-04-PLAN.md",
|
||||
"task": "Task 2: Complete migration, Translator, Translatable, admin, ML, markdown, and SPA test matrices"
|
||||
},
|
||||
{
|
||||
"command": "bash scripts/check-phase14.2.1.sh --all",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-04-PLAN.md",
|
||||
"task": "Task 3: Build the fail-closed phase gate, security review, and validation sign-off"
|
||||
},
|
||||
{
|
||||
"command": "test -f modules/cabana/field_ml.go && test -f ../sm-translate-plugin/classes/translatable.go && test -f ../sm-translate-plugin/classes/admin_writer.go",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-05-PLAN.md",
|
||||
"task": "Task 1: Confirm TranslationWriter.TranslatedExact as the hydration contract"
|
||||
},
|
||||
{
|
||||
"command": "go test ./modules/cabana -count=1 -v -run '^(TestMLHydration|TestMLNestedSave)$' && npm --prefix admin test -- --run admin/tests/form/MLFields.test.ts",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": "prefix",
|
||||
"rawTarget": "admin",
|
||||
"target": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin",
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-05-PLAN.md",
|
||||
"task": "Task 2: Hydrate one mltext create/GET/save path and show en+pl on the SPA"
|
||||
},
|
||||
{
|
||||
"command": "go test ./cmd/summer -count=1 -run 'TestDocsTree' && go run ./cmd/summer docs:build --check && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- --run admin/tests/form/MLFields.test.ts admin/tests/form/formState.test.ts && npm --prefix admin run build && go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./classes -count=1 -v -run '^(TestTranslatableGetSet)$'",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": "prefix",
|
||||
"rawTarget": "admin",
|
||||
"target": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin",
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-05-PLAN.md",
|
||||
"task": "Task 3: Cover mlmarkdown, relation-child adopt, and regenerate docs/OpenAPI/TS/dist"
|
||||
},
|
||||
{
|
||||
"command": "go test ./modules/cabana -count=1 -v -run '^(TestRelationChildMLNestedSave)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-06-PLAN.md",
|
||||
"task": "Task 1: Lift one nested mltext map through CreateChild"
|
||||
},
|
||||
{
|
||||
"command": "go test ./modules/cabana -count=1 -v -run '^(TestRelationChildMLNestedSave)$' && go test ./cmd/summer -count=1 -run 'TestDocsTree' && go run ./cmd/summer docs:build --check",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-06-PLAN.md",
|
||||
"task": "Task 2: Mirror lift/apply/hydrate on UpdateChild and ShowChild"
|
||||
},
|
||||
{
|
||||
"command": "go vet ./modules/cabana ./modules/surf ./cmd/summer && go test ./modules/cabana -count=1 -v -run '^(TestMLHydration|TestMLNestedSave|TestRelationChildMLNestedSave|TestMLFieldTypes)$' && npm --prefix admin test -- --run admin/tests/form/MLFields.test.ts && go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./... -count=1 -v -run '^(TestTranslateEndToEnd)$' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslate)$' && bash scripts/check-phase14.2.1.sh --all",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": "prefix",
|
||||
"rawTarget": "admin",
|
||||
"target": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin",
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "14.2.1-06-PLAN.md",
|
||||
"task": "Task 3: Named unit/Vitest/cabana tests and the phase gate (last plan of 14.2.1)"
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"blocker": 0,
|
||||
"warning": 0,
|
||||
"total": 18
|
||||
},
|
||||
"readError": null
|
||||
}
|
||||
155
.planning/tmp/14.2.1-verify-failure-directions.json
Normal file
155
.planning/tmp/14.2.1-verify-failure-directions.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"commands": [
|
||||
{
|
||||
"command": "git ls-remote git@git.golem15.com:golem15/sm-translate-plugin.git",
|
||||
"statement": "Non-zero exit, authentication denial, or repository-not-found text.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-01-PLAN.md",
|
||||
"task": "Task 1: Create the public Gitea remote and authorize local repository setup"
|
||||
},
|
||||
{
|
||||
"command": "test -f /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/models/Locale.php && git -C /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate rev-parse HEAD",
|
||||
"statement": "Non-zero exit or the printed SHA is not `725d547ec839f02b5fdc0f0a6faaed601a414d50`.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-01-PLAN.md",
|
||||
"task": "Task 2: Confirm the one-way table contract"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./... -short -count=1 && go vet ./modules/surf/... && go test ./modules/surf -short -count=1",
|
||||
"statement": "Non-zero exit, any package reports build failed, or either test command reports FAIL.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-01-PLAN.md",
|
||||
"task": "Task 3: Resolve one URL-prefixed request through the plugin, enabled Locale row, surf, and context"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin test ./classes -count=1 -v -run '^(TestFixtureTranslatableSaveRead)$'",
|
||||
"statement": "Non-zero exit, output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\", or lacks \"--- PASS: TestFixtureTranslatableSaveRead\".",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-02-PLAN.md",
|
||||
"task": "Task 1: Save one fixture title in en and pl, then read pl through the exported API"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./classes -count=1 -v -run '^(TestFixtureTranslatableSaveRead|TestFixtureTranslatedIndexSmoke)$'",
|
||||
"statement": "Non-zero exit, output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\", or either named PASS line is absent.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-02-PLAN.md",
|
||||
"task": "Task 2: Add indexed locale lookup, WithLocale query scope, and default fallback"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./... -short -count=1 -v -run '^(TestLocalesAdminSmoke|TestLocalesAdminForbiddenSmoke)$'",
|
||||
"statement": "Non-zero exit, output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\", or either named PASS line is absent.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-02-PLAN.md",
|
||||
"task": "Task 3: Manage Locales through permissioned YAML CRUD and separate phrasebook catalogs"
|
||||
},
|
||||
{
|
||||
"command": "go test ./modules/cabana -count=1 -v -run '^(TestMLNestedSaveSmoke)$' && npm --prefix admin test -- --run admin/tests/form/MLFields.test.ts",
|
||||
"statement": "Non-zero exit; Go output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\", lacks \"--- PASS: TestMLNestedSaveSmoke\", or Vitest reports no test files/tests or any failed test.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-03-PLAN.md",
|
||||
"task": "Task 1: Submit one mltext map through cabana and persist default plus Polish values"
|
||||
},
|
||||
{
|
||||
"command": "git -C ../sm-grzybyfunkcjonalne-app rev-parse --is-inside-work-tree && git -C ../sm-grzybyfunkcjonalne-app remote get-url origin",
|
||||
"statement": "Non-zero exit or origin is not the existing sm-grzybyfunkcjonalne-app remote.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-03-PLAN.md",
|
||||
"task": "Task 2: Confirm the one-way proof-host submodule layout"
|
||||
},
|
||||
{
|
||||
"command": "go test ./cmd/summer -count=1 -run 'TestDocsTree' && go run ./cmd/summer docs:build --check && npm --prefix admin run typecheck && npm --prefix admin test -- --run admin/tests/form/registry.test.ts admin/tests/form/MLFields.test.ts && npm --prefix admin run build && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslate)$'",
|
||||
"statement": "Non-zero exit; docs checker reports stale identifiers/links/forbidden names; Vitest reports no tests or failures; build omits index.html/assets; host output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\", or lacks \"--- PASS: TestBootUserTranslate\".",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-03-PLAN.md",
|
||||
"task": "Task 3: Complete docs/OpenAPI/TS/dist and boot the user+translate proof host"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin test ./... -count=1 -v -run '^(TestTranslateEndToEnd)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslate)$'",
|
||||
"statement": "Non-zero exit; either run prints \"--- FAIL\", \"--- SKIP\", \"no tests to run\", container startup failure treated as skip, or lacks its named \"--- PASS\" line.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-04-PLAN.md",
|
||||
"task": "Task 1: Prove migration → activation → resolver → Locales admin → ML save → WithLocale read end to end"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-translate-plugin test ./... -count=1 -race && go test ./modules/surf ./modules/cabana -count=1 -race && npm --prefix admin test -- --run admin/tests/form/registry.test.ts admin/tests/form/MLFields.test.ts admin/tests/form/MarkdownField.test.ts",
|
||||
"statement": "Non-zero exit; Go race detector reports a race; any package/test reports FAIL; integration tests unexpectedly SKIP in the plugin run; or Vitest reports no tests or failures.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-04-PLAN.md",
|
||||
"task": "Task 2: Complete migration, Translator, Translatable, admin, ML, markdown, and SPA test matrices"
|
||||
},
|
||||
{
|
||||
"command": "bash scripts/check-phase14.2.1.sh --all",
|
||||
"statement": "Non-zero exit; any stage is absent/skipped; output contains FAIL, a Go race, no-tests-to-run, unexpected SKIP, stale generated artifacts, forbidden deferred surface, unmitigated high threat, or lacks the final `Phase 14.2.1 gate passed` line.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-04-PLAN.md",
|
||||
"task": "Task 3: Build the fail-closed phase gate, security review, and validation sign-off"
|
||||
},
|
||||
{
|
||||
"command": "test -f modules/cabana/field_ml.go && test -f ../sm-translate-plugin/classes/translatable.go && test -f ../sm-translate-plugin/classes/admin_writer.go",
|
||||
"statement": "Non-zero exit, or any of the three paths is missing.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-05-PLAN.md",
|
||||
"task": "Task 1: Confirm TranslationWriter.TranslatedExact as the hydration contract"
|
||||
},
|
||||
{
|
||||
"command": "go test ./modules/cabana -count=1 -v -run '^(TestMLHydration|TestMLNestedSave)$' && npm --prefix admin test -- --run admin/tests/form/MLFields.test.ts",
|
||||
"statement": "Non-zero exit; Go output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\", lacks \"--- PASS: TestMLHydration\" or \"--- PASS: TestMLNestedSave\"; Vitest reports no test files/tests or any failed test.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-05-PLAN.md",
|
||||
"task": "Task 2: Hydrate one mltext create/GET/save path and show en+pl on the SPA"
|
||||
},
|
||||
{
|
||||
"command": "go test ./cmd/summer -count=1 -run 'TestDocsTree' && go run ./cmd/summer docs:build --check && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- --run admin/tests/form/MLFields.test.ts admin/tests/form/formState.test.ts && npm --prefix admin run build && go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./classes -count=1 -v -run '^(TestTranslatableGetSet)$'",
|
||||
"statement": "Non-zero exit; docs checker reports stale identifiers, broken links, or a consuming-application name; OpenAPI --check reports stale committed output; Vitest reports no tests or failures; build omits index.html/assets; plugin output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\", or lacks \"--- PASS: TestTranslatableGetSet\".",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-05-PLAN.md",
|
||||
"task": "Task 3: Cover mlmarkdown, relation-child adopt, and regenerate docs/OpenAPI/TS/dist"
|
||||
},
|
||||
{
|
||||
"command": "go test ./modules/cabana -count=1 -v -run '^(TestRelationChildMLNestedSave)$'",
|
||||
"statement": "Non-zero exit; output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\"; output lacks \"--- PASS: TestRelationChildMLNestedSave\".",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-06-PLAN.md",
|
||||
"task": "Task 1: Lift one nested mltext map through CreateChild"
|
||||
},
|
||||
{
|
||||
"command": "go test ./modules/cabana -count=1 -v -run '^(TestRelationChildMLNestedSave)$' && go test ./cmd/summer -count=1 -run 'TestDocsTree' && go run ./cmd/summer docs:build --check",
|
||||
"statement": "Non-zero exit; cabana output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\", or lacks \"--- PASS: TestRelationChildMLNestedSave\"; docs checker reports stale identifiers, broken links, or a consuming-application name.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-06-PLAN.md",
|
||||
"task": "Task 2: Mirror lift/apply/hydrate on UpdateChild and ShowChild"
|
||||
},
|
||||
{
|
||||
"command": "go vet ./modules/cabana ./modules/surf ./cmd/summer && go test ./modules/cabana -count=1 -v -run '^(TestMLHydration|TestMLNestedSave|TestRelationChildMLNestedSave|TestMLFieldTypes)$' && npm --prefix admin test -- --run admin/tests/form/MLFields.test.ts && go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./... -count=1 -v -run '^(TestTranslateEndToEnd)$' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslate)$' && bash scripts/check-phase14.2.1.sh --all",
|
||||
"statement": "Non-zero exit; any Go output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\"; output lacks \"--- PASS: TestMLHydration\", \"--- PASS: TestMLNestedSave\", \"--- PASS: TestRelationChildMLNestedSave\", \"--- PASS: TestMLFieldTypes\", \"--- PASS: TestTranslateEndToEnd\", or \"--- PASS: TestBootUserTranslate\"; Vitest reports no tests or failures; gate output lacks the exact line \"Phase 14.2.1 gate passed\" or reports a missing required test / unmapped high threat.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "14.2.1-06-PLAN.md",
|
||||
"task": "Task 3: Named unit/Vitest/cabana tests and the phase gate (last plan of 14.2.1)"
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"blocker": 0,
|
||||
"warning": 0,
|
||||
"total": 18
|
||||
},
|
||||
"readError": null
|
||||
}
|
||||
107
.planning/tmp/15-failing-directions.json
Normal file
107
.planning/tmp/15-failing-directions.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"commands": [
|
||||
{
|
||||
"command": "git ls-remote git@git.golem15.com:golem15/sm-journal-plugin.git && git -C /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal rev-parse HEAD && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestPostTranslatableSmoke)$' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'",
|
||||
"statement": "Non-zero exit; PHP SHA is not 02110eb1c0c3861370b0b9b47b209a0702ac5d88; output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\"; either named PASS line is absent; journal remote is missing.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-01-PLAN.md",
|
||||
"task": "Task 1: Clone the plugin, persist one en/pl Post title, and boot the host with three plugins"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./updates -count=1 -v -run '^(TestJournalTables)$'",
|
||||
"statement": "Non-zero exit; output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\"; lacks \"--- PASS: TestJournalTables\".",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-01-PLAN.md",
|
||||
"task": "Task 2: Add categories, tags, pivots, settings singleton, and author_slug"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1 && python3 -c 'import pathlib,sys; t=pathlib.Path(\"../sm-journal-plugin/README.md\").read_text(); bad=[\"grzybyfunkcjonalne\",\"Płytarium\",\"fonoteka.go\",\"plytarium\"];\nsys.exit(1 if any(b.lower() in t.lower() for b in bad) else 0)' && grep -F \"_journal/api/*\" ../sm-grzybyfunkcjonalne-app/config/http.yaml && test -f ../sm-journal-plugin/lang/en/lang.yaml && test -f ../sm-journal-plugin/lang/pl/lang.yaml",
|
||||
"statement": "Non-zero exit; plugin tests FAIL; README python check exits 1; grep does not print _journal/api/*; either lang YAML is missing.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-01-PLAN.md",
|
||||
"task": "Task 3: Add en/pl phrasebook, neutral README, and host CORS"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestPostsFormCompiles|TestPostsAdminCreateSmoke)$'",
|
||||
"statement": "Non-zero exit; output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\"; either named PASS line is absent.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-02-PLAN.md",
|
||||
"task": "Task 1: Create one Post through cabana with mlmarkdown and FormatHTML"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1 && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'",
|
||||
"statement": "Non-zero exit; output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\"; host run lacks \"--- PASS: TestBootUserTranslateJournal\".",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-02-PLAN.md",
|
||||
"task": "Task 2: Categories, Tags, Settings screens and remaining query guards"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalCommands)$'",
|
||||
"statement": "Non-zero exit; output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\"; lacks \"--- PASS: TestJournalCommands\".",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-02-PLAN.md",
|
||||
"task": "Task 3: Register journal:export-posts and journal:import-posts plus Posts toolbar"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalPublicList|TestJournalBuckets)$'",
|
||||
"statement": "Non-zero exit; output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\"; either named PASS line is absent.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-03-PLAN.md",
|
||||
"task": "Task 1: Serve anonymous GET /_journal/api/v1/posts as a published list"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalWriteUnauthenticated|TestJournal005DraftShow|TestJournalPublicCategories|TestJournalPublicTags|TestJournalFeaturedImageUnauthenticated)$'",
|
||||
"statement": "Non-zero exit; output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\"; any of the named PASS lines is absent (TestJournalWriteUnauthenticated, TestJournal005DraftShow, TestJournalPublicCategories, TestJournalPublicTags, TestJournalFeaturedImageUnauthenticated).",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-03-PLAN.md",
|
||||
"task": "Task 2: Slug show, draft 404, and backend-Bearer writes"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournal006MediaUpload|TestSearchGateOff|TestJournalRSS)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'",
|
||||
"statement": "Non-zero exit; output contains \"--- FAIL\", \"--- SKIP\", or \"no tests to run\"; any of the named PASS lines is absent (TestJournal006MediaUpload, TestSearchGateOff, TestJournalRSS, TestBootUserTranslateJournal).",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-03-PLAN.md",
|
||||
"task": "Task 3: Media upload, RSS, Typesense gate, and host route assertion"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalEndToEnd)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'",
|
||||
"statement": "Non-zero exit; either run prints \"--- FAIL\", \"--- SKIP\", \"no tests to run\", or container startup treated as skip; lacks its named PASS line.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-04-PLAN.md",
|
||||
"task": "Task 1: Prove migrate → admin save → anonymous list → Bearer write → draft 404 end to end"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin test ./... -count=1 -v -race && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -race",
|
||||
"statement": "Non-zero exit; Go race detector reports a race; any package reports FAIL; integration tests unexpectedly SKIP in the plugin run; output lacks PASS lines for TestJournalPublicCategories, TestJournalPublicTags, TestJournalRSS, and TestJournalFeaturedImageUnauthenticated.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-04-PLAN.md",
|
||||
"task": "Task 2: Complete PHPUnit map, migrations, YAML, FormatHTML, and search-gate tests"
|
||||
},
|
||||
{
|
||||
"command": "bash scripts/check-phase15.sh --all",
|
||||
"statement": "Non-zero exit; any stage absent or skipped; output contains FAIL, a Go race, no-tests-to-run, unexpected SKIP, unmitigated high threat, PHP tree dirty; lacks PASS evidence for TestJournalPublicCategories, TestJournalPublicTags, TestJournalRSS, or TestJournalFeaturedImageUnauthenticated; or lacks the final Phase 15 gate passed line.",
|
||||
"status": "ok",
|
||||
"severity": "none",
|
||||
"plan": "15-04-PLAN.md",
|
||||
"task": "Task 3: Phase gate, security review, and validation sign-off"
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"blocker": 0,
|
||||
"warning": 0,
|
||||
"total": 12
|
||||
},
|
||||
"readError": null
|
||||
}
|
||||
191
.planning/tmp/15-verify-paths.json
Normal file
191
.planning/tmp/15-verify-paths.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"status": "not_applicable",
|
||||
"commands": [
|
||||
{
|
||||
"command": "git ls-remote git@git.golem15.com:golem15/sm-journal-plugin.git && git -C /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal rev-parse HEAD && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestPostTranslatableSmoke)$' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-01-PLAN.md",
|
||||
"task": "Task 1: Clone the plugin, persist one en/pl Post title, and boot the host with three plugins"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./updates -count=1 -v -run '^(TestJournalTables)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-01-PLAN.md",
|
||||
"task": "Task 2: Add categories, tags, pivots, settings singleton, and author_slug"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1 && python3 -c 'import pathlib,sys; t=pathlib.Path(\"../sm-journal-plugin/README.md\").read_text(); bad=[\"grzybyfunkcjonalne\",\"Płytarium\",\"fonoteka.go\",\"plytarium\"];\nsys.exit(1 if any(b.lower() in t.lower() for b in bad) else 0)' && grep -F \"_journal/api/*\" ../sm-grzybyfunkcjonalne-app/config/http.yaml && test -f ../sm-journal-plugin/lang/en/lang.yaml && test -f ../sm-journal-plugin/lang/pl/lang.yaml",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-01-PLAN.md",
|
||||
"task": "Task 3: Add en/pl phrasebook, neutral README, and host CORS"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestPostsFormCompiles|TestPostsAdminCreateSmoke)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-02-PLAN.md",
|
||||
"task": "Task 1: Create one Post through cabana with mlmarkdown and FormatHTML"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1 && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-02-PLAN.md",
|
||||
"task": "Task 2: Categories, Tags, Settings screens and remaining query guards"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalCommands)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-02-PLAN.md",
|
||||
"task": "Task 3: Register journal:export-posts and journal:import-posts plus Posts toolbar"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalPublicList|TestJournalBuckets)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-03-PLAN.md",
|
||||
"task": "Task 1: Serve anonymous GET /_journal/api/v1/posts as a published list"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalWriteUnauthenticated|TestJournal005DraftShow|TestJournalPublicCategories|TestJournalPublicTags|TestJournalFeaturedImageUnauthenticated)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-03-PLAN.md",
|
||||
"task": "Task 2: Slug show, draft 404, and backend-Bearer writes"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournal006MediaUpload|TestSearchGateOff|TestJournalRSS)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-03-PLAN.md",
|
||||
"task": "Task 3: Media upload, RSS, Typesense gate, and host route assertion"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalEndToEnd)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-04-PLAN.md",
|
||||
"task": "Task 1: Prove migrate → admin save → anonymous list → Bearer write → draft 404 end to end"
|
||||
},
|
||||
{
|
||||
"command": "go -C ../sm-journal-plugin test ./... -count=1 -v -race && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -race",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-04-PLAN.md",
|
||||
"task": "Task 2: Complete PHPUnit map, migrations, YAML, FormatHTML, and search-gate tests"
|
||||
},
|
||||
{
|
||||
"command": "bash scripts/check-phase15.sh --all",
|
||||
"status": "not_applicable",
|
||||
"severity": "none",
|
||||
"reason": null,
|
||||
"form": null,
|
||||
"rawTarget": null,
|
||||
"target": null,
|
||||
"manifest": null,
|
||||
"script": null,
|
||||
"sentinel": false,
|
||||
"base": "/media/nvme/dev/golem15/summercms.io/summercms/summercms.go",
|
||||
"plan": "15-04-PLAN.md",
|
||||
"task": "Task 3: Phase gate, security review, and validation sign-off"
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"blocker": 0,
|
||||
"warning": 0,
|
||||
"total": 12
|
||||
},
|
||||
"readError": null
|
||||
}
|
||||
139
.planning/tmp/plan-pre-ai-integration.md
Normal file
139
.planning/tmp/plan-pre-ai-integration.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# API Coverage Decision Checkpoint
|
||||
|
||||
> Full API Coverage by Default — Opt Out, Never Opt In. Fires when a phase
|
||||
> integrates an external API / SDK / service. Most non-API phases will not fire
|
||||
> it — that is the point.
|
||||
|
||||
## Why this exists
|
||||
|
||||
"We integrated the API" too often silently means "we integrated whatever the
|
||||
first use case exercised." Every un-built capability is then an invisible hole,
|
||||
discovered later by a user who reasonably expected it to work. The phase sealed
|
||||
green because its tasks completed; nobody decided the gaps were acceptable,
|
||||
because nobody enumerated them. This checkpoint makes the surface **visible and
|
||||
decided** before the phase can seal.
|
||||
|
||||
## Detect whether this phase integrates an external API
|
||||
|
||||
The detector is a deterministic scan over the phase scope. It strips fenced
|
||||
code blocks first, so a trigger term inside a code snippet does not fire. It
|
||||
returns a typed result: `{ detected, signals[], terms }`. Run it on the phase
|
||||
scope (the concatenation of this phase's ROADMAP section + the PLAN body):
|
||||
|
||||
```bash
|
||||
SCOPE="$(cat "${PHASE_DIR}"/*-PLAN.md 2>/dev/null) $(gsd_run query roadmap.get-phase "${PHASE}" 2>/dev/null || true)"
|
||||
API_COVERAGE_JSON=$(printf '%s' "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs --json 2>/dev/null) || true
|
||||
[ -n "$API_COVERAGE_JSON" ] || API_COVERAGE_JSON='{"skipped":true,"reason":"probe_unavailable"}'
|
||||
```
|
||||
|
||||
The `|| true` neutralizes the assignment's status without discarding the
|
||||
detector's own payload: the detector exits **1** for a real "no integration"
|
||||
verdict, so treating any non-zero exit as failure would throw away a correct
|
||||
answer. Emptiness — not exit status — is what proves the probe never ran, and
|
||||
the second line is the only place the fragment manufactures a payload of its
|
||||
own — one that records the *absence* of a verdict rather than asserting one.
|
||||
|
||||
The detector's exit code and `--json` payload now distinguish a real negative
|
||||
from an unexamined input (ADR-3889 Phase 3, #3907): empty/whitespace-only
|
||||
`$SCOPE` or a stdin read failure emit `{"skipped":true,"reason":"no_input"|
|
||||
"stdin_error"}` — no `detected` key at all. **Check for `skipped` before
|
||||
reading `detected`**: a `skipped` payload is not a confirmed "no API
|
||||
integration" verdict, it means the detector never examined real input. Do not
|
||||
treat it as `detected:false`. Read `API_COVERAGE_JSON.detected` only when
|
||||
`skipped` is absent — act on it only, do **not** pattern-match the prose
|
||||
yourself.
|
||||
|
||||
**If `skipped` is `true`:** the detector could not establish a scope (empty
|
||||
`$SCOPE`) or failed to run (stdin read error). Skip the checkpoint for this
|
||||
run rather than asserting a verdict about input that was never examined; do
|
||||
not raise it with the user.
|
||||
|
||||
**If `detected` is `false`:** this phase does not integrate an external API. Skip
|
||||
the checkpoint entirely and continue planning. Do not raise it with the user.
|
||||
|
||||
**If `detected` is `true`:** an external-API integration is in scope. You MUST
|
||||
produce a **coverage matrix** before the plan is finalized.
|
||||
|
||||
**If `detected` is `true` but the phase genuinely integrates no external API**
|
||||
(the detector is deterministic, not infallible — confirm by re-reading the phase
|
||||
scope, not by preference): do NOT fabricate a matrix row for a capability that
|
||||
does not exist. Write a reasoned declaration to `${PHASE_DIR}/COVERAGE.md`
|
||||
instead:
|
||||
|
||||
```markdown
|
||||
No external API integration: <one-line reason — what the phase touches instead>.
|
||||
```
|
||||
|
||||
The reason is required, exactly like an `OPT-OUT` reason. The seal-time gate
|
||||
accepts this declaration in place of a matrix.
|
||||
|
||||
## Produce the coverage matrix
|
||||
|
||||
Enumerate the external API's full **capability surface** — the verb/endpoint/method
|
||||
list (e.g. for a music service: `search`, `play`, `pause`, `skip`, `set_volume`,
|
||||
`get_playlist`, `create_playlist`, `add_to_playlist`, …). For each capability
|
||||
record a decision, starting from **full coverage** as the default:
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| `<capability-id>` | `INTEGRATE` \| `OPT-OUT` | `<one-line reason if OPT-OUT>` |
|
||||
|
||||
Rules:
|
||||
|
||||
- **`INTEGRATE` is the default.** Every capability starts as INTEGRATE; the
|
||||
matrix is the *subtraction record*.
|
||||
- **Every `OPT-OUT` MUST carry a one-line reason** (`not needed`, `not needed
|
||||
yet`, `explicitly out of scope`, …). An opt-out without a reason is an
|
||||
un-decided hole — the exact failure mode this gate exists to close.
|
||||
- **A second integration against the same need** (e.g. a second platform for the
|
||||
same capability) starts from the **same full-coverage baseline** as the first.
|
||||
Do not carry over the first integration's opt-outs silently — re-decide each
|
||||
capability for the new surface, so a first-class/fallback asymmetry cannot
|
||||
accumulate.
|
||||
|
||||
Write the matrix to `${PHASE_DIR}/COVERAGE.md` (canonical markdown-table form):
|
||||
|
||||
```markdown
|
||||
# API Coverage — <service>
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| search | INTEGRATE | |
|
||||
| playlists | INTEGRATE | |
|
||||
| skip | OPT-OUT | not needed yet — tracked for follow-up phase |
|
||||
```
|
||||
|
||||
A fenced ` ```coverage ` JSON block is also accepted for machine-generated
|
||||
matrices; the markdown table is preferred (human-editable, diff-friendly).
|
||||
|
||||
## The seal-time gate
|
||||
|
||||
This checkpoint is enforced. At `verify:pre` the `api-coverage.verify-pre` gate
|
||||
runs `check api-coverage.verify-pre <phase-dir>`:
|
||||
|
||||
- If `COVERAGE.md` exists, it is validated — every row needs a valid decision and
|
||||
every `OPT-OUT` a reason. A malformed/partial matrix **blocks the seal**. A
|
||||
reasoned `No external API integration: …` declaration (and no rows) passes.
|
||||
- If `COVERAGE.md` is absent, the detector runs again over the phase scope. If a
|
||||
strong external-API-integration signal is found, the seal is **blocked** until a
|
||||
matrix is produced. If no signal is found, the phase is treated as a non-API
|
||||
phase and the seal proceeds.
|
||||
|
||||
So: an API-integrating phase cannot seal without a decided matrix. Produce it at
|
||||
plan time; do not leave it for seal time.
|
||||
|
||||
## Tuning the vocabulary (optional)
|
||||
|
||||
The trigger vocabulary is a curated, additive-only set in
|
||||
`gsd-core/bin/lib/api-coverage.cjs` (`DEFAULT_API_COVERAGE_TERMS`). To widen it
|
||||
for a project, override at the call site:
|
||||
|
||||
```bash
|
||||
printf '%s' "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs --json \
|
||||
--verbs integrate,wrap,connect,embed --nouns api,sdk,rest,grpc,webhook,plugin
|
||||
```
|
||||
|
||||
The whole checkpoint is toggleable via `workflow.api_coverage_gate` in
|
||||
`.planning/config.json`.
|
||||
56
.planning/tmp/plan-pre-assumption-delta.md
Normal file
56
.planning/tmp/plan-pre-assumption-delta.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Assumption-Delta Architecture Checkpoint
|
||||
|
||||
> Advisory, non-blocking. Fires **only** when the phase scope shows a singular→plural / required→optional / derived→chosen transition. When it fires, it surfaces ONE identity-model question before the plan is finalized. Most phases will not fire it — that is the point.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Most quietly-imported architectural debt does not come from a missing upfront design phase. It comes at the *seam*: a later phase introduces a second case (a second platform, auth method, tenant, region, source of truth) and nobody re-asks whether the original abstraction still names the right thing. The phase that adds the second case is exactly the 20-minute conversation that prevents an afternoon of later cleanup.
|
||||
|
||||
## Run the detector
|
||||
|
||||
The detector is a deterministic scan over the phase scope text. It strips fenced code blocks first, so a trigger word that appears only inside a code snippet does not fire. It returns a typed result: `{ detected, signals[], terms }`. Resolve it through the `assumption-delta scan` query (same phase-section resolver as `roadmap.get-phase`):
|
||||
|
||||
```bash
|
||||
ASSUMPTION_DELTA_JSON=$(gsd_run query assumption-delta scan "${PHASE}" --json 2>/dev/null) || true
|
||||
[ -n "$ASSUMPTION_DELTA_JSON" ] || ASSUMPTION_DELTA_JSON='{"skipped":true,"reason":"probe_unavailable"}'
|
||||
```
|
||||
|
||||
> If the phase section cannot be resolved (no `ROADMAP.md` / unknown phase, or a section with no body), the query emits `{ "skipped": true, "reason": "phase_unresolved" }` — **not** `detected:false`. A probe that never had input does not get to assert that this phase changes no core assumption. The checkpoint does not fire either way; the difference is that a skip is now distinguishable from a real negative. Do not block on it.
|
||||
>
|
||||
> Optional tuning — pass `--terms <comma-list>` to replace the curated pluralization cues for this project (the `optional`/`chosen` cues keep their defaults): `gsd_run query assumption-delta scan "${PHASE}" --json --terms second,alternative,fallback`.
|
||||
|
||||
## Decision branch
|
||||
|
||||
Read `ASSUMPTION_DELTA_JSON`. Act on `detected` only — do **not** pattern-match the human prose.
|
||||
|
||||
**If `skipped` is `true`:** the detector never examined a phase section — it could not resolve one (`phase_unresolved`) or could not run at all (`probe_unavailable`). Skip the checkpoint for this run rather than asserting a verdict about input that was never examined; do not raise it with the user. **Check for `skipped` before reading `detected`** — a skipped payload carries no `detected` key, and treating its absence as `false` re-creates the fabrication this branch exists to prevent.
|
||||
|
||||
**If `detected` is `false`:** this phase does not change a core assumption. Skip the checkpoint entirely and continue planning. Do not raise it with the user.
|
||||
|
||||
**If `detected` is `true`:** a core assumption may have lost its monopoly. The `signals[]` array tells you which family fired:
|
||||
|
||||
| `kind` | What changed | The question to answer |
|
||||
|---|---|---|
|
||||
| `pluralization` | A second X was introduced where there was one (second platform / auth method / tenant / region / source of truth) | Does the current primary key / identity model still name the right noun? |
|
||||
| `optional` | A required / `only` field became optional | Is the field still the right anchor, or has the anchor moved? |
|
||||
| `chosen` | A derived value became chosen, or a constant became a parameter | Has a configuration decision become a modeling decision? |
|
||||
|
||||
Before finalizing the plan, answer this for the user and record the decision explicitly:
|
||||
|
||||
> **Promote vs. add-alongside.** The usual correct move when a generalization occurs is to **promote** the new general representation to the primary and **demote** the old specific one to a detail of one variant — *not* to add the new one alongside the still-required old one. Adding alongside silently contradicts the generalized intent (a later variant that does not fit the old primary can be stored but never confirmed as a default).
|
||||
|
||||
Record the outcome in the PLAN.md front matter / a `<assumption_delta_decision>` block:
|
||||
|
||||
- The **noun** that is now primary (the generalized identity).
|
||||
- The **decision**: `promote` | `add-alongside` | `no-change`, with a one-line rationale.
|
||||
- If `add-alongside`: call it out as accepted debt and note what would force a later promote.
|
||||
|
||||
## Optional companion: an invariant test
|
||||
|
||||
When `detected` is `true`, suggest (do not require) a contract/invariant test that encodes the now-generalized intent — e.g. *"every confirmed default round-trips through the primary use-path, for every supported variant."* That test goes red the instant a future phase reintroduces the singular assumption, so the regression cannot land silently. If the user accepts, add the test as a task in the plan.
|
||||
|
||||
## Tuning the vocabulary (optional)
|
||||
|
||||
The trigger vocabulary is a curated, additive-only set in `gsd-core/bin/lib/assumption-delta.cjs` (`DEFAULT_ASSUMPTION_DELTA_TERMS`). Bare "or" is intentionally excluded — it is too common in prose and would make the gate fire constantly. To widen or narrow the cues for a project, override at the call site with `--terms <comma-list>` (replaces the pluralization cues; `optional`/`chosen` keep defaults). The whole checkpoint is toggleable via `workflow.assumption_delta` in `.planning/config.json`.
|
||||
|
||||
This checkpoint is advisory: it informs and records; it never blocks the phase.
|
||||
61
.planning/tmp/plan-pre-schema-gate.md
Normal file
61
.planning/tmp/plan-pre-schema-gate.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Schema Push Detection Gate
|
||||
|
||||
> Detects schema-relevant files in the phase scope and injects a mandatory `[BLOCKING]` schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.
|
||||
|
||||
Check if any files in the phase scope match schema patterns:
|
||||
|
||||
```bash
|
||||
PHASE_SECTION=$(gsd_run query roadmap.get-phase "${PHASE}" --pick section 2>/dev/null)
|
||||
```
|
||||
|
||||
Scan `PHASE_SECTION`, `CONTEXT.md` (if loaded), and `RESEARCH.md` (if exists) for file paths matching these ORM patterns:
|
||||
|
||||
| ORM | File Patterns |
|
||||
|-----|--------------|
|
||||
| Payload CMS | `src/collections/**/*.ts`, `src/globals/**/*.ts` |
|
||||
| Prisma | `prisma/schema.prisma`, `prisma/schema/*.prisma` |
|
||||
| Drizzle | `drizzle/schema.ts`, `src/db/schema.ts`, `drizzle/*.ts` |
|
||||
| Supabase | `supabase/migrations/*.sql` |
|
||||
| TypeORM | `src/entities/**/*.ts`, `src/migrations/**/*.ts` |
|
||||
|
||||
Also check if any existing PLAN.md files for this phase already reference these file patterns in `files_modified`.
|
||||
|
||||
**If schema-relevant files detected:**
|
||||
|
||||
Set `SCHEMA_PUSH_REQUIRED=true` and `SCHEMA_ORM={detected_orm}`.
|
||||
|
||||
Determine the push command for the detected ORM:
|
||||
|
||||
| ORM | Push Command | Non-TTY Workaround |
|
||||
|-----|-------------|-------------------|
|
||||
| Payload CMS | `npx payload migrate` | `CI=true PAYLOAD_MIGRATING=true npx payload migrate` |
|
||||
| Prisma | `npx prisma db push` | `npx prisma db push --accept-data-loss` (if destructive) |
|
||||
| Drizzle | `npx drizzle-kit push` | `npx drizzle-kit push` |
|
||||
| Supabase | `supabase db push` | Set `SUPABASE_ACCESS_TOKEN` env var |
|
||||
| TypeORM | `npx typeorm migration:run` | `npx typeorm migration:run -d src/data-source.ts` |
|
||||
|
||||
Inject the following into the planner prompt (step 8) as an additional constraint:
|
||||
|
||||
```markdown
|
||||
<schema_push_requirement>
|
||||
**[BLOCKING] Schema Push Required**
|
||||
|
||||
This phase modifies schema-relevant files ({detected_files}). The planner MUST include
|
||||
a `[BLOCKING]` task that runs the database schema push command AFTER all schema file
|
||||
modifications are complete but BEFORE verification.
|
||||
|
||||
- ORM detected: {SCHEMA_ORM}
|
||||
- Push command: {push_command}
|
||||
- Non-TTY workaround: {env_hint}
|
||||
- If push requires interactive prompts that cannot be suppressed, flag the task for
|
||||
manual intervention with `autonomous: false`
|
||||
|
||||
This task is mandatory — the phase CANNOT pass verification without it. Build and
|
||||
type checks will pass without the push (types come from config, not the live database),
|
||||
creating a false-positive verification state.
|
||||
</schema_push_requirement>
|
||||
```
|
||||
|
||||
Display: `Schema files detected ({SCHEMA_ORM}) — [BLOCKING] push task will be injected into plans`
|
||||
|
||||
**If no schema-relevant files detected:** Skip silently.
|
||||
1
.planning/tmp/plan-pre-security.md
Normal file
1
.planning/tmp/plan-pre-security.md
Normal file
@@ -0,0 +1 @@
|
||||
Each PLAN.md must include a <threat_model> block when security enforcement is active. Use the configured ASVS level and blocking threshold from workflow.security_asvs_level and workflow.security_block_on.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
created: 2026-10-07T16:31:50.525Z
|
||||
title: Translate Messages as a Nuxt i18n JSON editor
|
||||
area: sm-translate-plugin
|
||||
severity: minor
|
||||
files:
|
||||
- ../sm-translate-plugin/admin_settings.go
|
||||
- modules/pact/capabilities.go (SettingsItem.Controller)
|
||||
- ../examples/golem15-wintercms-starter/plugins/golem15/translate/Plugin.php (registerSettings: messages)
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
WinterCMS translate registers two settings entries: Locales and Messages. Messages
|
||||
manages CMS theme Twig strings such as `{{ 'x.y.z'|_ }}`, stored in a messages table.
|
||||
SummerCMS apps are headless and have no server-rendered theme strings, so the Go port
|
||||
(quick 261007-p4y) registers only Locales. The frontend strings live in the Nuxt app's
|
||||
i18n locale JSON files, which admins currently cannot edit.
|
||||
|
||||
## Solution
|
||||
|
||||
Integrate Nuxt i18n with the translate plugin as an i18n JSON editor, not a
|
||||
WinterCMS-style messages table:
|
||||
- admin screen edits per-locale message keys (nested JSON, one column per enabled
|
||||
Locale), with missing-key highlighting against the default locale;
|
||||
- the backend serves the merged messages to the Nuxt app (e.g. a lazy-load endpoint
|
||||
per locale that @nuxtjs/i18n can fetch), with the bundled JSON as fallback;
|
||||
- register as a second controller-link settings entry (`pact.SettingsItem.Controller`)
|
||||
under the Translate category, next to Locales.
|
||||
Open questions: storage (DB rows vs. versioned JSON), how keys are discovered/synced
|
||||
from the Nuxt repo, caching and invalidation on edit.
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1875,6 +1875,10 @@
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"controller": {
|
||||
"description": "Controller is the admin controller a link entry opens; empty for a\nsingleton settings form.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1900,6 +1904,7 @@
|
||||
"required": [
|
||||
"category",
|
||||
"code",
|
||||
"controller",
|
||||
"description",
|
||||
"icon",
|
||||
"keywords",
|
||||
|
||||
BIN
admin/public/favicon.ico
Normal file
BIN
admin/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, watchEffect } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { documentTitleForRoute } from './app/pageTitle'
|
||||
import AppShell from './components/shell/AppShell.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const inShell = computed(() => route.meta.shell === true)
|
||||
|
||||
watchEffect(() => {
|
||||
document.title = documentTitleForRoute(route)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
5
admin/src/api/schema.d.ts
vendored
5
admin/src/api/schema.d.ts
vendored
@@ -4886,6 +4886,11 @@ export interface components {
|
||||
"cabana.SettingsEntry": {
|
||||
category: string;
|
||||
code: string;
|
||||
/**
|
||||
* @description Controller is the admin controller a link entry opens; empty for a
|
||||
* singleton settings form.
|
||||
*/
|
||||
controller: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
keywords: string[];
|
||||
|
||||
@@ -85,7 +85,6 @@ import {
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Square,
|
||||
Star,
|
||||
Sun,
|
||||
Table,
|
||||
@@ -300,8 +299,8 @@ export const winterIcons: Readonly<Record<string, string>> = {
|
||||
'icon-wrench': 'wrench',
|
||||
}
|
||||
|
||||
/** Neutral icon for names neither lucide map knows. */
|
||||
export const fallbackIcon: Component = Square
|
||||
/** Brand icon for names neither lucide map knows. */
|
||||
export const fallbackIcon: Component = Sun
|
||||
|
||||
/** An own entry of a map: names come from the server, so inherited object
|
||||
* members such as "constructor" or "toString" never count as icons. */
|
||||
|
||||
42
admin/src/app/pageTitle.ts
Normal file
42
admin/src/app/pageTitle.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { controllerIdFromPath } from './controllerRoutes'
|
||||
import { t } from './i18n'
|
||||
import { recordCrumb } from '../state/useBreadcrumbs'
|
||||
import { activeEntry } from '../state/useNavigation'
|
||||
import { settingsEntry, settingsLinkFor } from '../state/useSettings'
|
||||
|
||||
const PRODUCT_TITLE = 'SummerCMS'
|
||||
|
||||
/** The visible page indicator used before the SummerCMS product name. */
|
||||
export function pageIndicator(route: RouteLocationNormalizedLoaded): string {
|
||||
if (route.name === 'login') {
|
||||
return t('backend::lang.auth.title')
|
||||
}
|
||||
if (route.name === 'settings') {
|
||||
return t('backend::lang.settings.title')
|
||||
}
|
||||
if (route.name === 'settings-form') {
|
||||
return settingsEntry(String(route.params.code ?? ''))?.label ?? String(route.params.code ?? '')
|
||||
}
|
||||
if (route.name === 'home') {
|
||||
return t('backend::lang.nav.empty')
|
||||
}
|
||||
if (route.name === 'not-found') {
|
||||
return t('backend::lang.page.not_found')
|
||||
}
|
||||
|
||||
const plugin = activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))
|
||||
const controller = controllerIdFromPath(route.path)
|
||||
const section = plugin?.sideMenu.find((item) => item.controller === controller)
|
||||
const controllerLabel = section?.label ?? plugin?.label ?? settingsLinkFor(controller)?.label ?? ''
|
||||
if (route.name === 'create' || route.name === 'record' || route.name === 'preview') {
|
||||
return recordCrumb.value || controllerLabel
|
||||
}
|
||||
return controllerLabel
|
||||
}
|
||||
|
||||
/** Browser title matching WinterCMS's "Current page | Product" pattern. */
|
||||
export function documentTitleForRoute(route: RouteLocationNormalizedLoaded): string {
|
||||
const indicator = pageIndicator(route).trim()
|
||||
return indicator === '' ? PRODUCT_TITLE : `${indicator} | ${PRODUCT_TITLE}`
|
||||
}
|
||||
@@ -34,12 +34,12 @@ function toggle(): void {
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="on ? 'bg-primary' : 'bg-border-strong'"
|
||||
class="relative h-[26px] w-11 shrink-0 rounded-pill transition-colors duration-150 ease-out"
|
||||
class="relative h-[26px] w-[44px] shrink-0 rounded-pill transition-colors duration-150 ease-out"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
:class="on ? 'translate-x-[18px] bg-on-primary' : 'translate-x-0 bg-white'"
|
||||
class="absolute top-[3px] left-[3px] size-5 rounded-full shadow-tab transition-transform duration-150 ease-out"
|
||||
class="absolute top-[3px] left-[3px] size-[20px] rounded-full shadow-tab transition-transform duration-150 ease-out"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -6,12 +6,14 @@ import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes
|
||||
import { t } from '../../app/i18n'
|
||||
import { recordCrumb } from '../../state/useBreadcrumbs'
|
||||
import { activeEntry, firstControllerPath } from '../../state/useNavigation'
|
||||
import { settingsEntry } from '../../state/useSettings'
|
||||
import { settingsEntry, settingsLinkFor, settingsPath } from '../../state/useSettings'
|
||||
|
||||
// Header breadcrumbs (design: Top header): plugin, controller and record
|
||||
// labels, e.g. "Demo › Widgets › Blue widget". Parent crumbs are muted
|
||||
// links; the current crumb uses the text colour at 600 with
|
||||
// aria-current="page". Separators are 14px chevron-right icons.
|
||||
// aria-current="page". Separators are 14px chevron-right icons. A controller
|
||||
// opened from a settings link entry, with no navigation entry of its own,
|
||||
// reads "Settings › <entry label>".
|
||||
interface Crumb {
|
||||
label: string
|
||||
to?: string
|
||||
@@ -29,11 +31,22 @@ const crumbs = computed<Crumb[]>(() => {
|
||||
return out
|
||||
}
|
||||
const plugin = activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))
|
||||
const id = controllerIdFromPath(route.path)
|
||||
if (!plugin) {
|
||||
return []
|
||||
const link = settingsLinkFor(id)
|
||||
if (!link) {
|
||||
return []
|
||||
}
|
||||
const out: Crumb[] = [
|
||||
{ label: t('backend::lang.nav.settings'), to: '/settings' },
|
||||
{ label: link.label, to: settingsPath(link) },
|
||||
]
|
||||
if ((route.name === 'record' || route.name === 'create') && recordCrumb.value !== '') {
|
||||
out.push({ label: recordCrumb.value })
|
||||
}
|
||||
return out
|
||||
}
|
||||
const out: Crumb[] = [{ label: plugin.label, to: firstControllerPath(plugin) ?? undefined }]
|
||||
const id = controllerIdFromPath(route.path)
|
||||
const section = plugin.sideMenu.find((item) => item.controller === id) ?? null
|
||||
if (section) {
|
||||
out.push({ label: section.label, to: controllerPath(section.controller) ?? undefined })
|
||||
|
||||
@@ -3,9 +3,10 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { PanelLeftOpen, Settings, Sun } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { controllerIdFromPath } from '../../app/controllerRoutes'
|
||||
import { iconFor } from '../../app/icons'
|
||||
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
|
||||
import { hasSettings } from '../../state/useSettings'
|
||||
import { hasSettings, settingsLinkFor } from '../../state/useSettings'
|
||||
import { useSidebar } from '../../state/useSidebar'
|
||||
import SectionFlyout from './SectionFlyout.vue'
|
||||
|
||||
@@ -25,8 +26,15 @@ const { collapsed, narrow, setCollapsed } = useSidebar()
|
||||
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
|
||||
|
||||
// Ustawienia (D-21) is pinned to the bottom and shown only when the admin may
|
||||
// manage at least one settings page.
|
||||
const inSettings = computed(() => route.path === '/settings' || route.path.startsWith('/settings/'))
|
||||
// manage at least one settings page. It is also current on a controller a
|
||||
// settings entry links to, unless that controller has its own navigation
|
||||
// entry (then its plugin stays highlighted).
|
||||
const inSettings = computed(
|
||||
() =>
|
||||
route.path === '/settings' ||
|
||||
route.path.startsWith('/settings/') ||
|
||||
(active.value === null && settingsLinkFor(controllerIdFromPath(route.path)) !== null),
|
||||
)
|
||||
|
||||
const items = computed(() =>
|
||||
railEntries.value.map((entry) => ({
|
||||
@@ -158,7 +166,7 @@ onBeforeUnmount(cancelClose)
|
||||
:aria-expanded="collapsed ? (openCode === item.entry.code ? 'true' : 'false') : undefined"
|
||||
:aria-controls="collapsed && openCode === item.entry.code ? flyoutId(item.entry.code) : undefined"
|
||||
:class="item.current ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'"
|
||||
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline transition-colors duration-150 ease-out"
|
||||
class="flex w-[92px] flex-col items-center gap-1 rounded-inner px-1 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline transition-colors duration-150 ease-out"
|
||||
@mouseenter="openFlyout(item.entry.code)"
|
||||
@mouseleave="scheduleClose"
|
||||
@focus="onItemFocus(item.entry.code)"
|
||||
@@ -166,7 +174,7 @@ onBeforeUnmount(cancelClose)
|
||||
@keydown="onItemKeydown($event, item.entry.code)"
|
||||
>
|
||||
<component :is="item.icon" :size="20" aria-hidden="true" />
|
||||
<span>{{ item.entry.label }}</span>
|
||||
<span class="max-w-full [overflow-wrap:anywhere]">{{ item.entry.label }}</span>
|
||||
</RouterLink>
|
||||
<SectionFlyout
|
||||
v-if="collapsed && openEntry"
|
||||
@@ -199,10 +207,10 @@ onBeforeUnmount(cancelClose)
|
||||
data-rail-settings
|
||||
:aria-current="inSettings ? 'page' : undefined"
|
||||
:class="inSettings ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'"
|
||||
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline transition-colors duration-150 ease-out"
|
||||
class="flex w-[92px] flex-col items-center gap-1 rounded-inner px-1 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline transition-colors duration-150 ease-out"
|
||||
>
|
||||
<Settings :size="20" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.nav.settings') }}</span>
|
||||
<span class="max-w-full [overflow-wrap:anywhere]">{{ t('backend::lang.nav.settings') }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Settings pages the admin may manage (D-21), server-filtered like the
|
||||
// navigation. The rail shows "Ustawienia" only when this list is non-empty.
|
||||
// An entry is either a singleton settings form (/settings/<code>) or a link
|
||||
// that opens an admin controller (its `controller` is set).
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import { controllerPath } from '../app/controllerRoutes'
|
||||
import type { SettingsEntry } from '../api/types'
|
||||
|
||||
const entries = ref<SettingsEntry[]>([])
|
||||
@@ -24,6 +27,25 @@ export function settingsEntry(code: string): SettingsEntry | null {
|
||||
return entries.value.find((entry) => entry.code === code) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a settings entry opens: its controller for a link entry (the index
|
||||
* when the ID is malformed), otherwise its singleton form.
|
||||
*/
|
||||
export function settingsPath(entry: SettingsEntry): string {
|
||||
if (entry.controller) {
|
||||
return controllerPath(entry.controller) ?? '/settings'
|
||||
}
|
||||
return `/settings/${encodeURIComponent(entry.code)}`
|
||||
}
|
||||
|
||||
/** The link entry that opens the given controller, if any. */
|
||||
export function settingsLinkFor(controllerId: string | null): SettingsEntry | null {
|
||||
if (!controllerId) {
|
||||
return null
|
||||
}
|
||||
return entries.value.find((entry) => entry.controller !== '' && entry.controller === controllerId) ?? null
|
||||
}
|
||||
|
||||
/** Entries grouped by category, categories in first-seen order, by order. */
|
||||
export const settingsGroups = computed(() => {
|
||||
const sorted = entries.value
|
||||
@@ -43,5 +65,5 @@ export const settingsGroups = computed(() => {
|
||||
})
|
||||
|
||||
export function useSettings() {
|
||||
return { settings, hasSettings, settingsGroups, loadSettings, settingsEntry }
|
||||
return { settings, hasSettings, settingsGroups, loadSettings, settingsEntry, settingsPath, settingsLinkFor }
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
--spacing-row: 54px;
|
||||
--spacing-row-head: 44px;
|
||||
--spacing-pager: 34px;
|
||||
--spacing-rail: 80px;
|
||||
--spacing-rail: 96px;
|
||||
--spacing-panel: 224px;
|
||||
|
||||
--shadow-card: var(--c-shadow-card);
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { t } from '../app/i18n'
|
||||
import { iconFor } from '../app/icons'
|
||||
import { settingsGroups } from '../state/useSettings'
|
||||
import { settingsGroups, settingsPath } from '../state/useSettings'
|
||||
|
||||
// Settings index (D-21): every permitted settings page, grouped by category,
|
||||
// with its icon, label and description. The server filters the list.
|
||||
// with its icon, label and description. The server filters the list. A
|
||||
// singleton entry opens its settings form; a link entry opens its controller.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,7 +28,7 @@ import { settingsGroups } from '../state/useSettings'
|
||||
<RouterLink
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.code"
|
||||
:to="`/settings/${encodeURIComponent(entry.code)}`"
|
||||
:to="settingsPath(entry)"
|
||||
:data-settings="entry.code"
|
||||
class="flex items-start gap-3.5 rounded-card border border-border bg-surface p-5 text-text no-underline shadow-card hover:bg-hover"
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
Network,
|
||||
Pencil,
|
||||
Settings,
|
||||
Square,
|
||||
Sun,
|
||||
Tags,
|
||||
WandSparkles,
|
||||
} from '@lucide/vue'
|
||||
@@ -59,16 +59,16 @@ describe('iconFor', () => {
|
||||
expect(iconFor('icon-edit')).toBe(Pencil)
|
||||
})
|
||||
|
||||
it('falls back to the neutral square for unknown, empty or missing names', () => {
|
||||
expect(fallbackIcon).toBe(Square)
|
||||
expect(iconFor('unknown-icon-name')).toBe(Square)
|
||||
expect(iconFor('icon-unknown')).toBe(Square)
|
||||
expect(iconFor('')).toBe(Square)
|
||||
expect(iconFor(null)).toBe(Square)
|
||||
expect(iconFor(undefined)).toBe(Square)
|
||||
expect(iconFor('constructor')).toBe(Square)
|
||||
expect(iconFor('toString')).toBe(Square)
|
||||
expect(iconFor('__proto__')).toBe(Square)
|
||||
it('falls back to the sun for unknown, empty or missing names', () => {
|
||||
expect(fallbackIcon).toBe(Sun)
|
||||
expect(iconFor('unknown-icon-name')).toBe(Sun)
|
||||
expect(iconFor('icon-unknown')).toBe(Sun)
|
||||
expect(iconFor('')).toBe(Sun)
|
||||
expect(iconFor(null)).toBe(Sun)
|
||||
expect(iconFor(undefined)).toBe(Sun)
|
||||
expect(iconFor('constructor')).toBe(Sun)
|
||||
expect(iconFor('toString')).toBe(Sun)
|
||||
expect(iconFor('__proto__')).toBe(Sun)
|
||||
})
|
||||
|
||||
it('maps every Winter alias onto a registered lucide icon', () => {
|
||||
|
||||
53
admin/tests/app/pageTitle.test.ts
Normal file
53
admin/tests/app/pageTitle.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { documentTitleForRoute } from '../../src/app/pageTitle'
|
||||
import { setRecordCrumb } from '../../src/state/useBreadcrumbs'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
beforeEach(resetState)
|
||||
|
||||
describe('documentTitleForRoute', () => {
|
||||
it('uses the active controller label on list pages', async () => {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
const router = await routerAt('/acme/demo/widgets')
|
||||
expect(documentTitleForRoute(router.currentRoute.value)).toBe('Widgets | SummerCMS')
|
||||
})
|
||||
|
||||
it('uses the loaded record name on create, update and preview pages', async () => {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
setRecordCrumb('Blue widget')
|
||||
for (const path of ['/acme/demo/widgets/create', '/acme/demo/widgets/12', '/acme/demo/widgets/12/preview']) {
|
||||
const router = await routerAt(path)
|
||||
expect(documentTitleForRoute(router.currentRoute.value), path).toBe('Blue widget | SummerCMS')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the settings page label', async () => {
|
||||
await signIn()
|
||||
setSettings(settingsFixture.list.data)
|
||||
expect(documentTitleForRoute((await routerAt('/settings')).currentRoute.value)).toBe('Ustawienia | SummerCMS')
|
||||
expect(documentTitleForRoute((await routerAt('/settings/mail')).currentRoute.value)).toBe('Mail | SummerCMS')
|
||||
})
|
||||
|
||||
it('uses the settings entry label on a settings-linked controller', async () => {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
const locales = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.lang.locales' }
|
||||
setSettings([...settingsFixture.list.data, locales])
|
||||
expect(documentTitleForRoute((await routerAt('/acme/lang/locales')).currentRoute.value)).toBe('Locales | SummerCMS')
|
||||
expect(documentTitleForRoute((await routerAt('/acme/lang/locales/3')).currentRoute.value)).toBe('Locales | SummerCMS')
|
||||
setRecordCrumb('Polish')
|
||||
expect(documentTitleForRoute((await routerAt('/acme/lang/locales/3')).currentRoute.value)).toBe('Polish | SummerCMS')
|
||||
})
|
||||
|
||||
it('uses translated indicators for login, the empty home and not found', async () => {
|
||||
expect(documentTitleForRoute((await routerAt('/login')).currentRoute.value)).toBe('Witaj ponownie | SummerCMS')
|
||||
await signIn()
|
||||
expect(documentTitleForRoute((await routerAt('/')).currentRoute.value)).toBe('Brak dostępnych sekcji. | SummerCMS')
|
||||
expect(documentTitleForRoute((await routerAt('/nowhere')).currentRoute.value)).toBe('Nie znaleziono strony. | SummerCMS')
|
||||
})
|
||||
})
|
||||
3
admin/tests/fixtures/settings.json
vendored
3
admin/tests/fixtures/settings.json
vendored
@@ -9,6 +9,7 @@
|
||||
"category": "System",
|
||||
"keywords": [],
|
||||
"model": "Acme\\Demo\\Models\\Settings",
|
||||
"controller": "",
|
||||
"order": 20
|
||||
},
|
||||
{
|
||||
@@ -19,6 +20,7 @@
|
||||
"category": "System",
|
||||
"keywords": [],
|
||||
"model": "Acme\\Demo\\Models\\MailSettings",
|
||||
"controller": "",
|
||||
"order": 10
|
||||
},
|
||||
{
|
||||
@@ -29,6 +31,7 @@
|
||||
"category": "Appearance",
|
||||
"keywords": [],
|
||||
"model": "Acme\\Demo\\Models\\Branding",
|
||||
"controller": "",
|
||||
"order": 5
|
||||
}
|
||||
],
|
||||
|
||||
@@ -53,6 +53,14 @@ describe('settings index (D-21)', () => {
|
||||
expect(link.text()).toBe('Search')
|
||||
expect(wrapper.find('[data-settings-group] h2').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('opens a controller-link entry on its controller route and keeps singleton links', async () => {
|
||||
const link = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.demo.locales' }
|
||||
setSettings([...settingsFixture.list.data, link])
|
||||
const { wrapper } = await mountApp('/settings', routes())
|
||||
expect(wrapper.find('[data-settings="locales"]').attributes('href')).toMatch(/\/acme\/demo\/locales$/)
|
||||
expect(wrapper.find('[data-settings="mail"]').attributes('href')).toBe('/settings/mail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings form (D-21)', () => {
|
||||
|
||||
@@ -72,6 +72,37 @@ describe('breadcrumbs', () => {
|
||||
expect((await crumbsAt('/settings/unknown')).labels).toEqual(['Ustawienia'])
|
||||
})
|
||||
|
||||
it('reads Ustawienia and the entry label on a settings-linked controller', async () => {
|
||||
const locales = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.lang.locales' }
|
||||
const crumbsWithLink = async (path: string) => {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
setSettings([...settingsFixture.list.data, locales])
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(Breadcrumbs, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[data-crumb]')
|
||||
return {
|
||||
labels: items.map((item) => item.text()),
|
||||
links: items.map((item) => (item.find('a').exists() ? item.find('a').attributes('href') : null)),
|
||||
current: items.map((item) => item.find('[aria-current="page"]').exists()),
|
||||
}
|
||||
}
|
||||
let crumbs = await crumbsWithLink('/acme/lang/locales')
|
||||
expect(crumbs.labels).toEqual(['Ustawienia', 'Locales'])
|
||||
expect(crumbs.links).toEqual(['/settings', null])
|
||||
expect(crumbs.current).toEqual([false, true])
|
||||
|
||||
setRecordCrumb('Polish')
|
||||
crumbs = await crumbsWithLink('/acme/lang/locales/3')
|
||||
expect(crumbs.labels).toEqual(['Ustawienia', 'Locales', 'Polish'])
|
||||
expect(crumbs.links).toEqual(['/settings', '/acme/lang/locales', null])
|
||||
|
||||
setRecordCrumb('')
|
||||
crumbs = await crumbsWithLink('/acme/lang/locales/create')
|
||||
expect(crumbs.labels).toEqual(['Ustawienia', 'Locales'])
|
||||
})
|
||||
|
||||
it('is empty outside any permitted plugin', async () => {
|
||||
expect((await crumbsAt('/acme/secret/things')).labels).toEqual([])
|
||||
expect((await crumbsAt('/acme/demo/unlisted')).labels).toEqual(['Demo'])
|
||||
|
||||
@@ -59,6 +59,20 @@ describe('plugin rail', () => {
|
||||
expect(link.attributes('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('marks Ustawienia current on a settings-linked controller without a navigation entry', async () => {
|
||||
const locales = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.lang.locales' }
|
||||
const widgets = { ...locales, code: 'widgets', label: 'Widgets', controller: 'acme.demo.widgets' }
|
||||
setSettings([...settingsFixture.list.data, locales, widgets])
|
||||
const { wrapper } = await mountRail('/acme/lang/locales')
|
||||
expect(wrapper.find('[data-rail-settings]').attributes('aria-current')).toBe('page')
|
||||
expect(wrapper.findAll('[data-rail-item][aria-current="page"]')).toHaveLength(0)
|
||||
|
||||
// A linked controller that also has a navigation entry keeps its plugin.
|
||||
const nav = await mountRail('/acme/demo/widgets')
|
||||
expect(nav.wrapper.find('[data-rail-settings]').attributes('aria-current')).toBeUndefined()
|
||||
expect(nav.wrapper.find('[data-rail-item="demo"]').attributes('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('offers menu semantics and the expand button after a manual collapse', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
settings,
|
||||
settingsEntry,
|
||||
settingsGroups,
|
||||
settingsLinkFor,
|
||||
settingsPath,
|
||||
useSettings,
|
||||
} from '../../src/state/useSettings'
|
||||
import { settingsFixture } from '../fixtures/typed'
|
||||
@@ -44,4 +46,22 @@ describe('useSettings (D-21)', () => {
|
||||
['System', ['mail', 'search']],
|
||||
])
|
||||
})
|
||||
|
||||
it('links a singleton to its form and a controller link to the controller route', () => {
|
||||
const singleton = settingsFixture.list.data[0]!
|
||||
expect(settingsPath({ ...singleton, code: 'a b' })).toBe('/settings/a%20b')
|
||||
expect(settingsPath({ ...singleton, code: 'locales', model: '', controller: 'acme.demo.locales' })).toBe('/acme/demo/locales')
|
||||
expect(settingsPath({ ...singleton, code: 'broken', model: '', controller: 'acme.demo' })).toBe('/settings')
|
||||
expect(useSettings().settingsPath).toBe(settingsPath)
|
||||
})
|
||||
|
||||
it('finds the link entry that opens a controller, never a singleton', () => {
|
||||
const link = { ...settingsFixture.list.data[0]!, code: 'locales', model: '', controller: 'acme.demo.locales' }
|
||||
setSettings([...settingsFixture.list.data, link])
|
||||
expect(settingsLinkFor('acme.demo.locales')?.code).toBe('locales')
|
||||
expect(settingsLinkFor('acme.demo.widgets')).toBeNull()
|
||||
expect(settingsLinkFor('')).toBeNull()
|
||||
expect(settingsLinkFor(null)).toBeNull()
|
||||
expect(useSettings().settingsLinkFor).toBe(settingsLinkFor)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
@@ -28,6 +29,7 @@ describe('app root', () => {
|
||||
const { wrapper } = await mountAt('/login')
|
||||
expect(wrapper.find('nav[aria-label="Wtyczki"]').exists()).toBe(false)
|
||||
expect(wrapper.find('form').exists()).toBe(true)
|
||||
expect(document.title).toBe('Witaj ponownie | SummerCMS')
|
||||
})
|
||||
|
||||
it('renders shell routes inside the shell and an unknown path as not found', async () => {
|
||||
@@ -37,6 +39,7 @@ describe('app root', () => {
|
||||
expect(wrapper.find('nav[aria-label="Wtyczki"]').exists()).toBe(true)
|
||||
expect(wrapper.findComponent(NotFoundView).exists()).toBe(true)
|
||||
expect(wrapper.find('main h1').text()).toBe('Nie znaleziono strony.')
|
||||
expect(document.title).toBe('Nie znaleziono strony. | SummerCMS')
|
||||
})
|
||||
|
||||
it('says so when the admin may open no plugin', async () => {
|
||||
|
||||
@@ -236,4 +236,4 @@ A relation's `messages` block overrides the relation manager's copy, each key a
|
||||
|
||||
## Relations in lists
|
||||
|
||||
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.
|
||||
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). On a to-many relation the column joins the selected value of every related record with `, `, as WinterCMS does, and stays empty when there are none. A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Settings
|
||||
description: Declare singleton settings pages with pact.SettingsItem, backed by a model, a fields.yaml form and validation rules, and read them from plugin code.
|
||||
description: Declare settings pages with pact.SettingsItem, as singleton forms backed by a model or as links to admin controllers, and read settings from plugin code.
|
||||
section: backend
|
||||
order: 60
|
||||
---
|
||||
@@ -64,6 +64,29 @@ fields:
|
||||
|
||||
Create the table in a migration like any other; see [Migrations](../database/migrations.md).
|
||||
|
||||
## Linking a settings entry to an admin controller
|
||||
|
||||
A WinterCMS `registerSettings` entry can point at a backend controller instead of a settings model, with `'url' => Backend::url('acme/blog/posts')`. The entry appears on the Settings page and opens that controller's list. In SummerCMS the `Controller` field of `pact.SettingsItem` does the same: set it to the admin controller ID (`vendor.plugin.controller`) and leave `Model`, `Form` and `NewModel` empty. The plugin returns this entry from `Settings` next to, or instead of, its singleton pages:
|
||||
|
||||
```go src=modules/cabana/example_settings_test.go#settings-link
|
||||
link := pact.SettingsItem{
|
||||
Code: "posts",
|
||||
Label: "acme.blog::lang.posts.title",
|
||||
Description: "acme.blog::lang.posts.description",
|
||||
Category: "acme.blog::lang.plugin.name",
|
||||
Icon: "icon-pencil",
|
||||
Order: 500,
|
||||
Permissions: []string{"acme.blog.access_settings"},
|
||||
Controller: "acme.blog.posts",
|
||||
}
|
||||
```
|
||||
|
||||
A link entry is not a singleton and needs no embedded admin tree. [cabana](../../modules/cabana/README.md) stops start-up when a link entry also declares `Model`, `Form` or `NewModel`, when it names a controller no plugin registered, or when its code repeats another settings code; singleton and link entries share one code namespace.
|
||||
|
||||
`GET .../settings` lists a link entry with its `controller` set (and `model` empty) only when the administrator passes the entry's permissions and may also open the controller, so a controller the administrator cannot reach is never advertised. Singleton entries carry an empty `controller`. The singleton endpoints (`.../settings/{code}`, `.../settings/{code}/schema`) answer 404 for a link entry's code.
|
||||
|
||||
In the admin, the entry's card on the Settings page opens the controller's pages. When the controller has no main navigation item of its own, the admin treats it as part of Settings: the rail marks Settings as current and the breadcrumbs read Settings, then the entry's label.
|
||||
|
||||
## How values are stored
|
||||
|
||||
The settings row is the one with ID 1. Before it exists, the page shows the fields' `default` values and the API reports that the row does not exist yet; the first save creates it. A save writes only fillable fields, validates them with the model's rules and the form's `required` flags, and runs in a transaction, as a controller form save does.
|
||||
|
||||
@@ -35,7 +35,7 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
|
||||
| Queued jobs | `pact.HasJobs` with jobs built by `conga.Job`, dispatched with `conga.Manager.Dispatch` inside the caller's transaction | [Queued jobs](../services/jobs.md), [conga](../../modules/conga/README.md) |
|
||||
| `registerSchedule` and the scheduler | `pact.HasSchedule` returning `pact.ScheduledCommand` entries with a `pact.Cadence` | [Task scheduling](../plugins/scheduling.md), [pact](../../modules/pact/README.md) |
|
||||
| Mail templates in `views/mail` | `pact.HasMailTemplates`, sent through `postcard.Mailer` | [Mail](../services/mail.md), [postcard](../../modules/postcard/README.md) |
|
||||
| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) |
|
||||
| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries; an entry with `'url' => Backend::url(...)` sets the `Controller` field to the admin controller ID | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) |
|
||||
| Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [Realtime](../services/realtime.md), [lighthouse](../../modules/lighthouse/README.md) |
|
||||
| Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [Search](../services/search.md), [beachcomber](../../modules/beachcomber/README.md) |
|
||||
| The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [Outbound HTTP](../services/outbound-http.md), [fetchguard](../../modules/fetchguard/README.md) |
|
||||
|
||||
@@ -36,6 +36,7 @@ var contentTypes = map[string]string{
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
".svg": "image/svg+xml",
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestAPIPathsAreDelegated(t *testing.T) {
|
||||
|
||||
func TestMissingFileWithExtensionIs404(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
for _, target := range []string{testPrefix + "/assets/missing.js", testPrefix + "/favicon.ico", testPrefix + "/golem/missing.css"} {
|
||||
for _, target := range []string{testPrefix + "/assets/missing.js", testPrefix + "/missing.ico", testPrefix + "/golem/missing.css"} {
|
||||
rec := get(h, target)
|
||||
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "summer-admin-base") {
|
||||
t.Fatalf("%s status=%d body=%s", target, rec.Code, rec.Body.String())
|
||||
@@ -276,6 +276,10 @@ func TestContentTypesAndCaching(t *testing.T) {
|
||||
if got := ContentType("x.svg"); got != "image/svg+xml" {
|
||||
t.Fatalf("svg type %q", got)
|
||||
}
|
||||
favicon := get(h, testPrefix+"/favicon.ico")
|
||||
if favicon.Code != http.StatusOK || favicon.Header().Get("Content-Type") != "image/x-icon" || favicon.Header().Get("Cache-Control") != "no-cache" {
|
||||
t.Fatalf("favicon headers = status %d, %v", favicon.Code, favicon.Header())
|
||||
}
|
||||
if got := ContentType("x.json"); got != "application/json" {
|
||||
t.Fatalf("json type %q", got)
|
||||
}
|
||||
|
||||
9
modules/boardwalk/dist/assets/index-BK2Ktgov.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-BK2Ktgov.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-BhDUy2Lr.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-BhDUy2Lr.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
modules/boardwalk/dist/favicon.ico
vendored
Normal file
BIN
modules/boardwalk/dist/favicon.ico
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
5
modules/boardwalk/dist/index.html
vendored
5
modules/boardwalk/dist/index.html
vendored
@@ -5,9 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-BonWcpEU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BUCXRqcT.css">
|
||||
<script type="module" crossorigin src="./assets/index-BK2Ktgov.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BhDUy2Lr.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -30,7 +30,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
||||
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
|
||||
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
|
||||
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
|
||||
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
|
||||
@@ -46,7 +46,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
|
||||
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
|
||||
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. |
|
||||
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. |
|
||||
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
|
||||
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
|
||||
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
|
||||
| GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. |
|
||||
@@ -191,7 +191,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. |
|
||||
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
|
||||
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
|
||||
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. |
|
||||
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. `cabana.Registry.Setting` returns singleton settings only, never a controller link. |
|
||||
| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options; `cabana.CRUDService.BulkAction` runs a declared bulk action on a scoped, locked id set. |
|
||||
| `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. |
|
||||
| `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. |
|
||||
|
||||
@@ -131,13 +131,18 @@ func (r *Registry) Get(id string) (*CompiledController, bool) {
|
||||
return cc, ok && cc != nil
|
||||
}
|
||||
|
||||
// Setting returns one compiled singleton setting by stable code.
|
||||
// Setting returns one compiled singleton setting by stable code. Link entries
|
||||
// (pact.SettingsItem with Controller set) are not singletons and are not
|
||||
// returned, so the singleton settings endpoints answer 404 for their codes.
|
||||
func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
|
||||
if r == nil {
|
||||
return nil, false
|
||||
}
|
||||
setting, ok := r.settings[code]
|
||||
return setting, ok && setting != nil
|
||||
if !ok || setting == nil || setting.Item.Controller != "" {
|
||||
return nil, false
|
||||
}
|
||||
return setting, true
|
||||
}
|
||||
|
||||
// Allows reports whether principal satisfies any of the required permission
|
||||
|
||||
24
modules/cabana/example_settings_test.go
Normal file
24
modules/cabana/example_settings_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package cabana_test
|
||||
|
||||
import "git.golem15.com/golem15/summercms/modules/pact"
|
||||
|
||||
// LinkedBlogPlugin adds a settings entry that opens the posts controller from
|
||||
// the Settings page.
|
||||
type LinkedBlogPlugin struct{ BlogPlugin }
|
||||
|
||||
// Settings keeps the singleton page and adds a controller link.
|
||||
func (p *LinkedBlogPlugin) Settings() []pact.SettingsItem {
|
||||
// docs:start settings-link
|
||||
link := pact.SettingsItem{
|
||||
Code: "posts",
|
||||
Label: "acme.blog::lang.posts.title",
|
||||
Description: "acme.blog::lang.posts.description",
|
||||
Category: "acme.blog::lang.plugin.name",
|
||||
Icon: "icon-pencil",
|
||||
Order: 500,
|
||||
Permissions: []string{"acme.blog.access_settings"},
|
||||
Controller: "acme.blog.posts",
|
||||
}
|
||||
// docs:end settings-link
|
||||
return append(p.BlogPlugin.Settings(), link)
|
||||
}
|
||||
@@ -101,3 +101,20 @@ func TestDocsDeclarations(t *testing.T) {
|
||||
t.Fatal("BlogSettings has no posts_per_page rule")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocsSettingsLink activates the plugin with a settings entry that links
|
||||
// to its posts controller, as the Settings page shows.
|
||||
func TestDocsSettingsLink(t *testing.T) {
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
|
||||
if _, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&LinkedBlogPlugin{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := (&LinkedBlogPlugin{}).Settings()
|
||||
if len(settings) != 2 || settings[1].Controller != "acme.blog.posts" || settings[1].Model != "" {
|
||||
t.Fatalf("Settings() = %+v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,3 +84,41 @@ func TestFieldByColumnTagBeatsGoName(t *testing.T) {
|
||||
t.Fatalf("a tagged field matched by its Go name: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type listTag struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
Name *string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
type listTagged struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
Tags []listTag `gorm:"many2many:tagged_tags"`
|
||||
Pointers []*listTag `gorm:"many2many:tagged_pointer_tags"`
|
||||
}
|
||||
|
||||
// TestProjectRowJoinsToManyRelation pins a to-many relation column (relation:
|
||||
// tags, select: name): the list shows every related record's selected value
|
||||
// joined with ", " like Winter, and leaves the key unset when there are none.
|
||||
func TestProjectRowJoinsToManyRelation(t *testing.T) {
|
||||
news, guides := "News", "Guides"
|
||||
row := &listTagged{
|
||||
ID: 3,
|
||||
Tags: []listTag{{ID: 1, Name: &news}, {ID: 2, Name: &guides}, {ID: 4}},
|
||||
Pointers: []*listTag{nil, {ID: 2, Name: &guides}},
|
||||
}
|
||||
cols := []ListColumn{
|
||||
{Key: "tags", Relation: "tags", Select: "name"},
|
||||
{Key: "pointers", Relation: "pointers", Select: "name"},
|
||||
}
|
||||
projected := projectRow(row, nil, cols)
|
||||
if projected["tags"] != "News, Guides" {
|
||||
t.Fatalf("tags = %#v, want %q", projected["tags"], "News, Guides")
|
||||
}
|
||||
if projected["pointers"] != "Guides" {
|
||||
t.Fatalf("pointers = %#v, want %q", projected["pointers"], "Guides")
|
||||
}
|
||||
empty := projectRow(&listTagged{ID: 5}, nil, cols)
|
||||
if _, ok := empty["tags"]; ok {
|
||||
t.Fatalf("empty relation projected %#v, want the key unset", empty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ type SettingsEntry struct {
|
||||
Order int `json:"order"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Model string `json:"model"`
|
||||
// Controller is the admin controller a link entry opens; empty for a
|
||||
// singleton settings form.
|
||||
Controller string `json:"controller"`
|
||||
}
|
||||
|
||||
// Metadata returns only entries the backend principal may open. Denied
|
||||
@@ -65,7 +68,9 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t
|
||||
})
|
||||
for _, compiled := range r.settings {
|
||||
item := compiled.Item
|
||||
if !Allows(principal, item.Permissions) {
|
||||
// A link entry is listed only when the principal can also open its
|
||||
// controller, so a denied controller ID never leaks.
|
||||
if !Allows(principal, item.Permissions) || (item.Controller != "" && !r.canOpen(principal, item.Controller)) {
|
||||
continue
|
||||
}
|
||||
keywords := append([]string(nil), item.Keywords...)
|
||||
@@ -77,6 +82,7 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t
|
||||
Description: translateKey(ctx, tr, item.Description),
|
||||
Category: translateKey(ctx, tr, item.Category), Icon: item.Icon,
|
||||
Order: item.Order, Keywords: keywords, Model: item.Model,
|
||||
Controller: item.Controller,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(settings, func(i, j int) bool {
|
||||
|
||||
@@ -580,16 +580,48 @@ func relatedSelect(row reflect.Value, controller pact.AdminController, col ListC
|
||||
}
|
||||
field = field.Elem()
|
||||
}
|
||||
column := listRelationColumn(controller, col.Relation, col.Select)
|
||||
if field.Kind() == reflect.Slice {
|
||||
return relatedSelectMany(field, column)
|
||||
}
|
||||
if field.Kind() != reflect.Struct {
|
||||
return nil, false
|
||||
}
|
||||
selected := fieldByColumn(field, listRelationColumn(controller, col.Relation, col.Select))
|
||||
selected := fieldByColumn(field, column)
|
||||
if !selected.IsValid() || !selected.CanInterface() {
|
||||
return nil, false
|
||||
}
|
||||
return selected.Interface(), true
|
||||
}
|
||||
|
||||
// relatedSelectMany joins the selected column of every related record with
|
||||
// ", ", as Winter's list does for a to-many relation column. No related
|
||||
// records leave the key unset, so the list shows its empty placeholder.
|
||||
func relatedSelectMany(records reflect.Value, column string) (any, bool) {
|
||||
values := make([]string, 0, records.Len())
|
||||
for i := 0; i < records.Len(); i++ {
|
||||
record := records.Index(i)
|
||||
for record.Kind() == reflect.Pointer && !record.IsNil() {
|
||||
record = record.Elem()
|
||||
}
|
||||
if record.Kind() != reflect.Struct {
|
||||
continue
|
||||
}
|
||||
selected := fieldByColumn(record, column)
|
||||
for selected.IsValid() && selected.Kind() == reflect.Pointer && !selected.IsNil() {
|
||||
selected = selected.Elem()
|
||||
}
|
||||
if !selected.IsValid() || !selected.CanInterface() || selected.Kind() == reflect.Pointer {
|
||||
continue
|
||||
}
|
||||
values = append(values, fmt.Sprint(selected.Interface()))
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return strings.Join(values, ", "), true
|
||||
}
|
||||
|
||||
func listRelationColumn(controller pact.AdminController, relation, column string) string {
|
||||
mapper, ok := controller.(pact.ListRelationColumnMapper)
|
||||
if !ok || mapper == nil {
|
||||
|
||||
@@ -196,6 +196,16 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
|
||||
if _, exists := reg.settings[item.Code]; exists {
|
||||
return fmt.Errorf("cabana: duplicate setting %s", item.Code)
|
||||
}
|
||||
if item.Controller != "" {
|
||||
// A link entry opens an admin controller and needs no
|
||||
// singleton form, so no AdminFS either.
|
||||
compiled, err := compileSettingLink(plugin.ID(), item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reg.settings[item.Code] = compiled
|
||||
continue
|
||||
}
|
||||
if !hasAssets || assets == nil || assets.AdminFS() == nil {
|
||||
return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID())
|
||||
}
|
||||
@@ -250,6 +260,11 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
|
||||
if err := reg.validatePermissions("setting "+code, setting.Item.Permissions); err != nil {
|
||||
return err
|
||||
}
|
||||
if controller := setting.Item.Controller; controller != "" {
|
||||
if _, ok := reg.byID[controller]; !ok {
|
||||
return fmt.Errorf("cabana: setting %s references unknown controller %s", code, controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
)
|
||||
|
||||
// CompiledSetting is a singleton settings registration after strict schema
|
||||
// compilation and writable-field binding.
|
||||
// compilation and writable-field binding. For a link entry (Item.Controller
|
||||
// set) Form and Writable are nil.
|
||||
type CompiledSetting struct {
|
||||
PluginID string
|
||||
Item pact.SettingsItem
|
||||
@@ -34,6 +35,19 @@ type SettingsResult struct {
|
||||
// SettingsService reads and transactionally updates compiled singleton rows.
|
||||
type SettingsService struct{ DB *gorm.DB }
|
||||
|
||||
// compileSettingLink registers a settings entry that opens an admin
|
||||
// controller. It declares no singleton form and no model; the controller ID
|
||||
// itself is checked against the registry once every plugin is collected.
|
||||
func compileSettingLink(pluginID string, item pact.SettingsItem) (*CompiledSetting, error) {
|
||||
if item.Form != "" || item.NewModel != nil {
|
||||
return nil, fmt.Errorf("cabana: setting %s links controller %s and declares a singleton form; declare one", item.Code, item.Controller)
|
||||
}
|
||||
if item.Model != "" {
|
||||
return nil, fmt.Errorf("cabana: setting %s links controller %s and names a model", item.Code, item.Controller)
|
||||
}
|
||||
return &CompiledSetting{PluginID: pluginID, Item: item}, nil
|
||||
}
|
||||
|
||||
func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) {
|
||||
if item.NewModel == nil {
|
||||
return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code)
|
||||
|
||||
202
modules/cabana/settings_link_test.go
Normal file
202
modules/cabana/settings_link_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// settingsLinkItem is a settings entry that opens an admin controller instead
|
||||
// of a singleton form.
|
||||
func settingsLinkItem() pact.SettingsItem {
|
||||
return pact.SettingsItem{
|
||||
Code: "locales", Label: "Locales", Description: "Manage locales", Category: "Demo",
|
||||
Icon: "languages", Order: 30, Permissions: []string{"acme.demo.manage_settings"},
|
||||
Controller: "acme.demo.widgets",
|
||||
}
|
||||
}
|
||||
|
||||
// settingsLinkPlugin declares only a link entry and ships no admin assets.
|
||||
func settingsLinkPlugin(items ...pact.SettingsItem) contributionPlugin {
|
||||
return contributionPlugin{
|
||||
id: "acme.links",
|
||||
permissions: []pact.Permission{{Code: "acme.links.unused", Roles: []string{"developer"}}},
|
||||
settings: items,
|
||||
}
|
||||
}
|
||||
|
||||
// settingsLinkRegistry compiles the metadata plugin (one singleton setting)
|
||||
// together with a plugin holding one controller link.
|
||||
func settingsLinkRegistry(t *testing.T) *Registry {
|
||||
t.Helper()
|
||||
reg := metadataRegistry()
|
||||
if err := compileContributions(reg, []party.Plugin{metadataPlugin(), settingsLinkPlugin(settingsLinkItem())}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func TestSettingsLinkCompilesWithoutAdminFS(t *testing.T) {
|
||||
reg := settingsLinkRegistry(t)
|
||||
stored, ok := reg.settings["locales"]
|
||||
if !ok || stored == nil {
|
||||
t.Fatal("link entry was not registered")
|
||||
}
|
||||
if stored.Form != nil || stored.Writable != nil || stored.Item.Controller != "acme.demo.widgets" || stored.PluginID != "acme.links" {
|
||||
t.Fatalf("link entry = %#v", stored)
|
||||
}
|
||||
if _, ok := reg.Setting("locales"); ok {
|
||||
t.Fatal("Registry.Setting returned a link entry")
|
||||
}
|
||||
if setting, ok := reg.Setting("demo"); !ok || setting.Form == nil {
|
||||
t.Fatalf("singleton lookup = %#v %v", setting, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsLinkCompileValidation(t *testing.T) {
|
||||
withForm := settingsLinkItem()
|
||||
withForm.Form = "models/settings/fields.yaml"
|
||||
withFactory := settingsLinkItem()
|
||||
withFactory.NewModel = func() any { return &singletonSetting{} }
|
||||
withModel := settingsLinkItem()
|
||||
withModel.Model = "Settings"
|
||||
missing := settingsLinkItem()
|
||||
missing.Controller = "acme.demo.missing"
|
||||
duplicate := settingsLinkItem()
|
||||
duplicate.Code = "demo"
|
||||
unknownPermission := settingsLinkItem()
|
||||
unknownPermission.Permissions = []string{"acme.demo.nope"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
item pact.SettingsItem
|
||||
want string
|
||||
}{
|
||||
{"form", withForm, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"},
|
||||
{"model factory", withFactory, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"},
|
||||
{"model", withModel, "cabana: setting locales links controller acme.demo.widgets and names a model"},
|
||||
{"unknown controller", missing, "cabana: setting locales references unknown controller acme.demo.missing"},
|
||||
{"duplicate code", duplicate, "duplicate setting demo"},
|
||||
{"unknown permission", unknownPermission, "unknown permission"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := compileContributions(metadataRegistry(), []party.Plugin{metadataPlugin(), settingsLinkPlugin(tc.item)})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsLinkMetadata(t *testing.T) {
|
||||
reg := settingsLinkRegistry(t)
|
||||
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
|
||||
_, settings := reg.Metadata(context.Background(), developer, nil)
|
||||
if len(settings) != 2 {
|
||||
t.Fatalf("developer settings = %#v", settings)
|
||||
}
|
||||
byCode := map[string]SettingsEntry{}
|
||||
for _, entry := range settings {
|
||||
byCode[entry.Code] = entry
|
||||
}
|
||||
link, single := byCode["locales"], byCode["demo"]
|
||||
if link.Controller != "acme.demo.widgets" || link.Model != "" || link.Label != "Locales" || link.Order != 30 {
|
||||
t.Fatalf("link entry = %#v", link)
|
||||
}
|
||||
if link.Keywords == nil {
|
||||
t.Fatal("link entry keywords are nil")
|
||||
}
|
||||
if single.Controller != "" || single.Model != "Settings" {
|
||||
t.Fatalf("singleton entry = %#v", single)
|
||||
}
|
||||
|
||||
// The principal passes the item's permission but cannot open the
|
||||
// controller (it requires acme.demo.access): the link is hidden, the
|
||||
// singleton stays.
|
||||
restricted := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.demo.manage_settings": true}}
|
||||
_, settings = reg.Metadata(context.Background(), restricted, nil)
|
||||
if len(settings) != 1 || settings[0].Code != "demo" {
|
||||
t.Fatalf("restricted settings = %#v", settings)
|
||||
}
|
||||
|
||||
// Controller access alone does not pass the item's own permission.
|
||||
controllerOnly := &bouncer.Principal{ID: 3, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
|
||||
_, settings = reg.Metadata(context.Background(), controllerOnly, nil)
|
||||
if len(settings) != 0 {
|
||||
t.Fatalf("controller-only settings = %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsLinkEndpoints(t *testing.T) {
|
||||
reg := settingsLinkRegistry(t)
|
||||
svc := &service{reg: reg}
|
||||
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
|
||||
request := func(method, rel, code string) *http.Request {
|
||||
var body *strings.Reader
|
||||
if method == http.MethodPut {
|
||||
body = strings.NewReader(`{"enabled":true}`)
|
||||
} else {
|
||||
body = strings.NewReader("")
|
||||
}
|
||||
req := httptest.NewRequest(method, adminAPI(rel), body)
|
||||
if code != "" {
|
||||
req.SetPathValue("code", code)
|
||||
}
|
||||
return req.WithContext(bouncer.WithUser(req.Context(), developer))
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
rel string
|
||||
handler func(*service, http.ResponseWriter, *http.Request)
|
||||
}{
|
||||
{"schema", http.MethodGet, "/settings/locales/schema", (*service).settingsSchema},
|
||||
{"get", http.MethodGet, "/settings/locales", (*service).settingsGet},
|
||||
{"put", http.MethodPut, "/settings/locales", (*service).settingsPut},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
tc.handler(svc, rec, request(tc.method, tc.rel, "locales"))
|
||||
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), `"not_found"`) {
|
||||
t.Fatalf("%s %s = %d %s", tc.method, tc.rel, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
svc.settingsList(rec, request(http.MethodGet, "/settings", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /settings = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(envelope.Data) != 2 {
|
||||
t.Fatalf("GET /settings data = %#v", envelope.Data)
|
||||
}
|
||||
for _, entry := range envelope.Data {
|
||||
controller, ok := entry["controller"]
|
||||
if !ok {
|
||||
t.Fatalf("entry without controller key: %#v", entry)
|
||||
}
|
||||
want := ""
|
||||
if entry["code"] == "locales" {
|
||||
want = "acme.demo.widgets"
|
||||
}
|
||||
if controller != want {
|
||||
t.Fatalf("entry %v controller = %v, want %q", entry["code"], controller, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
|
||||
|
||||
- Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasSchedule`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`.
|
||||
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
|
||||
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`.
|
||||
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page.
|
||||
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
|
||||
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders).
|
||||
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
|
||||
@@ -100,7 +100,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.HasMailTemplates` | Ships mail templates and layout aliases. |
|
||||
| `pact.Permission` | One backend permission entry (code, tab, label, roles). |
|
||||
| `pact.NavigationItem` | One backend navigation entry, with an optional side menu. |
|
||||
| `pact.SettingsItem` | One settings screen entry. |
|
||||
| `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. |
|
||||
| `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. |
|
||||
| `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. |
|
||||
| `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. |
|
||||
|
||||
@@ -398,7 +398,9 @@ type HasNavigation interface {
|
||||
Navigation() []NavigationItem
|
||||
}
|
||||
|
||||
// SettingsItem is one registerSettings() entry.
|
||||
// SettingsItem is one registerSettings() entry. An item is either a singleton
|
||||
// settings form (Model, Form and NewModel) or a link to an admin controller
|
||||
// (Controller), never both.
|
||||
type SettingsItem struct {
|
||||
Code string
|
||||
Label string
|
||||
@@ -409,8 +411,13 @@ type SettingsItem struct {
|
||||
Order int
|
||||
Keywords []string
|
||||
Permissions []string
|
||||
Form string `json:"-"`
|
||||
NewModel func() any `json:"-"`
|
||||
// Controller, when set, makes the entry a link to that admin controller
|
||||
// ID (vendor.plugin.controller): the equivalent of a WinterCMS
|
||||
// registerSettings 'url' => Backend::url(...) entry. A link entry
|
||||
// declares no Model, Form or NewModel and is not a singleton.
|
||||
Controller string
|
||||
Form string `json:"-"`
|
||||
NewModel func() any `json:"-"`
|
||||
}
|
||||
|
||||
// HasSettings is implemented by plugins that declare settings screens.
|
||||
|
||||
Reference in New Issue
Block a user