Files
summercms/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-05-PLAN.md
Jakub Zych 9c87a59532 docs(13): create phase plan
Six sequential plans: framework gaps, notifications/credentials/onboarding, wishlist, CSV, public views, unit tests and gate. Research open questions marked resolved per the plan-count checkpoint.
2026-10-02 20:12:35 +02:00

29 KiB

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
13-p-ytarium-api-wishlist-notifications-csv-credentials-public 05 execute 5
13-04
../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go
../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go
../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go
../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_public_album.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/public_share_controller.go
../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go
../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
../fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go
../fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/fixtures/routes/
../fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml
../fonoteka.go/parity/fixtures/nuxt/public-pubfail.yaml
../fonoteka.go/parity/fonoteka_seed_test.go
../fonoteka.go/parity/fonoteka_reset.php
../fonoteka.go/parity/fonoteka_flows_test.go
../fonoteka.go/parity/parity_test.go
../fonoteka.go/parity/README.md
true
API-03
API-07
tokens raw_tokens tasks confidence
220000 220000 3 low
truths artifacts key_links prohibitions
Per D-14, the public group carries only `public.share-headers`; `GET public/{token}` and `GET public-wishlist/{token}` carry the inline `throttle:10,1` only, and the four albums index/show routes carry `throttle:fonoteka-public-token` then `throttle:fonoteka-public-ip` only; every public response, 429s included, has `X-Robots-Tag: noindex, nofollow` and `Cache-Control: no-store, private` exactly as PHP sends them on the wire.
Per C-06, a public token resolves only when it is 16 characters of `[0-9a-zA-Z]`, a `LOWER(public_token)` lookup finds a collection with public_enabled true and the route's kind (`collection` or `wishlist`), and a constant-time compare of the exact token succeeds; anything else answers 404 `{"error":"Not found"}`; no route regex constrains `{token}`.
The `pubfail:<client ip>` counter ports PHP's anti-enumeration: before resolving, 10 failures within the 60 s window started by the first failure answer 429 `{"error":"Too many requests"}` without counting; each failed resolution counts one; the client IP is surf's trusted-proxy ClientIP; the counter is shared by all six public routes.
`GET public/{token}` and `GET public-wishlist/{token}` answer PHP's collection header body; `GET .../albums` validates its query with Validator::make semantics (422 `{"error":"Validation failed","errors":...}`, a `rating` parameter refused by PHP's closure rule), searches only the shared collection with `TEXT_FIELDS_PUBLIC` (query_by `name,artist_display,style_names,genre_name,track_titles`, weights `10,10,5,5,3`, SQL LIKE on name, artist_display, track_titles and artists.name) re-gated in SQL to that collection, and answers the list, the header and facets (artist, genre, decade, format, zero-count rows dropped) in PHP's shape.
`GET .../albums/{id}` (`[0-9]+`) answers `{"data": serializePublicAlbum}` for an album of the shared collection and 404 `{"error":"Not found"}` otherwise; the public album carries exactly SerializesPublicAlbum's twelve keys in order (id, name, artists, artist_display, year, format, medium, genre, styles, tracklist, photos, created_at) and nothing else, and public-wishlist mirrors the public collection with `kind='wishlist'` and no reservation data.
The six public routes are ported with re-recorded fixtures (`expectedPortedRoutes` 157, pending 14: the four Phase 13→14 routes and the ten routes outside this phase), and `TestFonotekaNuxtFlows/public-anonymous` and `TestFonotekaNuxtFlows/public-pubfail` (10 failures then the 429) replay green.
Edge (API-07 boundary): the 10th failed resolution within a minute still answers 404 and the 11th answers 429; after the window the counter starts again.
Edge (API-07 encoding): a token of 15 or 17 characters or with a non-alphanumeric character never reaches the database and counts as a failure; an uppercase/lowercase variant of a valid token finds the row through LOWER() but fails the exact compare.
Edge (API-07 adjacency): after the owner regenerates the share token (13-03), the old token answers 404 and the new one resolves; after the owner disables sharing, the token answers 404.
Edge (API-07 empty): an albums query matching nothing answers an empty list with the header and facets whose rows are all non-zero (empty arrays when the collection has no albums).
statement verification
Edge (API-07 concurrency): concurrent failures from one IP never let more than 10 failed resolutions through in one window (the counter's check and hit are serialized). backstop
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go PubfailCounter (TooMany, Hit), PublicAlbums, PublicFacets, PublicHeader pubfail:
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_public_album.go SerializePublicAlbum func SerializePublicAlbum(
path provides
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/public_share_controller.go PublicResolve, PublicAlbumsIndex, PublicAlbumsShow
path provides
../fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml recorded anonymous public journey
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/routes.go ../fonoteka.go/plugins/golem15/fonoteka/plugin.go per-route throttle:fonoteka-public-token and throttle:fonoteka-public-ip bucket names fonoteka-public-ip
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/public_share_controller.go ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go ResolvePublic(ctx, db, token, kind) after the pubfail check ResolvePublic
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go public search mode with TEXT_FIELDS_PUBLIC and a collection-only SQL re-gate track_titles
requirement_id category statement status verification
API-07 privacy An anonymous public view MUST NOT expose ratings, prices, notes, shelf, condition, reservations or any owner account data, for either kind resolved test
requirement_id category statement status verification
API-07 privacy A disabled, regenerated or wrong-kind share token MUST NOT resolve any collection resolved test

Phase Goal

ROADMAP Phase 13 goal (verbatim, not in user-story form): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets.

This plan's slice: anyone holding a shared link can browse a collection or a wishlist without an account, searching and filtering it, while guessing links is throttled exactly as PHP throttles it (API-07 and the public-wishlist part of API-03; ROADMAP SC-1 and SC-5).

Port the six anonymous public routes with token resolution, the anti-enumeration counter, the public search mode, facets and the public album serializer; correct the public headers and move the rate buckets per route (D-14); record the public flows.

Purpose: shared links are Płytarium's only anonymous surface and the most exposed one; it gets its own security-focused slice. Decisions implemented: D-12 (public-anonymous), D-14, C-01, C-02, C-03, C-06, C-07; RESEARCH Findings 6 (header) and 9 (public search mode). Output: classes, handlers, header fix, route layout, recordings and flows; ported count 157.

Repo: fonoteka.go only. Commits path-scoped; never add co-author tags.

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

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-03-SUMMARY.md @../fonoteka.go/plugins/golem15/fonoteka/routes.go - From 13-03: `classes.ResolvePublic(ctx, db, token, kind) (*models.Collection, error)` (shape check, LOWER() lookup, public_enabled, kind, constant-time compare), share token capture `share:collection` and `share:wishlist`. - Existing: routes.go public group `r.Group("/_fonoteka/api/v1", surf.Use("public.share-headers", "throttle:fonoteka-public-token", "throttle:fonoteka-public-ip"), ...)` (empty); plugin.go buckets `fonoteka-public-token` (60/min, key `pubtok:` + PathValue token) and `fonoteka-public-ip` (120/min, ClientIP); middleware/public_share_headers.go (sets X-Robots-Tag and Cache-Control, rewrites 429 to JSON); `surf.ClientIP(r, surf.TrustedProxies(cfg))`; inline throttle key `inline:domainless|` shared by every inline-throttled anonymous route; classes/album_search.go `SearchAlbums` (user/token scoped, authenticated TEXT_FIELDS), beachcomber `SearchPage` re-gated in SQL (Phase 12); routes_bucket_test.go boot-probe groups; `writeValidationFailed`. - PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{controllers/api/PublicShareApiController.php (whole file), middleware/PublicShareHeaders.php, traits/SerializesPublicAlbum.php, classes/AlbumSearchService.php (TEXT_FIELDS_PUBLIC lines 60-65, public mode), classes/CollectionShareService.php (resolvePublic), routes.php (lines 399-428)}; Nuxt /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/pages and composables for `/k/` and `/w/` public pages.

Artifacts this phase produces

(This plan's share.)

  • classes: PubfailCounter with TooMany(key string) bool and Hit(key string), PublicAlbums, PublicFacets, PublicHeader, SerializePublicAlbum, public search mode in album_search.go (TextFieldsPublic).
  • controllers/api: PublicResolve, PublicAlbumsIndex, PublicAlbumsShow (one handler per route, parameterised by kind).
  • Routes (group public.share-headers only): GET /_fonoteka/api/v1/public/{token} and GET .../public-wishlist/{token} (throttle:10,1); GET .../public/{token}/albums, GET .../public/{token}/albums/{id}, GET .../public-wishlist/{token}/albums, GET .../public-wishlist/{token}/albums/{id} (throttle:fonoteka-public-token, throttle:fonoteka-public-ip; {id} [0-9]+).
  • Parity: seed state public; fixtures/nuxt/public-anonymous.yaml, fixtures/nuxt/public-pubfail.yaml; TestFonotekaNuxtFlows/public-anonymous, TestFonotekaNuxtFlows/public-pubfail.
  • Tests: TestPublicResolve, TestPubfailCounter, TestPublicBucketsPerRoute, TestPublicAlbumFieldSet.
Task 1: An anonymous visitor opens a shared collection link and gets PHP's header, its exact cache headers and its throttle The isolated PHP parity instance can be reset (`php -v` exits 0); `php_parity.sh reset` clears the file cache before each anonymous recording. ../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/public_share_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go, ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/PublicShareApiController.php (resolve, collectionHeader, tooManyFailedResolves, failureKey, unavailable, tokenUnavailable, throttled), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/PublicShareHeaders.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 399-428), ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go, ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go (ResolvePublic from 13-03), ../fonoteka.go/plugins/golem15/fonoteka/routes.go (public group), ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (public buckets), summercms.go modules/surf/clientip.go, summercms.go modules/surf/limiter.go (inline key), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_public_{token}_public_share.yaml (header bytes), ../fonoteka.go/parity/README.md (Phase 13 recording: anonymous throttle budget) Per D-14, C-06 and RESEARCH Finding 6 point 1.

(1) Header fix: public_share_headers.go sets Cache-Control to the wire value PHP sends, no-store, private (Symfony re-normalises PHP's directive order), keeping X-Robots-Tag: noindex, nofollow and the JSON 429 rewrite with Retry-After and X-RateLimit-* kept; update its test to the recorded bytes.

(2) classes/public_share.go PubfailCounter: an in-memory fixed window per key (window starts at the first hit, 60 s, mutex-serialized), TooMany(key) bool peeks against the limit 10 without counting, Hit(key) counts one; one process-wide instance owned by the plugin; keys pubfail: plus surf's trusted-proxy ClientIP. PublicHeader(collection) ports collectionHeader.

(3) controllers/api/public_share_controller.go PublicResolve(app, kind): TooMany → 429 {"error":"Too many requests"}; ResolvePublic(token, kind) failure → Hit then 404 {"error":"Not found"}; success → PHP's resolve body.

(4) routes.go, per D-14: the public group becomes surf.Use("public.share-headers") only; register GET /public/{token} with throttle:10,1 (the albums routes and the wishlist twin follow in Task 2). Do not add a route regex for {token}.

(5) Seed state public in both seeds: alice's collection shared (token from the store's share:collection value, never committed), alice's wishlist shared (share:wishlist), a disabled share on another collection. Re-record GET public/{token} cases from the reset: valid, malformed token, well-formed unknown token, disabled share (each ≤ 10 inline-throttled cases per route). Flip; expectedPortedRoutes 152.

(6) public_share_smoke_test.go TestPublicResolve (valid, malformed, wrong kind, disabled, case-variant token fails the exact compare, headers on 200, 404 and 429) and TestPubfailCounter (10th failure 404, 11th 429 without a lookup, window expiry with an injected clock, concurrent hits never exceed the limit). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/middleware -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestPublicResolve|TestPubfailCounter)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v <fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestPublicResolve" and "--- PASS: TestPubfailCounter"; the parity run reports FAIL for the public resolve route (for example a header.Cache-Control diff) or lacks "--- PASS: TestParityCorpus/coverage".</fails_when> <acceptance_criteria> - grep -c '"no-store, private"' ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go prints 1. - awk '/public.share-headers/' ../fonoteka.go/plugins/golem15/fonoteka/routes.go | grep -c 'fonoteka-public-token' prints 0 (the buckets left the group line). - grep -c 'pubfail:' ../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go prints at least 1. - grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 152. </acceptance_criteria> A shared collection link resolves anonymously with PHP's body and exact headers, guessing is throttled by the shared failure counter, and the D-14 route layout is in place.

Task 2: The visitor searches and opens albums of a shared collection or wishlist and sees only public fields, each route under its own buckets The isolated PHP parity instance can be reset (`php -v` exits 0). ../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_public_album.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/public_share_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go, ../fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/PublicShareApiController.php (index, show, facetsFor, albumIdsFor, artistFacet, genreFacet, decadeFacet, formatFacet, the Validator::make rules and the rating closure), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesPublicAlbum.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSearchService.php (lines 40-80 and the public search path), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go (SearchAlbums, searchSQL, ScopedAlbums, text fields), ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (public buckets), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_public_{token}_albums_public_share.yaml, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_public-wishlist_{token}_public_share.yaml Per RESEARCH Finding 9 (public search mode), C-06 and D-14.

(1) album_search.go: a public search mode used only by the public index: TextFieldsPublic (query_by name,artist_display,style_names,genre_name,track_titles, weights 10,10,5,5,3, SQL LIKE on name, artist_display, track_titles and the artists.name relation), scoped to one collection id with no user, token or ratings join; engine ids are re-gated in SQL to that collection exactly as the Phase 12 recount does. Existing authenticated callers are unchanged.

(2) classes/public_share.go PublicAlbums and PublicFacets port index's query and facetsFor (artist, genre, decade, format; zero-count rows dropped; PHP order); serialize_public_album.go SerializePublicAlbum ports SerializesPublicAlbum's twelve keys in PHP order (id, name, artists, artist_display, year, format, medium, genre, styles, tracklist, photos, created_at) and nothing else.

(3) Handlers: PublicAlbumsIndex(app, kind) runs the pubfail check and ResolvePublic as resolve does, validates the query with lagoon.ValidateRequest using PHP's rules (the rating closure as a CustomRule with PHP's message) and answers 422 {"error":"Validation failed","errors"} on failure, else the list with header and facets in PHP's shape; PublicAlbumsShow(app, kind) answers {"data":...} for an album of the shared collection, else 404 {"error":"Not found"} (with the same pubfail accounting PHP applies). Routes: GET /public/{token}/albums, GET /public/{token}/albums/{id} ([0-9]+), GET /public-wishlist/{token} (throttle:10,1), GET /public-wishlist/{token}/albums, GET /public-wishlist/{token}/albums/{id} ([0-9]+); every albums route carries throttle:fonoteka-public-token then throttle:fonoteka-public-ip.

(4) routes_bucket_test.go: update the public boot probe to the D-14 layout and add TestPublicBucketsPerRoute over the assembled route table (resolve routes: exactly throttle:10,1; albums routes: exactly the two named buckets in that order; group: only public.share-headers; no other public middleware).

(5) Re-record from the public reset: index (no query, text query, filter, rating 422, invalid filter 422), show (shared album, album of another collection 404, missing 404), the wishlist twins (resolve, index, show). Keep the inline budget rule. Flip the five routes; expectedPortedRoutes 157.

(6) public_share_smoke_test.go TestPublicAlbumFieldSet (for both kinds the serialized object's keys, in order, equal the twelve PHP keys for an album that has a rating, a market price, notes, a shelf, a condition, a barcode and a reservation). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestPublicBucketsPerRoute|TestPublicAlbumFieldSet|TestPublicResolve|TestSearchLeak)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v <fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestPublicBucketsPerRoute, TestPublicAlbumFieldSet and TestSearchLeak (authenticated search unchanged); the parity run reports FAIL for a public route or lacks "--- PASS: TestParityCorpus/coverage".</fails_when> <acceptance_criteria> - grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 157. - grep -c 'name,artist_display,style_names,genre_name,track_titles' ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go prints 1. - grep -cE '"/public(-wishlist)?/\{token\}"' ../fonoteka.go/plugins/golem15/fonoteka/routes.go prints 2 and both lines carry throttle:10,1. - grep -c 'created_at' ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_public_album.go prints at least 1 and TestPublicAlbumFieldSet compares the exact ordered key list. </acceptance_criteria> Both kinds of shared link can be browsed, searched and opened anonymously with PHP's bodies, facets and per-route buckets, exposing nothing beyond the public field set.

Task 3: The anonymous journey and the link-guessing lockout replay against Go exactly as recorded from PHP The isolated PHP parity instance can be reset between the two anonymous recordings (file cache cleared). ../fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml, ../fonoteka.go/parity/fixtures/nuxt/public-pubfail.yaml, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/README.md /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/pages (the `/k/` and `/w/` public pages) and their composables, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml (flow shape), ../fonoteka.go/parity/README.md (Phase 13 recording), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Parity Harness Notes: throttle state, the pubfail sequence) Per D-12 (public-anonymous) and RESEARCH "The pubfail sequence".

(1) public-anonymous: author the request spec in the order the Nuxt public pages issue it, with no Authorization header: resolve the collection link, albums (first page, a text query, a filter), one album, resolve the wishlist link, its albums and one album, a bad token, then (as alice over JWT inside the same flow) regenerate the collection share and resolve the old token (404) and the new one (200). Record it from a fresh reset with {{share:collection}} and {{share:wishlist}} captures. TestFonotekaNuxtFlows/public-anonymous replays it on an isolated database from the public seed.

(2) public-pubfail: from a fresh reset, 10 requests to GET public/<well-formed unknown token>/albums (404 {"error":"Not found"}) then the 11th (429 {"error":"Too many requests"}), using the albums route because it has no inline throttle. TestFonotekaNuxtFlows/public-pubfail replays it against a fresh Go target so the counter starts empty.

(3) parity/README.md: both recipes, the reset-before-anonymous rule and why the pubfail flow uses the albums route. go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows|TestParityCorpus)$' -count=1 -v && go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets <fails_when>Any command exits non-zero; the run prints "--- FAIL", "--- SKIP" or "no tests to run", or lacks "--- PASS: TestFonotekaNuxtFlows/public-anonymous", "--- PASS: TestFonotekaNuxtFlows/public-pubfail" and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret (a raw share token), an unrecorded route or a ported case-status mismatch.</fails_when> <acceptance_criteria> - grep -c 'Too many requests' ../fonoteka.go/parity/fixtures/nuxt/public-pubfail.yaml prints 1 and grep -c '"error":"Not found"\|Not found' ../fonoteka.go/parity/fixtures/nuxt/public-pubfail.yaml prints at least 10. - grep -c 'share:collection' ../fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml prints at least 1 and grep -c 'Authorization' ../fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml prints 1 (only the JWT regenerate step is authenticated). - grep -ci 'pubfail' ../fonoteka.go/parity/README.md prints at least 1. </acceptance_criteria> The anonymous journey, link rotation and the link-guessing lockout are proven equal to PHP through recorded flows.

<threat_model>

Trust Boundaries

Boundary Description
Anonymous internet client → public routes No authentication; the share token is the only credential
Public token → collection row Token lookup must not leak existence of other collections or allow enumeration
Shared collection → public serializer Private album fields must not cross into the anonymous response

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-13-01 Information Disclosure token guessing / enumeration high mitigate 16-character shape check before any query, LOWER() lookup plus constant-time compare, pubfail 10 per 60 s per trusted-proxy IP (peek before, hit on failure), inline 10/min on resolve, per-token 60 and per-IP 120 on albums; TestPubfailCounter, TestPublicBucketsPerRoute and the public-pubfail flow (Tasks 1-3).
T-13-02 Information Disclosure public serializer and facets high mitigate SerializePublicAlbum's fixed field set, rating parameter refused with 422, collection-only search scope re-gated in SQL, zero-count facets dropped, no reservations on public-wishlist; TestPublicAlbumFieldSet (Task 2).
T-13-03 Spoofing disabled or regenerated share high mitigate ResolvePublic requires public_enabled, the route's kind and the exact current token; the public-anonymous flow proves the old token 404 after regenerate (Tasks 1, 3).
T-13-32 Denial of Service shared anonymous inline budget low accept PHP shares one throttle:10,1 guest key across onboarding, inspection and public resolves; Go mirrors it (`inline:domainless
T-13-33 Elevation of Privilege public route kind confusion medium mitigate Every handler passes its route's kind to ResolvePublic; a wishlist token on public/{token} and a collection token on public-wishlist/{token} answer 404; TestPublicResolve wrong-kind case (Task 1).
T-13-SC Tampering package installs low accept No new dependency in this plan.
</threat_model>
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus at 157 ported and passing with 14 pending; the two public flows pass; check_corpus `--require-recorded --check-secrets` green.

<success_criteria>

  • Six anonymous public routes ported with PHP bodies and exact headers.
  • D-14 bucket layout per route; pubfail lockout identical to PHP.
  • No private album field reachable anonymously. </success_criteria>
Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-05-SUMMARY.md` when done.