984 lines
60 KiB
Markdown
984 lines
60 KiB
Markdown
# 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
|