docs(15): create phase plan

This commit is contained in:
Jakub Zych
2026-10-06 18:02:17 +02:00
parent d9f0f47e47
commit a28bb1e4ec
8 changed files with 1807 additions and 19 deletions

View File

@@ -883,10 +883,21 @@ Plans:
3. Plugin README and docs stay application-neutral (`the application`, example names such as `blog`).
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
**Plans:** 0/4 plans
Plans:
- [ ] TBD (run $gsd-plan-phase 15 to break down)
**Wave 1**
- [ ] 15-01-PLAN.md — Clone plugin, squashed `golem15_journal_*` schema, Translatable, host 3-plugin boot + CORS
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 15-02-PLAN.md — Admin YAML (`mlmarkdown`), FormatHTML, permissions/nav SVG, import/export
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 15-03-PLAN.md — `/_journal/api/v1`, buckets, backend Bearer writes, media, Typesense gate off
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 15-04-PLAN.md — Unit/integration tests last, PHPUnit map, phase gate, security review
### Phase 16: grzybyfunkcjonalne.pl on reusable blog views
@@ -949,7 +960,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 14. Domain jobs and external integrations | 6/6 | In Progress| |
| 14.1. OAuth identities and fonoteka me routes (INSERTED) | 2/2 | Complete | 2026-10-05 |
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 0/TBD | Not started | - |
| 15. Journal plugin | 0/TBD | Not started | - |
| 15. Journal plugin | 0/4 | Ready to execute | - |
| 16. grzybyfunkcjonalne.pl on reusable blog views | 0/TBD | Not started | - |
| 20. Płytarium cutover | 0/TBD | Not started | - |

View File

@@ -1,18 +1,18 @@
---
gsd_state_version: "1.0"
milestone: v1.0
current_phase: 14.2.1
current_phase_name: Translate plugin (INSERTED)
current_phase: 15
current_phase_name: journal-plugin
status: executing
stopped_at: Completed 14.2.1-06-PLAN.md
last_updated: "2026-10-06T14:29:39.010Z"
last_updated: "2026-10-06T16:01:39.445Z"
last_activity: 2026-10-06
last_activity_desc: Phase 14.2.1 execution started
state_head: ef0301631448e7e287c4f68ea3f16eb7ae2004c1
last_activity_desc: Phase 15 planned (4 plans)
state_head: d9f0f47e470c25226d601048a66bcceeea516148
progress:
total_phases: 26
completed_phases: 12
total_plans: 131
total_plans: 135
completed_plans: 131
milestone_name: milestone
---
@@ -24,16 +24,16 @@ milestone_name: milestone
See: .planning/PROJECT.md (updated 2026-09-16)
**Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test.
**Current focus:** Phase 14.2.1 — Translate plugin (INSERTED)
**Current focus:** Phase 15 — Journal plugin
## Current Position
Phase: 14.2.1 (Translate plugin (INSERTED)) — EXECUTING
Plan: 6 of 6 complete
Status: Ready for phase verification
Last activity: 2026-10-06 — Completed 14.2.1-06-PLAN.md
Phase: 15 (journal-plugin) — READY TO EXECUTE
Plan: 0 of 4 complete
Status: Ready to execute
Last activity: 2026-10-06 — Phase 15 planned (4 plans)
Progress: [██████████] 100%
Progress: [░░░░░░░░░░] 0%
## Performance Metrics

View File

@@ -0,0 +1,298 @@
---
phase: 15-journal-plugin
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ../sm-journal-plugin/go.mod
- ../sm-journal-plugin/plugin.go
- ../sm-journal-plugin/README.md
- ../sm-journal-plugin/models/registry.go
- ../sm-journal-plugin/models/post.go
- ../sm-journal-plugin/models/category.go
- ../sm-journal-plugin/models/tag.go
- ../sm-journal-plugin/models/settings.go
- ../sm-journal-plugin/models/post_translatable_smoke_test.go
- ../sm-journal-plugin/updates/registry.go
- ../sm-journal-plugin/updates/postgres_test.go
- ../sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go
- ../sm-journal-plugin/updates/202610060002_create_golem15_journal_categories.go
- ../sm-journal-plugin/updates/202610060003_create_golem15_journal_tags.go
- ../sm-journal-plugin/updates/202610060004_create_golem15_journal_posts_categories.go
- ../sm-journal-plugin/updates/202610060005_create_golem15_journal_posts_tags.go
- ../sm-journal-plugin/updates/202610060006_create_golem15_journal_settings.go
- ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go
- ../sm-journal-plugin/lang/en/lang.yaml
- ../sm-journal-plugin/lang/pl/lang.yaml
- ../sm-grzybyfunkcjonalne-app/summer.yaml
- ../sm-grzybyfunkcjonalne-app/go.work
- ../sm-grzybyfunkcjonalne-app/go.mod
- ../sm-grzybyfunkcjonalne-app/.gitmodules
- ../sm-grzybyfunkcjonalne-app/config/http.yaml
- ../sm-grzybyfunkcjonalne-app/boot_test.go
- ../sm-grzybyfunkcjonalne-app/plugins.gen.go
- ../sm-grzybyfunkcjonalne-app/main.go
autonomous: true
requirements: [D-01, D-02, D-03, D-04, D-06, D-07, D-09, D-10, D-17, D-18, D-19, D-20, D-21, D-22, D-23]
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "D-01/D-02/D-03: Go schema and models are derived only from the read-only PHP tree at SHA 02110eb1c0c3861370b0b9b47b209a0702ac5d88."
- "D-04: gormigrate creates final golem15_journal_posts, golem15_journal_categories, golem15_journal_tags, golem15_journal_posts_categories, golem15_journal_posts_tags, and singleton golem15_journal_settings, plus backend_users.golem15_bloghub_author_slug."
- "D-09/D-10: Plugin.ID is golem15.journal, Requires is exactly golem15.translate, Post and Category implement Translatable/TranslatableIndexes/MorphName with PHP class strings."
- "D-18/D-19/D-20/D-21/D-22/D-23: sm-grzybyfunkcjonalne-app mounts user, translate, and journal submodules and Activate returns those three plugin IDs."
- "D-17: host config/http.yaml CORS paths include _journal/api/* and keep supports_credentials false."
- "D-06: plugin HasLang catalogs exist for en and pl only."
artifacts:
- path: "../sm-journal-plugin/plugin.go"
provides: "compiled plugin registration, Requires translate, migrations and models"
contains: "golem15.journal"
- path: "../sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go"
provides: "squashed posts DDL"
contains: "golem15_journal_posts"
- path: "../sm-journal-plugin/models/post.go"
provides: "Post TableName, MorphName, Translatable, TranslatableIndexes"
contains: "Golem15\\Journal\\Models\\Post"
- path: "../sm-grzybyfunkcjonalne-app/summer.yaml"
provides: "three compiled plugins including journal"
contains: "golem15.journal"
- path: "../sm-grzybyfunkcjonalne-app/boot_test.go"
provides: "proof-host boot of user+translate+journal"
contains: "TestBootUserTranslateJournal"
- path: "../sm-grzybyfunkcjonalne-app/config/http.yaml"
provides: "D-17 CORS path for the Journal API"
contains: "_journal/api/*"
key_links:
- from: "../sm-journal-plugin/plugin.go"
to: "../sm-translate-plugin/classes/translatable.go"
via: "Requires golem15.translate so Post/Category Translatable storage works"
pattern: "golem15.translate"
- from: "../sm-journal-plugin/models/post.go"
to: "../sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go"
via: "TableName matches squashed posts table"
pattern: "golem15_journal_posts"
- from: "../sm-grzybyfunkcjonalne-app/summer.yaml"
to: "../sm-grzybyfunkcjonalne-app/plugins.gen.go"
via: "summer build blank-imports sm-journal-plugin"
pattern: "sm-journal-plugin"
prohibitions:
- requirement_id: D-07
category: safety
statement: "This phase must not modify any file under the PHP journal tree at /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal"
status: resolved
verification: test
- requirement_id: D-09
category: safety
statement: "Journal must not port Golem15.Translate inside this plugin and must not Require an apparatus plugin ID"
status: resolved
verification: test
- requirement_id: D-18
category: architecture
statement: "Proof host must not be fonoteka.go, sm-summercmsio-app, or an in-module testhost"
status: resolved
verification: test
---
## Phase Goal
**As a** application developer, **I want to** mount `sm-journal-plugin` in a host the same way `sm-user-plugin` mounts, **so that** a blog can run on SummerCMS without the PHP plugin.
This plan's slice: clone the plugin, squash the schema, persist one translatable Post, and boot the proof host with three plugins.
<objective>
Clone sm-journal-plugin, ship squashed golem15_journal_* schema and models with Translatable, and mount the plugin in sm-grzybyfunkcjonalne-app with CORS and a three-plugin boot smoke.
Purpose: prove one production Post round-trip through compiled plugin + translate + Postgres + proof-host Activate before admin YAML or public HTTP.
Output: sibling plugin repo, host gitlink/workspace/CORS, TestBootUserTranslateJournal, TestPostTranslatableSmoke.
</objective>
<execution_context>
@~/.codex/gsd-core/workflows/execute-plan.md
@~/.codex/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/15-journal-plugin/15-CONTEXT.md
@.planning/phases/15-journal-plugin/15-RESEARCH.md
@.planning/phases/15-journal-plugin/15-PATTERNS.md
@.planning/notes/core-plugins-own-repos.md
@../sm-translate-plugin/go.mod
@../sm-translate-plugin/plugin.go
@../sm-grzybyfunkcjonalne-app/summer.yaml
@../sm-grzybyfunkcjonalne-app/boot_test.go
</context>
## Spec-less probe fallback
Phase 15 has no mapped REQUIREMENTS.md IDs (ROADMAP lists TBD). Spec-less requirement probing is a visible skip this run; no probe predicates are generated. Acceptance is D-01 through D-23 plus RESEARCH validation rows. Do not claim API-09 or QA-05 (Phase 20).
## Schema push gate
Skipped: this phase does not touch Prisma, Drizzle, Payload, TypeORM, or Supabase schema files. Journal DDL is plugin gormigrate (DATA-02). D-11 is not a new field type.
## Assumption-delta decision
<assumption_delta_decision>
signal: optional
term: optional editor / optional Typesense
decision: no-change
rationale: Public GET stays anonymous with an optional backend principal overlay copied from PHP; writes still require backend JWT. Typesense remains a settings kill-switch defaulting off. The identity primary does not move.
</assumption_delta_decision>
## Artifacts this phase produces
- Module `git.golem15.com/golem15/sm-journal-plugin`, package `journal`, `Plugin.ID() == "golem15.journal"`, `Requires() == []string{"golem15.translate"}`.
- Squashed gormigrate IDs `202610060001` through `202610060007` for the six journal tables plus `backend_users.golem15_bloghub_author_slug`.
- `models.Post`, `models.Category`, `models.Tag`, `models.Settings` with Fillable/MorphName/Translatable as RESEARCH §1–§2.
- Proof-host submodule at `plugins/golem15/journal`, `go.work` use, go.mod require+replace, CORS `_journal/api/*`, `TestBootUserTranslateJournal`.
- Application-neutral plugin README (`the application`, example `blog`).
<tasks>
<task type="tracer">
<name>Task 1: Clone the plugin, persist one en/pl Post title, and boot the host with three plugins</name>
<reversibility rating="one-way">D-18/D-22/D-23 submodule path plugins/golem15/journal and module git.golem15.com/golem15/sm-journal-plugin become the durable layout; moving them later needs coordinated gitlink and workspace changes. Locked in CONTEXT — do not re-ask.</reversibility>
<precondition>git ls-remote git@git.golem15.com:golem15/sm-journal-plugin.git and git@git.golem15.com:golem15/sm-grzybyfunkcjonalne-app.git succeed; PHP journal HEAD is 02110eb1c0c3861370b0b9b47b209a0702ac5d88; ../sm-grzybyfunkcjonalne-app already mounts user and translate.</precondition>
<files>../sm-journal-plugin/go.mod, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/models/registry.go, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/models/post_translatable_smoke_test.go, ../sm-journal-plugin/updates/registry.go, ../sm-journal-plugin/updates/postgres_test.go, ../sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go, ../sm-grzybyfunkcjonalne-app/summer.yaml, ../sm-grzybyfunkcjonalne-app/go.work, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/plugins.gen.go, ../sm-grzybyfunkcjonalne-app/main.go</files>
<read_first>.planning/phases/15-journal-plugin/15-CONTEXT.md (D-01 through D-04, D-09, D-10, D-18 through D-23), .planning/phases/15-journal-plugin/15-RESEARCH.md (Verified PHP pin, Tables, Translatable, Host wiring, Pitfall 8 and Pitfall 9), .planning/phases/15-journal-plugin/15-PATTERNS.md (JR/go.mod, plugin.go, post.go, updates, Proof host), .planning/notes/core-plugins-own-repos.md, ../sm-translate-plugin/go.mod, ../sm-translate-plugin/plugin.go, ../sm-translate-plugin/updates/202610060001_create_golem15_translate_locales.go, ../sm-translate-plugin/updates/postgres_test.go, ../sm-translate-plugin/classes/translatable.go, ../sm-grzybyfunkcjonalne-app/summer.yaml, ../sm-grzybyfunkcjonalne-app/go.work, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/boot_test.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/Plugin.php</read_first>
<action>Clone git@git.golem15.com:golem15/sm-journal-plugin.git into /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin (D-22, D-23). Read PHP only from /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal at SHA 02110eb1c0c3861370b0b9b47b209a0702ac5d88 (D-01, D-02, D-03); do not clone a second PHP tree and do not edit that tree (D-07).
Create module git.golem15.com/golem15/sm-journal-plugin, package journal, Go 1.27.0. Require summercms, gormigrate v2.1.7, gorm v1.31.2, and the same testcontainers pins translate uses. Sibling replace git.golem15.com/golem15/summercms => ../summercms.go (pitfall 9). Do not require sm-user-plugin. Do not add undecided libraries.
Register party.Plugin in init. ID golem15.journal. Requires exactly golem15.translate (D-09). Do not Require apparatus. Implement HasMigrations and HasModels. Copy translate's TestMain/testcontainers harness into updates/postgres_test.go so isolated plugin tests run against real Postgres and treat missing Docker as failure unless -short.
Ship gormigrate ID 202610060001_create_golem15_journal_posts with explicit CREATE/INDEX/UNIQUE for RESEARCH §1 posts columns (id, user_id, redactor_id, title, slug unique, excerpt, content, content_html, published_at, published default false, is_pinned default false, metadata JSONB, sources JSONB, nullable timestamps). Rollback DROP TABLE. Never schema auto-sync. Squash to final golem15_journal_* names only.
Implement models.Post: TableName golem15_journal_posts; MorphName the PHP class string Golem15\Journal\Models\Post (D-10, pitfall 10); Translatable title, content, content_html, excerpt, metadata; TranslatableIndexes slug. Admin/API Fillable later; this tracer must not mass-assign redactor_id, content_html, or user_id from a map. jsonable metadata and sources as JSONB.
Smoke TestPostTranslatableSmoke: migrate translate then journal (D-19 migrations run), seed en/pl via translate migrations, insert one Post, SetTranslated English on host columns and Polish title/slug through translate helpers, read Polish back. This is the production tracer, not the Plan 04 matrix.
On the D-18 host at D-20, add gitlink plugins/golem15/journal to the journal remote, go.work use ./plugins/golem15/journal, go.mod require+replace to ./plugins/golem15/journal, summer.yaml id golem15.journal module git.golem15.com/golem15/sm-journal-plugin after translate (D-21). Regenerate plugins.gen.go and main.go with summer build (build ./cmd/summer from this repo into a temp binary, run it with cwd the host); do not hand-author those files after the first generation.
Rename/extend host TestBootUserTranslate to TestBootUserTranslateJournal: len(plugins)==3, IDs golem15.user then golem15.translate then golem15.journal (or topological equivalent that includes all three), journal is allowed in PluginIDs, migrations non-empty for journal. Remove the production-list exclusion of the journal plugin ID (pitfall 8). Do not yet assert admin controller IDs or /_journal/api/v1 routes (Plans 02 and 03).</action>
<verify>
<automated>git ls-remote git@git.golem15.com:golem15/sm-journal-plugin.git &amp;&amp; git -C /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal rev-parse HEAD &amp;&amp; go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestPostTranslatableSmoke)$' &amp;&amp; go -C ../sm-grzybyfunkcjonalne-app vet ./... &amp;&amp; go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'</automated>
<fails_when>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.</fails_when>
</verify>
<acceptance_criteria>
- Local checkout exists at ../sm-journal-plugin with origin git@git.golem15.com:golem15/sm-journal-plugin.git.
- Plugin module/package/ID match D-22 and Requires is exactly golem15.translate.
- Migration 202610060001 creates golem15_journal_posts; plugin source does not create rainlab-era journal table names.
- Post.MorphName is Golem15\Journal\Models\Post; TranslatableIndexes contains slug.
- TestPostTranslatableSmoke stores English on the host row and Polish in golem15_translate_attributes.
- Host summer.yaml, go.work, go.mod replace, .gitmodules, and plugins.gen.go all name sm-journal-plugin / golem15.journal.
- TestBootUserTranslateJournal activates three plugins including golem15.journal.
- git diff -- /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal is empty.
</acceptance_criteria>
<done>A compiled journal plugin persists one translatable Post and the proof host boots user, translate, and journal.</done>
</task>
<task type="auto">
<name>Task 2: Add categories, tags, pivots, settings singleton, and author_slug</name>
<reversibility rating="costly">backend_users.golem15_bloghub_author_slug is a plugin-owned ALTER of a framework table; dropping it later needs a coordinated rollback in every host that migrated Journal.</reversibility>
<files>../sm-journal-plugin/models/category.go, ../sm-journal-plugin/models/tag.go, ../sm-journal-plugin/models/settings.go, ../sm-journal-plugin/models/registry.go, ../sm-journal-plugin/updates/registry.go, ../sm-journal-plugin/updates/202610060002_create_golem15_journal_categories.go, ../sm-journal-plugin/updates/202610060003_create_golem15_journal_tags.go, ../sm-journal-plugin/updates/202610060004_create_golem15_journal_posts_categories.go, ../sm-journal-plugin/updates/202610060005_create_golem15_journal_posts_tags.go, ../sm-journal-plugin/updates/202610060006_create_golem15_journal_settings.go, ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go</files>
<read_first>.planning/phases/15-journal-plugin/15-RESEARCH.md (Tables, Fillable, Settings singleton, companion author_slug, assumption A4), .planning/phases/15-journal-plugin/15-PATTERNS.md (category.go, tag.go, settings.go, updates table), ../sm-translate-plugin/updates/registry.go, ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/updates/202609220005_extend_users.go, modules/cabana/contracts.go (BackendUser TableName), modules/cabana/example_controller_test.go (BlogSettings), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Tag.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Settings.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/settings/fields.yaml</read_first>
<action>Expand from the proven posts table. Add gormigrate IDs 202610060002 through 202610060007 registered in updates.All in ID order (D-04).
Categories: columns id, name, slug unique indexed, code nullable, description, parent_id indexed nullable, nest_left, nest_right, nest_depth, timestamps. Keep nest_* in DDL; no NestedTree reorder UI this phase. Category Fillable only name, slug, code, description, parent_id (JOURNAL-002 — nest_* not fillable). Translatable name, description; TranslatableIndexes slug; MorphName Golem15\Journal\Models\Category.
Tags: id, name, slug unique indexed, description, timestamps. Fillable only name, slug, description (JOURNAL-001). Tag is not translatable.
Pivots golem15_journal_posts_categories and golem15_journal_posts_tags with the PHP pair of FKs. Post belongsToMany categories and tags through those tables. Post belongsTo cabana.BackendUser on user_id. redactor_id is a naked integer with no sm-user-plugin import.
Settings: dedicated table golem15_journal_settings ID=1, not PHP system_settings. Typed columns from Settings.php rules plus fields.yaml: show_all_posts bool default true, use_rich_editor bool default false, search_use_typesense bool default false, search_title_weight int default 5, search_excerpt_weight int default 3, search_content_weight int default 1, rss_enabled bool default true, rss_include_content bool default false, rss_title, rss_description, rss_language default en-us, rss_copyright, rss_image_url, rss_posts_per_feed int default 20. Fillable those columns; Rules match PHP. use_rich_editor is stored for later; compile-time ignore is Plan 02.
Author slug: ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS golem15_bloghub_author_slug TEXT UNIQUE. Do not put this column in lagoon framework migrations. Rollback drops the column only if safe (IF EXISTS).
Add TestJournalTables that migrates the journal set and asserts the six golem15_journal_* table names plus backend_users.golem15_bloghub_author_slug (D-04). Do not seed posts. Seed Uncategorized only if the frozen PHP seeder at this pin still inserts it (A4); RESEARCH grep found none — skip seed rows.
AttachRelations on Post for featured_images and content_images as attachMany using the same MorphName PHP class string and system_files. Do not import sm-user-plugin to type redactor.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test ./updates -count=1 -v -run '^(TestJournalTables)$'</automated>
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; lacks "--- PASS: TestJournalTables".</fails_when>
</verify>
<acceptance_criteria>
- After migrate, information_schema lists golem15_journal_posts, golem15_journal_categories, golem15_journal_tags, both pivot tables, and golem15_journal_settings.
- backend_users has golem15_bloghub_author_slug.
- Category.Fillable is exactly name, slug, code, description, parent_id.
- Tag.Fillable is exactly name, slug, description.
- Settings table defaults search_use_typesense to false (D-12 storage ready).
- No rainlab-era journal table names appear in updates/*.go.
- models package does not import sm-user-plugin.
</acceptance_criteria>
<done>All D-04 tables exist and Category/Tag/Settings fillable boundaries match the PHP pin.</done>
</task>
<task type="auto">
<name>Task 3: Add en/pl phrasebook, neutral README, and host CORS</name>
<files>../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/README.md, ../sm-grzybyfunkcjonalne-app/config/http.yaml</files>
<read_first>.planning/phases/15-journal-plugin/15-CONTEXT.md (D-06, D-17), .planning/phases/15-journal-plugin/15-RESEARCH.md (CORS, phrasebook, Pitfall 13), .planning/phases/15-journal-plugin/15-PATTERNS.md (Phrasebook + README, Proof host CORS), ../sm-translate-plugin/lang/en/lang.yaml, ../sm-translate-plugin/README.md, ../sm-grzybyfunkcjonalne-app/config/http.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/lang/en/lang.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/lang/pl/lang.php, CLAUDE.md Documentation section</read_first>
<action>Implement pact.HasLang. Port plugin.*, journal.*, post.*, category.*, tag.* UI keys from PHP lang/en/lang.php and lang/pl/lang.php into lang/en/lang.yaml and lang/pl/lang.yaml (D-06). Do not add the other 19 PHP locales. Phrasebook catalogs are UI strings, not model translation JSON.
Write plugin README following translate: H1 name, one-sentence summary, import line, Overview, Features, Usage, API reference, Dependencies, Testing. Say the application or host application; example names blog and acme. Do not name the proof host or Płytarium (pitfall 13). Document MorphName PHP class strings. Note Winter component pages (journalPost, journalPosts, and similar) as a Phase 16 successor, not implemented here.
Add CORS path _journal/api/* to host config/http.yaml alongside existing _user/api/* (D-17). Keep supports_credentials false. Do not invent handler-level CORS headers.
Host README may name itself; plugin README must not.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test ./... -short -count=1 &amp;&amp; python3 -c 'import pathlib,sys; t=pathlib.Path("../sm-journal-plugin/README.md").read_text(); bad=["grzybyfunkcjonalne","Płytarium","fonoteka.go","plytarium"];
sys.exit(1 if any(b.lower() in t.lower() for b in bad) else 0)' &amp;&amp; grep -F "_journal/api/*" ../sm-grzybyfunkcjonalne-app/config/http.yaml &amp;&amp; test -f ../sm-journal-plugin/lang/en/lang.yaml &amp;&amp; test -f ../sm-journal-plugin/lang/pl/lang.yaml</automated>
<fails_when>Non-zero exit; plugin tests FAIL; README python check exits 1; grep does not print _journal/api/*; either lang YAML is missing.</fails_when>
</verify>
<acceptance_criteria>
- lang/en/lang.yaml and lang/pl/lang.yaml exist and plugin LangFS embeds them.
- No lang directory other than en and pl is added.
- Plugin README uses the application / blog and does not name the proof host or Płytarium.
- Host http.yaml CORS paths include both _user/api/* and _journal/api/*.
- supports_credentials remains false.
</acceptance_criteria>
<done>Operators have en/pl UI catalogs, a neutral plugin README, and host CORS ready for /_journal/api/v1 in Plan 03.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Plugin gormigrate → Postgres | Persistent blog schema and a column on backend_users |
| Host gitlinks → compiled binary | Remote plugin commits become trusted build inputs |
| Model Fillable → GORM | Untrusted maps must not set redactor_id, nest_*, user_id |
| PHP tree → planner/executor | Frozen contract is read-only |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-15-04 | Elevation of Privilege | Category/Tag Fillable | high | mitigate | Allow-lists name/slug/description (+ category code, parent_id); nest_* and redactor_id excluded; tests in Plan 04 |
| T-15-05 | Tampering | gormigrate DDL | high | mitigate | Explicit CREATE/ALTER from frozen SHA; no schema auto-sync; squash to golem15_journal_* only |
| T-15-06 | Tampering | MorphName | high | mitigate | Hard-code PHP class strings; never reflect.Type.String; smoke writes translate attributes under that morph |
| T-15-SC | Tampering | package installs | high | mitigate | No new external packages; goldmark wait until Plan 02 FormatHTML if imported |
ASVS L1: high threats are blocked by implementation and become fail-closed tests in Plan 04. T-15-01..T-15-03 are Journal HTTP threats owned by Plan 03 (VALIDATION map).
</threat_model>
<verification>
Run the three task commands. Confirm PHP journal git diff is empty. Confirm go -C ../sm-journal-plugin vet ./... and host TestBootUserTranslateJournal pass.
</verification>
<success_criteria>
- Plugin repo exists at D-23 with compiling module and squashed tables.
- One Post en/pl round-trip works through translate storage.
- Proof host Activate returns three plugins including golem15.journal.
- CORS path _journal/api/* is present.
- No PHP edits, no apparatus Require, no extra locales, no admin YAML or public routes yet.
</success_criteria>
<output>
Create `.planning/phases/15-journal-plugin/15-01-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,267 @@
---
phase: 15-journal-plugin
plan: 02
type: execute
wave: 2
depends_on: ["15-01"]
files_modified:
- ../sm-journal-plugin/plugin.go
- ../sm-journal-plugin/admin.go
- ../sm-journal-plugin/admin_permissions.go
- ../sm-journal-plugin/admin_navigation.go
- ../sm-journal-plugin/assets/images/journal-icon.svg
- ../sm-journal-plugin/classes/format_html.go
- ../sm-journal-plugin/controllers/admin_registry.go
- ../sm-journal-plugin/controllers/posts.go
- ../sm-journal-plugin/controllers/categories.go
- ../sm-journal-plugin/controllers/tags.go
- ../sm-journal-plugin/controllers/posts/config_list.yaml
- ../sm-journal-plugin/controllers/posts/config_form.yaml
- ../sm-journal-plugin/controllers/posts/config_filter.yaml
- ../sm-journal-plugin/controllers/categories/config_list.yaml
- ../sm-journal-plugin/controllers/categories/config_form.yaml
- ../sm-journal-plugin/controllers/tags/config_list.yaml
- ../sm-journal-plugin/controllers/tags/config_form.yaml
- ../sm-journal-plugin/models/post.go
- ../sm-journal-plugin/models/post/fields.yaml
- ../sm-journal-plugin/models/post/columns.yaml
- ../sm-journal-plugin/models/category/fields.yaml
- ../sm-journal-plugin/models/category/columns.yaml
- ../sm-journal-plugin/models/tag/fields.yaml
- ../sm-journal-plugin/models/tag/columns.yaml
- ../sm-journal-plugin/models/settings.go
- ../sm-journal-plugin/models/settings/fields.yaml
- ../sm-journal-plugin/console/export_posts.go
- ../sm-journal-plugin/console/import_posts.go
- ../sm-journal-plugin/posts_admin_smoke_test.go
- ../sm-journal-plugin/go.mod
- ../sm-journal-plugin/README.md
- ../sm-grzybyfunkcjonalne-app/boot_test.go
autonomous: true
requirements: [D-04, D-06, D-08, D-10, D-11, D-13]
estimate:
tokens: 100000
raw_tokens: 100000
tasks: 3
confidence: low
must_haves:
truths:
- "D-04/D-08: Posts, Categories, and Tags admin controllers are YAML-driven, permission-gated, and the nav item embeds assets/images/journal-icon.svg."
- "D-11: post content YAML type is mlmarkdown; cabana markdown/mltext/mlmarkdown already shipped in 14.2.1 and are not re-added."
- "D-10: title, slug, excerpt, content use mltext/mlmarkdown so translatable attributes stay translatable."
- "D-13: journal:export-posts and journal:import-posts are registered, and Posts toolbar actions require golem15.journal.access_import_export."
- "FormatHTML regenerates content_html on admin save using goldmark footnote/table/attribute extensions plus cabana's rejectUnsafe gate, without editing cabana.RenderMarkdown."
- "Without golem15.journal.access_other_posts, list/form queries restrict to user_id of the backend principal; publish writes without golem15.journal.access_publish return ForbiddenError."
artifacts:
- path: "../sm-journal-plugin/controllers/posts.go"
provides: "golem15.journal.posts controller, ListExtendQuery, FormBeforeCreate, FormatHTML hook"
contains: "golem15.journal.access_posts"
- path: "../sm-journal-plugin/models/post/fields.yaml"
provides: "adapted cabana-legal post form including mlmarkdown content"
contains: "mlmarkdown"
- path: "../sm-journal-plugin/assets/images/journal-icon.svg"
provides: "embedded admin nav SVG (D-08)"
contains: "svg"
- path: "../sm-journal-plugin/classes/format_html.go"
provides: "journal.FormatHTML"
contains: "func FormatHTML"
- path: "../sm-journal-plugin/console/export_posts.go"
provides: "journal:export-posts"
contains: "journal:export-posts"
- path: "../sm-journal-plugin/models/settings/fields.yaml"
provides: "HasSettings code journal"
contains: "search_use_typesense"
key_links:
- from: "../sm-journal-plugin/controllers/posts.go"
to: "../sm-journal-plugin/classes/format_html.go"
via: "FormBeforeCreate/Update regenerate content_html"
pattern: "FormatHTML"
- from: "../sm-journal-plugin/admin_permissions.go"
to: "../sm-journal-plugin/controllers/posts.go"
via: "RequiredPermissions golem15.journal.access_posts"
pattern: "access_posts"
- from: "../sm-journal-plugin/console/export_posts.go"
to: "../sm-journal-plugin/plugin.go"
via: "HasCommands registers PHP command names"
pattern: "journal:export-posts"
prohibitions:
- requirement_id: D-11
category: architecture
statement: "Do not add a second cabana YAML field type and do not edit modules/cabana/field_markdown.go or cabana.RenderMarkdown"
status: resolved
verification: test
- requirement_id: D-13
category: architecture
statement: "Do not port Winter.Pages menu item types or an admin dashboard report widget"
status: resolved
verification: test
- requirement_id: D-06
category: privacy
statement: "Do not ship the remaining 19 PHP locales beyond en and pl"
status: resolved
verification: test
---
## Phase Goal
**As a** application developer, **I want to** mount `sm-journal-plugin` in a host the same way `sm-user-plugin` mounts, **so that** a blog can run on SummerCMS without the PHP plugin.
This plan's slice: an administrator with `golem15.journal.*` can create a Post whose `mlmarkdown` content is stored and whose `content_html` is regenerated through plugin FormatHTML.
<objective>
Ship YAML admin for Posts, Categories, Tags, and Settings, embed the nav SVG, implement FormatHTML, and register CSV import/export CLI plus toolbar actions.
Purpose: operators can manage Journal content in the existing admin SPA without PHP widgets or illegal YAML keys.
Output: adapted YAML, permissions, navigation, FormatHTML, commands, Posts admin smoke.
</objective>
<execution_context>
@~/.codex/gsd-core/workflows/execute-plan.md
@~/.codex/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/15-journal-plugin/15-CONTEXT.md
@.planning/phases/15-journal-plugin/15-RESEARCH.md
@.planning/phases/15-journal-plugin/15-PATTERNS.md
@../sm-journal-plugin/plugin.go
@../fonoteka.go/plugins/golem15/user/admin.go
@../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
@modules/cabana/form_schema.go
@modules/cabana/field_markdown.go
@docs/backend/forms.md
</context>
## Spec-less probe fallback
Visible skip: no REQUIREMENTS.md IDs. D-04/D-06/D-08/D-10/D-11/D-13 and RESEARCH §3–§4 are the contract. D-11 is a no-op field-type addition: Journal uses existing mlmarkdown.
## Artifacts this phase produces
- Controllers `golem15.journal.posts|categories|tags`, Settings code `journal`, permission codes copied from PHP Plugin.php 55-90.
- Adapted fields/columns/list/filter YAML using only cabana-legal types and keys.
- Embedded `assets/images/journal-icon.svg`.
- `journal.FormatHTML` in the plugin; goldmark v1.8.6 required only if this file imports it.
- Commands `journal:export-posts` / `journal:import-posts` and Posts toolbar actions gated by `golem15.journal.access_import_export`.
<tasks>
<task type="tracer">
<name>Task 1: Create one Post through cabana with mlmarkdown and FormatHTML</name>
<reversibility rating="costly">D-10/D-11 couple post content to cabana mlmarkdown and translate nested maps; changing the YAML type later is an admin contract change.</reversibility>
<files>../sm-journal-plugin/plugin.go, ../sm-journal-plugin/admin.go, ../sm-journal-plugin/admin_permissions.go, ../sm-journal-plugin/admin_navigation.go, ../sm-journal-plugin/assets/images/journal-icon.svg, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/controllers/admin_registry.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/posts/config_list.yaml, ../sm-journal-plugin/controllers/posts/config_form.yaml, ../sm-journal-plugin/controllers/posts/config_filter.yaml, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/models/post/fields.yaml, ../sm-journal-plugin/models/post/columns.yaml, ../sm-journal-plugin/posts_admin_smoke_test.go, ../sm-journal-plugin/go.mod</files>
<read_first>.planning/phases/15-journal-plugin/15-CONTEXT.md (D-08, D-10, D-11), .planning/phases/15-journal-plugin/15-RESEARCH.md (§3 YAML rewrite, §4 D-11 already shipped, FormatHTML, permissions), .planning/phases/15-journal-plugin/15-PATTERNS.md (admin.go, posts controller, YAML rewrite table, format_html.go), ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_permissions.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, modules/cabana/form_schema.go (formFieldTypes and allowed keys), modules/cabana/filter_schema.go, modules/cabana/field_markdown.go (rejectUnsafeMarkdownHTML), docs/backend/forms.md (mltext/mlmarkdown), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/Plugin.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/post/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/Posts.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/assets/images/journal-icon.svg, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (formatHtml, canEdit, filterFields, beforeSave)</read_first>
<action>Implement HasAdminControllers, HasPermissions, HasNavigation. Embed YAML file-by-file (not the whole controllers directory). Copy permission codes exactly: golem15.journal.manage_settings, access_posts, access_categories, access_other_posts, access_import_export, access_publish, access_tags. Roles developer like the user plugin. cabana.Allows is OR.
Controller ID golem15.journal.posts, ModelName Golem15\Journal\Models\Post, ConfigDir controllers/posts, RequiredPermissions golem15.journal.access_posts. ListExtendQuery and FormExtendQuery: without access_other_posts, where user_id equals bouncer.User principal ID. FormBeforeCreate stamps user_id from the backend principal when empty. Publish writes (published true or published_at set) without access_publish return cabana.ForbiddenError — hiding fields is optional UX; refuse is mandatory.
Rewrite post fields.yaml to cabana-legal keys only (D-04, D-10, D-11). title mltext; slug mltext plus preset field title type slug; content mlmarkdown (not a PHP form widget class); excerpt mltext; categories relation nameFrom name; tags relation nameFrom name (create tags on the Tags admin, not an on-the-fly list widget); published switch; is_pinned checkbox; user relation nameFrom login emptyOption current user; published_at datepicker mode datetime; featured_images fileupload mode image imageWidth/imageHeight 200. Omit the sources repeater field from the admin form (keep JSONB; API still accepts sources in Plan 03). Omit metadata preview_page (Phase 16). Omit toolbar partial. Drop PHP keys cabana refuses (stretch, cssClass, commentAbove, widget class, paneCssClass). D-11 is already shipped in 14.2.1 — do not add a YAML type and do not edit modules/cabana.
config_filter.yaml: no raw SQL condition keys (cabana boot-fails those). published switch via FilterPublished; published_date daterange column created_at; category scope FilterCategories including child categories as PHP does. Implement FilterScopes on Post. List columns may use type date. recordsPerPage 25. recordUrl golem15/journal/posts/update/:id.
Copy PHP assets/images/journal-icon.svg bytes into the Go plugin (D-08). Navigation Code journal, Permissions golem15.journal.*, Order 300, Controller golem15.journal.posts, icon pencil or icon-pencil (existing lucide map). Side menu new_post, posts, categories, tags with PHP permission lists. Skip dashboard widget.
Implement journal.FormatHTML in classes/format_html.go: goldmark with footnote, table, and parser attribute extensions from the existing github.com/yuin/goldmark module (A1 — if a separate module is required, stop and do not add it). Reuse the same rejectUnsafeMarkdownHTML checks as cabana (script, iframe, event handlers, javascript/vbscript/data schemes). Do not change cabana.RenderMarkdown (pitfall 15). If FormatHTML imports goldmark, require github.com/yuin/goldmark v1.8.6 in plugin go.mod. Call FormatHTML from FormBeforeCreate and FormBeforeUpdate so stored content_html matches admin saves, not only API writes. Ignore use_rich_editor when compiling the form (always mlmarkdown; WYSIWYG deferred).
Smoke TestPostsFormCompiles / TestPostsAdminCreateSmoke: cabana.Activate compiles the posts form; a privileged backend principal creates a post with nested en/pl title and content maps; English lands on host columns; Polish reaches translate attributes; content_html is non-empty and contains no script tags. Full PHPUnit map stays Plan 04.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestPostsFormCompiles|TestPostsAdminCreateSmoke)$'</automated>
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; either named PASS line is absent.</fails_when>
</verify>
<acceptance_criteria>
- models/post/fields.yaml contains type mlmarkdown for content and type mltext for title, slug, excerpt.
- models/post/fields.yaml does not use PHP widget class names, on-the-fly tag widgets, or source-repeater fields.
- controllers/posts/config_filter.yaml compiles and does not use cabana-illegal filter keys.
- assets/images/journal-icon.svg is embedded and byte-comparable to the PHP SVG.
- FormatHTML lives in the plugin; modules/cabana/field_markdown.go is unmodified in this plan.
- Creating a post without access_publish cannot persist published=true.
- Host/plugin README still does not name a consuming application.
</acceptance_criteria>
<done>An administrator can create a translatable markdown Post through cabana, with content_html regenerated by plugin FormatHTML.</done>
</task>
<task type="auto">
<name>Task 2: Categories, Tags, Settings screens and remaining query guards</name>
<files>../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/controllers/tags.go, ../sm-journal-plugin/controllers/categories/config_list.yaml, ../sm-journal-plugin/controllers/categories/config_form.yaml, ../sm-journal-plugin/controllers/tags/config_list.yaml, ../sm-journal-plugin/controllers/tags/config_form.yaml, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/models/category/columns.yaml, ../sm-journal-plugin/models/tag/fields.yaml, ../sm-journal-plugin/models/tag/columns.yaml, ../sm-journal-plugin/models/settings.go, ../sm-journal-plugin/models/settings/fields.yaml, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/admin.go, ../sm-grzybyfunkcjonalne-app/boot_test.go</files>
<read_first>.planning/phases/15-journal-plugin/15-PATTERNS.md (categories/tags controllers, settings analog, YAML), ../sm-translate-plugin/controllers/locales.go, ../sm-translate-plugin/models/locale/fields.yaml, modules/cabana/example_controller_test.go (Settings), docs/backend/settings.md, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/Categories.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/Tags.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/category/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/tag/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/settings/fields.yaml, ../sm-grzybyfunkcjonalne-app/boot_test.go</read_first>
<action>Add controllers golem15.journal.categories (access_categories) and golem15.journal.tags (access_tags). Category parent_id is a relation; keep nest_* off the form. Tag fields name, slug, description only.
HasSettings item Code journal, Permissions golem15.journal.manage_settings, Form models/settings/fields.yaml, NewModel Settings. Rewrite settings YAML: drop every show/hide trigger block so search weight fields always display; drop placeholder keys (use comment). Keep use_rich_editor stored; do not switch the post editor off mlmarkdown.
Extend host TestBootUserTranslateJournal to assert controller IDs golem15.journal.posts, golem15.journal.categories, golem15.journal.tags and settings code journal are registered. Still do not require /_journal/api/v1 routes (Plan 03).
canEdit on Post: owner or access_other_posts. Use it in FormExtendQuery/update/delete paths so a second admin without access_other_posts cannot open another author's post.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test ./... -short -count=1 &amp;&amp; go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'</automated>
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; host run lacks "--- PASS: TestBootUserTranslateJournal".</fails_when>
</verify>
<acceptance_criteria>
- AdminControllers include golem15.journal.posts, golem15.journal.categories, golem15.journal.tags.
- Settings() includes Code journal with manage_settings.
- models/settings/fields.yaml has search_use_typesense default off and always-visible weight fields.
- Host boot test asserts the three Journal controller IDs.
- Category form has no nest_left field.
</acceptance_criteria>
<done>All three Journal admin screens and the settings singleton are registered and boot in the proof host.</done>
</task>
<task type="auto">
<name>Task 3: Register journal:export-posts and journal:import-posts plus Posts toolbar</name>
<files>../sm-journal-plugin/console/export_posts.go, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/README.md</files>
<read_first>.planning/phases/15-journal-plugin/15-CONTEXT.md (D-13), .planning/phases/15-journal-plugin/15-RESEARCH.md (Import/export), .planning/phases/15-journal-plugin/15-PATTERNS.md (console, HasAdminActions), ../fonoteka.go/plugins/golem15/user/console/require_password_change.go, modules/pact/capabilities.go (HasCommands, HasAdminActions), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/Plugin.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/console/ExportPosts.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/console/ImportPosts.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/PostExport.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/PostImport.php</read_first>
<action>Add TestJournalCommands that asserts Commands() contains journal:export-posts and journal:import-posts. Implement pact.HasCommands. Names journal:export-posts and journal:import-posts matching PHP Plugin.php 169-170 (D-13). Port PostExport/PostImport column sets; flags --path and --dry-run as PHP. Do not invent a generic CSV framework. Do not add Winter ImportExport behavior.
On Posts, HasAdminActions toolbar buttons with Permissions golem15.journal.access_import_export. Names must not be create or delete (reserved). Actions call the same import/export column logic as the CLI.
Document the two command names in the plugin README. Keep examples as blog. Do not implement Pages menu types or a dashboard widget.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalCommands)$'</automated>
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; lacks "--- PASS: TestJournalCommands".</fails_when>
</verify>
<acceptance_criteria>
- Plugin Commands() includes journal:export-posts and journal:import-posts.
- Posts AdminActions include import/export names whose Permissions contain golem15.journal.access_import_export.
- README documents both command names.
- No Pages menu-type registration and no dashboard widget type appear in plugin.go or admin_navigation.go.
</acceptance_criteria>
<done>CSV import/export is available from CLI and from the Posts toolbar for operators holding access_import_export.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin JSON → cabana CRUD | Untrusted form maps cross permission and Fillable |
| Markdown source → content_html | Stored HTML can carry active markup |
| Toolbar/CLI → posts table | Import must not bypass fillable or publish permission |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-15-07 | Elevation of Privilege | Posts admin | high | mitigate | RequiredPermissions, ListExtendQuery owner scope, access_other_posts, 403 smoke in Plan 04 |
| T-15-08 | Tampering | FormatHTML | high | mitigate | rejectUnsafe on stored HTML; do not enable unsafe goldmark HTML globally |
| T-15-09 | Elevation of Privilege | access_publish | high | mitigate | ForbiddenError on publish writes without grant |
| T-15-SC | Tampering | package installs | high | mitigate | goldmark v1.8.6 only if FormatHTML imports it; already in the framework graph |
ASVS L1: high threats mitigated here; fail-closed tests in Plan 04.
</threat_model>
<verification>
Run all three task commands, then go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1.
</verification>
<success_criteria>
- Posts form compiles with mlmarkdown and an admin can create a post.
- Categories, Tags, and Settings are registered.
- FormatHTML is plugin-local; cabana.RenderMarkdown unchanged.
- Import/export commands and toolbar exist.
- D-11 adds no new field type. Pages/dashboard/extra locales remain absent.
</success_criteria>
<output>
Create `.planning/phases/15-journal-plugin/15-02-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,263 @@
---
phase: 15-journal-plugin
plan: 03
type: execute
wave: 3
depends_on: ["15-02"]
files_modified:
- ../sm-journal-plugin/plugin.go
- ../sm-journal-plugin/routes.go
- ../sm-journal-plugin/controllers/api/posts.go
- ../sm-journal-plugin/controllers/api/posts_test.go
- ../sm-journal-plugin/controllers/api/media.go
- ../sm-journal-plugin/controllers/api/auth.go
- ../sm-journal-plugin/search.go
- ../sm-journal-plugin/models/post.go
- ../sm-journal-plugin/README.md
- ../sm-journal-plugin/journal_public_list_smoke_test.go
- ../sm-grzybyfunkcjonalne-app/boot_test.go
autonomous: true
requirements: [D-12, D-14, D-15, D-16, D-17]
estimate:
tokens: 110000
raw_tokens: 110000
tasks: 3
confidence: low
must_haves:
truths:
- "D-14: anonymous GET /_journal/api/v1/posts, posts/{slug}, categories, tags, and rss work; writes use the same prefix."
- "D-15: POST/PUT/DELETE posts, featured-images, and media/upload require a cabana backend JWT (audience backend); missing Bearer returns JSON {error:Authentication required}."
- "D-17: buckets journal-public-api and journal-api are Max 120 per minute; 429 body stays surf Too Many Attempts."
- "JOURNAL-005: unpublished or future published_at show is 404 with no data key unless owner or access_other_posts."
- "D-12: search_use_typesense defaults false; Post ShouldBeSearchable is false when unpublished or the beachcomber Gate is off; a fresh save makes zero Typesense HTTP."
- "routes.php wins: show is GET posts/{slug}; list per_page default 9 max 30."
artifacts:
- path: "../sm-journal-plugin/routes.go"
provides: "public and write groups under /_journal/api/v1"
contains: "/_journal/api/v1"
- path: "../sm-journal-plugin/controllers/api/posts.go"
provides: "PHP {error} string JSON, slug show, draft 404"
contains: "Authentication required"
- path: "../sm-journal-plugin/controllers/api/media.go"
provides: "JOURNAL-006 media upload under journal/ prefix"
contains: "media/upload"
- path: "../sm-journal-plugin/plugin.go"
provides: "Buckets journal-public-api and journal-api"
contains: "journal-public-api"
- path: "../sm-journal-plugin/search.go"
provides: "beachcomber Gate on golem15_journal_settings.search_use_typesense"
contains: "search_use_typesense"
key_links:
- from: "../sm-journal-plugin/routes.go"
to: "../sm-journal-plugin/controllers/api/auth.go"
via: "writes authenticate backend JWT in-handler; public GET optionally verifies Bearer"
pattern: "backend"
- from: "../sm-journal-plugin/controllers/api/posts.go"
to: "../sm-journal-plugin/classes/format_html.go"
via: "store/update regenerate content_html"
pattern: "FormatHTML"
- from: "../sm-journal-plugin/search.go"
to: "../sm-journal-plugin/models/settings.go"
via: "Gate reads ID=1 search_use_typesense; errors count as off"
pattern: "SetGate"
prohibitions:
- requirement_id: D-15
category: safety
statement: "Write routes must not accept frontend sm-user-plugin personal tokens and must not accept Apparatus personal tokens"
status: resolved
verification: test
- requirement_id: D-14
category: architecture
statement: "Journal API errors must stay flat PHP {error} string JSON and must not use the cabana admin error envelope"
status: resolved
verification: test
- requirement_id: D-12
category: safety
statement: "A fresh install must not contact Typesense; search_use_typesense stays false by default"
status: resolved
verification: test
- requirement_id: D-16
category: architecture
statement: "Journal must not be added to the Płytarium tide 154-route harness"
status: resolved
verification: test
---
## Phase Goal
**As a** application developer, **I want to** mount `sm-journal-plugin` in a host the same way `sm-user-plugin` mounts, **so that** a blog can run on SummerCMS without the PHP plugin.
This plan's slice: anonymous GET `/_journal/api/v1/posts` returns published posts, and a backend-Bearer POST creates one.
<objective>
Ship the full /_journal/api/v1 surface with PHP shapes, named limiter buckets, optional editor on GET, required backend Bearer on writes, media upload, RSS, and Typesense gate off by default.
Purpose: Phase 16 views and importers can call the frozen PHP contract without Winter.
Output: routes, API controllers, buckets, search gate, public-list smoke.
</objective>
<execution_context>
@~/.codex/gsd-core/workflows/execute-plan.md
@~/.codex/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/15-journal-plugin/15-CONTEXT.md
@.planning/phases/15-journal-plugin/15-RESEARCH.md
@.planning/phases/15-journal-plugin/15-PATTERNS.md
@../sm-journal-plugin/plugin.go
@../fonoteka.go/plugins/golem15/user/routes.go
@modules/surf/limiter.go
@modules/cabana/http.go
@modules/bouncer/jwt.go
@modules/beachcomber/searchable.go
</context>
## Spec-less probe fallback
Visible skip: no REQUIREMENTS.md IDs. D-12/D-14/D-15/D-16/D-17 and RESEARCH §5–§6 are the contract. Do not claim API-09/QA-05.
## Artifacts this phase produces
- Public GETs and backend-authenticated writes under `/_journal/api/v1` as RESEARCH route table.
- Plugin `surf.BucketProvider` buckets `journal-public-api` and `journal-api`.
- Plugin-owned backend JWT verify that writes PHP `{error}` strings, not cabana admin envelopes.
- Media upload JOURNAL-006; RSS XML; beachcomber Gate default off.
<tasks>
<task type="tracer">
<name>Task 1: Serve anonymous GET /_journal/api/v1/posts as a published list</name>
<reversibility rating="one-way">D-14 public prefix /_journal/api/v1 and list envelope become the Phase 16 contract; changing them later breaks views and importers. Locked in CONTEXT — do not re-ask.</reversibility>
<files>../sm-journal-plugin/plugin.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/auth.go, ../sm-journal-plugin/journal_public_list_smoke_test.go</files>
<read_first>.planning/phases/15-journal-plugin/15-CONTEXT.md (D-14, D-15, D-16, D-17), .planning/phases/15-journal-plugin/15-RESEARCH.md (§5 route table, routes.php vs API.md, optional editor, PHP error strings, limiters, Pitfall 3 and 4 and 12), .planning/phases/15-journal-plugin/15-PATTERNS.md (routes.go, API controllers, buckets), ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/plugin.go (Buckets), modules/surf/limiter.go, modules/pact/capabilities.go (HasRoutes), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/routes.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/PostApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/API.md</read_first>
<action>Implement pact.HasRoutes and surf.BucketProvider on Plugin (D-17). Buckets journal-public-api and journal-api, Max 120, Decay one minute. Public key is journal-public| plus surf.ClientIP with TrustedProxies. Write bucket key is journal-api|u: plus backend principal ID when bouncer.User is a backend principal, else ClientIP. 429 body remains surf {"message":"Too Many Attempts."} — do not invent a Journal 429 shape.
Two groups, same prefix /_journal/api/v1 (D-14). Public group middleware throttle:journal-public-api only. Register GET posts, posts/{slug} with Where slug [a-z0-9][a-z0-9\-/]*, categories, tags, rss. Do not attach cabana middleware name backend to the public group (missing token would 401 anonymous callers — pitfall 4).
Implement GET posts index now as the tracer: anonymous callers filter published=true (and published_at not in the future). per_page default 9 max 30 (controller, not API.md 15/50). Envelope {data, meta{current_page,last_page,per_page,total}} with empty arrays as []. Do not apply UNPUBLISHED_TITLE_PREFIX on JSON. Use wire.WriteJSON. Do not wrap errors in the cabana admin envelope (pitfall 3).
Optional editor (PHP isEditor): if Authorization Bearer is present, Lookup *bouncer.Registry is not enough to avoid UnauthorizedWriter — Registry.Middleware(backend) writes cabana unauthenticated. Instead, plugin helper requireBackendPrincipal / optionalBackendPrincipal constructs bouncer.NewBackendJWTGuard with the host admin.jwt.secret, audience backend, and backend_jwt_blacklist, using a PHP-shaped writer only on the write path. On public GET, call Authenticate only when the Bearer header is present; on failure treat as anonymous and do not write 401. If principal has golem15.journal.access_posts, index may include drafts unless ?published=true. author filter is editors only.
Do not add Journal routes to fonoteka.go tide fixtures (D-16).
Smoke TestJournalPublicList: assemble plugin routes, seed one published and one draft post, GET /_journal/api/v1/posts without Authorization returns 200 and only the published row.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalPublicList|TestJournalBuckets)$'</automated>
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; either named PASS line is absent.</fails_when>
</verify>
<acceptance_criteria>
- GET /_journal/api/v1/posts without Authorization is 200 and omits drafts.
- Buckets() contains journal-public-api and journal-api with Max 120.
- Public group registration does not use cabana middleware name backend.
- Default per_page in index source is 9 and max is 30.
- No tide/parity harness file in fonoteka.go is modified.
</acceptance_criteria>
<done>Anonymous clients can list published Journal posts at the PHP prefix with the PHP limiter names.</done>
</task>
<task type="auto">
<name>Task 2: Slug show, draft 404, and backend-Bearer writes</name>
<reversibility rating="costly">D-15 write auth is backend Bearer only; clients that later expect Apparatus personal tokens need a second guard (deferred todo).</reversibility>
<files>../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_test.go, ../sm-journal-plugin/controllers/api/auth.go, ../sm-journal-plugin/plugin.go</files>
<read_first>.planning/phases/15-journal-plugin/15-RESEARCH.md (Draft visibility JOURNAL-005, Serialize, error strings, write group, translations object, featured-images), .planning/phases/15-journal-plugin/15-PATTERNS.md (API error analog, write auth, optional editor), modules/cabana/http.go (writeUnauthenticated — do not reuse on this API), modules/bouncer/jwt.go (NewBackendJWTGuard, AudienceBackend), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/PostApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/security/AccessControlTest.php</read_first>
<action>Register write group: same prefix, throttle:journal-api, required backend principal in-handler (D-15). POST /posts, PUT /posts/{id}, DELETE /posts/{id}, POST /posts/{id}/featured-images, DELETE /posts/{id}/featured-images/{fileId}. Where id [0-9]+. Numeric public show is the slug route with ctype_digit fallback to id (PHP 192-201). Register writes so they do not steal the slug GET.
PHP error strings via wire.WriteJSON: 401 Authentication required; 403 Insufficient permissions or You do not have permission to publish posts; 404 Post not found with no data key; 422 Validation failed plus errors map. Do not emit Apparatus-token copy from stale API.md.
Writes: verify backend JWT HS256 audience backend and blacklist backend_jwt_blacklist. Reject frontend user JWTs (wrong audience). Do not implement Apparatus personal-token parsing. Store/update assign field-by-field (title, slug, content, excerpt, published, published_at, is_pinned, sources, metadata, category/tag ids, translations.* via translate SetTranslated). Never lagoon.Fill the whole body onto Post. Stamp user_id from principal on create. access_posts required; canEdit on update/delete; access_publish for publish flags. Regenerate content_html with FormatHTML.
Show: unpublished or published_at in the future is unpublished. 404 no data unless caller is owner (user_id) or holds access_other_posts (JOURNAL-005). Anonymous always 404 for drafts (never 403 that confirms existence). Include previous_post, next_post, related_posts on show. Do not prefix titles with the unpublished lock emoji.
GET categories and GET tags list public serialized rows (D-14). Categories JSON is {data} of id, name, slug, description, parent_id, nest_depth, post_count, optional children; omit zero-published-post categories unless include_empty=1. Tags JSON is {data} of id, name, slug, post_count ordered by name. Empty data is [].
Keep featured-image upload/delete behind the write group and access_posts (D-15). Write TestJournalPublicCategories, TestJournalPublicTags, and TestJournalFeaturedImageUnauthenticated in posts_test.go so skipping those handlers fails this task: anonymous GET /_journal/api/v1/categories and /tags return 200 with the PHP list keys; POST /posts/{id}/featured-images and DELETE /posts/{id}/featured-images/{fileId} without Bearer return 401 with JSON error string Authentication required; a backend Bearer that cannot edit the post returns 403 with You do not have permission to edit this post.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalWriteUnauthenticated|TestJournal005DraftShow|TestJournalPublicCategories|TestJournalPublicTags|TestJournalFeaturedImageUnauthenticated)$'</automated>
<fails_when>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).</fails_when>
</verify>
<acceptance_criteria>
- POST /_journal/api/v1/posts without Bearer is 401 and the JSON error value is the string Authentication required.
- That 401 body is not the cabana admin unauthenticated envelope.
- Draft show to a stranger is 404 and the object has no data key.
- Owner or access_other_posts can GET a draft by slug or numeric id.
- Publish without access_publish is 403 with the PHP publish permission string.
- Frontend-audience JWTs do not authorize writes.
- Anonymous GET /_journal/api/v1/categories is 200 with PHP {data} rows of id, name, slug, description, parent_id, nest_depth, post_count.
- Anonymous GET /_journal/api/v1/tags is 200 with PHP {data} rows of id, name, slug, post_count.
- POST and DELETE featured-images without Bearer are 401 with JSON error string Authentication required.
- Featured-image writes with a backend Bearer that cannot edit the post are 403 with You do not have permission to edit this post.
</acceptance_criteria>
<done>Editors can create and edit posts with backend Bearer, and drafts do not leak to anonymous clients.</done>
</task>
<task type="auto">
<name>Task 3: Media upload, RSS, Typesense gate, and host route assertion</name>
<files>../sm-journal-plugin/controllers/api/media.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_test.go, ../sm-journal-plugin/search.go, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/README.md, ../sm-grzybyfunkcjonalne-app/boot_test.go</files>
<read_first>.planning/phases/15-journal-plugin/15-CONTEXT.md (D-12), .planning/phases/15-journal-plugin/15-RESEARCH.md (Media JOURNAL-006, Search D-12, RSS, Pitfall 6 and 11), .planning/phases/15-journal-plugin/15-PATTERNS.md (media.go, Searchable analog), modules/beachcomber/searchable.go, modules/beachcomber/example_test.go (installGate), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/MediaApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (shouldBeSearchable, searchableAs), ../sm-grzybyfunkcjonalne-app/boot_test.go</read_first>
<action>POST /_journal/api/v1/media/upload: require backend principal plus access_posts (JOURNAL-006). Folder regex ^[A-Za-z0-9_\-/]*$; force under journal/; strip a leading journal segment; reject .. . Image MIME jpg/jpeg/png/gif/webp; max 10240 KB in the handler even if host upload_bytes is larger. 201 {data:{url,path}}. Use gocloud blob already in the host graph.
GET rss: stdlib encoding/xml, PHP PostApiController::rss contract (D-14). Honor rss_* settings; empty/disabled still a well-formed feed. Write TestJournalRSS in posts_test.go: anonymous GET /_journal/api/v1/rss is 200, Content-Type application/rss+xml, body parses as RSS 2.0 with a channel, channel title follows rss_title, item count follows rss_posts_per_feed, unpublished posts are omitted, rss_include_content false omits content:encoded, rss_enabled false still returns well-formed XML. Route registration alone does not satisfy this task.
Post SearchableAs golem15_journal_posts. Set beachcomber.Gate from golem15_journal_settings.search_use_typesense for ID=1; read errors count as off (D-12). ShouldBeSearchable false when unpublished or gate off. List search: if query length at least 3 and gate on, SearchPage then SQL re-gate; on engine error log and ILIKE fallback. Fresh install never dials Typesense. Do not enable the setting by default.
Extend host TestBootUserTranslateJournal to assert assembled routes include GET /_journal/api/v1/posts and POST /_journal/api/v1/posts. Document the public prefix in the plugin README with blog examples.
Index search wiring may be a method on Post plus search.go Gate install at Boot.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournal006MediaUpload|TestSearchGateOff|TestJournalRSS)$' &amp;&amp; go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'</automated>
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; any of the named PASS lines is absent (TestJournal006MediaUpload, TestSearchGateOff, TestJournalRSS, TestBootUserTranslateJournal).</fails_when>
</verify>
<acceptance_criteria>
- Media upload without access_posts is 403; with permission is 201 and path is under journal/.
- Folder values containing .. are rejected.
- TestSearchGateOff saves a published post with default settings and records zero outbound search HTTP.
- Host boot test sees GET and POST /_journal/api/v1/posts.
- Anonymous GET /_journal/api/v1/rss is 200, Content-Type is application/rss+xml, body is well-formed RSS 2.0, channel title follows rss_title, item count follows rss_posts_per_feed, unpublished posts are omitted.
</acceptance_criteria>
<done>Media, RSS, and the off-by-default search gate complete the D-14/D-12 HTTP surface.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Anonymous HTTP → public GET | Untrusted clients must not see drafts |
| Bearer header → write handlers | Only backend-audience JWTs may mutate posts |
| Multipart folder/path → blob | Traversal must not write outside journal/ |
| Post save → Typesense | Gate off must produce zero network |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-15-01 | Spoofing | POST /_journal/api/v1/posts | high | mitigate | In-handler backend JWT; 401 Authentication required; reject frontend audience |
| T-15-02 | Information Disclosure | GET posts/{slug} drafts | high | mitigate | 404 without data unless owner or access_other_posts (JOURNAL-005) |
| T-15-03 | Tampering | POST /media/upload | high | mitigate | access_posts, folder regex, forced journal/ prefix, MIME/size (JOURNAL-006) |
| T-15-10 | Spoofing | write API tokens | high | mitigate | Backend aud only; no frontend personal token; no Apparatus personal tokens |
| T-15-11 | Information Disclosure | Typesense sync | high | mitigate | Gate default off; unpublished not searchable |
| T-15-12 | Denial of Service | X-Forwarded-For | medium | mitigate | surf.TrustedProxies + ClientIP on both buckets |
| T-15-13 | Tampering | error envelope | high | mitigate | PHP {error} string writer; do not call cabana admin error helper |
| T-15-SC | Tampering | package installs | high | mitigate | No new packages |
ASVS L1: all high threats mitigated; Plan 04 removal tests. block_on high.
</threat_model>
<verification>
Run all three task commands plus go -C ../sm-journal-plugin test ./... -short -count=1.
</verification>
<success_criteria>
- Anonymous list/show/categories/tags/rss work.
- Writes require backend Bearer with PHP error strings.
- Drafts 404 to strangers; media stays under journal/.
- Typesense is never contacted on a fresh install.
- Journal is not in the Płytarium tide harness.
</success_criteria>
<output>
Create `.planning/phases/15-journal-plugin/15-03-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,287 @@
---
phase: 15-journal-plugin
plan: 04
type: execute
wave: 4
depends_on: ["15-03"]
files_modified:
- ../sm-journal-plugin/updates/postgres_test.go
- ../sm-journal-plugin/updates/migrations_test.go
- ../sm-journal-plugin/models/fillable_test.go
- ../sm-journal-plugin/models/translatable_test.go
- ../sm-journal-plugin/classes/format_html_test.go
- ../sm-journal-plugin/admin_harness_test.go
- ../sm-journal-plugin/controllers/api/posts_test.go
- ../sm-journal-plugin/controllers/api/media_test.go
- ../sm-journal-plugin/search_test.go
- ../sm-journal-plugin/integration_test.go
- ../sm-grzybyfunkcjonalne-app/boot_test.go
- scripts/check-phase15.sh
- .planning/phases/15-journal-plugin/15-SECURITY-REVIEW.md
- .planning/phases/15-journal-plugin/15-VALIDATION.md
autonomous: true
requirements: [D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19, D-20, D-21, D-22, D-23]
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "D-05: every RESEARCH §8 PHPUnit row has a named Go test (JOURNAL-001/002 fillable, JOURNAL-005 draft 404, JOURNAL-006 media, FormatHTML substitutes JOURNAL-003/004 templates, redactor_id not fillable)."
- "Real Postgres migrates all golem15_journal_* tables plus author_slug, rolls back, and remigrates."
- "Anonymous list hides drafts; GET categories and tags return 200 PHP {data} lists; GET rss is well-formed RSS 2.0 honoring rss_*; write without Bearer is 401 Authentication required including featured-images POST/DELETE; Typesense gate off records zero HTTP."
- "Host Activate still returns user+translate+journal; CORS includes _journal/api/*; plugin README stays application-neutral."
- "scripts/check-phase15.sh --all is fail-closed; 15-SECURITY-REVIEW.md closes every high T-15-* threat."
artifacts:
- path: "../sm-journal-plugin/integration_test.go"
provides: "end-to-end migrate, admin create, public GET, Bearer write, draft 404"
contains: "TestJournalEndToEnd"
- path: "../sm-journal-plugin/models/fillable_test.go"
provides: "JOURNAL-001/002"
contains: "TestFillable"
- path: "../sm-journal-plugin/controllers/api/posts_test.go"
provides: "JOURNAL-005, 401 PHP shape, categories/tags lists, RSS XML, featured-image auth"
contains: "TestJournal005DraftShow"
- path: "scripts/check-phase15.sh"
provides: "fail-closed phase gate"
contains: "sm-journal-plugin"
- path: ".planning/phases/15-journal-plugin/15-SECURITY-REVIEW.md"
provides: "ASVS L1 threat evidence"
contains: "T-15-01"
key_links:
- from: "../sm-journal-plugin/integration_test.go"
to: "../sm-journal-plugin/routes.go"
via: "assembled public GET and Bearer POST through production handlers"
pattern: "TestJournalEndToEnd"
- from: "scripts/check-phase15.sh"
to: "../sm-journal-plugin/updates/migrations_test.go"
via: "full plugin suite with Docker/Postgres, not -short as final evidence"
pattern: "go -C"
- from: ".planning/phases/15-journal-plugin/15-SECURITY-REVIEW.md"
to: "../sm-journal-plugin/controllers/api/posts_test.go"
via: "each high threat cites an executed TestName"
pattern: "T-15-"
prohibitions:
- requirement_id: D-07
category: safety
statement: "Gate fails if the PHP journal tree at SHA 02110eb has a git diff"
status: resolved
verification: test
- requirement_id: D-16
category: architecture
statement: "Gate fails if fonoteka.go parity/tide files newly mention /_journal/api/v1"
status: resolved
verification: test
- requirement_id: D-12
category: safety
statement: "TestSearchGateOff must not skip and must observe zero search HTTP"
status: resolved
verification: test
---
## Phase Goal
**As a** application developer, **I want to** mount `sm-journal-plugin` in a host the same way `sm-user-plugin` mounts, **so that** a blog can run on SummerCMS without the PHP plugin.
This plan's slice: the last plan of the phase — full unit/integration coverage, PHPUnit map, phase gate, and security review.
<objective>
Finish Phase 15 with the dedicated test plan: PHPUnit behavioral map, migration rollback, HTTP/security cases, fail-closed gate, and ASVS L1 review.
Purpose: every locked D-ID and high threat fails closed when broken.
Output: test matrix, scripts/check-phase15.sh, 15-SECURITY-REVIEW.md, validated 15-VALIDATION.md.
</objective>
<execution_context>
@~/.codex/gsd-core/workflows/execute-plan.md
@~/.codex/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/15-journal-plugin/15-VALIDATION.md
@.planning/phases/15-journal-plugin/15-RESEARCH.md
@.planning/phases/15-journal-plugin/15-PATTERNS.md
@../sm-translate-plugin/updates/postgres_test.go
@../sm-translate-plugin/admin_harness_test.go
@scripts/check-phase14.2.1.sh
</context>
## Spec-less probe fallback
Visible skip: no REQUIREMENTS.md IDs and no phase SPEC Edge Coverage/Prohibitions to lift. Tests map to D-01..D-23, RESEARCH §8, and VALIDATION rows. Do not generate probe predicates. Do not claim API-09 or QA-05.
## API coverage
No external API integration: this phase ports a compiled plugin's own `/_journal/api/v1` surface and an optional beachcomber Gate that stays off; it does not integrate a third-party SaaS SDK. Do not fabricate a capability matrix.
## Artifacts this phase produces
- `TestJournalEndToEnd` spanning migrate, Activate, admin mlmarkdown save, anonymous list, Bearer write, JOURNAL-005, media 403.
- Real-Postgres up/down for all seven journal migrations.
- `scripts/check-phase15.sh` with plugin/host/PHP-pin/docs-neutral/security stages using `go -C ../sm-journal-plugin` and `go -C ../sm-grzybyfunkcjonalne-app`.
- `15-SECURITY-REVIEW.md` and completed `15-VALIDATION.md`.
## Multi-source coverage audit
| SOURCE | ID | Feature/Requirement | Plan | Status | Notes |
|---|---|---|---|---|---|
| GOAL | — | Port Golem15.Journal to sm-journal-plugin and mount in a host | 01-04 | COVERED | Schema, admin, API, tests |
| REQ | — | No mapped requirement IDs (TBD) | — | COVERED | Visible spec-less fallback; D-IDs used; API-09/QA-05 are Phase 20 |
| CONTEXT | D-01..D-04 | Frozen PHP pin, tables, YAML/API binding | 01-04 | COVERED | SHA asserted; PHP unchanged |
| CONTEXT | D-05 | PHPUnit map | 04 | COVERED | RESEARCH §8 |
| CONTEXT | D-06 | en+pl only | 01,04 | COVERED | Lang files + gate |
| CONTEXT | D-07 | No PHP edits | 01,04 | COVERED | git diff empty |
| CONTEXT | D-08 | Nav SVG | 02,04 | COVERED | Embedded bytes |
| CONTEXT | D-09 | Translate is a prior phase; Journal Requires it | 01,04 | COVERED | No Translate port inside Journal |
| CONTEXT | D-10 | Translatable attributes | 01-04 | COVERED | MorphName PHP strings |
| CONTEXT | D-11 | cabana markdown | 02,04 | COVERED | No-op; mlmarkdown only |
| CONTEXT | D-12 | Typesense off by default | 03,04 | COVERED | Gate + TestSearchGateOff |
| CONTEXT | D-13 | CSV CLI + toolbar | 02,04 | COVERED | TestJournalCommands |
| CONTEXT | D-14 | Full /_journal/api/v1 | 03,04 | COVERED | routes.php wins |
| CONTEXT | D-15 | Backend Bearer writes | 03,04 | COVERED | Not frontend tokens |
| CONTEXT | D-16 | Not tide | 03,04 | COVERED | Gate forbids harness add |
| CONTEXT | D-17 | Limiters + CORS | 01,03,04 | COVERED | Buckets + http.yaml |
| CONTEXT | D-18..D-23 | Proof host and remotes | 01,04 | COVERED | Three-plugin boot |
| RESEARCH | — | Squash golem15_journal_*; no rainlab-era names | 01,04 | COVERED | Migration tests |
| RESEARCH | — | YAML rewrite; FilterScopes | 02,04 | COVERED | Form compile tests |
| RESEARCH | — | FormatHTML plugin-local | 02,04 | COVERED | XSS substitute tests |
| RESEARCH | — | PHP {error} JSON; per_page 9/30; slug show | 03,04 | COVERED | API tests |
| RESEARCH | — | Sibling replace ../summercms.go | 01,04 | COVERED | Isolated go test |
Excluded (deferred / other phases): Phase 16 HTML/views/components; Winter.Pages menu types; dashboard widget; Apparatus personal tokens; 19 extra locales; WYSIWYG/redactor; editing wn-journal-plugin; tide 154-route harness; sitemap.
<tasks>
<task type="tracer">
<name>Task 1: Prove migrate → admin save → anonymous list → Bearer write → draft 404 end to end</name>
<files>../sm-journal-plugin/updates/postgres_test.go, ../sm-journal-plugin/admin_harness_test.go, ../sm-journal-plugin/integration_test.go, ../sm-grzybyfunkcjonalne-app/boot_test.go</files>
<read_first>../sm-translate-plugin/updates/postgres_test.go, ../sm-translate-plugin/admin_harness_test.go, ../sm-translate-plugin/integration_test.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/api/posts.go, .planning/phases/15-journal-plugin/15-VALIDATION.md, .planning/phases/15-journal-plugin/15-RESEARCH.md (§8 PHPUnit map)</read_first>
<action>Copy the translate/user fail-closed TestMain: testcontainers Postgres, dedicated database, Docker unavailability fails full runs; only explicit -short may skip.
TestJournalEndToEnd (D-05, D-14, D-15, D-19): migrate user, translate, and journal in Requires order; party.Activate those three plus a process-local test fixture only if needed (no production fixture plugin); cabana.Activate; surf.Assemble. Mint a backend principal with golem15.journal.access_posts plus access_publish, and a second principal without access_other_posts.
Create a published post and a draft via admin or model helpers using mlmarkdown maps for en/pl. Assert English on host columns and Polish in golem15_translate_attributes under MorphName Golem15\Journal\Models\Post.
GET /_journal/api/v1/posts with no Authorization returns 200 and only the published post (D-14). POST /posts without Bearer is 401 Authentication required (D-15, T-15-01). POST with backend Bearer creates a row. GET draft slug as anonymous is 404 with no data key (JOURNAL-005, T-15-02). Owner or access_other_posts sees 200.
Host TestBootUserTranslateJournal still activates three plugins, sees Journal controller IDs, CORS path, and /_journal/api/v1 GET+POST. It must not duplicate the full fixture matrix.
Do not import sm-user-plugin from journal production code; host tests may join users if present. redactor_id column exists and is not in Fillable; set only via explicit assign (PostRedactor analog, D-05).</action>
<verify>
<automated>go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalEndToEnd)$' &amp;&amp; go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'</automated>
<fails_when>Non-zero exit; either run prints "--- FAIL", "--- SKIP", "no tests to run", or container startup treated as skip; lacks its named PASS line.</fails_when>
</verify>
<acceptance_criteria>
- Integration uses real Postgres and production gormigrate/party/surf/cabana paths.
- Anonymous list hides the draft; draft show 404 has no data key.
- Bearer write succeeds for a permitted backend principal and 401s without Authorization using the PHP string error.
- Polish title is in translate attributes keyed by the PHP Post morph string.
- Host boot still lists exactly the three production plugins.
</acceptance_criteria>
<done>The Phase 15 user-visible path is proven on real Postgres before the horizontal test matrix.</done>
</task>
<task type="auto">
<name>Task 2: Complete PHPUnit map, migrations, YAML, FormatHTML, and search-gate tests</name>
<files>../sm-journal-plugin/updates/migrations_test.go, ../sm-journal-plugin/models/fillable_test.go, ../sm-journal-plugin/models/translatable_test.go, ../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/controllers/api/posts_test.go, ../sm-journal-plugin/controllers/api/media_test.go, ../sm-journal-plugin/search_test.go, ../sm-journal-plugin/admin_harness_test.go</files>
<read_first>.planning/phases/15-journal-plugin/15-RESEARCH.md (§8 PHPUnit map, JOURNAL-001..006), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/security/AccessControlTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/security/MassAssignmentTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/security/XssTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/unit/models/PostRedactorTest.php, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/controllers/api/media.go, modules/cabana/markdown_test.go</read_first>
<action>Complete D-05 without porting Phase 16 Twig templates.
Migrations (D-01, D-04): TestJournalTables and rollback/remigrate assert every final table/column/index, unique slugs, JSONB metadata/sources, settings defaults including search_use_typesense false, author_slug on backend_users, and absence of rainlab-era journal table names.
Fillable (JOURNAL-001/002, T-15-04): Tag allow-list name/slug/description only; Category excludes nest_*; extra JSON keys dropped; Post API assigns never persist redactor_id or user_id from the body.
Translatable (D-10): Post/Category MorphName PHP strings; slug is indexed; Tag has no Translatable.
FormatHTML (JOURNAL-003/004 substitute): reject script, iframe, event handlers, javascript/vbscript/data schemes; footnotes/tables from goldmark extensions still pass the reject gate. Do not port .htm files.
API: per_page 9/30; slug show; numeric fallback; previous_post/next_post/related_posts present on show; unpublished lock prefix absent from JSON; editor Bearer on GET sees drafts; invalid frontend-audience token on POST is 401; publish without access_publish 403. Named TestJournalPublicCategories and TestJournalPublicTags: anonymous GET /_journal/api/v1/categories and /tags return 200 PHP {data} list shapes (categories honor include_empty; tags ordered by name). Named TestJournalRSS: GET /rss is well-formed RSS 2.0 XML honoring rss_title, rss_posts_per_feed, rss_include_content, and rss_enabled. Named TestJournalFeaturedImageUnauthenticated: featured-image POST/DELETE without Bearer is 401 Authentication required; without canEdit is 403 You do not have permission to edit this post.
Media (JOURNAL-006, T-15-03): 403 without access_posts; 201 with permission; folder .. rejected; stored path under journal/.
Search (D-12, T-15-11): TestSearchGateOff zero HTTP; unpublished ShouldBeSearchable false even if someone flipped the setting in-memory.
Admin: 403 without access_posts; YAML compile TestPostsFormCompiles; FilterScopes without illegal filter keys; commands registered; SVG embed present.
Limiter names and CORS: TestJournalBuckets; host or plugin test reading ../sm-grzybyfunkcjonalne-app/config/http.yaml requires _journal/api/* (D-17). Isolated plugin tests that cannot see the host file skip only that assertion, not the bucket test.
D-11: assert content field type mlmarkdown in compiled schema; assert modules/cabana/form_schema.go already lists markdown/mltext/mlmarkdown (no-op this phase). D-16: no new tide fixtures.</action>
<verify>
<automated>go -C ../sm-journal-plugin test ./... -count=1 -v -race &amp;&amp; go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -race</automated>
<fails_when>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.</fails_when>
</verify>
<acceptance_criteria>
- Named tests exist for JOURNAL-001, JOURNAL-002, JOURNAL-005, JOURNAL-006, FormatHTML unsafe tags, redactor_id not fillable, TestSearchGateOff, TestJournalCommands, TestPostsFormCompiles, TestJournalBuckets, TestJournalPublicCategories, TestJournalPublicTags, TestJournalRSS, TestJournalFeaturedImageUnauthenticated.
- Migration rollback and remigrate pass on real Postgres.
- High threats T-15-01, T-15-02, T-15-03, T-15-04, T-15-07, T-15-08, T-15-09, T-15-10, T-15-11, T-15-13 have fail-when-broken tests.
- No test requires Pages menu types, dashboard widgets, extra locales, or PHP tree writes.
</acceptance_criteria>
<done>Every D-05 PHPUnit row and every in-scope high threat has named Go evidence.</done>
</task>
<task type="auto">
<name>Task 3: Phase gate, security review, and validation sign-off</name>
<files>scripts/check-phase15.sh, .planning/phases/15-journal-plugin/15-SECURITY-REVIEW.md, .planning/phases/15-journal-plugin/15-VALIDATION.md</files>
<read_first>scripts/check-phase14.2.1.sh, .planning/phases/15-journal-plugin/15-VALIDATION.md, .planning/phases/15-journal-plugin/15-RESEARCH.md (Security Domain), .planning/phases/15-journal-plugin/15-01-PLAN.md, .planning/phases/15-journal-plugin/15-02-PLAN.md, .planning/phases/15-journal-plugin/15-03-PLAN.md</read_first>
<action>Create scripts/check-phase15.sh modeled on check-phase14.2.1.sh. Stages: PHP SHA 02110eb1c0c3861370b0b9b47b209a0702ac5d88 and empty git diff on the PHP journal tree (D-01, D-02, D-03, D-07); plugin module/layout/replace ../summercms.go (D-22, D-23, pitfall 9); plugin go vet, full tests, race; host go vet/test/build including TestBootUserTranslateJournal (D-18..D-21); CORS _journal/api/*; plugin README forbidden-name scan (the application / blog only); no rainlab-era table names; no cabana field_markdown.go diff from this phase (D-11 no-op); no tide harness add (D-16); security-review file present. Use go -C ../sm-journal-plugin and go -C ../sm-grzybyfunkcjonalne-app. Full mode requires Postgres; Docker missing is failure; -short is not final evidence. Require named PASS lines for TestJournalEndToEnd, TestJournal005DraftShow, TestJournal006MediaUpload, TestFillable, TestSearchGateOff, TestJournalWriteUnauthenticated, TestBootUserTranslateJournal, TestJournalPublicCategories, TestJournalPublicTags, TestJournalRSS, TestJournalFeaturedImageUnauthenticated. End with Phase 15 gate passed.
Run the security-review lane over local Phase 15 changes (CONTEXT discretion: after implementation, not a code-writing plan). If a typed security-review subagent is unavailable, self-perform as 14.2.1-04 did and disclose that in 15-SECURITY-REVIEW.md frontmatter. Produce 15-SECURITY-REVIEW.md at ASVS L1, block_on high. Preserve unique threat IDs T-15-01 through T-15-15 plus T-15-SC (reserved, never colliding). For every high threat cite source control and executed TestName. Review draft enumeration, media traversal, fillable, stored XSS in content_html, cross-user edit, publish permission, frontend token on writes, Typesense leak, rate-limit XFF, envelope mixup, submodule provenance.
Record the API-coverage declaration in the review: no external SaaS SDK this phase.
Update 15-VALIDATION.md frontmatter to validated / nyquist_compliant / wave_0_complete only after mapped commands pass. Replace pending rows with exact test names and threat refs. Keep the manual SPA UAT row (admin login, Journal nav, mlmarkdown post) as human-check, not a silent pass.
Do not commit unless the user asks; this planner run also does not commit.</action>
<verify>
<automated>bash scripts/check-phase15.sh --all</automated>
<fails_when>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.</fails_when>
</verify>
<acceptance_criteria>
- Gate uses go -C ../sm-journal-plugin and go -C ../sm-grzybyfunkcjonalne-app.
- PHP pin SHA matches 02110eb1c0c3861370b0b9b47b209a0702ac5d88 and the PHP tree has no diff.
- 15-SECURITY-REVIEW.md lists each T-15-NN once, keeps T-15-SC, and blocks on high findings.
- VALIDATION rows name existing tests; frontmatter is validated only after green execution.
- Gate confirms no PHP edits, no extra locales, no Pages/dashboard, no tide add, no Typesense contact in TestSearchGateOff, no consuming-application name in the plugin README.
- Gate requires named PASS for TestJournalPublicCategories, TestJournalPublicTags, TestJournalRSS, and TestJournalFeaturedImageUnauthenticated.
</acceptance_criteria>
<done>The two-repository phase gate is green, high threats are mitigated with executed evidence, and validation is signed off.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Test/gate → production claims | A no-op filter, skipped container, or dirty PHP tree must not pass |
| Security review → phase completion | High findings block completion |
| Public HTTP → draft rows | JOURNAL-005 regressions must fail the gate |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-15-14 | Repudiation | phase gate | high | mitigate | Require named PASS lines and final marker; reject no-tests and unexpected skips |
| T-15-15 | Information Disclosure | unpublished title prefix on API | medium | mitigate | Assert JSON titles omit the unpublished lock prefix |
| T-15-SC | Tampering | package installs | high | mitigate | Gate confirms no new undecided require in plugin go.mod |
ASVS L1: block_on high. T-15-01..T-15-13 originate in plans 01–03 and must appear in 15-SECURITY-REVIEW.md with executed tests, not as duplicate rows here.
</threat_model>
<verification>
Run TestJournalEndToEnd, the race suites, then bash scripts/check-phase15.sh --all.
</verification>
<success_criteria>
- PHPUnit map D-05 is covered by named Go tests.
- Plugin and host vet/test/race are green.
- Phase 15 gate passed.
- High T-15 threats are mitigated with evidence.
- Deferred Phase 16/Pages/dashboard/Apparatus-token/extra-locale/PHP-edit items remain absent.
</success_criteria>
<output>
Create `.planning/phases/15-journal-plugin/15-04-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,662 @@
# Phase 15: Journal plugin - Pattern Map
**Mapped:** 2026-10-06
**Files analyzed:** 54 new or modified files (plugin + host + tests); 0 framework field-type files
**Analogs found:** 52 / 54
Path roots:
- `FW/` = `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go` (working directory; relative paths below are from here unless prefixed).
- `TR/` = `../sm-translate-plugin/` — closest compiled-plugin analog (sibling checkout, `Requires` empty, `Translatable`, host already mounts it).
- `USR/` = `../fonoteka.go/plugins/golem15/user/` — the `sm-user-plugin` submodule. Closest analog for `HasRoutes`, `Buckets`, CLI commands, YAML with filters/relations/fileupload, `ListExtendQuery`, CORS test. `/media/nvme/dev/golem15/fonoteka.go/plugins/golem15/user/` does **not** exist; this path does.
- `APP/` = `../sm-grzybyfunkcjonalne-app/` — proof host from 14.2.1 (user + translate only today).
- `PHP/` = `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal` at SHA `02110eb1c0c3861370b0b9b47b209a0702ac5d88` (verified `git rev-parse HEAD` this session). Read-only contract. Do not edit (D-07).
- `JR/` = `../sm-journal-plugin/` — **does not exist yet** (D-23). Plan 01 clones `git@git.golem15.com:golem15/sm-journal-plugin.git` there.
All analog paths below are git-tracked (`git ls-files` in `summercms.go`, inside `sm-translate-plugin`, inside the user submodule, inside `sm-grzybyfunkcjonalne-app`, and inside the PHP journal plugin). No gitignored mirror is named. `modules/boardwalk/dist`, `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` are generated outputs: this phase does **not** regenerate them (D-11 already shipped).
**D-11 no-op:** `type: markdown` / `mltext` / `mlmarkdown` already live in `FW/modules/cabana/form_schema.go` lines 24-29 and `docs/backend/forms.md` 324-346. Do not add a second YAML type. Do not change `cabana.RenderMarkdown`. Journal post `content` is `mlmarkdown`. Plugin `FormatHTML` owns footnotes/tables/attributes + the same rejectUnsafe gate.
**Replace paths (locked RESEARCH pitfall 9):** sibling plugin `go.mod` uses `replace git.golem15.com/golem15/summercms => ../summercms.go` (`TR/go.mod` line 122). Host uses `replace …sm-journal-plugin => ./plugins/golem15/journal`. Do **not** copy `USR/go.mod`'s `../../../../summercms.go` into the sibling checkout.
**Tables (locked):** squash to `golem15_journal_*`. Do not emit `rainlab_journal_*`. Settings is a dedicated singleton table ID=1, not PHP `system_settings`.
Suggested plan split from RESEARCH (present at the plan-count checkpoint; unit tests last): **15-01** clone plugin + squashed schema + models + Translatable + host submodule/`summer.yaml`/`go.work`/`http.yaml` CORS + boot_test 3 plugins; **15-02** admin controllers, adapted YAML, settings, permissions, nav SVG, FormatHTML, import/export CLI + toolbar; **15-03** `/_journal/api/v1`, buckets, optional editor on GET, backend Bearer writes, media upload, Typesense gate; **15-04** unit/integration tests last.
## File Classification
### New plugin (`JR/` = `sm-journal-plugin`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `JR/go.mod` | config | — | `TR/go.mod` (sibling replace `../summercms.go`) | exact |
| `JR/plugin.go` | provider | request-response | `TR/plugin.go` + `USR/plugin.go` (`HasRoutes`/`Buckets`/`HasCommands`) | exact |
| `JR/README.md` | docs | — | `TR/README.md` (neutral names; never name the host) | exact |
| `JR/admin.go` | provider | — | `TR/admin.go` + `USR/admin.go` (file-by-file embed) | exact |
| `JR/admin_permissions.go` | config | — | `USR/admin_permissions.go` + `PHP/Plugin.php` 55-90 | exact |
| `JR/admin_navigation.go` | config | — | `USR/admin_navigation.go` + `PHP/Plugin.php` 96-134; icon alias `FW/admin/src/app/icons.ts` 273 | exact |
| `JR/lang/en/lang.yaml`, `JR/lang/pl/lang.yaml` | config | transform | `TR/lang/{en,pl}/lang.yaml` + `PHP/lang/en/lang.php` keys `plugin.*` / `journal.*` / `post.*` | exact |
| `JR/assets/images/journal-icon.svg` | config | file-I/O | `PHP/assets/images/journal-icon.svg` (embed bytes; D-08) | exact |
| `JR/models/registry.go` | utility | — | `TR/models/registry.go` | exact |
| `JR/models/post.go` | model | CRUD | `USR/models/user.go` (Fillable/MorphName/AttachRelations/FilterScope) + `PHP/models/Post.php` (translatable, canEdit, formatHtml, Searchable) | exact |
| `JR/models/category.go` | model | CRUD | `USR/models/user_group.go` + `PHP/models/Category.php` fillable 45-51 | exact |
| `JR/models/tag.go` | model | CRUD | `USR/models/user_group.go` + `PHP/models/Tag.php` fillable 27-31 | exact |
| `JR/models/settings.go` | model | CRUD | `FW/modules/cabana/example_controller_test.go` `BlogSettings` 58-72 | exact |
| `JR/updates/registry.go` | utility | — | `TR/updates/registry.go` | exact |
| `JR/updates/202610060001_create_golem15_journal_posts.go` | migration | CRUD | `TR/updates/202610060001_create_golem15_translate_locales.go` | exact |
| `JR/updates/202610060002_create_golem15_journal_categories.go` | migration | CRUD | same CREATE pattern | exact |
| `JR/updates/202610060003_create_golem15_journal_tags.go` | migration | CRUD | same CREATE pattern | exact |
| `JR/updates/202610060004_create_golem15_journal_posts_categories.go` | migration | CRUD | same CREATE pattern (pivot) | exact |
| `JR/updates/202610060005_create_golem15_journal_posts_tags.go` | migration | CRUD | same CREATE pattern (pivot) | exact |
| `JR/updates/202610060006_create_golem15_journal_settings.go` | migration | CRUD | same CREATE pattern (singleton ID=1) | exact |
| `JR/updates/202610060007_add_author_slug_to_backend_users.go` | migration | CRUD | `USR/updates/202609220005_extend_users.go` (ALTER); use `IF NOT EXISTS` | role-match |
| `JR/classes/format_html.go` | service | transform | `FW/modules/cabana/field_markdown.go` rejectUnsafe + `PHP/models/Post.php` `formatHtml` 199-225 | partial |
| `JR/controllers/admin_registry.go` | config | — | `TR/controllers/admin_registry.go` + `USR/controllers/admin_registry.go` | exact |
| `JR/controllers/posts.go` | controller | CRUD | `USR/controllers/users_admin_controller.go` (ListExtendQuery, FormBeforeCreate, permissions) | exact |
| `JR/controllers/categories.go` | controller | CRUD | `TR/controllers/locales.go` | exact |
| `JR/controllers/tags.go` | controller | CRUD | `TR/controllers/locales.go` | exact |
| `JR/controllers/posts/{config_list,config_form,config_filter}.yaml` | config | file-I/O | `USR/controllers/users/*.yaml` + `PHP/controllers/posts/*` (adapt, do not byte-copy) | exact |
| `JR/controllers/categories/{config_list,config_form}.yaml` | config | file-I/O | `TR/controllers/locales/*.yaml` + `USR/controllers/usergroups/*.yaml` | exact |
| `JR/controllers/tags/{config_list,config_form}.yaml` | config | file-I/O | same | exact |
| `JR/models/post/{fields,columns}.yaml` | config | file-I/O | `USR/models/user/fields.yaml` (relation/fileupload) + `FW/docs/backend/forms.md` 332-346 (`mltext`/`mlmarkdown`) | exact |
| `JR/models/category/{fields,columns}.yaml` | config | file-I/O | `TR/models/locale/fields.yaml` + `PHP/models/category/*` | exact |
| `JR/models/tag/{fields,columns}.yaml` | config | file-I/O | same | exact |
| `JR/models/settings/fields.yaml` | config | file-I/O | `PHP/models/settings/fields.yaml` minus `trigger`/`placeholder` | exact |
| `JR/routes.go` | route | request-response | `USR/routes.go` + `PHP/routes.php` | exact |
| `JR/controllers/api/posts.go` | controller | request-response | `USR/controllers/api_controller.go` (flat `{error}` JSON) + `PHP/controllers/api/PostApiController.php` | exact |
| `JR/controllers/api/media.go` | controller | file-I/O | `PHP/controllers/api/MediaApiController.php` + `USR` avatar upload (blob, mime, size) | exact |
| `JR/console/export_posts.go`, `JR/console/import_posts.go` | utility | batch | `USR/console/require_password_change.go` + `PHP/console/ExportPosts.php` | exact |
| `JR/search.go` (Gate on Post, or methods on `post.go`) | service | CRUD | `FW/modules/beachcomber/searchable.go` + `example_test.go` `installGate` 123-133 | exact |
### Proof host (`APP/` = `sm-grzybyfunkcjonalne-app`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `APP/summer.yaml` | config | — | same file today (add journal) | exact |
| `APP/go.work` | config | — | same file (add `./plugins/golem15/journal`) | exact |
| `APP/go.mod` | config | — | same file replace block 85-87 | exact |
| `APP/.gitmodules` | config | — | same file (add journal submodule) | exact |
| `APP/config/http.yaml` | config | request-response | same file CORS paths; add `_journal/api/*` | exact |
| `APP/boot_test.go` | test | request-response | same file `TestBootUserTranslate` (become 3 plugins) | exact |
| `APP/plugins.gen.go`, `APP/main.go` | route | — | same files (`summer build`; do not hand-author after first boot) | exact |
### Tests (plan 04 last)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `JR/admin_harness_test.go` + Posts/Categories/Tags admin tests | test | request-response | `TR/admin_harness_test.go` `newAdminEnv` 172-182 | exact |
| `JR/updates/postgres_test.go` | test | CRUD | `TR/updates/postgres_test.go` TestMain + testcontainers | exact |
| `JR/controllers/api/*_test.go` JOURNAL-005/006, 401 shape, per_page 9/30 | test | request-response | PHP `tests/security/` + `USR/register_test.go` `TestRegisterCORSPath` 263-271 | exact |
| `JR/models/*_test.go` Fillable / Translatable / MorphName | test | CRUD | `USR/models/admin_models_test.go` FilterScopes | exact |
| `APP/boot_test.go` (3 plugins + CORS + controller IDs) | test | — | same file (modify) | exact |
### Framework (`FW/`) — do not create
| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| cabana markdown / mlmarkdown | — | — | already shipped Phase 14.2.1 | n/a (no-op) |
## Pattern Assignments
### `JR/go.mod` (config) — plan 01
**Analog:** `TR/go.mod` lines 1-14, 122
```
module git.golem15.com/golem15/sm-translate-plugin
go 1.27.0
require (
git.golem15.com/golem15/summercms v0.0.0
github.com/go-gormigrate/gormigrate/v2 v2.1.7
...
gorm.io/gorm v1.31.2
)
replace git.golem15.com/golem15/summercms => ../summercms.go
```
Copy: module `git.golem15.com/golem15/sm-journal-plugin`, Go 1.27.0, require GORM + gormigrate + summercms, **identical** sibling replace `../summercms.go`. Do not add goldmark unless `FormatHTML` lives in the plugin and imports it (then pin `github.com/yuin/goldmark v1.8.6`, already `FW/go.mod` line 30). Do **not** require `sm-user-plugin` (redactor_id is a naked integer; RESEARCH pitfall 7 / anti-pattern). `TR/go.mod` currently requires user for admin harness only — Journal must not copy that unless the last-plan harness truly needs it; host tests may join `users`.
Decision note: `.planning/notes/core-plugins-own-repos.md` — module path equals repo path; package `journal`; plugin ID `golem15.journal`. README never names a consuming app.
---
### `JR/plugin.go` (provider) — plan 01 / 03
**Analog (compiled init):** `TR/plugin.go` lines 19-68
```go
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
)
func (p *Plugin) ID() string { return "golem15.translate" }
func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
func (p *Plugin) Models() []any { return models.All() }
func init() { party.Register(&Plugin{}) }
```
Journal: `ID() "golem15.journal"`, **`Requires() []string{"golem15.translate"}`**. PHP `$require` also names Apparatus — Apparatus is dissolved; do **not** require an apparatus plugin (`PHP/Plugin.php` 15-18 vs RESEARCH §2).
**Analog (routes / buckets / commands):** `USR/plugin.go` lines 28-40, 145-166, 203-204 and `USR/routes.go` 9-31
```go
_ pact.HasRoutes = (*Plugin)(nil)
_ pact.HasCommands = (*Plugin)(nil)
_ surf.BucketProvider = (*Plugin)(nil)
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(p.app.Config)
return map[string]surf.Bucket{
"user-api": {
Max: 120,
Decay: time.Minute,
Key: func(r *http.Request) string {
if sub, err := bouncer.Verify(bearerFrom(r), secret); err == nil {
return "u:" + sub
}
return surf.ClientIP(r, trusted)
},
},
}
}
```
Copy two buckets named **`journal-public-api`** and **`journal-api`**, Max 120, Decay `time.Minute` (`PHP/routes.php` 7-14; `FW/modules/surf/limiter.go` 17-33). Public key is IP only. Write-bucket key is `u:` + backend principal ID when `bouncer.User(r.Context())` is a backend principal, else IP. 429 body stays surf's `{"message":"Too Many Attempts."}` (`limiter.go` 17) — do not invent a Journal 429 shape.
Do **not** copy user JWT/bouncer/mail from `USR/plugin.go` Boot. Do **not** register `"backend"` middleware onto public GET (pitfall 4). Admin capability assertions live in `admin.go`. Also `_ pact.HasSettings`.
**PHP contract (do not port):** `registerComponents`, Winter.Pages menu types, dashboard report widget, Scout service-provider registration as a required dep. Port Scout only as `beachcomber.Searchable` behind a Gate that defaults off.
---
### `JR/admin.go` + permissions + navigation — plan 02
**Analog:** `TR/admin.go` 12-34, `USR/admin.go` 12-38, `USR/admin_permissions.go` 14-21, `USR/admin_navigation.go` 11-43
```go
//go:embed controllers/locales/config_list.yaml controllers/locales/config_form.yaml models/locale/fields.yaml models/locale/columns.yaml
var adminFS embed.FS
func (p *Plugin) AdminFS() fs.FS { return adminFS }
func (p *Plugin) AdminControllers() []pact.AdminController {
return controllers.AdminControllers(func() *backpack.App { return p.app })
}
```
Embed YAML **file-by-file** (controllers/ also holds `.go`). Include settings `models/settings/fields.yaml` and the SVG if AdminFS is the right tree; otherwise `//go:embed assets/images/journal-icon.svg` on the plugin for nav. Do not embed the whole `controllers/` directory.
**Permissions** — copy codes exactly from `PHP/Plugin.php` 55-90:
- `golem15.journal.manage_settings`
- `golem15.journal.access_posts`
- `golem15.journal.access_categories`
- `golem15.journal.access_other_posts`
- `golem15.journal.access_import_export`
- `golem15.journal.access_publish`
- `golem15.journal.access_tags`
Tab/label phrase keys from `PHP/lang/en/lang.php` 8-24. `Roles: []string{"developer"}` like user. `cabana.Allows` is Winter `hasAnyAccess` (OR) (`FW/modules/cabana/contracts.go` 143-161).
**Navigation analog:** main item `Code: "journal"`, `Permissions: []string{"golem15.journal.*"}`, `Order: 300`, `Controller: "golem15.journal.posts"`. PHP `icon-pencil` already maps to lucide `pencil` (`FW/admin/src/app/icons.ts` 273) — use `"pencil"` or `"icon-pencil"`; do not invent a pack. Side menu: `new_post` (create URL → controller create), `posts`, `categories`, `tags` with the PHP permission lists. Embed `PHP/assets/images/journal-icon.svg` (D-08). Skip dashboard widget.
**Settings analog:** `FW/modules/cabana/example_controller_test.go` 120-132 and `docs/backend/settings.md` 7-29
```go
func (p *BlogPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{
Code: "blog",
Label: "acme.blog::lang.settings.label",
Icon: "icon-pencil",
Permissions: []string{"acme.blog.access_settings"},
Form: "models/settings/fields.yaml",
NewModel: func() any { return &BlogSettings{} },
}}
}
```
Journal: `Code: "journal"`, permissions `golem15.journal.manage_settings`, `Form: "models/settings/fields.yaml"`, `NewModel: func() any { return &models.Settings{} }`. Dedicated table `golem15_journal_settings` ID=1 — not `system_settings` (`PHP/models/Settings.php` 13 vs cabana docs).
---
### `JR/models/post.go` (model, CRUD) — plan 01/02
**Analog (Go struct + Fillable + MorphName + Attach):** `USR/models/user.go` 57-71, 75-80
```go
func (User) TableName() string { return "users" }
func (User) MorphName() string { return `Golem15\User\Models\User` }
func (User) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: AvatarField, Public: true}}
}
func (User) Fillable() []string { return []string{ "name", "surname", "email", ... } }
```
**Contract:** `PHP/models/Post.php` 37, 48-65, 102-128, 144-197
- `TableName() "golem15_journal_posts"`
- `MorphName() \`Golem15\Journal\Models\Post\`` — never `journal.Post` or `reflect.TypeOf` (pitfall 10)
- `Translatable()`: `title`, `content`, `content_html`, `excerpt`, `metadata`
- `TranslatableIndexes()`: `slug`
- Admin Fillable: form columns only. **Never** fill `redactor_id`, `content_html`, `user_id` from the public API body. `user_id` stamped from backend principal on create (`PHP` beforeSave 145-150).
- Rules: `title` required; `slug` required + regex + unique; `content` required; published+published_at together (PHP `afterValidate` 181-187) as `FormBeforeCreate`/`FormBeforeUpdate` returning `&cabana.ValidationError`
- Relations: `user` belongsTo `cabana.BackendUser`; `categories`/`tags` belongsToMany via `golem15_journal_posts_categories` / `_tags`; `featured_images`/`content_images` attachMany (`system_files`, morph PHP class string)
- `canEdit`: owner or `golem15.journal.access_other_posts` (194-197)
- `FilterScopes`: `FilterPublished`, `FilterCategories` (and daterange uses `column: created_at` — no `conditions` key)
**FilterScope analog:** `USR/models/user.go` 99-117
```go
func (User) FilterScopes() []string { return []string{FilterByGroup} }
func (User) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if name != FilterByGroup {
return db.Where("1 = 0")
}
...
}
```
PHP `config_filter.yaml` `conditions:` **boot-fails** in cabana (`FW/modules/cabana/filter_schema.go` 17-20). Re-express:
- `published` switch → `FilterPublished` applying `published <> true` / `published = true`
- `published_date` daterange → YAML `type: daterange` `column: created_at` (legal keys only)
- `category` `scope: FilterCategories` — include child categories as PHP does
**Searchable analog:** `FW/modules/beachcomber/searchable.go` 12-22 and `example_test.go` 31-34, 123-133
```go
func (Post) SearchableAs() string { return "acme_blog_posts" }
func (p *Post) ShouldBeSearchable() bool { return p.Published }
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
var enabled bool
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
return err == nil && enabled
}))
```
Journal: `SearchableAs() "golem15_journal_posts"`. Gate reads `golem15_journal_settings.search_use_typesense` for ID=1; read errors count as off. `ShouldBeSearchable` false when unpublished **or** gate off. Fresh install never contacts Typesense.
---
### `JR/models/category.go` / `tag.go` (model, CRUD) — plan 01
**Analog:** `USR/models/user_group.go` 24-46 + PHP fillable
Category (`PHP/models/Category.php` 21, 27-40, 45-51): `TableName "golem15_journal_categories"`, Fillable **only** `name`, `slug`, `code`, `description`, `parent_id`. **Not** `nest_left`/`nest_right`/`nest_depth` (JOURNAL-002). Translatable `name`, `description`; indexes `slug`. `MorphName() \`Golem15\Journal\Models\Category\``. Keep nest_* columns in DDL; no NestedTree reorder UI; `parent_id` is a `relation`.
Tag (`PHP/models/Tag.php` 14, 19-31): `TableName "golem15_journal_tags"`, Fillable **only** `name`, `slug`, `description`. **Not translatable.** Rules `name` required; `slug` required|between:3,64|unique.
---
### `JR/models/settings.go` (model, CRUD) — plan 02
**Analog:** `FW/modules/cabana/example_controller_test.go` 58-72 + `FW/modules/cabana/settings.go` 19-26, 46-51 (Fillable + Rules required at boot)
PHP `$rules` (`PHP/models/Settings.php` 17-31): `show_all_posts`, `use_rich_editor`, `search_use_typesense`, weights, RSS fields. Defaults: show_all_posts true, use_rich_editor false, search_use_typesense false, weights 5/3/1. `use_rich_editor` stored and **ignored** at compile time (always `mlmarkdown`). Settings YAML: drop every `trigger` block; always show weight fields; drop `placeholder`.
---
### `JR/updates/*` (migration, CRUD) — plan 01
**Analog (CREATE + Register):** `TR/updates/202610060001_create_golem15_translate_locales.go` 8-38 and `TR/updates/registry.go` 7-15
```go
ID: "202610060001_create_golem15_translate_locales",
Migrate: func(tx *gorm.DB) error {
for _, stmt := range []string{`CREATE TABLE golem15_translate_locales (... )`, `CREATE INDEX ...`} {
if err := tx.Exec(stmt).Error; err != nil { return err }
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS golem15_translate_locales`).Error
},
```
**Analog (ALTER another plugin's table):** `USR/updates/202609220005_extend_users.go` 10-31 — Journal's author_slug uses `ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS golem15_bloghub_author_slug TEXT UNIQUE`. Do **not** put this in lagoon's framework migration. `BackendUser.TableName()` is `"backend_users"` (`FW/modules/cabana/contracts.go` 56).
Squash IDs (planner may adjust date prefix, not table names):
| ID | Table / change |
|----|----------------|
| `202610060001_create_golem15_journal_posts` | posts columns from RESEARCH §1 (incl. content_html, metadata JSONB, sources JSONB, is_pinned, redactor_id, unique slug) |
| `202610060002_create_golem15_journal_categories` | + nest_* + unique slug |
| `202610060003_create_golem15_journal_tags` | unique slug |
| `202610060004_create_golem15_journal_posts_categories` | pivot |
| `202610060005_create_golem15_journal_posts_tags` | pivot |
| `202610060006_create_golem15_journal_settings` | typed singleton columns |
| `202610060007_add_author_slug_to_backend_users` | ALTER |
Do not emit `rainlab_journal_*`. Seed optional Uncategorized only if PHP seeder at this pin still inserts it (assumption A4). AutoMigrate is never the schema source.
---
### `JR/classes/format_html.go` (service, transform) — plan 02
**No plugin analog.** Combine:
1. **Contract:** `PHP/models/Post.php` 199-225 — footnotes + attributes + tables, then Html::clean unless `backend.allow_unsafe_markdown`. Ignore `use_rich_editor` (deferred WYSIWYG).
2. **Reject gate analog:** `FW/modules/cabana/field_markdown.go` 11-46 — goldmark **without** `html.WithUnsafe`; reject script/iframe/event handlers/dangerous URLs.
```go
markdownEngine = goldmark.New()
func RenderMarkdown(src string) (string, error) {
...
if err := rejectUnsafeMarkdownHTML(html); err != nil { return "", err }
return html, nil
}
```
**Do not change `cabana.RenderMarkdown` globally** (pitfall 15; mail-aligned). Put `journal.FormatHTML` in the plugin: same `github.com/yuin/goldmark` module with footnote/table/attribute extensions (assumption A1 — if a separate module is required, stop). Stored `content_html` and public API use FormatHTML. SPA preview may use `cabana.RenderMarkdown`. Call FormatHTML from admin save hooks and API writes, not only API.
---
### Admin controllers (controller, CRUD) — plan 02
**Analog:** `USR/controllers/users_admin_controller.go` 40-96, 243-258 + `TR/controllers/locales.go` 14-33 + `USR/controllers/admin_registry.go` 12-36
```go
func (usersAdminController) ID() string { return "golem15.user.users" }
func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` }
func (usersAdminController) ConfigDir() string { return "controllers/users" }
func (usersAdminController) RequiredPermissions() []string {
return []string{PermissionAccessUsers}
}
func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Unscoped()
}
```
| PHP | Go ID | Perm | ModelName |
|-----|-------|------|-----------|
| Posts.php | `golem15.journal.posts` | `access_posts` | `Golem15\Journal\Models\Post` |
| Categories.php | `golem15.journal.categories` | `access_categories` | `Golem15\Journal\Models\Category` |
| Tags.php | `golem15.journal.tags` | `access_tags` | `Golem15\Journal\Models\Tag` |
PHP Posts `$requiredPermissions` is OR of `access_other_posts` and `access_posts` (`PHP/controllers/Posts.php` 21). Cabana `Allows` is OR — `RequiredPermissions: []string{"golem15.journal.access_posts"}` for the screen; **additionally** restrict query without `access_other_posts`.
**ListExtendQuery / FormExtendQuery analog:** `PHP/controllers/Posts.php` 60-72 — without `access_other_posts`, `where user_id = principal.ID`. Principal from `bouncer.User(ctx)` (`FW/modules/bouncer/context.go` 32-38).
**FormBeforeCreate analog:** stamp `user_id` from backend principal if empty (`PHP/models/Post.php` 145-150). Pattern of hook shape: `USR/controllers/users_admin_controller.go` 243-258 (stamp fields, return `ValidationError`/`ForbiddenError`). Also regenerate `content_html` via FormatHTML on create/update.
**Publish without permission:** return `&cabana.ForbiddenError{Message: ...}` (`FW/modules/cabana/crud.go` 71-84) on publish writes without `golem15.journal.access_publish`. Hiding fields is UX; refuse is mandatory (`PHP/models/Post.php` filterFields 164-178).
**Import/export toolbar:** no cabana ImportExport behavior. `pact.HasAdminActions` (`FW/modules/pact/capabilities.go` 216-228, 266-269) with `Permissions: []string{"golem15.journal.access_import_export"}`. CLI names `journal:export-posts` / `journal:import-posts` (`PHP/Plugin.php` 169-170). Command analog: `USR/console/require_password_change.go` 16-25 (`bonfire.Command{Name, Description, Args, Run}`). Port PHP `PostImport`/`PostExport` column sets; do not invent a generic CSV framework.
Error types: `FW/modules/cabana/crud.go` 65-84 `ValidationError` (422) / `ForbiddenError` (403).
---
### Admin YAML (config, file-I/O) — plan 02
**Go analog (list/form):** `USR/controllers/users/config_list.yaml` 1-16, `config_form.yaml` 1-17, `config_filter.yaml` 1-14 (legal keys only: `label`, `type`, `column`, `modelClass`, `nameFrom`, `scope`). `TR/controllers/locales/config_list.yaml` `recordUrl` / `defaultSort`.
**Go analog (fields):** `USR/models/user/fields.yaml` 38-57 (`type: relation`, `type: fileupload` `mode: image`) and `FW/docs/backend/forms.md` 332-346:
```yaml
fields:
title:
type: mltext
body:
type: mlmarkdown
size: huge
```
**PHP contract to rewrite, not copy:** `PHP/models/post/fields.yaml` uses boot-fail keys (`placeholder`, `cssClass`, `stretch`, `commentAbove`, `trigger`, `type: taglist`, `type: repeater`, widget class, `tabs.stretch`). Cabana allow-list: `FW/modules/cabana/form_schema.go` 24-47.
| PHP field | Go YAML |
|-----------|---------|
| `title` | `type: mltext` |
| `slug` | `type: mltext` + `preset` field title type slug |
| `content` | `type: mlmarkdown` — not JournalMarkdown widget |
| `excerpt` | `type: mltext` |
| `categories` | `type: relation` `nameFrom: name` |
| `tags` | `type: relation` `nameFrom: name` — **not** `taglist`; create tags on Tags admin |
| `published` | `type: switch` or `checkbox` |
| `is_pinned` | `type: checkbox` |
| `user` | `type: relation` `nameFrom: login` `emptyOption` current user |
| `published_at` | `type: datepicker` `mode: datetime`. Drop `trigger`. |
| `featured_images` | `type: fileupload` `mode: image` `imageWidth`/`imageHeight` 200 |
| `sources` repeater | **Omit from admin form.** Keep JSONB; write API still accepts `sources`. |
| `metadata[preview_page]` | **Omit** (Phase 16). Keep `metadata` JSONB. |
| `toolbar` partial | Omit |
List: `type: date` is legal (`FW/modules/cabana/list_schema.go` 22-24). Unpublished row class → `pact.RowStateDisabled` if wired (`USR` ListRowStates 98-125); else skip. `config_list.yaml` `recordUrl: golem15/journal/posts/update/:id` like users/locales. PHP `recordsPerPage: 25` (`PHP/controllers/posts/config_list.yaml` 21).
Filters: **no `conditions:`**. Analog `USR/controllers/users/config_filter.yaml`:
```yaml
scopes:
groups:
type: (omit — modelClass + scope)
modelClass: Golem15\User\Models\UserGroup
nameFrom: name
scope: filterByGroup
created_date:
type: daterange
column: created_at
activated:
type: switch
column: is_activated
```
---
### `JR/routes.go` + API controllers (controller, request-response) — plan 03
**Analog (group + throttle + Where):** `USR/routes.go` 9-31 and `FW/modules/surf/example_test.go` 75-88
```go
func (p *Plugin) Routes(r pact.Router) error {
r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) {
g.Get("/fetch", controllers.Fetch(p.app))
g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth")
g.Where("id", "[0-9]+")
})
return nil
}
```
**Contract:** `PHP/routes.php` 16-67. Two groups, same prefix `/_journal/api/v1`:
1. Public: `throttle:journal-public-api` **only**. GET `posts`, `posts/{slug}` with `Where("slug", `[a-z0-9][a-z0-9\-/]*`)`, `categories`, `tags`, `rss`.
2. Writes: required backend principal + `throttle:journal-api`. POST/PUT/DELETE posts, featured-images, media/upload. `Where("id", `[0-9]+`)`.
Do **not** attach cabana `"backend"` middleware (writes `WriteError` 401 `unauthenticated` / `"Unauthenticated"` — `FW/modules/cabana/http.go` 200-201, `contracts.go` 215-231). PHP shape is flat `{"error":"..."}`.
**Flat JSON analog:** `USR/controllers/api_tokens.go` 96 (`writeJSON(w, 404, map[string]any{"error": "Token not found"})`) and `USR/controllers/api_controller.go` 1025-1028 (`wire.WriteJSON`). **Not** `cabana.WriteError`.
**PHP error strings** (`PHP/controllers/api/PostApiController.php` 237, 263, 268; MediaApi 26-41; show 204-216):
- 401 `{"error":"Authentication required"}`
- 403 `{"error":"Insufficient permissions"}` / `{"error":"You do not have permission to publish posts"}`
- 404 `{"error":"Post not found"}` — **no `data` key** on drafts (JOURNAL-005)
- 422 `{"error":"Validation failed","errors":{...}}`
**Write auth:** verify cabana backend JWT (HS256, audience `backend`, blacklist `backend_jwt_blacklist`). Analog: `FW/modules/bouncer/jwt.go` 90-104 `NewBackendJWTGuard` + `FW/modules/cabana/http.go` 137-153 (cabana registers `"backend"`). Journal write handlers Lookup `*bouncer.Registry`, Authenticate the `"backend"` guard **in-handler** (or a plugin middleware that writes PHP JSON, not cabana's `writeUnauthenticated`). D-15: backend Bearer only — not `user.api_token`, not `g15_`.
**Optional editor on public GET:** `PHP/controllers/api/PostApiController.php` `isEditor` 685-688. Do **not** attach `"backend"` (missing token would 401). If `Authorization: Bearer` is present, verify with the same backend JWT and `bouncer.WithUser`; otherwise anonymous. Analog pieces: `bouncer.Registry.Middleware` 67-101 (on failure **without** UnauthorizedWriter, next runs unauthenticated — but cabana's guard **does** implement UnauthorizedWriter, so attaching it 401s). Therefore: call `Guard.Authenticate` only when the header is present; on failure treat as anonymous for GET (do not write 401 on public GET). `bouncer.User(ctx)` (`context.go` 32-38) afterwards. `cabana.Allows(pr, []string{"golem15.journal.access_posts"})`.
**Draft visibility (JOURNAL-005):** unpublished **or** `published_at` in the future. Show 404 no `data` unless owner or `access_other_posts` (`PHP` 207-217, `canViewDraft` 696-708). Index: anonymous `published=true`; editor default includes drafts unless `?published=true`. `per_page` default **9** max **30** (`PHP` 34) — not API.md 15/50.
**Serialize:** do not apply `UNPUBLISHED_TITLE_PREFIX` on API JSON. Include `previous_post`, `next_post`, `related_posts` on show. Translations object on write via translate plugin helpers (`classes.SetTranslated`), not extra columns. Field-by-field assign on store (`PHP` 276-287) — never `lagoon.Fill` the whole body onto Post.
**RSS:** ship GET `rss` as XML (`PHP/routes.php` 33-35). No Go analog; stdlib `encoding/xml`.
---
### `JR/controllers/api/media.go` (controller, file-I/O) — plan 03
**Contract:** `PHP/controllers/api/MediaApiController.php` 21-62. Require backend user + `access_posts`; folder regex `^[A-Za-z0-9_\-\/]*$`; force under `journal/`; strip leading `journal`; reject `..`. Image mimes jpg/jpeg/png/gif/webp max 10240 KB. 201 `{data:{url,path}}`. Use gocloud blob + host `upload_bytes` but still enforce 10MB in the handler. Analog blob/size: `USR` avatar (`avatarMaxBytes` in `api_controller.go` 37) — copy the permission/path rules from PHP, not the avatar field.
---
### `JR/console/*` (utility, batch) — plan 02
**Analog:** `USR/console/require_password_change.go` 16-25 + `USR/plugin.go` 203-204 `Commands() []bonfire.Command`. Names from `PHP/Plugin.php` 169-170 and `PHP/console/ExportPosts.php` 11-15 (`journal:export-posts`, `--path`, `--dry-run`). Register via `pact.HasCommands` (`FW/modules/pact/capabilities.go` 15-18).
---
### Phrasebook + README — plan 01/02
**Lang analog:** `TR/lang/en/lang.yaml` — `plugin:` + screen keys. Port `PHP/lang/en/lang.php` and `lang/pl/lang.php` only (D-06). Do not load the other 19 PHP locales.
**README analog:** `TR/README.md` 1-15, 30-73 — H1 plugin name, one-sentence summary, import line, Overview/Features/Usage. Neutral `the application`, example `blog` / `acme`. **Never** name grzybyfunkcjonalne or Płytarium (pitfall 13). Document Phase 16 successor for Winter components (`journalPost`, …) in a short out-of-scope note. MorphName example for Journal must be the PHP class string, not the translate README's `Acme\Blog\Models\Post` except as a generic illustration in framework docs.
---
### Proof host (`APP/`) — plan 01
**Analog:** current `APP/summer.yaml` 1-7, `APP/go.work` 5-9, `APP/go.mod` 1-11 and 85-87, `APP/.gitmodules` 1-8, `APP/config/http.yaml` 1-13, `APP/boot_test.go` 17-90, `APP/plugins.gen.go` 1-14
Today:
```yaml
plugins:
- id: golem15.user
module: git.golem15.com/golem15/sm-user-plugin
- id: golem15.translate
module: git.golem15.com/golem15/sm-translate-plugin
```
Add:
```yaml
- id: golem15.journal
module: git.golem15.com/golem15/sm-journal-plugin
```
`go.work` `use ./plugins/golem15/journal`. `go.mod` `require` + `replace git.golem15.com/golem15/sm-journal-plugin => ./plugins/golem15/journal`. `.gitmodules` path `plugins/golem15/journal` url `git@git.golem15.com:golem15/sm-journal-plugin.git`. CORS add `_journal/api/*`; keep `supports_credentials: false`.
`boot_test.go`: `len(plugins) != 2` → 3; allow `golem15.journal`; assert controller IDs `golem15.journal.posts|categories|tags` and that `/_journal/api/v1` routes exist; `assertGitlink` for journal. Today's test **forbids** `golem15.journal` (line 40-42) — change first in 15-01 (pitfall 8).
`plugins.gen.go` / `main.go`: regenerate with `summer build`; stamp `// Code generated by summer build. DO NOT EDIT.` Host README may name itself; plugin README must not.
---
### Tests — plan 04
**Admin boot analog:** `TR/admin_harness_test.go` 172-182 `newAdminEnv` — `party.Activate`, `lagoon.Migrate`, mint backend admin with a permission set. Posts 403 without `access_posts`.
**Postgres analog:** `TR/updates/postgres_test.go` 25-36 TestMain + testcontainers, `-short` skip.
**CORS analog:** `USR/register_test.go` 263-271 `TestRegisterCORSPath` — read host `config/http.yaml`, require `_journal/api/*` (and keep `_user/api/*`).
**PHPUnit map:** RESEARCH §8. JOURNAL-005 draft 404; JOURNAL-006 media 403 + path prefix; JOURNAL-001/002 fillable; FormatHTML rejectUnsafe substitutes JOURNAL-003/004 templates (Phase 16). Redactor_id column exists, not Fillable. Also: limiter names, anonymous list hides drafts, write without Bearer 401 PHP shape, publish without `access_publish` 403, YAML compile of adapted fields, `mlmarkdown` save through TranslationWriter, Typesense gate off (zero HTTP).
## Shared Patterns
### Plugin mount (submodule + go.work + replace)
**Source:** `TR/go.mod` line 122, `APP/go.mod` 85-87, `APP/go.work` 5-9, `APP/summer.yaml`, `.planning/notes/core-plugins-own-repos.md`
**Apply to:** `JR/` repo creation and `APP/` journal mount
Module `git.golem15.com/golem15/sm-journal-plugin`, package `journal`, ID `golem15.journal`, `party.Register` in `init`, `Requires` translate. Sibling replace `../summercms.go`. Host replace `./plugins/golem15/journal`. Submodule `plugins/golem15/journal`.
### Admin CRUD + permissions
**Source:** `USR/admin.go`, `users_admin_controller.go`, `pact.HasAdminControllers` / `AdminPermissioned` (`FW/modules/pact/capabilities.go` 184-197)
**Apply to:** Posts, Categories, Tags
YAML + `CRUDService`. `RequiredPermissions`. Fillable allow-list. Lifecycle hooks return `ValidationError` (422) or `ForbiddenError` (403). Fail-loud YAML at `cabana.Activate`.
### Translatable + MorphName
**Source:** `TR/classes/translatable.go` 16-24; `USR/models/user.go` 59-60; `TR/README.md` 70-72
**Apply to:** Post, Category
Implement `Translatable()`, `TranslatableIndexes()`, `MorphName()` with PHP class strings. Default locale on host columns; other locales via translate plugin. Do not invent `title_pl` columns.
### Public plugin HTTP vs cabana admin envelope
**Source:** `USR/routes.go` + `USR/controllers/api_tokens.go` flat `{error}`; `FW/modules/cabana/contracts.go` 215-231 (do **not** use on `/_journal/api/v1`)
**Apply to:** all Journal API handlers
Cabana SPA admin uses `{error:{code,message,details}}`. Journal API uses PHP `{error: string}`. Two envelopes, one binary.
### Rate limits
**Source:** `USR/plugin.go` Buckets 145-166; `FW/modules/surf/limiter.go` 17-33
**Apply to:** `journal-public-api` / `journal-api`
Named `surf.BucketProvider`. TrustedProxies + ClientIP. 429 framework shape.
### Backend principal
**Source:** `FW/modules/bouncer/context.go` 24-38; `jwt.go` 90-104; `cabana/http.go` 137-153; `cabana/contracts.go` Allows 143-161
**Apply to:** write API (required), public GET (optional), admin ListExtendQuery
Writes: authenticate backend JWT, 401 PHP string if missing. Public GET: optional Authenticate when Bearer present; never 401 anonymous. Admin: `bouncer.User` + `cabana.Allows`.
### Search gate
**Source:** `FW/modules/beachcomber/searchable.go` Gate 146-163; `example_test.go` installGate
**Apply to:** Post Searchable
Off by default. Errors count as off. Unpublished not indexed.
### Settings singleton
**Source:** `FW/docs/backend/settings.md`; `example_controller_test.go` BlogSettings / Settings()
**Apply to:** `golem15_journal_settings` ID=1
`pact.HasSettings`. Fillable + Rules. No `system_settings`.
### Test harness
**Source:** `TR/admin_harness_test.go` `newAdminEnv`; `TR/updates/postgres_test.go`
**Apply to:** plugin admin + migration + API tests last
`party.Activate` + `lagoon.Migrate` + `surf.Assemble`. testcontainers behind `-short`.
### Markdown
**Source:** `FW/modules/cabana/field_markdown.go`; `FW/go.mod` goldmark v1.8.6
**Apply to:** admin field type (existing `mlmarkdown`) vs stored HTML (plugin FormatHTML)
One library. Two pipelines. Do not merge them.
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `JR/classes/format_html.go` (goldmark footnote/table/attribute + rejectUnsafe, not cabana.RenderMarkdown) | service | transform | No plugin currently enables goldmark extensions. Copy PHP `formatHtml` contract and cabana's rejectUnsafe helper; do not edit `field_markdown.go`. |
| `JR` RSS XML handler | controller | request-response | No compiled plugin serves RSS. Use stdlib `encoding/xml` and PHP `PostApiController::rss` as the contract. |
Planner should use RESEARCH.md §4–§5 for those two.
## Do not copy / anti-patterns
- **Byte-copy PHP YAML** — `taglist`, `repeater`, `trigger`, `placeholder`, `conditions` fail boot.
- **Attaching `"backend"` middleware to public GET** — anonymous 401 (pitfall 4).
- **Cabana `WriteError` envelope on `/_journal/api/v1`** — PHP `{error}` string (pitfall 3).
- **`user.api_token` or `g15_`** — D-15.
- **Replaying `rainlab_journal_*` table names.**
- **Changing `cabana.RenderMarkdown` to enable footnotes globally** (pitfall 15).
- **Adding a second markdown YAML type** — D-11 shipped.
- **Importing `sm-user-plugin` only to type `redactor`.**
- **`replace … => ../../../../summercms.go` in the sibling checkout** — use `../summercms.go` (pitfall 9).
- **MorphName `journal.Post`** — PHP class strings (pitfall 10).
- **Typesense on by default** (pitfall 11).
- **API.md per_page 15/50** — controller 9/30 (pitfall 12).
- **Naming the host in the plugin README** (pitfall 13).
- **Editing `wn-journal-plugin` / PHP tree** (D-07).
- **Adding Journal to the tide 154-route harness** (D-16).
- **Porting Translate, Pages menu types, dashboard widgets, or remaining 19 locales.**
- **Hand-authoring `plugins.gen.go` after first `summer build`.**
- **AutoMigrate as schema.**
## Metadata
**Analog search scope:** `TR/` (sm-translate-plugin), `USR/` (sm-user-plugin), `APP/` (sm-grzybyfunkcjonalne-app), `FW/modules/{cabana,pact,surf,bouncer,beachcomber,lagoon}`, `FW/docs/backend`, `FW/admin/src/app/icons.ts`, `PHP/` journal plugin at `02110eb1c0c3861370b0b9b47b209a0702ac5d88`
**Files scanned:** ~70 analog files read or grepped; 3–5 strong matches per new file; PHP pin SHA verified
**Pattern extraction date:** 2026-10-06
**Tracked-source gate:** every analog path printed by `git ls-files` in its own repository

View File

@@ -844,20 +844,20 @@ DATA_c6d9k2hx_END
**If this table is empty:** not applicable — four assumptions remain.
## Open Questions
## Open Questions (RESOLVED)
1. **Should plan 01 still include "add cabana markdown"?**
- What we know: 14.2.1 already shipped the types and SPA controls.
- What's unclear: whether the planner treats D-11 as a checkbox that needs a tagged no-op.
- Recommendation: no-op D-11; Journal uses `mlmarkdown`. Mention in plan 02 YAML only.
- RESOLVED: no-op D-11; Journal uses `mlmarkdown`. Mention in plan 02 YAML only.
2. **On-the-fly tags (`customTags: true`)**
- What we know: cabana has no taglist type.
- Recommendation: relation picker + Tags admin. Do not add a framework type this phase.
- RESOLVED: relation picker + Tags admin. Do not add a framework type this phase.
3. **Optional backend principal helper**
- What we know: cabana guard is all-or-nothing middleware.
- Recommendation: small function in the journal plugin that Lookup's `bouncer.Registry` guard `"backend"` if the header is present.
- RESOLVED: small function in the journal plugin that Lookup's `bouncer.Registry` guard `"backend"` if the header is present.
These are execution details inside Claude's Discretion, not blockers.