# Phase 15: Journal plugin - Research **Researched:** 2026-10-06 **Domain:** PHP-to-Go plugin port (WinterCMS `Golem15.Journal` → compiled `sm-journal-plugin`) plus proof-host wiring **Confidence:** HIGH ## User Constraints (from CONTEXT.md) ### Locked Decisions ### PHP source of truth - **D-01:** The port contract is `github.com/golem15com/wn-journal-plugin` at git SHA `02110eb` (2026-08-27), the pin currently checked out in Płytarium. That tree is newer than grzybyfunkcjonalne.pl (`2ad25e0`) and the Winter starter (`7237ed0`); it includes tags, `/_journal/api/v1`, Typesense, and the security fixes. — **Reversibility:** costly — every model, table, YAML key and route in the Go plugin is copied from this tree. - **D-02:** Freeze at `02110eb`. Planner and researcher must not silently pick a newer `master`. — **Reversibility:** reversible — a later discussion can re-pin. - **D-03:** Downstream agents read the frozen source from `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal` (already at that SHA). Do not clone a second copy unless that path is missing or the SHA does not match. - **D-04:** Binding from that tree: models, tables, YAML forms/lists/filters, permission codes, console commands, and `/_journal/api/v1` request/response shapes. Same bar as the user plugin port. - **D-05:** PHPUnit under `tests/` (security access/XSS/mass-assignment plus `PostRedactor`) is a behavioral spec. Researcher maps each test to a Go case; the phase's last plan covers them. - **D-06:** The Go plugin ships English and Polish phrasebook files only. The other 19 PHP locales stay in PHP until a later request. - **D-07:** This phase does not edit or PR `wn-journal-plugin`. An urgent PHP upgrade on a live Winter site before that site migrates is allowed as operations. If it lands before plan or execute, re-pin the SHA rather than mixing trees. - **D-08:** Embed the same admin nav SVG (`assets/images/journal-icon.svg`) in the Go plugin. ### What ships this phase - **D-09:** Do not port `Golem15.Translate` inside Phase 15. Insert `sm-translate-plugin` as its own roadmap phase **before** Phase 15 (`$gsd-phase` after this discussion). — **Reversibility:** one-way — Journal's translatable fields and Phase 16 blogs assume that plugin exists first. - **D-10:** Once that translate phase exists, Journal depends on it. Title, slug, content and the other PHP `$translatable` attributes stay translatable, so Phase 16 blogs work. — **Reversibility:** costly — the storage shape and admin fields couple to the translate plugin's contract. - **D-11:** Add a cabana markdown field (and preview) in `summercms.go` this phase, the same "framework first" pattern as Phase 12.1. Journal's post content uses it instead of a textarea stub. — **Reversibility:** costly — a new field type in the typed admin schema and generated TS types. - **D-12:** Port `search_use_typesense`, keep it off by default, and wire it to the Phase 11 search stack when enabled. A fresh install never contacts Typesense. — **Reversibility:** reversible — the setting and the hook can sit unused. - **D-13:** CSV import/export and the `journal:export-posts` / `journal:import-posts` commands ship now (admin + CLI). Winter.Pages menu item types and the dashboard report widget are deferred (no pages plugin, no dashboard widgets in the admin SPA). ### Public HTTP - **D-14:** Ship the full `/_journal/api/v1` surface this phase: anonymous GET `posts`, `posts/{slug}`, `categories`, `tags`, `rss`; backend-authenticated POST/PUT/DELETE posts, featured-image and media upload. Shapes follow `API.md` and `routes.php` in the frozen tree. — **Reversibility:** one-way — Phase 16 views and any importer will call this contract. - **D-15:** Write routes authenticate with the Phase 9 backend Bearer guard (admin session). They do **not** use frontend `sm-user-plugin` `user.api_token` (wrong identity) and they do **not** fold Apparatus `PersonalApiToken` / `g15_` tokens (still `.planning/todos/pending/backend-admin-api-tokens.md`). — **Reversibility:** costly — clients that later expect `g15_` tokens would need a second guard. - **D-16:** Proof is `API.md` plus the mapped PHPUnit tests. Journal is not added to the Płytarium tide 154-route harness. - **D-17:** Keep PHP's two limiter buckets (`journal-public-api` and `journal-api`, 120/min) and the CORS path `_journal/api/*`. ### Where it boots - **D-18:** The proof host is the empty repo `git@git.golem15.com:golem15/sm-grzybyfunkcjonalne-app.git` (already created). Not an in-module testhost and not `fonoteka.go` / `sm-summercmsio-app`. — **Reversibility:** one-way — the Go module path and submodule layout land in that app. - **D-19:** Phase 15 proof on that host: Journal mounts, migrations run, admin screens work, `/_journal/api/v1` answers. Live site HTML and reusable blog views stay Phase 16. - **D-20:** Local checkout: `/media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/` (sibling of `summercms.go`, same as `fonoteka.go` and `sm-summercmsio-app`). - **D-21:** The app mounts `sm-journal-plugin`, `sm-translate-plugin` and `sm-user-plugin` (submodules at `plugins/golem15/{journal,translate,user}`), following `.planning/notes/core-plugins-own-repos.md`. - **D-22:** The plugin remote `git@git.golem15.com:golem15/sm-journal-plugin.git` already exists and is public. Module path `git.golem15.com/golem15/sm-journal-plugin`, package `journal`, plugin ID `golem15.journal`. - **D-23:** Local plugin checkout: `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/` (sibling of `summercms.go`). The host mounts it as a submodule at `plugins/golem15/journal` with a `go.work` replace, same as `sm-user-plugin` in `fonoteka.go`. ### Claude's Discretion - Markdown field YAML type name, Go identifiers and SPA registry keys, provided they follow existing cabana fail-loud rules and update the module README, `docs/`, admin OpenAPI, generated TS types and committed `dist/` in the same change. - Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Framework markdown work may land in an early plan and be tagged if a framework version bump is needed. - Exact app wiring (`summer.yaml`, `go.work`, embed vs not) inside `sm-grzybyfunkcjonalne-app`, as long as D-18 to D-21 hold. - How Winter component pages (`journalPost`, `journalPosts`, …) are documented as a Phase 16 successor — they are not implemented here. - Run the security-review agent: the phase touches the plugin API (`pact`), authorization (`golem15.journal.*`, draft visibility) and public HTTP. ### Deferred Ideas (OUT OF SCOPE) - Insert `sm-translate-plugin` as a new phase before Phase 15 (`$gsd-phase`). Journal will depend on it (D-09, D-10). - Phase 16: grzybyfunkcjonalne.pl HTML, reusable blog views, Winter component successors (`journalPost`, `journalPosts`, RSS page, author/tags pages). - Winter.Pages menu item types (`journal-category`, `journal-post`, …) until a pages plugin exists. - Admin dashboard report widget until the SPA has dashboard widgets. - Apparatus backend personal API tokens (`g15_`) — todo `backend-admin-api-tokens.md`. - Remaining 19 PHP locales beyond `en` and `pl`. - WYSIWYG / redactor as a second editor mode, unless research shows the markdown field must cover PHP's switch this phase. ### Reviewed Todos (not folded) - `sitemap-plugin-port.md`: next blog / Phase 16; user chose leave on backlog. - `2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md`, `nest-framework-packages-under-modules.md`, `bonfire-duplicate-command-names.md`, `lagoon-readme-after-commit-callback-order.md`, `mailblocker-with-mailing.md`, `per-module-readmes-after-nest.md`, `readme-go-fences-src.md`, `refresh-fonoteka-readme.md`, `rewrite-summercms-readme.md`, `scaffold-admin-controller-incomplete.md`, `scaffold-same-second-migration-order.md`, `wristband-neutral-resource-default.md`, `scaffold-generated-header-and-command-deps.md`, `backend-admin-api-tokens.md`: keyword matches only, not this phase. ## Phase Requirements ROADMAP lists **Requirements: TBD**. REQUIREMENTS.md has **no Journal IDs**. Stale traceability still maps **API-09** and **QA-05** to Phase 15; those IDs belong to **Phase 20 cutover** (154 Płytarium routes). Do not treat them as Phase 15 acceptance. Map locked decisions and success criteria instead: | ID | Behavior this phase must prove | |----|-------------------------------| | D-01–D-08 | Frozen PHP pin; models/tables/YAML/perms/commands/API; PHPUnit mapped; en+pl only; no PHP edits; embed SVG | | D-09–D-13 | Require `golem15.translate`; translatable attributes; markdown/mlmarkdown; Typesense off by default; CSV + CLI | | D-14–D-17 | Full `/_journal/api/v1`; backend Bearer writes; not tide; limiter names + CORS | | D-18–D-23 | Proof host `sm-grzybyfunkcjonalne-app`; plugin module/path/ID | | SC-1 | Plugin repo: models, migrations, YAML admin, permission-gated SPA | | SC-2 | Host mounts plugin and serves public content / PHP-equivalent routes | | SC-3 | Neutral README (`the application`, `blog`) | | SC-4 | Unit tests in the last plan | ## Project Constraints (from CLAUDE.md) - Lean planning: few larger plans; unit tests are always the last plan of a phase; earlier plans may smoke-test but must not be blocked on coverage. - Plan-count checkpoint before writing PLAN.md files. - Stdlib first (`net/http` ServeMux, `html/template`, `encoding/json`). Add a dependency only when this research or a phase decision names it. `go vet` and `go test ./...` green at every commit. - Compiled plugins only. No runtime plugin loading. - API parity is the acceptance test for ported contracts; do not "improve" shapes. - GORM on Postgres only; gormigrate (not goose/atlas); AutoMigrate is never the schema source (DATA-02). - Two-repo rule: `summercms.go` is framework only and knows nothing about Płytarium. This phase writes the plugin in `sm-journal-plugin`, the proof host in `sm-grzybyfunkcjonalne-app`, and `summercms.go` only if a framework feature is missing. Planning docs stay in `summercms.go/.planning`. - Per-request state travels in `context.Context`; no package-level globals hold request state (KERN-07). - Framework READMEs never name a consuming application. Neutral examples (`blog`, `acme`). Plugin README same rule. - A change to a module's exported API, config keys or CLI commands updates that module's `README.md` and the affected `docs/` pages in the same change. Cabana field types also update admin OpenAPI, generated TS types, and committed `boardwalk` `dist/`. - PHP originals are not changed. Core plugin contracts stay non-breaking. - Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits. - This phase **is** plugin API (`pact`) plus security (public HTTP, draft visibility, `golem15.journal.*`). Do not skip the security-review agent later. No `AGENTS.md` in the working directory. ## Summary Phase 15 ports frozen PHP `Golem15.Journal` at SHA `02110eb1c0c3861370b0b9b47b209a0702ac5d88` into a new compiled plugin `sm-journal-plugin` (package `journal`, ID `golem15.journal`) and mounts it in `sm-grzybyfunkcjonalne-app` beside `sm-user-plugin` and `sm-translate-plugin`. The PHP tree is present at `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal` and matches D-01. Do not clone a second copy. The plugin checkout path D-23 does **not** exist yet; clone the empty remote in plan 01. **Primary recommendation:** Copy the user/translate mount pattern. Squash PHP rainlab→golem15 history into final `golem15_journal_*` tables. Require `golem15.translate` and implement `Translatable()` / `TranslatableIndexes()` / `MorphName()` with PHP class strings. Treat D-11 as **already shipped** in Phase 14.2.1 (`type: markdown` / `mlmarkdown`); do not add a second YAML type. Adapt PHP YAML to cabana's fail-loud key set (do not byte-copy). Ship `/_journal/api/v1` with PHP `{error}` JSON, optional backend principal on public GET, required backend Bearer on writes (not cabana's admin envelope, not `g15_`, not `user.api_token`). Keep limiter names and add host CORS `_journal/api/*`. Unit tests last. ## Architectural Responsibility Map | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | Posts/categories/tags/pivots/settings DDL | Database / Storage | API / Backend | gormigrate squash; plugin models own rows | | Translatable title/slug/content/excerpt | API / Backend | Database / Storage | sm-translate-plugin tables; Journal implements Translatable | | Admin Posts/Categories/Tags/Settings | API / Backend | Browser / Client | cabana YAML schema + SPA | | Public `/_journal/api/v1` JSON + RSS | API / Backend | — | pact.HasRoutes; PHP shapes | | Write API auth | API / Backend | — | cabana `backend` JWT verification; plugin-owned 401 JSON | | Rate limits `journal-public-api` / `journal-api` | API / Backend | — | surf.BucketProvider | | CORS `_journal/api/*` | API / Backend | — | host `config/http.yaml` | | Typesense sync | API / Backend | External | beachcomber Gate off by default | | Markdown → `content_html` | API / Backend | — | plugin FormatHTML; goldmark already in tree | | CSV import/export CLI | CLI | API / Backend | pact.HasCommands + toolbar gated by permission | | Proof host boot | API / Backend | — | sm-grzybyfunkcjonalne-app | | HTML/blog views | — | — | Phase 16; document successor only | | Pages menu types, dashboard widget, g15_ tokens | — | — | Deferred | ## Standard Stack No new libraries. Use the already-decided stack in `summercms.go` `go.mod` and `sm-translate-plugin`. ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | Go | 1.27.0 | Language | Project pin; `go version go1.27.0-X:nodwarf5` this session | | GORM | v1.31.2 | Models | Decided; translate/user plugins require it | | gorm.io/driver/postgres | v1.6.3 | Postgres | Decided | | go-gormigrate/gormigrate/v2 | v2.1.7 | Plugin migrations | Decided | | goldmark | v1.8.6 | Markdown → HTML | Already a direct framework dep; cabana.RenderMarkdown | | goccy/go-yaml | v1.19.2 | fields.yaml / columns.yaml | Cabana already parses with this | | gocloud.dev | v0.46.0 | Media blob for `/media/upload` | Already in host/plugin graphs | | Vue 3 + TypeScript | admin SPA | Existing markdown/mlmarkdown controls | No new SPA field type this phase | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | testcontainers-go + modules/postgres | v0.44.0 | Real Postgres for migration/API tests | Last plan; `-short` skip | | stretchr/testify | v1.12.1 | Assertions | Last plan only; keep `func Test...(*testing.T)` | | go-i18n/v2 | v2.6.1 | Phrasebook | Plugin `HasLang` catalogs; not model translations | | golang-jwt/jwt/v5 | v5.3.1 | Backend Bearer on writes | Already cabana `backend` guard | ### Alternatives Considered Do not use these. CONTEXT locked the pin, auth, host, and limiter names. | Instead of | Could Use | Why rejected | |------------|-----------|--------------| | SHA `02110eb` | Newer `master` | D-02 forbids it | | Backend Bearer | `user.api_token` or `g15_` | D-15 forbids both | | `sm-grzybyfunkcjonalne-app` | testhost / fonoteka.go | D-18 forbids them | | Cabana `mlmarkdown` | New YAML type / plugin widget | D-11 already shipped; fail-loud registry | | `golem15_journal_*` squash | Replay rainlab history | Same as translate/user ports | | Cabana admin `WriteError` envelope on `/_journal/api/v1` | PHP `{error}` | D-14 shapes | | New markdown library | goldmark | Already in go.mod | | goose / atlas | gormigrate | Project stack | **Installation:** none. Plugin `go.mod` copies the translate-plugin require set (GORM, gormigrate, summercms replace). Do not add goldmark to the plugin unless FormatHTML lives there and needs the import (then require the same `v1.8.6` already in the framework graph). **Version verification:** read `summercms.go/go.mod`, `sm-translate-plugin/go.mod`, and host `go.mod` this session. Goldmark `v1.8.6` is already a host indirect require. ## Package Legitimacy Audit This phase installs **no new external packages**. | Package | Registry | Verdict | Disposition | |---------|----------|---------|-------------| | (none new) | — | — | — | **Packages removed due to [SLOP] verdict:** none **Packages flagged as suspicious [SUS]:** none ## Verified PHP pin `git rev-parse HEAD` in `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal` is `02110eb1c0c3861370b0b9b47b209a0702ac5d88`. Matches D-01/D-02/D-03. Do not read a newer tree. Do not edit it (D-07). Local plugin path `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/` is **missing**. Plan 01 clones `git@git.golem15.com:golem15/sm-journal-plugin.git` there. Proof host exists at `/media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/` with user+translate only. ## 1. Tables, columns, indexes (squash to final names) PHP created `rainlab_journal_*` then renamed to `golem15_journal_*` in v2.0.0. Go **squashes to the final names**. Do not replay the rainlab era. Do not emit `rainlab_journal_*`. ### Final tables (use these) | Table | PHP `$table` / create | Role | |-------|----------------------|------| | `golem15_journal_posts` | `"public $table = 'golem15_journal_posts';"` [VERIFIED: `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php:37`] | Posts | | `golem15_journal_categories` | `"public $table = 'golem15_journal_categories';"` [VERIFIED: `.../models/Category.php:21`] | Categories | | `golem15_journal_tags` | `"public $table = 'golem15_journal_tags';"` [VERIFIED: `.../models/Tag.php:14`] | Tags | | `golem15_journal_posts_categories` | pivot `'table' => 'golem15_journal_posts_categories'` [VERIFIED: `Post.php:110`] | Post↔category | | `golem15_journal_posts_tags` | pivot `'table' => 'golem15_journal_posts_tags'` [VERIFIED: `Post.php:116`] | Post↔tag | | `golem15_journal_settings` | PHP `settingsCode = 'golem15_journal_settings'` in `system_settings` [VERIFIED: `.../models/Settings.php:13`] | **Go: dedicated singleton table ID=1**, same as cabana settings docs — not `system_settings` JSON | ### Posts columns (final, after all PHP ALTERS) Squash create + content_html (already in v1.0.1 create) + metadata + is_pinned + sources + redactor_id. Unique slug. Nullable timestamps. | Column | Type (Postgres) | Notes | |--------|-----------------|-------| | `id` | SERIAL PK | | | `user_id` | INTEGER NULL indexed | Backend author (`backend_users.id`) | | `redactor_id` | INTEGER NULL indexed | Frontend user; **not fillable** | | `title` | TEXT NULL | Translatable host column | | `slug` | TEXT NOT NULL UNIQUE | Translatable + indexed in translate plugin | | `excerpt` | TEXT NULL | Translatable | | `content` | TEXT NULL | Translatable markdown source | | `content_html` | TEXT NULL | Translatable; regenerated on save | | `published_at` | TIMESTAMPTZ NULL | | | `published` | BOOLEAN NOT NULL DEFAULT FALSE | | | `is_pinned` | BOOLEAN NOT NULL DEFAULT FALSE | v2.4.0 | | `metadata` | JSONB NULL | v1.3.0 `jsonable` | | `sources` | JSONB NULL | v2.4.0 `jsonable` | | `created_at`, `updated_at` | TIMESTAMPTZ NULL | PHP v1.2.4 nullable | Attachments stay in framework `system_files` with `attachment_type` = PHP class string `Golem15\Journal\Models\Post` (v2.0.0 rename already used that string). ### Categories columns From create + nested fields (already in the create_categories file at this pin): `id`, `name`, `slug` indexed, `code` nullable, `description`, `parent_id` indexed nullable, `nest_left`, `nest_right`, `nest_depth`, timestamps. Unique slug. Keep nest_* columns. **No NestedTree reorder UI** this phase; `parent_id` is a `relation`. ### Tags columns `id`, `name`, `slug` indexed, `description`, timestamps. Unique slug. ### Companion: `backend_users.golem15_bloghub_author_slug` PHP v2.2.2 adds `golem15_bloghub_author_slug` VARCHAR(128) NULL UNIQUE on `backend_users` [VERIFIED: `updates/v2.2.2/add_author_slug_to_backend_users.php:13-16`]. Framework `backend_users` table exists [VERIFIED: `modules/cabana/contracts.go:56` `return "backend_users"`]. Ship a **Journal plugin migration** that `ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS golem15_bloghub_author_slug TEXT UNIQUE`. Do not put the column in lagoon's framework migration. Phase 16 `journalAuthor` consumes it; include the column now (D-04 tables). ### Settings singleton Cabana stores settings as ID=1 of the plugin's own table, not PHP `system_settings` [VERIFIED: `docs/backend/settings.md:7-9`]. Create `golem15_journal_settings` with typed columns matching PHP `$rules` / 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 fields. `use_rich_editor` is stored and ignored at compile time (deferred WYSIWYG; always `mlmarkdown`). ### Migration IDs Use dated squash IDs in plugin history (pattern `202610060001_create_golem15_journal_posts`, …). One migration per table plus settings plus author_slug ALTER. Seed optional Uncategorized category only if PHP seeder at this pin still inserts it — verify in execute; do not invent seed posts. ## 2. Models, fillable, translatable, permissions ### Translatable (D-10) Post [VERIFIED: `Post.php:58-65`]: DATA_k7m2p9qx_START ``` public $translatable = [ 'title', 'content', 'content_html', 'excerpt', 'metadata', ['slug', 'index' => true], ]; ``` DATA_k7m2p9qx_END Category [VERIFIED: `Category.php:36-40`]: `'name'`, `'description'`, `['slug', 'index' => true]`. Tag: **not** translatable. Go: `Translatable() []string`, `TranslatableIndexes() []string` (`slug` only on Post/Category), `MorphName()` returns PHP class strings: - `Golem15\Journal\Models\Post` - `Golem15\Journal\Models\Category` Do not invent a Go type string. Translate indexes and attributes morph on those names. ### Fillable / Rules | Model | Fillable | Rules | |-------|----------|-------| | Post | PHP has **no** `$fillable`; API writes field-by-field. Admin: cabana Fillable on GORM struct for form columns only. **Never fill** `redactor_id`, `content_html`, `user_id` from public API body (`user_id` stamped from backend principal on create). | `title` required; `slug` required+regex+unique; `content` required; `published`+`published_at` together (PHP `afterValidate`) | | Category | `name`, `slug`, `code`, `description`, `parent_id` [VERIFIED: `Category.php:45-51`] | `name` required; `slug` required\|between:3,64\|unique; `code` nullable\|unique | | Tag | `name`, `slug`, `description` [VERIFIED: `Tag.php:27-31`] | `name` required; `slug` required\|between:3,64\|unique | | Settings | all settings columns | PHP `$rules` [VERIFIED: `Settings.php:17-31`] | `protected $guarded = ['*']` with fillable on Tag/Category is JOURNAL-001/002. Go: `Fillable()` lists only those names. Post relations: `user` belongsTo `cabana.BackendUser` (`user_id`); `redactor_id` stored without importing `sm-user-plugin` (PHP `$require` is Translate+Apparatus, not User). `categories`/`tags` belongsToMany. `featured_images`/`content_images` attachMany via `system_files`. `canEdit`: owner or `golem15.journal.access_other_posts` [VERIFIED: `Post.php:194-197`]. `filterFields`: hide `published`/`published_at` without `golem15.journal.access_publish` [VERIFIED: `Post.php:164-178`]. Go: controller hook returns `cabana.ForbiddenError` on publish writes without permission, and omit those fields from the compiled form when the principal lacks the grant (or keep fields but refuse save — refuse is mandatory; hiding is UX). ### Permissions (copy codes exactly) [VERIFIED: `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` Navigation `golem15.journal.*` on the main item; sideMenu: new_post / posts / categories / tags with the PHP permission lists [VERIFIED: `Plugin.php:96-134`]. Embed `assets/images/journal-icon.svg` (D-08). Map `icon-pencil` to the existing lucide mapping used by other plugins; do not invent a new icon pack. `cabana.Allows` is Winter `hasAnyAccess` (OR) [VERIFIED: `modules/cabana/contracts.go:143-161`]. ### Requires PHP: `Golem15.Translate` + `Golem15.Apparatus` [VERIFIED: `Plugin.php:15-18`]. Apparatus is dissolved — do **not** require an apparatus plugin. Go: `Requires() []string{ "golem15.translate" }`. User plugin is optional (KERN-05) for redactor FK tests on the host. ## 3. YAML admin — adapt, do not byte-copy Cabana `formFieldTypes` [VERIFIED: `modules/cabana/form_schema.go:24-30`]: DATA_w3n8r4jt_START ``` "text": {}, "textarea": {}, "number": {}, "checkbox": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, "widget": {}, "partial": {}, "fileupload": {}, "datepicker": {}, "password": {}, "permissioneditor": {}, "markdown": {}, "mltext": {}, "mlmarkdown": {}, ``` DATA_w3n8r4jt_END Allowed field keys [VERIFIED: `form_schema.go:37-48`]: `label`, `comment`, `span`, `type`, `required`, `tab`, `context`, `attributes`, `size`, `default`, `nameFrom`, `emptyOption`, `options`, `relation`, widget keys, fileupload keys, datepicker keys, `preset`. PHP `models/post/fields.yaml` uses keys that **boot-fail** in cabana: `placeholder`, `cssClass`, `stretch`, `commentAbove`, `trigger`, `type: taglist`, `type: repeater`, widget class `Golem15\Journal\FormWidgets\JournalMarkdown`, `toolbar` partial, `metadata[preview_page]` CMS dropdown, `tabs.stretch` / `paneCssClass` / `icons`. **Rewrite** the YAML: | PHP field | Go YAML | |-----------|---------| | `title` | `type: mltext` | | `slug` | `type: mltext` + `preset` field title type slug (preset is a legal key) | | `content` | `type: mlmarkdown` (D-11 + D-10). Not JournalMarkdown widget. | | `excerpt` | `type: mltext` or `textarea` for default locale only — use `mltext` | | `categories` | `type: relation` `nameFrom: name` | | `tags` | `type: relation` `nameFrom: name` — **not** `taglist`. On-the-fly `customTags` is a UX gap; create tags on the 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 column; write API still accepts `sources`. | | `metadata[preview_page]` | **Omit** (CMS pages are Phase 16). Keep `metadata` JSONB. | | `toolbar` partial | Omit | Settings YAML: drop every `trigger` block on search weights; always show the weight fields. `placeholder` → drop or use `comment`. ### Filters PHP `config_filter.yaml` uses `conditions:` [VERIFIED: `controllers/posts/config_filter.yaml:30-43`]. Cabana `filterScopeKeys` has **no** `conditions` [VERIFIED: `modules/cabana/filter_schema.go:17-20`]. A `conditions` key is a boot error (`list_schema_test.go` "conditions list"). Re-express as `pact.FilterScope`: - `published` switch → `FilterScopes` name matching YAML `scope:` (e.g. `FilterPublished`) applying `published <> true` / `published = true`. - `published_date` daterange → scope on `created_at` between `:after`/`:before`. - `category` already has `scope: FilterCategories` — implement `FilterCategories` (include child categories as PHP does). ### Lists `type: date` is a legal list column [VERIFIED: `modules/cabana/list_schema.go:23`]. Keep. Row class unpublished → list row state `disabled` if cabana supports it; else skip (SPA-only). ### Import/export (D-13) No cabana ImportExport behavior. Ship: 1. CLI `journal:export-posts` / `journal:import-posts` via `pact.HasCommands` (PHP `Plugin.php:169-170`). 2. Admin toolbar actions on Posts, `RequiredPermissions: []string{"golem15.journal.access_import_export"}`. Port PHP `PostImport`/`PostExport` column sets. Do not invent a generic CSV framework. ### Controllers | PHP | Go `pact.AdminController` ID | Perm | |-----|------------------------------|------| | `controllers/Posts.php` | `golem15.journal.posts` | `access_posts` | | `controllers/Categories.php` | `golem15.journal.categories` | `access_categories` | | `controllers/Tags.php` | `golem15.journal.tags` | `access_tags` | | Settings `registerSettings` | `HasSettings` code `journal` | `manage_settings` | `ListExtendQuery` / `FormExtendQuery`: without `access_other_posts`, restrict to `user_id = principal.ID`. `FormBeforeCreate`: stamp `user_id` from backend principal if empty [VERIFIED: `Post.php:144-150`]. Hooks regenerate `content_html` via plugin `FormatHTML` on save (not only API). ## 4. D-11 markdown field — already shipped Phase 14.2.1 landed `markdown` / `mltext` / `mlmarkdown` in cabana and the SPA (`MarkdownField.vue`, `MLMarkdownField.vue`). `cabana.RenderMarkdown` uses goldmark **without** `html.WithUnsafe` and rejects script/iframe/event handlers/dangerous URLs [VERIFIED: `modules/cabana/field_markdown.go:19-32`]. **Discretion recommendation:** Do **not** add a second YAML type this phase. Journal post `content` is `mlmarkdown`. If a plan still lists "add markdown field", treat it as a no-op unless docs/README are missing a Journal example — they already document the types [VERIFIED: `docs/backend/forms.md:324-346`]. PHP `formatHtml` enables footnotes, attributes, tables then `Html::clean` unless `backend.allow_unsafe_markdown` [VERIFIED: `Post.php:199-225`]. Cabana's default goldmark does **not** enable those extensions. **Do not change `cabana.RenderMarkdown` globally** (mail-aligned gate). Put `journal.FormatHTML` in the plugin: goldmark with footnote/table/attribute extensions (same `github.com/yuin/goldmark` module, no new package) + the same rejectUnsafe gate. Stored `content_html` and public API use FormatHTML. SPA preview may use `cabana.RenderMarkdown` (simpler). Ignore `use_rich_editor` when compiling the form (deferred WYSIWYG). ## 5. `/_journal/api/v1` contract ### routes.php wins over API.md where they disagree | Topic | API.md | routes.php / controller | Use | |-------|--------|-------------------------|-----| | Auth | All endpoints need `g15_` | Public GETs anonymous; writes `token.auth` | D-15 backend Bearer on **writes only** | | Get post | `GET /posts/{id}` | `GET /posts/{slug}` with `[a-z0-9][a-z0-9\-\/]*`; numeric slug falls back to id | **routes.php** | | List `per_page` | default 15 max 50 | default **9** max **30** [VERIFIED: `PostApiController.php:34`] | **controller** | | RSS | not in endpoint table | `GET rss` | **ship** (D-14) | | 401 body | `{"error":"Invalid token"}` | writes `{"error":"Authentication required"}` | writes: Authentication required; do not emit g15_ copy | ### Route table (implement this) Public group prefix `/_journal/api/v1`, middleware `throttle:journal-public-api` only (no required auth): | Method | Path | Handler | |--------|------|---------| | GET | `/posts` | index | | GET | `/posts/{slug}` | show; `Where("slug", `[a-z0-9][a-z0-9\\-/]*`)` — note Go mux: constrain so it does not steal `/posts/{id}` writes | | GET | `/categories` | categories | | GET | `/tags` | tags | | GET | `/rss` | rss XML | Write group same prefix, required backend principal + `throttle:journal-api`: | Method | Path | Extra perm | |--------|------|------------| | POST | `/posts` | `access_posts`; publish needs `access_publish` | | PUT | `/posts/{id}` | `Where("id", `[0-9]+`)`; `canEdit` | | DELETE | `/posts/{id}` | `canEdit` | | POST | `/posts/{id}/featured-images` | `access_posts` | | DELETE | `/posts/{id}/featured-images/{fileId}` | `access_posts` | | POST | `/media/upload` | `access_posts` (JOURNAL-006) | Go mux: register write `/posts/{id}` **and** public `/posts/{slug}` carefully. Numeric show is the public slug route with `ctype_digit` fallback [VERIFIED: `PostApiController.php:192-201`]. Writes use `{id}` digits only. ### Optional editor on public GET PHP: public group still consults `BackendAuth::getUser()` so an editor with `access_posts` sees drafts [VERIFIED: `routes.php:16-17` and `PostApiController.php:26-28`]. **Do not** attach middleware `"backend"` to the public group (missing token would 401). In the handler, if `Authorization: Bearer` is present, verify with the **same** backend JWT (audience `backend`) and set principal; otherwise anonymous. ### Do not use cabana `"backend"` middleware on these routes Cabana's guard writes `WriteError` 401 `unauthenticated` / `"Unauthenticated"` [VERIFIED: `modules/cabana/http.go:200-201`]. PHP Journal API is flat `{"error":"..."}`. Implement plugin middleware or in-handler checks that: 1. Verify the cabana backend JWT (HS256, same secret, same blacklist table `backend_jwt_blacklist`). 2. Write PHP JSON: 401 `{"error":"Authentication required"}`, 403 `{"error":"Insufficient permissions"}` / `{"error":"You do not have permission to publish posts"}`, 404 `{"error":"Post not found"}`, 422 `{"error":"Validation failed","errors":{...}}`. D-15 is satisfied by **which token** is accepted, not by wrapping cabana's admin envelope. ### Draft visibility (JOURNAL-005) Unpublished **or** `published_at` in the future is unpublished. Show returns 404 with **no** `data` key unless caller is owner (`user_id`) or holds `golem15.journal.access_other_posts` [VERIFIED: `PostApiController.php:207-217` and `AccessControlTest.php:49-69`]. Anonymous always 404 for drafts. Index: anonymous `published=true`; editor default includes drafts unless `?published=true` [VERIFIED: `PostApiController.php:49-58`]. `author` filter editors only. ### Serialize Do not apply `UNPUBLISHED_TITLE_PREFIX = '🔒 '` [VERIFIED: `Post.php:43`] on API JSON (PHP uses raw attributes). Include `previous_post`, `next_post`, `related_posts` on show (controller adds them; API.md list shape is incomplete). Translations object on write: `translations.*.title|slug|content|excerpt|metadata` via translate plugin helpers, not extra columns. ### Limiters (D-17) [VERIFIED: `routes.php:7-14`] DATA_p4c1v8qm_START ``` RateLimiter::for('journal-api', function (Request $request) { $user = \Backend\Facades\BackendAuth::getUser(); return Limit::perMinute(120)->by($user?->id ?: $request->ip()); }); RateLimiter::for('journal-public-api', function (Request $request) { return Limit::perMinute(120)->by($request->ip()); }); ``` DATA_p4c1v8qm_END Go `surf.BucketProvider` [VERIFIED: `modules/surf/limiter.go:21-33`]: ```go func (p *Plugin) Buckets() map[string]surf.Bucket { trusted := surf.TrustedProxies(p.app.Config) return map[string]surf.Bucket{ "journal-public-api": { Name: "journal-public-api", Max: 120, Decay: time.Minute, Key: func(r *http.Request) string { return "journal-public|" + surf.ClientIP(r, trusted) }, }, "journal-api": { Name: "journal-api", Max: 120, Decay: time.Minute, Key: func(r *http.Request) string { if pr := bouncer.PrincipalFrom(/* ctx */); pr != nil && pr.Backend { return "journal-api|u:" + strconv.FormatUint(uint64(pr.ID), 10) } return "journal-api|" + surf.ClientIP(r, trusted) }, }, } } ``` 429 body is surf's `{"message":"Too Many Attempts."}` [VERIFIED: `modules/surf/limiter.go:17`]. Keep it (framework-wide), do not invent a Journal 429 shape. ### CORS (D-17) Host `config/http.yaml` currently only `_user/api/*` [VERIFIED: `sm-grzybyfunkcjonalne-app/config/http.yaml:1-3`]. Add `_journal/api/*`. Plugin tests that read host CORS must be updated the way user plugin `TestRegisterCORSPath` does. ### Media upload (JOURNAL-006) [VERIFIED: `MediaApiController.php:21-62`]: require backend user + `access_posts`; folder regex `^[A-Za-z0-9_\-\/]*$`; force under `journal/`; strip leading `journal` segment; 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. ## 6. Search (D-12) PHP `shouldBeSearchable` returns the settings flag (default false) [VERIFIED: `Post.php:245-248`]. `searchableAs` is `golem15_journal_posts` [VERIFIED: `Post.php:234-237`]. Go: implement `beachcomber.Searchable`. Set a `beachcomber.Gate` that reads `golem15_journal_settings.search_use_typesense` for ID=1 and treats read errors as off (fonoteka pattern). `ShouldBeSearchable` false for unpublished posts **and** when the gate is off. Fresh install never contacts Typesense. List `search` query: if length ≥ 3 and gate on, `SearchPage` then SQL re-gate; on engine error log and fall back to ILIKE (PHP try/catch). Weights from settings. ## 7. Host wiring (D-18–D-23) Copy translate-phase host edits. Current host: `summer.yaml` plugins user + translate only [VERIFIED: `sm-grzybyfunkcjonalne-app/summer.yaml:3-7`]. `go.work` uses `./plugins/golem15/user` and `./plugins/golem15/translate` [VERIFIED: `go.work:5-9`]. Add `./plugins/golem15/journal`. `go.mod` replace translate/user to submodule paths [VERIFIED: `go.mod:85-87`]. Add `require` + `replace` for `git.golem15.com/golem15/sm-journal-plugin => ./plugins/golem15/journal`. Plugin `go.mod` (sibling checkout): `module git.golem15.com/golem15/sm-journal-plugin`, `go 1.27.0`, `replace git.golem15.com/golem15/summercms => ../summercms.go` like translate [VERIFIED: `sm-translate-plugin/go.mod:122`]. Do **not** copy PATTERNS' `../../../../summercms.go` into the sibling checkout; that path is for a plugin that only lives under `plugins/golem15/journal` without a sibling `go.mod` replace. Host `go.work` covers the mounted tree. `.gitmodules`: add journal submodule. `boot_test.go` currently `len(plugins) != 2`, forbids `golem15.journal` [VERIFIED: `boot_test.go:33-42`]. Change to 3 plugins, allow journal, assert controller IDs and `/_journal/api/v1` routes exist. `plugins.gen.go` / `main.go`: regenerate with `summer build`; do not hand-author after first boot. README: host may name itself; **plugin** README must not name grzybyfunkcjonalne or Płytarium. ## 8. PHPUnit → Go test map (D-05) | PHP test | Finding | Go case (last plan) | |----------|---------|---------------------| | `AccessControlTest::test_journal_005_api_show_returns_unpublished` | JOURNAL-005 | httptest show draft: other user 404 no `data`; owner 200; `access_other_posts` 200; anonymous 404 | | `AccessControlTest::test_journal_006_media_upload_no_permission_check` | JOURNAL-006 | authenticated backend **without** `access_posts` → 403; with permission → 201; folder `../` rejected | | `MassAssignmentTest::test_journal_001_tag_fillable` | JOURNAL-001 | Tag `Fillable()` is name/slug/description only; extra JSON keys dropped | | `MassAssignmentTest::test_journal_002_category_fillable` | JOURNAL-002 | Category fillable allow-list; `nest_left` not mass-assigned | | `XssTest::test_journal_003_post_xss_filter` | JOURNAL-003 | PHP asserts Twig `\|raw_safe` on **component templates** (Phase 16). Go this phase: FormatHTML + rejectUnsafe on `content_html`; do not port `.htm` files | | `XssTest::test_journal_004_summary_xss_filter` | JOURNAL-004 | Same: Phase 16 templates. This phase: summary accessor does not wrap unsanitized HTML into API JSON as executable markup | | `PostRedactorTest` | redactor_id | Column exists, not in Fillable, set via explicit assign; null on backend-only rows. Do not import user plugin in journal `go.mod`; host test may join `users` if present | Also last-plan: migration table names, limiter names, CORS path, anonymous list hides drafts, write without Bearer 401 PHP shape, publish without `access_publish` 403, cabana YAML compile of adapted fields, `mlmarkdown` save through TranslationWriter, Typesense gate off (zero HTTP), `go vet`/`go test ./...`. ## 9. Suggested plan split (recommendation, not PLAN.md) Present at the plan-count checkpoint. **Four** plans, tests last: 1. **15-01** — Clone `sm-journal-plugin`; squashed schema + models + Translatable + host submodule/`summer.yaml`/`go.work`/`http.yaml` CORS + boot_test 3 plugins. 2. **15-02** — Admin controllers, adapted YAML, settings, permissions, nav SVG, FormatHTML, import/export commands + toolbar. 3. **15-03** — `/_journal/api/v1`, buckets, optional editor on GET, backend Bearer writes, media upload, Typesense gate. 4. **15-04** — Unit/integration tests last (PHPUnit map + smoke). No separate framework-markdown plan: D-11 is done. Skip optional agents except **security-review** after implementation (CONTEXT discretion). ## Architecture Patterns ### System Architecture Diagram ``` Admin SPA --backend JWT--> cabana admin API --> Journal YAML controllers \-> HasSettings journal Anonymous GET /_journal/api/v1/* --> throttle:journal-public-api \ optional Bearer --> Post/Category/Tag GORM \ if search+gate on --> beachcomber \ FormatHTML on save Editor write /_journal/api/v1/* --> throttle:journal-api \ required backend JWT --> permission + canEdit \ featured_images / media blob Host sm-grzybyfunkcjonalne-app party.Activate(user, translate, journal) journal Requires translate ``` ### Recommended Project Structure ``` sm-journal-plugin/ ├── go.mod ├── plugin.go # ID, Requires, Register, Buckets, Routes ├── README.md ├── admin.go / admin_permissions.go / admin_navigation.go ├── models/ # post, category, tag, settings + Fillable/Rules ├── updates/ # squashed gormigrate ├── controllers/ # admin + api ├── console/ # export/import ├── lang/en/lang.yaml ├── lang/pl/lang.yaml ├── assets/images/journal-icon.svg └── controllers/{posts,categories,tags}/ # adapted YAML sm-grzybyfunkcjonalne-app/ ├── summer.yaml # add golem15.journal ├── go.work / go.mod # use + replace ├── plugins/golem15/journal # submodule └── config/http.yaml # CORS _journal/api/* ``` ### Pattern 1: Compiled plugin init **What:** `party.Register` in `init`, `pact.Plugin` + optional capabilities. **When:** every shared plugin. **Analog:** translate `plugin.go`, user plugin in host submodule. ### Pattern 2: Squash PHP migrations **What:** one DDL per final table named `golem15_journal_*`. **When:** always for Winter ports with rainlab history. ### Pattern 3: Adapted YAML **What:** rewrite fields to cabana-legal types/keys; FilterScopes instead of `conditions`. **When:** every PHP fields.yaml/columns.yaml/config_filter.yaml. ### Pattern 4: PHP JSON API beside cabana admin **What:** HasRoutes for `/_journal/api/v1` with PHP error keys; cabana for SPA admin. **When:** public plugin APIs. Do not mix envelopes. ### Anti-Patterns to Avoid - **Byte-copy PHP YAML:** boot fails on `taglist`, `repeater`, `trigger`, `placeholder`. - **Attaching `"backend"` middleware to public GET:** anonymous 401. - **Using `user.api_token` or `g15_`:** D-15. - **Replaying rainlab table names.** - **Porting Translate or Pages or dashboard widgets.** - **Naming grzybyfunkcjonalne in the plugin README.** - **Adding Journal to tide 154-route harness (D-16).** - **Changing cabana.RenderMarkdown to enable footnotes globally.** - **Importing sm-user-plugin only to type `redactor`.** ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Admin CRUD | Custom JSON admin | cabana HasAdminControllers | Schema pipeline already exists | | Settings JSON blob | system_settings port | HasSettings singleton table | docs/backend/settings.md | | Rate limits | Custom counters | surf.BucketProvider | Named buckets, 429 shape | | CORS | Handler headers | host `http.cors.paths` | surf OPTIONS 204 | | Markdown parse | New library | goldmark v1.8.6 | Already pinned | | Search client | typesense-go | beachcomber Gate + engine | Phase 11; typesense-go was not added | | Backend auth | New JWT | cabana backend JWT verify | Same secret/audience | | Translations | title_pl columns | sm-translate-plugin | D-10 | | Nested filters | Raw SQL in YAML | pact.FilterScope | conditions key refused | | File attachments | Ad-hoc disk | system_files + fileupload | cabana | **Key insight:** Journal is a compiled plugin over existing framework seams. The only new behavior is the PHP API/domain, not a new stack. ## Runtime State Inventory This is a **port/migration** (new Go plugin + host mount), not a rename of running production. Greenfield Go side; PHP Winter sites unchanged. | Category | Items Found | Action Required | |----------|-------------|------------------| | Stored data | PHP tables `golem15_journal_*` on live Winter sites; Go starts empty | No data migration this phase. Cutover later (Phase 16/20). Squash Go DDL to final names. | | Live service config | Host `http.yaml` CORS `_user/api/*` only | Add `_journal/api/*` | | OS-registered state | None — verified: no systemd unit in this phase's repos | None | | Secrets/env vars | Host already has `admin.jwt.secret` (boot_test sets it) | Writes reuse that secret. No new secret names. Typesense key already `SUMMER_SEARCH__TYPESENSE__API_KEY` — unused while gate off | | Build artifacts | `sm-journal-plugin/` missing; host `plugins.gen.go` for 2 plugins | Clone remote; `summer build` after summer.yaml change | **Nothing found in category:** OS-registered state — none in these checkouts. ## Common Pitfalls ### Pitfall 1: Byte-copy PHP YAML **What goes wrong:** `cabana.Activate` boot error on `taglist` / `repeater` / `trigger` / `placeholder` / `conditions`. **Why:** fail-loud schema. **How to avoid:** rewrite YAML per §3. **Warning signs:** start-up log `unknown field` / `unknown type`. ### Pitfall 2: `conditions` in config_filter.yaml **What goes wrong:** boot error naming `conditions`. **Why:** Phase 9 already used this file as a negative example. **How to avoid:** FilterScopes. **Warning signs:** `config_filter.yaml` still contains `conditions:`. ### Pitfall 3: Cabana 401 envelope on Journal API **What goes wrong:** SPA-shaped `{error:{code,message}}` instead of `{"error":"Authentication required"}`. **Why:** wrapping `"backend"` middleware. **How to avoid:** in-handler / plugin middleware; still verify backend JWT. **Warning signs:** clients parsing `error` as a string fail. ### Pitfall 4: Public GET requires Bearer **What goes wrong:** anonymous list/show 401. **Why:** putting `backend` on the public group. **How to avoid:** optional verify only. **Warning signs:** curl without Authorization fails GET /posts. ### Pitfall 5: Draft leak (JOURNAL-005) **What goes wrong:** unpublished post JSON to strangers (200 or 401 instead of 404). **Why:** forgetting `published_at` in the future; returning 403 that confirms existence. **How to avoid:** 404 no `data` unless owner or `access_other_posts`. **Warning signs:** test_journal_005 analog fails. ### Pitfall 6: Media folder traversal (JOURNAL-006) **What goes wrong:** upload writes outside `journal/`. **Why:** concatenating `folder` unchecked. **How to avoid:** copy PHP regex + segment filter + forced prefix. **Warning signs:** path contains `..` or `/media/../`. ### Pitfall 7: Mass-assign `redactor_id` / nest_* / user_id **What goes wrong:** API client becomes another author or frontend user. **Why:** Post has no PHP fillable; easy to `lagoon.Fill` the whole body. **How to avoid:** explicit allow-list in store/update matching PHP field-by-field assigns. **Warning signs:** PostRedactorTest fillable analog fails. ### Pitfall 8: Host boot_test still wants 2 plugins **What goes wrong:** `TestBootUserTranslate` fatals on journal. **Why:** 14.2.1 test **forbids** `golem15.journal`. **How to avoid:** update first in 15-01. **Warning signs:** `unexpected plugin golem15.journal`. ### Pitfall 9: Sibling go.mod replace vs ../../../../ **What goes wrong:** plugin tests cannot compile from `sm-journal-plugin/`. **Why:** 14.2.1 PATTERNS mixed two replace paths. **How to avoid:** sibling `../summercms.go`; host replace `./plugins/golem15/journal`. **Warning signs:** `module not found` summercms. ### Pitfall 10: MorphName mismatch **What goes wrong:** translations and files attach to the wrong morph. **Why:** using `journal.Post` instead of `Golem15\Journal\Models\Post`. **How to avoid:** PHP class strings. **Warning signs:** empty `golem15_translate_attributes` after mlmarkdown save. ### Pitfall 11: Typesense on by default **What goes wrong:** seeder/create hits missing API key. **Why:** PHP Scout observer. **How to avoid:** Gate off; settings default 0; ShouldBeSearchable false. **Warning signs:** network to :8181 in tests. ### Pitfall 12: API.md per_page 15/50 **What goes wrong:** pagination disagrees with PHP runtime. **Why:** stale API.md. **How to avoid:** controller 9/30. **Warning signs:** `per_page` 15 in Go. ### Pitfall 13: Plugin README names the host **What goes wrong:** docs checker / CLAUDE.md violation. **How to avoid:** `the application`, example `blog`. **Warning signs:** string `grzyby` in plugin README. ### Pitfall 14: Editing wn-journal-plugin **What goes wrong:** D-07. **How to avoid:** read-only PHP path. **Warning signs:** git status in fonoteka journal submodule. ### Pitfall 15: Enabling footnotes in cabana.RenderMarkdown **What goes wrong:** mail HTML / other markdown fields change. **Why:** shared goldmark.New(). **How to avoid:** plugin FormatHTML only. **Warning signs:** cabana markdown_test.go fails or mail snapshots change. ## Code Examples ### Plugin ID and require (PHP) DATA_h9s2b6kd_START ``` public $require = [ 'Golem15.Apparatus', 'Golem15.Translate', ]; ``` DATA_h9s2b6kd_END [VERIFIED: `Plugin.php:15-18`] Go: `Requires() []string { return []string{"golem15.translate"} }`. ### BackendUser table name DATA_r5t0c3ny_START ``` func (BackendUser) TableName() string { return "backend_users" } ``` DATA_r5t0c3ny_END [VERIFIED: `modules/cabana/contracts.go:56`] ### Cabana Allows is OR DATA_m8q1z4wp_START ``` // Allows reports whether principal satisfies any of the required permission // codes, the way Winter's hasAnyAccess does. ``` DATA_m8q1z4wp_END [VERIFIED: `modules/cabana/contracts.go:143-145`] ### Host summer.yaml today (must gain journal) DATA_v2j7f5la_START ``` plugins: - id: golem15.user module: git.golem15.com/golem15/sm-user-plugin - id: golem15.translate module: git.golem15.com/golem15/sm-translate-plugin ``` DATA_v2j7f5la_END [VERIFIED: `sm-grzybyfunkcjonalne-app/summer.yaml:3-7`] ### Translate plugin replace (copy for journal sibling checkout) DATA_c6d9k2hx_START ``` replace git.golem15.com/golem15/summercms => ../summercms.go ``` DATA_c6d9k2hx_END [VERIFIED: `sm-translate-plugin/go.mod:122`] ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | PHP JournalMarkdown widget | cabana `mlmarkdown` | 14.2.1 | D-11 satisfied | | PHP `system_settings` JSON | cabana settings table ID=1 | Phase 9 | Dedicated `golem15_journal_settings` | | Apparatus `g15_` write tokens | cabana backend Bearer | D-15 | API.md auth section is stale | | Laravel Scout | beachcomber Gate | Phase 11 | Off by default | | Rainlab table names | `golem15_journal_*` | PHP v2.0.0 | Squash in Go | | Twig components | Phase 16 views | deferred | Document successor only | **Deprecated/outdated:** - API.md "all endpoints require g15_": superseded by D-15 + routes.php public group. - CONTEXT "Existing Code Insights" still says cabana has no markdown field: stale after 14.2.1. ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | Goldmark footnote/table/attribute extensions are importable from the existing `github.com/yuin/goldmark` module without a new require | FormatHTML | If a separate module is required, stop and do not add it without a decision; fall back to cabana.RenderMarkdown (no footnotes) | | A2 | Omitting admin `taglist`/`repeater`/`preview_page` is acceptable MVP vs PHP UX | YAML | Editors need Tags admin + API for sources; Phase 16 may ask for taglist | | A3 | `bouncer` exposes a way to optionally parse a backend JWT from a request without the `"backend"` middleware | Public GET editor | If no helper exists, copy a few lines from cabana guard; do not invent a second JWT library | | A4 | PHP seeder "Uncategorized" still runs at this pin | Seed | Execute reads `seed_all_tables.php`; skip if empty | **If this table is empty:** not applicable — four assumptions remain. ## 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. - 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. - 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. - 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. ## Environment Availability | Dependency | Required By | Available | Version | Fallback | |------------|------------|-----------|---------|----------| | Go | compile | ✓ | 1.27.0 | — | | Postgres | migrations/tests | ✓ | 18.6 | testcontainers | | Docker | testcontainers | ✓ | 29.7.2 | `-short` skip last-plan Postgres tests | | PHP journal tree | contract | ✓ | SHA 02110eb | Do not clone | | sm-journal-plugin checkout | D-23 | ✗ | missing | Clone empty remote in 15-01 | | sm-journal-plugin remote | D-22 | ✓ | empty public repo | — | | sm-grzybyfunkcjonalne-app | D-18 | ✓ | user+translate mounted | — | | sm-translate-plugin | D-09/D-10 | ✓ | sibling + host submodule | Phase 14.2.1 must stay green | | Typesense | D-12 when on | optional | Phase 11 image | Gate off; no contact | **Missing dependencies with no fallback:** - Local `sm-journal-plugin/` directory — create in plan 01 (clone). **Missing dependencies with fallback:** - Typesense server — feature off by default. ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Go `testing` + testcontainers-go v0.44.0 (Postgres) | | Config file | none — `go test ./...` | | Quick run command | `go test ./... -short -count=1` in `sm-journal-plugin` + host `go test ./... -short -count=1` | | Full suite command | `go test ./... -count=1` in `sm-journal-plugin`, host, and `summercms.go` only if framework files change | ### Phase Requirements → Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|--------------| | D-01 | Tables `golem15_journal_posts\|categories\|tags` after migrate | integration | `go test ./updates -run TestJournalTables -count=1` | ❌ Wave 0 | | D-10 | Post Translatable list + MorphName PHP string | unit | `go test ./models -run TestPostTranslatable -count=1` | ❌ Wave 0 | | D-11 | `type: mlmarkdown` compiles on posts form | unit | `go test ./... -run TestPostsFormCompiles -count=1` | ❌ Wave 0 | | D-12 | Gate off → zero search HTTP on Post save | unit | `go test ./... -run TestSearchGateOff -count=1` | ❌ Wave 0 | | D-13 | Commands registered `journal:export-posts` / `journal:import-posts` | unit | `go test ./... -run TestJournalCommands -count=1` | ❌ Wave 0 | | D-14 | GET `/_journal/api/v1/posts` anonymous 200 | integration | `go test ./... -run TestJournalPublicList -count=1` | ❌ Wave 0 | | D-15 | POST posts without Bearer 401 `Authentication required` | integration | `go test ./... -run TestJournalWriteUnauthenticated -count=1` | ❌ Wave 0 | | D-17 | Buckets named `journal-public-api` and `journal-api` Max 120 | unit | `go test ./... -run TestJournalBuckets -count=1` | ❌ Wave 0 | | D-17 | Host CORS includes `_journal/api/*` | unit | host `go test ./... -run TestCORS -count=1` | ❌ Wave 0 | | D-19 | Host Activate 3 plugins including `golem15.journal` | smoke | host `go test ./... -run TestBootUserTranslateJournal -count=1` | ❌ Wave 0 | | JOURNAL-005 | Draft show 404 to stranger | integration | `go test ./... -run TestJournal005DraftShow -count=1` | ❌ Wave 0 | | JOURNAL-006 | Media upload 403 without `access_posts`; path prefix | integration | `go test ./... -run TestJournal006MediaUpload -count=1` | ❌ Wave 0 | | JOURNAL-001/002 | Tag/Category fillable | unit | `go test ./models -run TestFillable -count=1` | ❌ Wave 0 | ### Sampling Rate - **Per task commit:** `go test ./... -short -count=1` in the repo that changed - **Per wave merge:** full `go test ./... -count=1` in that repo + `go vet ./...` - **Phase gate:** plugin + host green before `$gsd-verify-work` ### Wave 0 Gaps - [ ] `sm-journal-plugin` module and all test files listed above - [ ] Host boot_test updated for three plugins + CORS - [ ] Plugin Postgres harness (copy translate/user TestMain / testcontainers) - [ ] No new test framework install - [ ] PHPUnit XSS template tests deferred to Phase 16; FormatHTML unsafe-tag tests substitute this phase ## Security Domain `security_enforcement` absent in `.planning/config.json` → enabled. Run the **security-review agent after implementation** (CONTEXT discretion). Phase touches pact, `golem15.journal.*`, draft visibility, public HTTP. ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | yes | Backend JWT (cabana), not frontend user tokens, not g15_ | | V3 Session Management | yes | Same backend JWT blacklist `backend_jwt_blacklist`; public routes unauthenticated | | V4 Access Control | yes | `golem15.journal.*`; `canEdit`; JOURNAL-005 404; JOURNAL-006 permission | | V5 Input Validation | yes | fillable allow-lists; slug regex; media folder regex; goldmark rejectUnsafe; file mime/size | | V6 Cryptography | no | Do not hand-roll tokens; reuse cabana backend JWT | ### Known Threat Patterns | Pattern | STRIDE | Standard Mitigation | |---------|--------|---------------------| | Draft enumeration via 403 | Information disclosure | 404 without `data` (JOURNAL-005) | | Media path traversal | Tampering | folder regex + forced `journal/` prefix (JOURNAL-006) | | Mass assignment nest_*/redactor_id | Elevation | Fillable + explicit API assigns | | Stored XSS in content_html | Tampering | FormatHTML + rejectUnsafe; Phase 16 raw_safe | | Cross-user post edit | Elevation | canEdit + ListExtendQuery | | Publish without permission | Elevation | access_publish 403 | | Frontend token on write API | Spoofing | Reject user.api_token; backend aud only | | Typesense data leak of drafts | Information disclosure | ShouldBeSearchable false when unpublished or gate off | | Rate-limit bypass via X-Forwarded-For | DoS | surf.TrustedProxies + ClientIP | | CORS credentialed * | Information disclosure | keep `supports_credentials: false` as host already has | ## Sources ### Primary (HIGH confidence) - Frozen PHP `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal` at `02110eb1c0c3861370b0b9b47b209a0702ac5d88` — Plugin.php, routes.php, API.md, models, YAML, tests, migrations - `15-CONTEXT.md` D-01–D-23 - `modules/cabana/form_schema.go`, `field_markdown.go`, `filter_schema.go`, `contracts.go`, `http.go`, `docs/backend/forms.md`, `docs/backend/settings.md`, `docs/backend/admin-controllers.md` - `modules/surf/limiter.go`, `docs/services/routing.md`, `docs/services/rate-limiting.md` - `modules/pact/capabilities.go` - `sm-translate-plugin/go.mod`, `sm-grzybyfunkcjonalne-app/{summer.yaml,go.work,go.mod,boot_test.go,config/http.yaml}` - `.planning/notes/core-plugins-own-repos.md`, `apparatus-dissolved-into-framework.md` - 14.2.1 RESEARCH/PATTERNS pitfalls (YAML, replace paths, host boot) ### Secondary (MEDIUM confidence) - None required for stack versions; go.mod and `go version` read directly ### Tertiary (LOW confidence) - A1–A4 in Assumptions Log ## Metadata **Confidence breakdown:** - Standard stack: HIGH — no new packages; versions from go.mod / `go version` - Architecture: HIGH — analog ports + PHP pin read this session - Pitfalls: HIGH — 14.2.1/12.1 plus journal-specific YAML/auth/draft/media **Research date:** 2026-10-06 **Valid until:** SHA re-pin (D-02) or cabana field-type change **Research-plan seam:** skipped (in-repo PHP→Go port; same as 14.2.1) **Graphify:** disabled this session