diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c3aa1d7..40042da 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index dd5086d..948498d 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 diff --git a/.planning/phases/15-journal-plugin/15-01-PLAN.md b/.planning/phases/15-journal-plugin/15-01-PLAN.md new file mode 100644 index 0000000..b371b68 --- /dev/null +++ b/.planning/phases/15-journal-plugin/15-01-PLAN.md @@ -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. + + +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. + + + +@~/.codex/gsd-core/workflows/execute-plan.md +@~/.codex/gsd-core/templates/summary.md + + + +@.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 + + +## 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 + + +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. + + +## 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`). + + + + + Task 1: Clone the plugin, persist one en/pl Post title, and boot the host with three plugins + 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. + 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. + ../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 + .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 + 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). + + git ls-remote git@git.golem15.com:golem15/sm-journal-plugin.git && git -C /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal rev-parse HEAD && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestPostTranslatableSmoke)$' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$' + 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. + + + - 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. + + A compiled journal plugin persists one translatable Post and the proof host boots user, translate, and journal. + + + + Task 2: Add categories, tags, pivots, settings singleton, and author_slug + 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. + ../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 + .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 + 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. + + go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./updates -count=1 -v -run '^(TestJournalTables)$' + Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; lacks "--- PASS: TestJournalTables". + + + - 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. + + All D-04 tables exist and Category/Tag/Settings fillable boundaries match the PHP pin. + + + + Task 3: Add en/pl phrasebook, neutral README, and host CORS + ../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 + .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 + 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. + + go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1 && python3 -c 'import pathlib,sys; t=pathlib.Path("../sm-journal-plugin/README.md").read_text(); bad=["grzybyfunkcjonalne","Płytarium","fonoteka.go","plytarium"]; +sys.exit(1 if any(b.lower() in t.lower() for b in bad) else 0)' && grep -F "_journal/api/*" ../sm-grzybyfunkcjonalne-app/config/http.yaml && test -f ../sm-journal-plugin/lang/en/lang.yaml && test -f ../sm-journal-plugin/lang/pl/lang.yaml + Non-zero exit; plugin tests FAIL; README python check exits 1; grep does not print _journal/api/*; either lang YAML is missing. + + + - 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. + + Operators have en/pl UI catalogs, a neutral plugin README, and host CORS ready for /_journal/api/v1 in Plan 03. + + + + + +## 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). + + + +Run the three task commands. Confirm PHP journal git diff is empty. Confirm go -C ../sm-journal-plugin vet ./... and host TestBootUserTranslateJournal pass. + + + +- 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. + + + +Create `.planning/phases/15-journal-plugin/15-01-SUMMARY.md` when done + diff --git a/.planning/phases/15-journal-plugin/15-02-PLAN.md b/.planning/phases/15-journal-plugin/15-02-PLAN.md new file mode 100644 index 0000000..20bb422 --- /dev/null +++ b/.planning/phases/15-journal-plugin/15-02-PLAN.md @@ -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. + + +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. + + + +@~/.codex/gsd-core/workflows/execute-plan.md +@~/.codex/gsd-core/templates/summary.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 +@../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 + + +## 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`. + + + + + Task 1: Create one Post through cabana with mlmarkdown and FormatHTML + D-10/D-11 couple post content to cabana mlmarkdown and translate nested maps; changing the YAML type later is an admin contract change. + ../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 + .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) + 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. + + go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestPostsFormCompiles|TestPostsAdminCreateSmoke)$' + Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; either named PASS line is absent. + + + - 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. + + An administrator can create a translatable markdown Post through cabana, with content_html regenerated by plugin FormatHTML. + + + + Task 2: Categories, Tags, Settings screens and remaining query guards + ../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 + .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 + 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. + + go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1 && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$' + Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; host run lacks "--- PASS: TestBootUserTranslateJournal". + + + - 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. + + All three Journal admin screens and the settings singleton are registered and boot in the proof host. + + + + Task 3: Register journal:export-posts and journal:import-posts plus Posts toolbar + ../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 + .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 + 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. + + go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalCommands)$' + Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; lacks "--- PASS: TestJournalCommands". + + + - 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. + + CSV import/export is available from CLI and from the Posts toolbar for operators holding access_import_export. + + + + + +## 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. + + + +Run all three task commands, then go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1. + + + +- 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. + + + +Create `.planning/phases/15-journal-plugin/15-02-SUMMARY.md` when done + diff --git a/.planning/phases/15-journal-plugin/15-03-PLAN.md b/.planning/phases/15-journal-plugin/15-03-PLAN.md new file mode 100644 index 0000000..7a11e9b --- /dev/null +++ b/.planning/phases/15-journal-plugin/15-03-PLAN.md @@ -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. + + +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. + + + +@~/.codex/gsd-core/workflows/execute-plan.md +@~/.codex/gsd-core/templates/summary.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 +@../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 + + +## 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. + + + + + Task 1: Serve anonymous GET /_journal/api/v1/posts as a published list + 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. + ../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 + .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 + 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. + + go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalPublicList|TestJournalBuckets)$' + Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; either named PASS line is absent. + + + - 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. + + Anonymous clients can list published Journal posts at the PHP prefix with the PHP limiter names. + + + + Task 2: Slug show, draft 404, and backend-Bearer writes + D-15 write auth is backend Bearer only; clients that later expect Apparatus personal tokens need a second guard (deferred todo). + ../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 + .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 + 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. + + go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalWriteUnauthenticated|TestJournal005DraftShow|TestJournalPublicCategories|TestJournalPublicTags|TestJournalFeaturedImageUnauthenticated)$' + 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). + + + - 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. + + Editors can create and edit posts with backend Bearer, and drafts do not leak to anonymous clients. + + + + Task 3: Media upload, RSS, Typesense gate, and host route assertion + ../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 + .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 + 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. + + go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournal006MediaUpload|TestSearchGateOff|TestJournalRSS)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$' + Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; any of the named PASS lines is absent (TestJournal006MediaUpload, TestSearchGateOff, TestJournalRSS, TestBootUserTranslateJournal). + + + - 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. + + Media, RSS, and the off-by-default search gate complete the D-14/D-12 HTTP surface. + + + + + +## 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. + + + +Run all three task commands plus go -C ../sm-journal-plugin test ./... -short -count=1. + + + +- 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. + + + +Create `.planning/phases/15-journal-plugin/15-03-SUMMARY.md` when done + diff --git a/.planning/phases/15-journal-plugin/15-04-PLAN.md b/.planning/phases/15-journal-plugin/15-04-PLAN.md new file mode 100644 index 0000000..56f6a18 --- /dev/null +++ b/.planning/phases/15-journal-plugin/15-04-PLAN.md @@ -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. + + +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. + + + +@~/.codex/gsd-core/workflows/execute-plan.md +@~/.codex/gsd-core/templates/summary.md + + + +@.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 + + +## 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. + + + + + Task 1: Prove migrate → admin save → anonymous list → Bearer write → draft 404 end to end + ../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 + ../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) + 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). + + go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalEndToEnd)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$' + Non-zero exit; either run prints "--- FAIL", "--- SKIP", "no tests to run", or container startup treated as skip; lacks its named PASS line. + + + - 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. + + The Phase 15 user-visible path is proven on real Postgres before the horizontal test matrix. + + + + Task 2: Complete PHPUnit map, migrations, YAML, FormatHTML, and search-gate tests + ../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 + .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 + 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. + + go -C ../sm-journal-plugin test ./... -count=1 -v -race && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -race + 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. + + + - 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. + + Every D-05 PHPUnit row and every in-scope high threat has named Go evidence. + + + + Task 3: Phase gate, security review, and validation sign-off + scripts/check-phase15.sh, .planning/phases/15-journal-plugin/15-SECURITY-REVIEW.md, .planning/phases/15-journal-plugin/15-VALIDATION.md + 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 + 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. + + bash scripts/check-phase15.sh --all + 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. + + + - 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. + + The two-repository phase gate is green, high threats are mitigated with executed evidence, and validation is signed off. + + + + + +## 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. + + + +Run TestJournalEndToEnd, the race suites, then bash scripts/check-phase15.sh --all. + + + +- 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. + + + +Create `.planning/phases/15-journal-plugin/15-04-SUMMARY.md` when done + diff --git a/.planning/phases/15-journal-plugin/15-PATTERNS.md b/.planning/phases/15-journal-plugin/15-PATTERNS.md new file mode 100644 index 0000000..cfa731c --- /dev/null +++ b/.planning/phases/15-journal-plugin/15-PATTERNS.md @@ -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 diff --git a/.planning/phases/15-journal-plugin/15-RESEARCH.md b/.planning/phases/15-journal-plugin/15-RESEARCH.md index 31c74e3..7aaf702 100644 --- a/.planning/phases/15-journal-plugin/15-RESEARCH.md +++ b/.planning/phases/15-journal-plugin/15-RESEARCH.md @@ -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.