Files
summercms/.planning/phases/15-journal-plugin/15-RESEARCH.md
2026-10-06 18:02:17 +02:00

984 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>
## 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.
</user_constraints>
## 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