---
phase: 15-journal-plugin
plan: 03
type: execute
wave: 3
depends_on: ["15-02"]
files_modified:
- ../sm-journal-plugin/plugin.go
- ../sm-journal-plugin/routes.go
- ../sm-journal-plugin/controllers/api/posts.go
- ../sm-journal-plugin/controllers/api/posts_test.go
- ../sm-journal-plugin/controllers/api/media.go
- ../sm-journal-plugin/controllers/api/auth.go
- ../sm-journal-plugin/search.go
- ../sm-journal-plugin/models/post.go
- ../sm-journal-plugin/README.md
- ../sm-journal-plugin/journal_public_list_smoke_test.go
- ../sm-grzybyfunkcjonalne-app/boot_test.go
autonomous: true
requirements: [D-12, D-14, D-15, D-16, D-17]
estimate:
tokens: 110000
raw_tokens: 110000
tasks: 3
confidence: low
must_haves:
truths:
- "D-14: anonymous GET /_journal/api/v1/posts, posts/{slug}, categories, tags, and rss work; writes use the same prefix."
- "D-15: POST/PUT/DELETE posts, featured-images, and media/upload require a cabana backend JWT (audience backend); missing Bearer returns JSON {error:Authentication required}."
- "D-17: buckets journal-public-api and journal-api are Max 120 per minute; 429 body stays surf Too Many Attempts."
- "JOURNAL-005: unpublished or future published_at show is 404 with no data key unless owner or access_other_posts."
- "D-12: search_use_typesense defaults false; Post ShouldBeSearchable is false when unpublished or the beachcomber Gate is off; a fresh save makes zero Typesense HTTP."
- "routes.php wins: show is GET posts/{slug}; list per_page default 9 max 30."
artifacts:
- path: "../sm-journal-plugin/routes.go"
provides: "public and write groups under /_journal/api/v1"
contains: "/_journal/api/v1"
- path: "../sm-journal-plugin/controllers/api/posts.go"
provides: "PHP {error} string JSON, slug show, draft 404"
contains: "Authentication required"
- path: "../sm-journal-plugin/controllers/api/media.go"
provides: "JOURNAL-006 media upload under journal/ prefix"
contains: "media/upload"
- path: "../sm-journal-plugin/plugin.go"
provides: "Buckets journal-public-api and journal-api"
contains: "journal-public-api"
- path: "../sm-journal-plugin/search.go"
provides: "beachcomber Gate on golem15_journal_settings.search_use_typesense"
contains: "search_use_typesense"
key_links:
- from: "../sm-journal-plugin/routes.go"
to: "../sm-journal-plugin/controllers/api/auth.go"
via: "writes authenticate backend JWT in-handler; public GET optionally verifies Bearer"
pattern: "backend"
- from: "../sm-journal-plugin/controllers/api/posts.go"
to: "../sm-journal-plugin/classes/format_html.go"
via: "store/update regenerate content_html"
pattern: "FormatHTML"
- from: "../sm-journal-plugin/search.go"
to: "../sm-journal-plugin/models/settings.go"
via: "Gate reads ID=1 search_use_typesense; errors count as off"
pattern: "SetGate"
prohibitions:
- requirement_id: D-15
category: safety
statement: "Write routes must not accept frontend sm-user-plugin personal tokens and must not accept Apparatus personal tokens"
status: resolved
verification: test
- requirement_id: D-14
category: architecture
statement: "Journal API errors must stay flat PHP {error} string JSON and must not use the cabana admin error envelope"
status: resolved
verification: test
- requirement_id: D-12
category: safety
statement: "A fresh install must not contact Typesense; search_use_typesense stays false by default"
status: resolved
verification: test
- requirement_id: D-16
category: architecture
statement: "Journal must not be added to the Płytarium tide 154-route harness"
status: resolved
verification: test
---
## Phase Goal
**As a** application developer, **I want to** mount `sm-journal-plugin` in a host the same way `sm-user-plugin` mounts, **so that** a blog can run on SummerCMS without the PHP plugin.
This plan's slice: anonymous GET `/_journal/api/v1/posts` returns published posts, and a backend-Bearer POST creates one.
Ship the full /_journal/api/v1 surface with PHP shapes, named limiter buckets, optional editor on GET, required backend Bearer on writes, media upload, RSS, and Typesense gate off by default.
Purpose: Phase 16 views and importers can call the frozen PHP contract without Winter.
Output: routes, API controllers, buckets, search gate, public-list smoke.
@~/.codex/gsd-core/workflows/execute-plan.md
@~/.codex/gsd-core/templates/summary.md
@.planning/phases/15-journal-plugin/15-CONTEXT.md
@.planning/phases/15-journal-plugin/15-RESEARCH.md
@.planning/phases/15-journal-plugin/15-PATTERNS.md
@../sm-journal-plugin/plugin.go
@../fonoteka.go/plugins/golem15/user/routes.go
@modules/surf/limiter.go
@modules/cabana/http.go
@modules/bouncer/jwt.go
@modules/beachcomber/searchable.go
## Spec-less probe fallback
Visible skip: no REQUIREMENTS.md IDs. D-12/D-14/D-15/D-16/D-17 and RESEARCH §5–§6 are the contract. Do not claim API-09/QA-05.
## Artifacts this phase produces
- Public GETs and backend-authenticated writes under `/_journal/api/v1` as RESEARCH route table.
- Plugin `surf.BucketProvider` buckets `journal-public-api` and `journal-api`.
- Plugin-owned backend JWT verify that writes PHP `{error}` strings, not cabana admin envelopes.
- Media upload JOURNAL-006; RSS XML; beachcomber Gate default off.
Task 1: Serve anonymous GET /_journal/api/v1/posts as a published list
D-14 public prefix /_journal/api/v1 and list envelope become the Phase 16 contract; changing them later breaks views and importers. Locked in CONTEXT — do not re-ask.
../sm-journal-plugin/plugin.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/auth.go, ../sm-journal-plugin/journal_public_list_smoke_test.go
.planning/phases/15-journal-plugin/15-CONTEXT.md (D-14, D-15, D-16, D-17), .planning/phases/15-journal-plugin/15-RESEARCH.md (§5 route table, routes.php vs API.md, optional editor, PHP error strings, limiters, Pitfall 3 and 4 and 12), .planning/phases/15-journal-plugin/15-PATTERNS.md (routes.go, API controllers, buckets), ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/plugin.go (Buckets), modules/surf/limiter.go, modules/pact/capabilities.go (HasRoutes), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/routes.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/PostApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/API.md
Implement pact.HasRoutes and surf.BucketProvider on Plugin (D-17). Buckets journal-public-api and journal-api, Max 120, Decay one minute. Public key is journal-public| plus surf.ClientIP with TrustedProxies. Write bucket key is journal-api|u: plus backend principal ID when bouncer.User is a backend principal, else ClientIP. 429 body remains surf {"message":"Too Many Attempts."} — do not invent a Journal 429 shape.
Two groups, same prefix /_journal/api/v1 (D-14). Public group middleware throttle:journal-public-api only. Register GET posts, posts/{slug} with Where slug [a-z0-9][a-z0-9\-/]*, categories, tags, rss. Do not attach cabana middleware name backend to the public group (missing token would 401 anonymous callers — pitfall 4).
Implement GET posts index now as the tracer: anonymous callers filter published=true (and published_at not in the future). per_page default 9 max 30 (controller, not API.md 15/50). Envelope {data, meta{current_page,last_page,per_page,total}} with empty arrays as []. Do not apply UNPUBLISHED_TITLE_PREFIX on JSON. Use wire.WriteJSON. Do not wrap errors in the cabana admin envelope (pitfall 3).
Optional editor (PHP isEditor): if Authorization Bearer is present, Lookup *bouncer.Registry is not enough to avoid UnauthorizedWriter — Registry.Middleware(backend) writes cabana unauthenticated. Instead, plugin helper requireBackendPrincipal / optionalBackendPrincipal constructs bouncer.NewBackendJWTGuard with the host admin.jwt.secret, audience backend, and backend_jwt_blacklist, using a PHP-shaped writer only on the write path. On public GET, call Authenticate only when the Bearer header is present; on failure treat as anonymous and do not write 401. If principal has golem15.journal.access_posts, index may include drafts unless ?published=true. author filter is editors only.
Do not add Journal routes to fonoteka.go tide fixtures (D-16).
Smoke TestJournalPublicList: assemble plugin routes, seed one published and one draft post, GET /_journal/api/v1/posts without Authorization returns 200 and only the published row.
go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalPublicList|TestJournalBuckets)$'
Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; either named PASS line is absent.
- GET /_journal/api/v1/posts without Authorization is 200 and omits drafts.
- Buckets() contains journal-public-api and journal-api with Max 120.
- Public group registration does not use cabana middleware name backend.
- Default per_page in index source is 9 and max is 30.
- No tide/parity harness file in fonoteka.go is modified.
Anonymous clients can list published Journal posts at the PHP prefix with the PHP limiter names.
Task 2: Slug show, draft 404, and backend-Bearer writes
D-15 write auth is backend Bearer only; clients that later expect Apparatus personal tokens need a second guard (deferred todo).
../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_test.go, ../sm-journal-plugin/controllers/api/auth.go, ../sm-journal-plugin/plugin.go
.planning/phases/15-journal-plugin/15-RESEARCH.md (Draft visibility JOURNAL-005, Serialize, error strings, write group, translations object, featured-images), .planning/phases/15-journal-plugin/15-PATTERNS.md (API error analog, write auth, optional editor), modules/cabana/http.go (writeUnauthenticated — do not reuse on this API), modules/bouncer/jwt.go (NewBackendJWTGuard, AudienceBackend), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/PostApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/security/AccessControlTest.php
Register write group: same prefix, throttle:journal-api, required backend principal in-handler (D-15). POST /posts, PUT /posts/{id}, DELETE /posts/{id}, POST /posts/{id}/featured-images, DELETE /posts/{id}/featured-images/{fileId}. Where id [0-9]+. Numeric public show is the slug route with ctype_digit fallback to id (PHP 192-201). Register writes so they do not steal the slug GET.
PHP error strings via wire.WriteJSON: 401 Authentication required; 403 Insufficient permissions or You do not have permission to publish posts; 404 Post not found with no data key; 422 Validation failed plus errors map. Do not emit Apparatus-token copy from stale API.md.
Writes: verify backend JWT HS256 audience backend and blacklist backend_jwt_blacklist. Reject frontend user JWTs (wrong audience). Do not implement Apparatus personal-token parsing. Store/update assign field-by-field (title, slug, content, excerpt, published, published_at, is_pinned, sources, metadata, category/tag ids, translations.* via translate SetTranslated). Never lagoon.Fill the whole body onto Post. Stamp user_id from principal on create. access_posts required; canEdit on update/delete; access_publish for publish flags. Regenerate content_html with FormatHTML.
Show: unpublished or published_at in the future is unpublished. 404 no data unless caller is owner (user_id) or holds access_other_posts (JOURNAL-005). Anonymous always 404 for drafts (never 403 that confirms existence). Include previous_post, next_post, related_posts on show. Do not prefix titles with the unpublished lock emoji.
GET categories and GET tags list public serialized rows (D-14). Categories JSON is {data} of id, name, slug, description, parent_id, nest_depth, post_count, optional children; omit zero-published-post categories unless include_empty=1. Tags JSON is {data} of id, name, slug, post_count ordered by name. Empty data is [].
Keep featured-image upload/delete behind the write group and access_posts (D-15). Write TestJournalPublicCategories, TestJournalPublicTags, and TestJournalFeaturedImageUnauthenticated in posts_test.go so skipping those handlers fails this task: anonymous GET /_journal/api/v1/categories and /tags return 200 with the PHP list keys; POST /posts/{id}/featured-images and DELETE /posts/{id}/featured-images/{fileId} without Bearer return 401 with JSON error string Authentication required; a backend Bearer that cannot edit the post returns 403 with You do not have permission to edit this post.
go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalWriteUnauthenticated|TestJournal005DraftShow|TestJournalPublicCategories|TestJournalPublicTags|TestJournalFeaturedImageUnauthenticated)$'
Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; any of the named PASS lines is absent (TestJournalWriteUnauthenticated, TestJournal005DraftShow, TestJournalPublicCategories, TestJournalPublicTags, TestJournalFeaturedImageUnauthenticated).
- POST /_journal/api/v1/posts without Bearer is 401 and the JSON error value is the string Authentication required.
- That 401 body is not the cabana admin unauthenticated envelope.
- Draft show to a stranger is 404 and the object has no data key.
- Owner or access_other_posts can GET a draft by slug or numeric id.
- Publish without access_publish is 403 with the PHP publish permission string.
- Frontend-audience JWTs do not authorize writes.
- Anonymous GET /_journal/api/v1/categories is 200 with PHP {data} rows of id, name, slug, description, parent_id, nest_depth, post_count.
- Anonymous GET /_journal/api/v1/tags is 200 with PHP {data} rows of id, name, slug, post_count.
- POST and DELETE featured-images without Bearer are 401 with JSON error string Authentication required.
- Featured-image writes with a backend Bearer that cannot edit the post are 403 with You do not have permission to edit this post.
Editors can create and edit posts with backend Bearer, and drafts do not leak to anonymous clients.
Task 3: Media upload, RSS, Typesense gate, and host route assertion
../sm-journal-plugin/controllers/api/media.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_test.go, ../sm-journal-plugin/search.go, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/README.md, ../sm-grzybyfunkcjonalne-app/boot_test.go
.planning/phases/15-journal-plugin/15-CONTEXT.md (D-12), .planning/phases/15-journal-plugin/15-RESEARCH.md (Media JOURNAL-006, Search D-12, RSS, Pitfall 6 and 11), .planning/phases/15-journal-plugin/15-PATTERNS.md (media.go, Searchable analog), modules/beachcomber/searchable.go, modules/beachcomber/example_test.go (installGate), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/MediaApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (shouldBeSearchable, searchableAs), ../sm-grzybyfunkcjonalne-app/boot_test.go
POST /_journal/api/v1/media/upload: require backend principal plus access_posts (JOURNAL-006). Folder regex ^[A-Za-z0-9_\-/]*$; force under journal/; strip a leading journal segment; reject .. . Image MIME jpg/jpeg/png/gif/webp; max 10240 KB in the handler even if host upload_bytes is larger. 201 {data:{url,path}}. Use gocloud blob already in the host graph.
GET rss: stdlib encoding/xml, PHP PostApiController::rss contract (D-14). Honor rss_* settings; empty/disabled still a well-formed feed. Write TestJournalRSS in posts_test.go: anonymous GET /_journal/api/v1/rss is 200, Content-Type application/rss+xml, body parses as RSS 2.0 with a channel, channel title follows rss_title, item count follows rss_posts_per_feed, unpublished posts are omitted, rss_include_content false omits content:encoded, rss_enabled false still returns well-formed XML. Route registration alone does not satisfy this task.
Post SearchableAs golem15_journal_posts. Set beachcomber.Gate from golem15_journal_settings.search_use_typesense for ID=1; read errors count as off (D-12). ShouldBeSearchable false when unpublished or gate off. List search: if query length at least 3 and gate on, SearchPage then SQL re-gate; on engine error log and ILIKE fallback. Fresh install never dials Typesense. Do not enable the setting by default.
Extend host TestBootUserTranslateJournal to assert assembled routes include GET /_journal/api/v1/posts and POST /_journal/api/v1/posts. Document the public prefix in the plugin README with blog examples.
Index search wiring may be a method on Post plus search.go Gate install at Boot.
go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournal006MediaUpload|TestSearchGateOff|TestJournalRSS)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'
Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; any of the named PASS lines is absent (TestJournal006MediaUpload, TestSearchGateOff, TestJournalRSS, TestBootUserTranslateJournal).
- Media upload without access_posts is 403; with permission is 201 and path is under journal/.
- Folder values containing .. are rejected.
- TestSearchGateOff saves a published post with default settings and records zero outbound search HTTP.
- Host boot test sees GET and POST /_journal/api/v1/posts.
- Anonymous GET /_journal/api/v1/rss is 200, Content-Type is application/rss+xml, body is well-formed RSS 2.0, channel title follows rss_title, item count follows rss_posts_per_feed, unpublished posts are omitted.
Media, RSS, and the off-by-default search gate complete the D-14/D-12 HTTP surface.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Anonymous HTTP → public GET | Untrusted clients must not see drafts |
| Bearer header → write handlers | Only backend-audience JWTs may mutate posts |
| Multipart folder/path → blob | Traversal must not write outside journal/ |
| Post save → Typesense | Gate off must produce zero network |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-15-01 | Spoofing | POST /_journal/api/v1/posts | high | mitigate | In-handler backend JWT; 401 Authentication required; reject frontend audience |
| T-15-02 | Information Disclosure | GET posts/{slug} drafts | high | mitigate | 404 without data unless owner or access_other_posts (JOURNAL-005) |
| T-15-03 | Tampering | POST /media/upload | high | mitigate | access_posts, folder regex, forced journal/ prefix, MIME/size (JOURNAL-006) |
| T-15-10 | Spoofing | write API tokens | high | mitigate | Backend aud only; no frontend personal token; no Apparatus personal tokens |
| T-15-11 | Information Disclosure | Typesense sync | high | mitigate | Gate default off; unpublished not searchable |
| T-15-12 | Denial of Service | X-Forwarded-For | medium | mitigate | surf.TrustedProxies + ClientIP on both buckets |
| T-15-13 | Tampering | error envelope | high | mitigate | PHP {error} string writer; do not call cabana admin error helper |
| T-15-SC | Tampering | package installs | high | mitigate | No new packages |
ASVS L1: all high threats mitigated; Plan 04 removal tests. block_on high.
Run all three task commands plus go -C ../sm-journal-plugin test ./... -short -count=1.
- Anonymous list/show/categories/tags/rss work.
- Writes require backend Bearer with PHP error strings.
- Drafts 404 to strangers; media stays under journal/.
- Typesense is never contacted on a fresh install.
- Journal is not in the Płytarium tide harness.