docs(15): create phase plan
This commit is contained in:
263
.planning/phases/15-journal-plugin/15-03-PLAN.md
Normal file
263
.planning/phases/15-journal-plugin/15-03-PLAN.md
Normal file
@@ -0,0 +1,263 @@
|
||||
---
|
||||
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.
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.codex/gsd-core/workflows/execute-plan.md
|
||||
@~/.codex/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
</context>
|
||||
|
||||
## 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.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Serve anonymous GET /_journal/api/v1/posts as a published list</name>
|
||||
<reversibility rating="one-way">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.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>.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</read_first>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalPublicList|TestJournalBuckets)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; either named PASS line is absent.</fails_when>
|
||||
</verify>
|
||||
<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>
|
||||
<done>Anonymous clients can list published Journal posts at the PHP prefix with the PHP limiter names.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Slug show, draft 404, and backend-Bearer writes</name>
|
||||
<reversibility rating="costly">D-15 write auth is backend Bearer only; clients that later expect Apparatus personal tokens need a second guard (deferred todo).</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>.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</read_first>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalWriteUnauthenticated|TestJournal005DraftShow|TestJournalPublicCategories|TestJournalPublicTags|TestJournalFeaturedImageUnauthenticated)$'</automated>
|
||||
<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>
|
||||
</verify>
|
||||
<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>
|
||||
<done>Editors can create and edit posts with backend Bearer, and drafts do not leak to anonymous clients.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Media upload, RSS, Typesense gate, and host route assertion</name>
|
||||
<files>../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</files>
|
||||
<read_first>.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</read_first>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>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)$'</automated>
|
||||
<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>
|
||||
</verify>
|
||||
<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>
|
||||
<done>Media, RSS, and the off-by-default search gate complete the D-14/D-12 HTTP surface.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
Run all three task commands plus go -C ../sm-journal-plugin test ./... -short -count=1.
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/15-journal-plugin/15-03-SUMMARY.md` when done
|
||||
</output>
|
||||
Reference in New Issue
Block a user