diff --git a/.planning/phases/15-journal-plugin/15-RESEARCH.md b/.planning/phases/15-journal-plugin/15-RESEARCH.md new file mode 100644 index 0000000..31c74e3 --- /dev/null +++ b/.planning/phases/15-journal-plugin/15-RESEARCH.md @@ -0,0 +1,983 @@ +# 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 + +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. + +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. + +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. + +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