docs(quick-261009-i6e): add the anonymous author endpoint to sm-journal-plugin (D-20)

This commit is contained in:
2026-10-09 13:23:28 +02:00
parent f9324470bf
commit 56f3d9c59f
4 changed files with 451 additions and 1 deletions

View File

@@ -32,7 +32,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
Phase: 16 (grzybyfunkcjonalne-pl-on-reusable-blog-views) — READY TO EXECUTE
Plan: 4 of 4
Status: Ready to execute
Last activity: 2026-10-08 - Completed quick task 261008-o6k: summercms.io/update updates one application in place
Last activity: 2026-10-09 - Completed quick task 261009-i6e: add the anonymous author endpoint to sm-journal-plugin (D-20)
Progress: [██████████] 100%
@@ -674,6 +674,7 @@ Recent decisions affecting current work:
| 261008-kdf | summer delegates pass flags through, admin/route/key delegates, signal forwarding | 2026-10-08 | 50c8cee | [261008-kdf-delegated-summer-commands-pass-flags-thr](./quick/261008-kdf-delegated-summer-commands-pass-flags-thr/) |
| 261008-kdh | serve prints URL and admin status; summer new apps admin-ready | 2026-10-08 | f70d80e | [261008-kdh-serve-startup-and-admin-feedback-admin-r](./quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/) |
| 261008-o6k | summercms.io/update: update one application in place (framework checkout, CLI, .env admin secret, build) | 2026-10-08 | 9b3803c | [261008-o6k-bootstrap-upgrade-mode-for-existing-chec](./quick/261008-o6k-bootstrap-upgrade-mode-for-existing-chec/) |
| 261009-i6e | add the anonymous author endpoint to sm-journal-plugin (D-20) | 2026-10-09 | sm-journal-plugin 908ade9, 57a347c | [261009-i6e-add-the-anonymous-author-endpoint-to-sm-](./quick/261009-i6e-add-the-anonymous-author-endpoint-to-sm-/) |
## Deferred Items

View File

@@ -0,0 +1,246 @@
---
phase: quick-261009-i6e
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ../sm-journal-plugin/controllers/api/authors.go
- ../sm-journal-plugin/controllers/api/authors_test.go
- ../sm-journal-plugin/journal_api_authors_test.go
- ../sm-journal-plugin/routes.go
- ../sm-journal-plugin/README.md
- .planning/todos/pending/journal-author-profile-fields.md
autonomous: true
requirements: [QUICK-261009-i6e]
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "D-20/D-05: anonymous GET /_journal/api/v1/authors/{author} answers 200 with {\"data\":{\"id\",\"slug\",\"first_name\",\"last_name\"}}, exactly those four keys. {author} resolves first against backend_users.golem15_bloghub_author_slug, then against login, but the login match counts only when that user has at least one published post. Soft-deleted backend users never resolve. data.slug is the author slug when set, otherwise the login."
- "D-20: an unknown author, a login without published posts, a soft-deleted user and a malformed value (empty after trim, over 255 bytes, invalid UTF-8 or containing a control character) all answer 404 with body {\"error\":\"Author not found\"}: the plugin's existing {error} shape, the same shape as Post not found."
- "D-20: anonymous GET /_journal/api/v1/authors/{author}/posts answers 200 with the lagoon.Paginate envelope {data, meta{current_page,last_page,per_page,total}}. Each row is built by serializePost, the same row shape as GET /posts. Rows are only that author's published posts (published true, published_at set and in the past). page defaults to 1, per_page defaults to 9 and is clamped to 1..30, and sort uses the /posts allow-list (published_at desc by default). A Bearer token is ignored, so an editor also gets published posts only. An unresolvable author answers the same 404 as the profile route."
- "The author payload never carries the backend user's contact address, credential hash, activation, persist or reset codes, permissions or role, and it omits login. The handler selects only id, first_name, last_name, login and golem15_bloghub_author_slug from backend_users."
- "D-20 Reversibility costly (published API on a shared plugin): the change is additive only. Both routes sit in the existing throttle:journal-public-api group. controllers/api/posts.go, posts_helpers.go, auth.go and rss.go, go.mod and go.sum are unchanged, so every existing route and response shape stays the same."
- "README.md documents both routes, the resolution rule, the payload fields, the query keys, the envelope and the 404 body, and names no consuming application. go vet ./... and go test ./... (with Docker) are green in sm-journal-plugin."
artifacts:
- path: ../sm-journal-plugin/controllers/api/authors.go
provides: "AuthorShow and AuthorPosts handlers, resolveAuthor (slug, then login with a published post), validAuthorParam, the authorRow public-column projection and errAuthorNotFound"
contains: "func AuthorPosts"
- path: ../sm-journal-plugin/routes.go
provides: "GET /authors/{author} and GET /authors/{author}/posts in the public throttled group"
contains: "/authors/{author}/posts"
- path: ../sm-journal-plugin/journal_api_authors_test.go
provides: "Postgres integration tests for the profile route, the posts route, the 404 cases and the private-field absence"
contains: "TestJournalAuthorPosts"
- path: ../sm-journal-plugin/controllers/api/authors_test.go
provides: "table test for validAuthorParam"
contains: "TestValidAuthorParam"
- path: ../sm-journal-plugin/README.md
provides: "Public author API section and updated Public HTTP feature line"
contains: "/authors/{author}"
key_links:
- from: ../sm-journal-plugin/routes.go
to: ../sm-journal-plugin/controllers/api/authors.go
via: "g.Get inside the r.Group(\"/_journal/api/v1\", surf.Use(\"throttle:journal-public-api\"), ...) block"
pattern: "authors/\\{author\\}"
- from: ../sm-journal-plugin/controllers/api/authors.go
to: "backend_users.golem15_bloghub_author_slug (updates/202610060007_add_author_slug_to_backend_users.go)"
via: "resolveAuthor slug lookup"
pattern: "golem15_bloghub_author_slug"
- from: ../sm-journal-plugin/controllers/api/authors.go
to: ../sm-journal-plugin/controllers/api/posts.go
via: "reuses applyPublished, clampPerPage, atoiDefault, listOrder and serializePost unchanged, plus lagoon.Paginate"
pattern: "applyPublished\\("
---
<objective>
Add an anonymous, read-only author API to the shared Journal plugin. It is the D-20 prerequisite that Phase 16 consumes for the author page (D-05, route `/autor/:author` per D-15). The plugin gets two GET routes: one returns an author's public profile, the other a paginated list of that author's published posts.
**Repository.** All code changes land in `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin`, a separate git repo (branch `main`) next to `summercms.go`. Commit the code there. Only planning documents (this plan, the SUMMARY and one todo) live in `summercms.go/.planning`.
Purpose: Phase 16 plan 16-01 checkpoints until `routes.go` has an author route. Its layer client then calls `getAuthor(slug)` and `listAuthorPosts(slug, {page, perPage})` against these routes. Plan 16-03's `migrate.sh --author-slug` writes `backend_users.golem15_bloghub_author_slug`, which this endpoint reads.
Output: two commits in sm-journal-plugin (`feat(api)` profile route, `feat(api)` author posts route, each with tests and README), one docs commit in summercms.go (follow-up todo), and the SUMMARY with an "Author endpoint contract" section for 16-01 Task 1.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/STATE.md
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
@../sm-journal-plugin/README.md
@../sm-journal-plugin/routes.go
@../sm-journal-plugin/controllers/api/posts.go
@../sm-journal-plugin/controllers/api/auth.go
@../sm-journal-plugin/admin_harness_test.go
@../sm-journal-plugin/journal_public_list_smoke_test.go
Facts established while planning (do not re-derive):
- **Endpoint shape (chosen to match the 16-01 layer assumptions):** `GET /_journal/api/v1/authors/{author}` returns `{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}`. `GET /_journal/api/v1/authors/{author}/posts` returns `lagoon.Paginate(rows, page, perPage, total)`, which is `{"data":[...],"meta":{"current_page","last_page","per_page","total"}}`, the same envelope as `GET /posts`. A miss is 404 `{"error":"Author not found"}` through the existing `writeAPIError`. Do not add an anonymous `author=` filter to `GET /posts`: that would change an existing route (D-20 says additive only).
- **The author slug column already exists.** `updates/202610060007_add_author_slug_to_backend_users.go` adds `backend_users.golem15_bloghub_author_slug TEXT UNIQUE`. No migration is needed. The framework struct `cabana.BackendUser` has no field for it, so query `backend_users` through a plugin-local projection struct.
- **PHP reference** (`grzybyfunkcjonalne.pl/plugins/golem15/journal/components/Author.php`, `journalAuthor`). It loads the author by `golem15_bloghub_author_slug`, then by `login`. It refuses the login when the user has a slug (canonical URL rule). It renders `first_name`, `last_name` and `metadata['subtitle']`, and lists that user's posts (10 per page, `published_at desc`). Its SEO add-on also reads `avatar` and a description.
- **Two deliberate deviations from PHP, both to keep in the README:**
1. A login resolves even when the user has a slug. The existing post `author` object is `{id, first_name, last_name, login}` and has no slug (posts.go serializeAuthor). Changing it would alter an existing response shape, so a consumer can only link by login. The response's `slug` names the canonical identifier, and the consumer sets the canonical URL from it.
2. A login resolves only when that user has at least one published post. This stops anonymous callers from enumerating backend accounts: a published author's login is already public through `GET /posts`. A slug-only author, an explicit opt-in, resolves with zero posts (PHP renders an empty list).
- **Profile fields.** The Go `backend_users` table (framework DDL in `summercms.go/modules/lagoon/backend_admin_migrations.go`) has `first_name`, `last_name` (both nullable TEXT) and `login`, but no `metadata` column and no avatar attachment. Subtitle, description and avatar therefore have no Go storage. The 16-03 import also creates its author with none of them. Exposing them needs a framework change to `backend_users`, which this quick task does not make: all code changes stay in the plugin. Task 3 records this as a todo. Never emit a key without a source.
- **Routing.** `surf` `Where` attaches only to the last registered route, and a constraint mismatch answers with `http.NotFound` (plain text), not the plugin's JSON. Do not add a `Where` for `{author}`. Validate in the handler so every miss returns the JSON 404. `{author}` is one path segment, and the empty `/authors/` path never reaches the handler.
- **Reusable helpers in package `api`** (posts.go, use them, never edit them): `gdb`, `writeAPIError`, `applyPublished`, `clampPerPage` (default 9, max 30), `atoiDefault`, `listOrder`, `serializePost`, and `wire.WriteJSON`. `lagoon.Paginate` comes from `summercms/modules/lagoon`. `postQuery` in posts_helpers.go preloads User, Categories and Tags.
- **Test harness** (package `journal_test`): `newPostsEnv(t)` boots translate + journal on a dedicated testcontainers Postgres DB and runs migrations, including the author slug column. `env.call(t, method, path, token, body)` returns an httptest recorder. `env.admin(t, perms...)` creates a backend user with a role and returns a backend Bearer token. `phpErrorString(t, body)` returns the `error` string and the decoded map. `ptrTime` is defined. Permission constants: `controllers.PermissionAccessPosts` and `controllers.PermissionAccessOtherPosts`. Create posts with `env.gdb.Create(&models.Post{...})`, setting `UserID` to the author's id. Missing Docker fails the root package unless `-short`. This planning sandbox could not reach the Docker socket, so run the integration tests where Docker is reachable (outside the sandbox if needed). Never pass `-short` to the acceptance runs.
- **Concurrent prerequisite.** The D-21 quick task (category content) also edits `README.md` in this repo. Work on top of the current HEAD and keep any D-21 README text intact.
- **Repo hygiene.** `go.work.sum` is untracked in sm-journal-plugin: never stage it. In summercms.go, `.planning/state.json` and `.msd/` are dirty or untracked: never stage them. Stage by explicit path. Commit messages carry no co-author or attribution trailer (the user's global and project CLAUDE.md override the session reminder). No tag, push or require bump: Phase 16 plan 16-03 moves the host app's journal gitlink.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Anonymous author profile at GET /_journal/api/v1/authors/{author}, end to end</name>
<files>../sm-journal-plugin/controllers/api/authors.go, ../sm-journal-plugin/controllers/api/authors_test.go, ../sm-journal-plugin/journal_api_authors_test.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/README.md</files>
<read_first>../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/api/posts.go (lines 1-160 and 430-583), ../sm-journal-plugin/controllers/api/auth.go (lines 1-50, 115-130), ../sm-journal-plugin/admin_harness_test.go (lines 155-334), ../sm-journal-plugin/journal_api_writes_test.go (lines 1-35), ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go, ../sm-journal-plugin/README.md</read_first>
<behavior>
- TestValidAuthorParam (package api): "jane-doe", "jan.kowalski", "a@b", and a 255-byte ASCII string are valid. The empty string, a whitespace-only string (after TrimSpace), a 256-byte string, "a\x00b", "a\nb", "a\tb" and "\xff" are invalid.
- TestJournalAuthorProfile (package journal_test, real Postgres): insert backend user A (first Jane, last Doe, login "jdoe", author slug "jane-doe", contact address jdoe@example.test, credential hash column set to the marker string `$2y$10$authorSecretMarker`, persist_code "persist-marker"). GET /_journal/api/v1/authors/jane-doe answers 200, and data has exactly the keys id, slug, first_name and last_name, with slug "jane-doe", first_name "Jane", last_name "Doe" and id equal to A's id. The raw body contains none of: jdoe@example.test, authorSecretMarker, persist-marker, or the login "jdoe" as a JSON value.
- Login fallback: give A one published post. GET /authors/jdoe answers 200 with data.slug "jane-doe" (canonical). User B (login "ewa", no slug, first and last name NULL) has one published post. GET /authors/ewa answers 200 with slug "ewa", first_name "" and last_name "".
- Enumeration guard: user C (login "carol", no slug) has only a draft and a future-dated published post. GET /authors/carol answers 404.
- 404 cases, each answering status 404 with a JSON body whose only key is error, equal to "Author not found": an unknown slug "nobody"; user D with slug "gone-author" and deleted_at set; and a 300-character value. A slug-only user E (slug "fresh-author", no posts) answers 200 (explicit opt-in).
</behavior>
<action>Implement the profile route per D-20, D-05 and D-15.
1. Create controllers/api/authors.go in package api. It holds:
- The const errAuthorNotFound with the text Author not found, and the const maxAuthorParamBytes set to 255.
- An unexported struct authorRow with fields ID uint, FirstName string, LastName string, Login string and AuthorSlug string.
- validAuthorParam(s string) bool: true only when s is non-empty, len(s) is at most maxAuthorParamBytes, utf8.ValidString(s) holds, and no rune satisfies unicode.IsControl. The caller trims first with strings.TrimSpace.
- resolveAuthor(db *gorm.DB, param string) (*authorRow, error). A not-found result is (nil, nil), and only real database errors return an error. It runs one query shape through db.Table("backend_users").Select(...) with an explicit column list: id, COALESCE(first_name, '') AS first_name, COALESCE(last_name, '') AS last_name, login, and COALESCE(golem15_bloghub_author_slug, '') AS author_slug. Never use a select-all or the cabana.BackendUser struct, so the private columns are never read. It always filters deleted_at IS NULL. The first lookup is golem15_bloghub_author_slug = param. On a miss, it looks up login = param. That match is accepted only when applyPublished(db.Model(&models.Post{})).Where("user_id = ?", row.ID).Count(&n) gives n > 0, which reuses the exact published predicate that /posts uses. Use Limit(1) with Find into a slice, or Take with errors.Is(err, gorm.ErrRecordNotFound), so a miss is not a 500.
- authorProfile(row *authorRow) map[string]any with exactly the keys id, slug (AuthorSlug when non-empty, else Login), first_name and last_name.
- AuthorShow(app *backpack.App) http.HandlerFunc: get the db through gdb, falling back to 500 Internal server error as Index does. Read strings.TrimSpace(r.PathValue("author")). An invalid value, or a resolveAuthor result of nil, writes writeAPIError(w, http.StatusNotFound, errAuthorNotFound). A resolve error writes 500. Success writes wire.WriteJSON(w, http.StatusOK, map[string]any{"data": authorProfile(row)}).
- The handler does not call optionalBackendPrincipal: the route is anonymous and ignores Bearer tokens.
- Write doc comments in the style of posts.go: one line naming the route and the resolution rule.
2. routes.go: inside the existing public group (the one with surf.Use("throttle:journal-public-api")), after the /rss line, add g.Get("/authors/{author}", apicontrollers.AuthorShow(p.app)). Do not add a Where, because handler validation keeps the JSON 404. Extend the Routes doc comment to mention the public author routes. Leave the write group untouched.
3. Tests first (RED, then GREEN):
- controllers/api/authors_test.go: table-driven TestValidAuthorParam covering the behavior cases.
- journal_api_authors_test.go (package journal_test): add a helper insertBackendUser(t, env, first, last, login, slug string, deleted bool) uint. It runs env.gdb.Raw with an INSERT ... RETURNING id into backend_users, setting first_name and last_name through NULLIF(?, '') so empty strings store NULL, login, the contact address login+"@example.test", the credential hash marker, persist_code "persist-marker", is_activated TRUE, golem15_bloghub_author_slug through NULLIF(?, ''), deleted_at NOW() when deleted is true, and created_at/updated_at NOW(). Add a helper insertAuthorPost(t, env, userID uint, slug string, published bool, at *time.Time) that creates a models.Post with Title "Post "+slug, Content "body "+slug, ContentHTML "<p>body "+slug+"</p>" and UserID set. Then write TestJournalAuthorProfile covering every behavior bullet. Decode data into map[string]any and compare its sorted key list to [first_name id last_name slug]. Check 404 bodies with phpErrorString plus len(map) == 1.
4. README.md, same commit:
- Extend the Features "Public HTTP." line with `/authors/{author}`.
- Extend the "Author slug." line: the column stores the public author slug that the author routes resolve.
- Add GET /_journal/api/v1/authors/jane-doe to the anonymous http example block.
- Add a "### Public author API" subsection right after that block. It states the resolution order (author slug, then login only for a user with at least one published post; soft-deleted users never resolve), the four response keys with a JSON example for Jane Doe, that slug is the canonical identifier, and the 404 body. It also states that the author object carries no contact, credential or login data.
- Update the API reference row for journal.Plugin.Routes to "Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`."
- Use only neutral example names (jane-doe, acme) and never name a consuming application.
5. Run gofmt. Commit in sm-journal-plugin, staging explicit paths only: feat(api): anonymous author profile at /_journal/api/v1/authors/{author}. The subject must contain the word author (Task 3 checks it).</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && fmt=$(gofmt -l .) && test -z "$fmt" && go vet ./... && unit=$(go test ./controllers/api -count=1 -run '^TestValidAuthorParam$' -v 2>&1) && test "$(printf '%s\n' "$unit" | grep -c -- '^--- PASS: TestValidAuthorParam ')" -ge 1 && integ=$(go test . -count=1 -run '^TestJournalAuthorProfile$' -v 2>&1) && test "$(printf '%s\n' "$integ" | grep -c -- '^--- PASS: TestJournalAuthorProfile ')" -ge 1 && grep -qF 'g.Get("/authors/{author}", apicontrollers.AuthorShow(p.app))' routes.go && grep -qF '/authors/{author}' README.md && subj=$(git log -1 --format=%s) && printf '%s\n' "$subj" | grep -q author && files=$(git show --name-only --format= HEAD) && test -z "$(printf '%s\n' "$files" | grep -v '^$' | grep -vxE 'routes.go|README.md|controllers/api/authors.go|controllers/api/authors_test.go|journal_api_authors_test.go')" && git diff --quiet HEAD -- go.mod go.sum</automated>
</verify>
<done>Both tests show `--- PASS` (not SKIP) and there is no FAIL. go vet and gofmt are clean. The route is registered in the public throttled group, and the README documents it. The commit touches only the five allowed files, so posts.go, posts_helpers.go, auth.go, rss.go, go.mod and go.sum are unchanged. One `feat(api)` commit containing the word author is on sm-journal-plugin `main`.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Paginated published posts at GET /_journal/api/v1/authors/{author}/posts</name>
<files>../sm-journal-plugin/controllers/api/authors.go, ../sm-journal-plugin/journal_api_authors_test.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/README.md</files>
<read_first>../sm-journal-plugin/controllers/api/authors.go (as written in Task 1), ../sm-journal-plugin/controllers/api/posts.go (Index, lines 28-114), ../sm-journal-plugin/journal_public_list_smoke_test.go</read_first>
<behavior>
- TestJournalAuthorPosts: author A (slug "jane-doe") has three published posts a1, a2 and a3, published 3h, 2h and 1h ago, plus one draft, a-draft, and one published post dated an hour in the future, a-future. Author B has one published post, b1.
- GET /authors/jane-doe/posts answers 200. Slugs come in order [a3 a2 a1], and meta is {current_page 1, last_page 1, per_page 9, total 3}. Neither a-draft, a-future nor b1 appears. The sorted key set of each row equals the sorted key set of a row from GET /_journal/api/v1/posts for the same post, which proves the row shape matches.
- per_page=2&page=2 gives one row (a1) with meta {current_page 2, last_page 2, per_page 2, total 3}. per_page=100 gives meta.per_page 30. page=abc gives current_page 1. sort=published_at asc gives [a1 a2 a3].
- A Bearer token from env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts) still gets total 3, with no draft or future post (published only, D-20).
- GET /authors/jdoe/posts (the login of slugged author A, who has published posts) answers 200 with total 3. GET /authors/fresh-author/posts (slug only, no posts) answers 200 with data [] and meta {current_page 1, last_page 1, per_page 9, total 0}.
- GET /authors/nobody/posts and GET /authors/carol/posts (login with no published post) answer 404 with error "Author not found".
</behavior>
<action>Add the posts route per D-20 and D-05.
1. In controllers/api/authors.go, add AuthorPosts(app *backpack.App) http.HandlerFunc. It resolves the author exactly as AuthorShow does: the same validAuthorParam, resolveAuthor, 404 errAuthorNotFound and 500 paths. Then, mirroring Index without its editor branch:
- perPage := clampPerPage(query per_page) and page := max(1, atoiDefault(query page, 1)).
- q := applyPublished(db.WithContext(r.Context()).Model(&models.Post{}).Preload("User").Preload("Categories").Preload("Tags")).Where("user_id = ?", author.ID).
- Count into total (500 on error), then Order(listOrder(strings.TrimSpace(query sort), false)).Offset((page-1)*perPage).Limit(perPage).Find.
- Build each row with serializePost(&posts[i], db.WithContext(r.Context())), and write wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total)).
- No optionalBackendPrincipal call and no published, search, category, tag or author query keys: the list is always published-only and Bearer tokens are ignored.
- Pull the shared resolve-or-respond step into one unexported helper used by both handlers, so the 404 and 500 paths stay identical.
2. routes.go: add g.Get("/authors/{author}/posts", apicontrollers.AuthorPosts(p.app)) right after the Task 1 author line, in the same public group.
3. Tests first: add TestJournalAuthorPosts to journal_api_authors_test.go covering every behavior bullet. Reuse the Task 1 helpers, but create this test's own users (jane-doe/jdoe, carol, fresh-author and author B), because newPostsEnv gives every test a fresh database. Give each post a distinct published_at. Decode meta as in journal_public_list_smoke_test.go.
4. README.md, same commit: add GET /_journal/api/v1/authors/jane-doe/posts?page=2 to the anonymous http example block. In "### Public author API", document:
- the posts route and its query keys: page (default 1), per_page (default 9, max 30) and sort (same columns as /posts, default published_at desc);
- that rows have the same shape as GET /posts rows and only include that author's published posts;
- that Bearer tokens are ignored;
- the {data, meta{current_page, last_page, per_page, total}} envelope, with a trimmed JSON example;
- the same 404 body for an unknown author.
5. Run gofmt. Commit in sm-journal-plugin with explicit paths: feat(api): list an author's published posts at /authors/{author}/posts.</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && fmt=$(gofmt -l .) && test -z "$fmt" && go vet ./... && integ=$(go test . -count=1 -run '^TestJournalAuthor(Profile|Posts)$' -v 2>&1) && test "$(printf '%s\n' "$integ" | grep -c -- '^--- PASS: TestJournalAuthorProfile ')" -ge 1 && test "$(printf '%s\n' "$integ" | grep -c -- '^--- PASS: TestJournalAuthorPosts ')" -ge 1 && grep -qF 'g.Get("/authors/{author}/posts", apicontrollers.AuthorPosts(p.app))' routes.go && grep -qF '/authors/jane-doe/posts' README.md && subj=$(git log -1 --format=%s) && printf '%s\n' "$subj" | grep -q author && files=$(git show --name-only --format= HEAD) && test -z "$(printf '%s\n' "$files" | grep -v '^$' | grep -vxE 'routes.go|README.md|controllers/api/authors.go|controllers/api/authors_test.go|journal_api_authors_test.go')" && git diff --quiet HEAD -- go.mod go.sum</automated>
</verify>
<done>TestJournalAuthorProfile and TestJournalAuthorPosts both show `--- PASS` with no SKIP or FAIL. The posts route answers with the /posts envelope and row shape, published only, with Bearer ignored, and it 404s like the profile route. The README documents query keys, envelope and 404. A second `feat(api)` commit containing the word author is on sm-journal-plugin `main`.</done>
</task>
<task type="auto">
<name>Task 3: Full regression gate, additive-only proof, contract hand-off and profile-field todo</name>
<files>.planning/todos/pending/journal-author-profile-fields.md</files>
<read_first>../sm-journal-plugin/README.md (Public author API section as committed), ../sm-journal-plugin/controllers/api/authors.go, .planning/todos/pending/menu-plugin-port.md (todo frontmatter format), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-PLAN.md (Task 1: what the Prerequisite contract must record)</read_first>
<action>1. In sm-journal-plugin, run the complete suite without -short, with Docker reachable: go vet ./... and go test ./... -count=1. Every package must pass, including the existing journal_api_* tests. They prove that /posts, /posts/{slug}, /categories, /tags, /rss and the write routes behave exactly as before (D-20 additive only). If something fails, fix the new code and never the existing tests or handlers. A fix goes into a separate fix(api) commit whose subject contains the word author.
2. Additive-only proof: the files changed by this task's commits must be a subset of routes.go, README.md, controllers/api/authors.go, controllers/api/authors_test.go and journal_api_authors_test.go. Check this over the plugin's commits since the planning-time HEAD f5cd5b1 whose subject contains author (see verify). Confirm that the README names no consuming application.
3. In summercms.go, create .planning/todos/pending/journal-author-profile-fields.md with the frontmatter used by the other pending todos: created (ISO timestamp), title "Expose author subtitle, description and avatar on the Journal author API", area plugins, severity minor, and files listing ../sm-journal-plugin/controllers/api/authors.go and modules/lagoon/backend_admin_migrations.go.
- Problem: the WinterCMS author component shows metadata.subtitle, and its SEO add-on reads avatar and a description. The framework's backend_users has no metadata column and no avatar attachment, so the D-20 endpoint exposes id, slug, first_name and last_name only.
- Solution: a framework decision on backend user profile storage (a metadata column plus an avatar attachment on backend users, or a journal-owned author profile table edited in the admin). Then add the keys to the author payload additively.
- Commit it alone, staging the explicit path only: docs: todo for Journal author profile fields.
4. In the SUMMARY, write an "Author endpoint contract" section that 16-01 Task 1 copies into its Prerequisite contract. It lists:
- both exact route paths;
- the profile keys (id, slug, first_name, last_name), with slug canonical;
- the resolution rule, including that a post's author.login resolves when that author has published posts, so the layer can link by login and set the canonical URL from data.slug;
- the posts query keys and the paginated envelope;
- the 404 body {"error":"Author not found"};
- the slug storage column backend_users.golem15_bloghub_author_slug, which 16-03 migrate.sh writes;
- the profile fields not exposed, pointing to the new todo;
- both plugin commit SHAs.</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && go vet ./... && go test ./... -count=1 && shas=$(git log --format=%H --grep=author f5cd5b1..HEAD) && test "$(printf '%s\n' "$shas" | grep -c .)" -ge 2 && files=$(for s in $shas; do git show --name-only --format= "$s" || exit 1; done) && test -z "$(printf '%s\n' "$files" | grep -v '^$' | sort -u | grep -vxE 'routes.go|README.md|controllers/api/authors.go|controllers/api/authors_test.go|journal_api_authors_test.go')" && ! grep -Eiq 'grzyby|fonoteka|plytarium|płytarium' README.md && git diff --quiet HEAD -- go.mod go.sum && cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && grep -qF 'first_name' .planning/todos/pending/journal-author-profile-fields.md && todo_subj=$(git log -1 --format=%s -- .planning/todos/pending/journal-author-profile-fields.md) && printf '%s\n' "$todo_subj" | grep -q 'todo'</automated>
</verify>
<done>The full sm-journal-plugin suite is green without -short, and go vet is clean. The author commits touch only the five allowed files. go.mod and go.sum are unchanged. The README names no consuming application. The todo is committed alone in summercms.go. The SUMMARY holds the Author endpoint contract for 16-01.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| anonymous client → /_journal/api/v1/authors/* | Untrusted path segment {author} and query keys page, per_page and sort cross here, with no authentication |
| plugin handler → backend_users | A table holding admin credentials and contact data, read by a public route |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-i6e-01 | Information Disclosure | resolveAuthor / authorProfile | high | mitigate | Explicit five-column projection (id, first_name, last_name, login, author slug) into a plugin-local struct. The payload is built from exactly four keys and omits login. TestJournalAuthorProfile asserts the exact key set and that the fixture's contact address, credential hash marker, persist code and login are absent from the raw body. |
| T-i6e-02 | Information Disclosure | login fallback (backend account enumeration) | medium | mitigate | A login resolves only for a user with at least one published post, whose login GET /posts already exposes. Every other login gets the same 404 as an unknown value. Soft-deleted users never resolve. Both routes sit in the throttle:journal-public-api bucket. The enumeration-guard and soft-delete cases pin this. |
| T-i6e-03 | Information Disclosure | AuthorPosts (drafts and scheduled posts) | high | mitigate | applyPublished (the /posts predicate) is always applied, and there is no optionalBackendPrincipal call. The editor-token case proves drafts and future posts stay hidden. |
| T-i6e-04 | Denial of Service | AuthorPosts paging | low | mitigate | clampPerPage caps per_page at 30, page is clamped to at least 1, and the routes are rate-limited by the existing public bucket. The per_page=100 case pins the cap. |
| T-i6e-05 | Tampering | {author} and sort input | medium | mitigate | GORM parameter binding only. validAuthorParam rejects empty, over-255-byte, invalid UTF-8 and control-character values with the JSON 404. sort goes through the existing listOrder column allow-list. TestValidAuthorParam pins this. |
| T-i6e-06 | Tampering | shared plugin API contract (D-20 costly) | medium | mitigate | Routes are additive only. posts.go, posts_helpers.go, auth.go, rss.go, go.mod and go.sum are unchanged (diff gates in every task), and the full existing suite passes in Task 3. |
| T-i6e-SC | Tampering | npm/pip/cargo/go installs | low | accept | No package installs and no new Go requirement. The go.mod and go.sum diff gates prove it. |
</threat_model>
<verification>
- go vet ./... and go test ./... -count=1 (without -short, Docker reachable) are green in sm-journal-plugin.
- TestValidAuthorParam, TestJournalAuthorProfile and TestJournalAuthorPosts pass and are not skipped.
- routes.go registers both author routes inside the throttle:journal-public-api group. The write group and the existing public routes are unchanged.
- The author commits touch only routes.go, README.md, controllers/api/authors.go, controllers/api/authors_test.go and journal_api_authors_test.go.
- The README documents both routes and names no consuming application.
</verification>
<success_criteria>
- Phase 16 plan 16-01 Task 1's gate (`grep -qi 'author' ../sm-journal-plugin/routes.go`) passes for the D-20 half, and its client can call getAuthor(slug) and listAuthorPosts(slug, {page, perPage}) against these routes.
- /autor/admin on staging (16-03 sets slug admin) resolves through the slug lookup. Post author links built from author.login also resolve, and the canonical identifier comes from data.slug.
- Unknown authors answer 404 {"error":"Author not found"}. No private backend user data and no unpublished post appears in any response.
</success_criteria>
<output>
Create `.planning/quick/261009-i6e-add-the-anonymous-author-endpoint-to-sm-/261009-i6e-SUMMARY.md` when done, including the "Author endpoint contract" section (Task 3) and the two sm-journal-plugin commit SHAs.
</output>

View File

@@ -0,0 +1,186 @@
---
phase: quick-261009-i6e
plan: 01
subsystem: sm-journal-plugin public API
tags: [journal, api, authors, d-20, phase-16-prerequisite]
status: complete
requires:
- backend_users.golem15_bloghub_author_slug (updates/202610060007_add_author_slug_to_backend_users.go)
provides:
- GET /_journal/api/v1/authors/{author}
- GET /_journal/api/v1/authors/{author}/posts
affects:
- Phase 16 plan 16-01 Task 1 (layer client getAuthor / listAuthorPosts)
- Phase 16 plan 16-03 (migrate.sh --author-slug writes the slug column)
tech-stack:
added: []
patterns:
- plugin-local projection struct over backend_users (explicit column list, never select-all)
- handler-side path validation instead of surf Where, so misses stay JSON 404
key-files:
created:
- ../sm-journal-plugin/controllers/api/authors.go
- ../sm-journal-plugin/controllers/api/authors_test.go
- ../sm-journal-plugin/journal_api_authors_test.go
- .planning/todos/pending/journal-author-profile-fields.md
modified:
- ../sm-journal-plugin/routes.go
- ../sm-journal-plugin/README.md
decisions:
- "Journal author API resolves {author} by golem15_bloghub_author_slug, then by login only when that user has a published post; payload is exactly id, slug, first_name, last_name (D-20)"
- "Author profile fields subtitle, description and avatar are not exposed until the framework decides backend user profile storage (todo journal-author-profile-fields)"
metrics:
duration: ~25min
completed: 2026-10-09
actuals:
tokens: 6256
tasks: 3
commits: 2
commits: 2
plan_head_before: f5cd5b16fe444c4f9ea40d060218a9f1396ee380
plan_head_after: 57a347c97bafcc99fb83f09d5ddc1bfd8df32280
---
# Quick 261009-i6e Plan 01: Anonymous Journal author API Summary
The Journal plugin now has two anonymous, rate-limited GET routes: an author profile (`{id, slug, first_name, last_name}`) and that author's published posts in the `/posts` pagination envelope. `{author}` resolves by author slug first, then by login, and a login only resolves for a user who has at least one published post. The change is additive only.
`commits`, `plan_head_before` and `plan_head_after` are measured in the sm-journal-plugin repo (branch `main`), where all code landed.
## Commits (sm-journal-plugin, branch main)
| Task | Commit | Subject |
|------|--------|---------|
| 1 | `908ade9` | feat(api): anonymous author profile at /_journal/api/v1/authors/{author} |
| 2 | `57a347c` | feat(api): list an author's published posts at /authors/{author}/posts |
| 3 | none | Regression gate and todo only. No fix commit was needed. |
The todo `.planning/todos/pending/journal-author-profile-fields.md` is written in summercms.go and left **uncommitted**, as the orchestrator instructed. That overrides the plan's own "commit it alone" step, so Task 3's last verify clause (`git log -- <todo>`) does not hold yet. It will hold after the orchestrator's docs commit.
## Test results
**Docker was not reachable.** User `golem` is not in the `docker` group, so `/var/run/docker.sock` answers "permission denied" both inside and outside the sandbox. A plain `go test ./... -count=1` therefore fails in TestMain for the three testcontainers packages (root, `models`, `updates`) before any test runs. The literal acceptance command, with testcontainers and `postgres:16-alpine`, was **not** run green here.
To exercise the real-Postgres suites anyway:
- I started a private, throwaway PostgreSQL **18.6** cluster with `initdb` in the session scratchpad (127.0.0.1:55491, no unix socket).
- I made a temporary, **uncommitted** edit to each package's `start*Postgres`, so it used a DSN from an env var instead of testcontainers.
- I ran the suites, then reverted the three harness files with `git checkout --` and stopped the cluster. The working tree is clean except for the pre-existing untracked `go.work.sum`.
Results on that cluster:
| Command | Result |
|---------|--------|
| `gofmt -l .` | clean |
| `go vet ./...` | green, run after the harness revert |
| `go test ./controllers/api -run '^TestValidAuthorParam$' -v` | `--- PASS: TestValidAuthorParam` (no Docker needed) |
| `go test . -run '^TestJournalAuthor(Profile\|Posts)$' -v` | `--- PASS: TestJournalAuthorProfile`, `--- PASS: TestJournalAuthorPosts` |
| `go test . ./controllers/api ./classes -count=1 -v` | ok on all three: 35 top-level PASS, 0 SKIP, 0 FAIL. This includes every existing `journal_api_*` test, so the existing routes still behave as before. |
| `go test ./models -count=1` | ok |
| `go test ./updates -count=1` | ok |
Every package passed. Before relying on this for release, re-run `go test ./... -count=1` with no overrides on a machine where Docker is reachable, to confirm on Postgres 16 via testcontainers.
**RED evidence:** `TestValidAuthorParam` failed to build before `authors.go` existed (`undefined: validAuthorParam`). `TestJournalAuthorPosts` failed with `404 page not found` before the posts route was registered.
## Additive-only proof
- `git log --grep=author f5cd5b1..HEAD` finds 2 commits. Together they touch only `routes.go`, `README.md`, `controllers/api/authors.go`, `controllers/api/authors_test.go` and `journal_api_authors_test.go`.
- `go.mod`, `go.sum`, `controllers/api/posts.go`, `posts_helpers.go`, `auth.go` and `rss.go` are byte-identical to `f5cd5b1`.
- The README does not name any consuming application: grep finds none of grzyby, fonoteka or plytarium.
## Author endpoint contract
This is for 16-01 Task 1's Prerequisite contract.
**Routes.** Both are anonymous, sit in the `throttle:journal-public-api` group and ignore Bearer tokens.
- `GET /_journal/api/v1/authors/{author}`
- `GET /_journal/api/v1/authors/{author}/posts`
**Profile response:** `200 {"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}`.
- `data` has exactly these four keys.
- `slug` is the canonical identifier: the author slug when set, otherwise the login.
- A missing first or last name is `""`.
- There is no `login`, contact address, credential, permission or role key.
**Resolution of `{author}`** (the value is trimmed first):
1. `backend_users.golem15_bloghub_author_slug = {author}` resolves even when the author has no posts.
2. Otherwise `backend_users.login = {author}`, but only when that user has at least one published post (`published = true`, `published_at` set and in the past).
3. Soft-deleted users (`deleted_at` set) never resolve.
A slugged author's login also resolves. The post `author` object from `/posts` carries `{id, first_name, last_name, login}` and no slug, so the layer can link by `author.login` and set the canonical URL from `data.slug`.
**Posts response.** It uses the `lagoon.Paginate` envelope, the same one as `GET /posts`:
```json
{"data":[...],"meta":{"current_page":1,"last_page":1,"per_page":9,"total":3}}
```
- Rows are built by `serializePost`, so they have the same row shape as `GET /posts`.
- Only that author's published posts appear. A Bearer token does not unlock drafts or scheduled posts.
Query keys for the posts route:
| Key | Default | Rule |
|-----|---------|------|
| `page` | `1` | Clamped to at least 1. A non-numeric value means 1. |
| `per_page` | `9` | Clamped to 1..30. |
| `sort` | `published_at desc` | The `/posts` allow-list: `published_at`, `created_at`, `updated_at`, `title`, `id`, each with `asc` or `desc`. |
A slug-only author with no posts gets `data: []` and `meta {1,1,9,0}`.
**404** comes from both routes, with the body `{"error":"Author not found"}` and no other keys. It covers:
- an unknown value;
- a login without a published post;
- a soft-deleted user;
- a malformed value (empty after trim, over 255 bytes, invalid UTF-8 or containing a control character).
**Slug storage.** The column is `backend_users.golem15_bloghub_author_slug` (TEXT UNIQUE, plugin migration `202610060007`). 16-03's `migrate.sh --author-slug` writes it, so the staging URL `/autor/admin` resolves through the slug lookup.
**Not exposed:** subtitle (`metadata.subtitle`), description and avatar. The Go `backend_users` table has no storage for them. See `.planning/todos/pending/journal-author-profile-fields.md`.
**Plugin commits:** `908ade9` (profile), `57a347c` (posts).
## Deviations from Plan
### Auto-fixed Issues
None in the code. The plan's code steps were followed as written.
### Execution adjustments
1. **[Rule 3 - Blocking] Docker unreachable.** The integration tests ran on a private, throwaway Postgres 18.6 cluster through temporary, uncommitted harness overrides, and those overrides were reverted afterwards. None of this touched a commit. Details are under Test results above.
2. **The resolve-or-respond helper (`authorOrRespond`) was introduced in Task 1, not Task 2.** Task 1 needed the same 404 and 500 paths, and Task 2 reuses the helper unchanged.
3. **Extra test cases beyond the behavior list:**
- A slugged author's login answers 404 before their first published post and 200 after it.
- A soft-deleted user with a published post is 404 by login as well as by slug.
- A `%09` (tab) path value answers the JSON 404.
- Post rows from the author route carry `author.login`.
4. **The todo is not committed.** This follows the orchestrator constraint, which overrides plan Task 3 step 3's "commit it alone".
5. **Commits are on `main` in sm-journal-plugin**, as both the plan and the orchestrator direct. That repo has no per-agent branch workflow.
## Threat Flags
None. The new surface (two anonymous GETs that read `backend_users`) is the subject of the plan's threat model. Each item in the register has a test:
- T-i6e-01: the exact key set, and the absence of the email, hash marker, persist code and login from the response.
- T-i6e-02: the enumeration guard and the soft-delete cases.
- T-i6e-03: the editor-token case.
- T-i6e-04: `per_page=100` gives 30.
- T-i6e-05: `TestValidAuthorParam`, plus the oversized and control-character path cases.
- T-i6e-06: the additive-only diff gate.
## Known Stubs
None.
## Self-Check: PASSED
- FOUND: ../sm-journal-plugin/controllers/api/authors.go, controllers/api/authors_test.go, journal_api_authors_test.go
- FOUND: .planning/todos/pending/journal-author-profile-fields.md (uncommitted by instruction)
- FOUND: commits 908ade9 and 57a347c in sm-journal-plugin
- Caveat, not a missing item: the testcontainers-based `go test ./...` was not run because Docker is unreachable. The equivalent real-Postgres run passed, as described under Test results.

View File

@@ -0,0 +1,17 @@
---
created: 2026-10-09T11:22:00Z
title: Expose author subtitle, description and avatar on the Journal author API
area: plugins
severity: minor
files:
- ../sm-journal-plugin/controllers/api/authors.go
- modules/lagoon/backend_admin_migrations.go
---
## Problem
The WinterCMS author component (`journalAuthor`) shows `metadata.subtitle`, and its SEO add-on reads the author avatar and a description. The framework's `backend_users` table has no `metadata` column and no avatar attachment, so the D-20 endpoint `GET /_journal/api/v1/authors/{author}` exposes only `id`, `slug`, `first_name` and `last_name`. An author page built on it has no subtitle, bio or picture, and the import into `backend_users` creates authors without them.
## Solution
Make a framework decision on backend user profile storage first. Either add a `metadata` JSON column plus an avatar attachment on backend users (in `modules/lagoon/backend_admin_migrations.go` and the admin user form), or give the journal plugin its own author profile table edited in the admin. Then add the new keys to the author payload additively (`authorProfile` in `controllers/api/authors.go`), document them in the plugin README, and extend `TestJournalAuthorProfile`'s exact key set. Never emit a key without a storage source.