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.
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 |
|
|
true |
|
|
|
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>
Artifacts this phase produces
(This plan's share.)
- classes:
PubfailCounterwithTooMany(key string) boolandHit(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-headersonly):GET /_fonoteka/api/v1/public/{token}andGET .../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.
(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.
(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.
(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> |
<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>