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

21 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
15-journal-plugin 03 execute 3
15-02
../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
true
D-12
D-14
D-15
D-16
D-17
tokens raw_tokens tasks confidence
110000 110000 3 low
truths artifacts key_links prohibitions
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.
path provides contains
../sm-journal-plugin/routes.go public and write groups under /_journal/api/v1 /_journal/api/v1
path provides contains
../sm-journal-plugin/controllers/api/posts.go PHP {error} string JSON, slug show, draft 404 Authentication required
path provides contains
../sm-journal-plugin/controllers/api/media.go JOURNAL-006 media upload under journal/ prefix media/upload
path provides contains
../sm-journal-plugin/plugin.go Buckets journal-public-api and journal-api journal-public-api
path provides contains
../sm-journal-plugin/search.go beachcomber Gate on golem15_journal_settings.search_use_typesense search_use_typesense
from to via pattern
../sm-journal-plugin/routes.go ../sm-journal-plugin/controllers/api/auth.go writes authenticate backend JWT in-handler; public GET optionally verifies Bearer backend
from to via pattern
../sm-journal-plugin/controllers/api/posts.go ../sm-journal-plugin/classes/format_html.go store/update regenerate content_html FormatHTML
from to via pattern
../sm-journal-plugin/search.go ../sm-journal-plugin/models/settings.go Gate reads ID=1 search_use_typesense; errors count as off SetGate
requirement_id category statement status verification
D-15 safety Write routes must not accept frontend sm-user-plugin personal tokens and must not accept Apparatus personal tokens resolved test
requirement_id category statement status verification
D-14 architecture Journal API errors must stay flat PHP {error} string JSON and must not use the cabana admin error envelope resolved test
requirement_id category statement status verification
D-12 safety A fresh install must not contact Typesense; search_use_typesense stays false by default resolved test
requirement_id category statement status verification
D-16 architecture Journal must not be added to the Płytarium tide 154-route harness resolved 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.

<execution_context> @/.codex/gsd-core/workflows/execute-plan.md @/.codex/gsd-core/templates/summary.md </execution_context>

@.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)$' <fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; either named PASS line is absent.</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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)$' <fails_when>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).</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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)$' <fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; any of the named PASS lines is absent (TestJournal006MediaUpload, TestSearchGateOff, TestJournalRSS, TestBootUserTranslateJournal).</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> Media, RSS, and the off-by-default search gate complete the D-14/D-12 HTTP surface.

<threat_model>

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. </threat_model>

Run all three task commands plus go -C ../sm-journal-plugin test ./... -short -count=1.

<success_criteria>

  • 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. </success_criteria>
Create `.planning/phases/15-journal-plugin/15-03-SUMMARY.md` when done