Files
summercms/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-05-SUMMARY.md
2026-10-03 10:30:18 +02:00

20 KiB
Raw Blame History

phase, plan, subsystem, tags, requires, provides, affects, actuals, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status, plan_head_before, plan_head_after
phase plan subsystem tags requires provides affects actuals tech-stack key-files key-decisions patterns-established requirements-completed coverage duration completed status plan_head_before plan_head_after
13-p-ytarium-api-wishlist-notifications-csv-credentials-public 05 api
public-share
anonymous
rate-limit
pubfail
search
facets
parity
tide
phase provides
13-03 ResolvePublic(ctx, db, token, kind) in share_service.go, share:wishlist capture
phase provides
13-04 album_search.go SQL path used by the CSV export (left unchanged)
phase provides
12 album search with engine re-gate, album embeds serializer, share service enable/disable/regenerate, album parity state
Six anonymous public routes: GET public/{token}, public/{token}/albums, public/{token}/albums/{id} and the public-wishlist twins
classes.PubfailCounter (TooMany, Hit, Begin) with PubfailKey, one per app in the service registry
classes.PublicHeader, PublicFacets, PublicAlbums, FindPublicAlbum; SerializePublicAlbum(s) with the twelve-key PublicAlbumDTO
Public search mode in album_search.go: AlbumSearchParams.Public, TextFieldsPublic / TextFieldsAuthenticated
D-14 route layout: group public.share-headers only, resolve routes throttle:10,1, albums routes fonoteka-public-token then fonoteka-public-ip
PublicShareHeaders sends Cache-Control: no-store, private (PHP's wire value)
Parity: public seed state on both sides (share:collection, share:wishlist, share:disabled, share:stale), 6 routes re-recorded (157 ported, 14 pending), public-anonymous and public-pubfail flows
13-06
14
tokens tasks commits
32755 3 3
added patterns
Per-app state of a plugin lives in the app's service registry (app.Publish / app.Lookup), never on the process-wide registered plugin value
A limiter whose check and count are separate (PHP tooManyAttempts + hit) is ported with a slot reservation (Begin/done) so concurrent requests cannot overshoot
A flow that rotates a captured token seeds a second var (share:stale) with the old value; steps before the rotation are normalized to the primary var after recording
created modified
fonoteka.go/plugins/golem15/fonoteka/classes/public_share.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/public_share_smoke_test.go
fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml
fonoteka.go/parity/fixtures/nuxt/public-pubfail.yaml
fonoteka.go/parity/fixtures/routes/ (31 new public route fixtures)
fonoteka.go/plugins/golem15/fonoteka/classes/album_search.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/middleware/token_scope_coverage_test.go
fonoteka.go/plugins/golem15/fonoteka/routes.go
fonoteka.go/plugins/golem15/fonoteka/plugin.go
fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go
fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go
fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go
fonoteka.go/parity/manifest.yaml
fonoteka.go/parity/fixtures/routes/ (5 re-recorded public route fixtures)
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/parity_contract_test.go
fonoteka.go/parity/README.md
The pubfail counter is in memory and one per app (published in the app's service registry at Register). In production that is one counter per serve process; PHP's file cache shared it across the PHP workers of one host. With several Go instances behind a balancer, a guesser gets 10 failures per instance per minute.
PubfailCounter.Begin reserves a failure slot for the request in flight, so the check and the count are serialized: concurrent failures from one IP never let more than 10 resolutions through in a window. PHP checks and hits separately and can overshoot under concurrency; sequentially the two behave identically.
The pubfail check runs before any database access, so a throttled request never reaches the database (PHP checks before resolvePublic too).
Facets order artists and genres by name COLLATE "C" with the id breaking ties, as the recording SQLite (BINARY) orders them; production MariaDB with a Polish _ci collation may order differently. Decades are folded in Go from a year histogram; formats follow Album::FORMATS.
The public search is SearchAlbums with AlbumSearchParams.Public: the shared collection is the only tenant rule (no user, token or ratings join), rating and price are not sorts, query_by and LIKE use TEXT_FIELDS_PUBLIC, and engine ids are re-gated in SQL to that collection. Authenticated callers and the CSV export are unchanged.
Plugin-owned mutable state that must not leak across apps is published per app (pubfailCounter(app))
Public route families are pinned by one table (wantPublicRoutes) shared by the Phase 8 coverage subtests and TestPublicBucketsPerRoute
API-03
API-07
id description requirement verification human_judgment
D1 GET public/{token} and public-wishlist/{token}: PHP's header body, one 404 body for malformed, unknown, disabled, regenerated, wrong-kind and case-variant tokens, the shape check before any lookup, the exact public headers on 200, 404 and 429 API-07
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go#TestPublicResolve pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus (public/{token} 5 cases, public-wishlist/{token} 3 cases) pass
false
id description requirement verification human_judgment
D2 Anti-enumeration: pubfail:<ip> counter shared by all six routes of both kinds, 10th failure 404, 11th request 429 without a lookup, 60 s window from the first failure, album misses behind a valid token never count, concurrent failures capped at 10, one counter per app API-07
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go#TestPubfailCounter pass
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go#TestPublicAlbumsIndex/pubfail-shared-across-routes pass
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go#TestPubfailCounterPerApp pass
kind ref status
e2e fonoteka.go/parity/fonoteka_flows_test.go#TestFonotekaNuxtFlows/public-pubfail pass
false
id description requirement verification human_judgment
D3 D-14 layout: group public.share-headers only, resolve routes exactly throttle:10,1, albums routes exactly fonoteka-public-token then fonoteka-public-ip, no auth middleware, bucket keys pubtok:<token> and the client IP; Cache-Control no-store, private API-07
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go#TestPublicBucketsPerRoute pass
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go pass
false
id description requirement verification human_judgment
D4 public/{token}/albums and the wishlist twin: Validator::make semantics (rating refused, 422 envelope in PHP rule order), public text fields (notes not searchable, artists.name searchable), collection-only scope and engine re-gate, facets narrowed to the collection with Various Artists and zero rows dropped, empty result and empty collection bodies API-07
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go#TestPublicAlbumsIndex pass
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go#TestPublicAlbumsEngine pass
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/search_leak_test.go#TestSearchLeak (authenticated search unchanged) pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus (public albums 11 cases, public-wishlist albums 4 cases) pass
false
id description requirement verification human_judgment
D5 albums/{id} on both kinds and the public album payload: exactly the twelve SerializesPublicAlbum keys in PHP order for an album with a rating, a market price, notes, a shelf, a condition, a barcode and a reservation; none of those values in any public body API-03
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go#TestPublicAlbumFieldSet pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus (public albums/{id} 6 cases, public-wishlist albums/{id} 3 cases) pass
false
id description requirement verification human_judgment
D6 Recorded anonymous journey (Nuxt /k/ and /w/ calls, a dead link, owner regenerate: old token 404, new token 200) and the link-guessing lockout replay against Go; corpus 157 ported and passing; secrets check green API-07
kind ref status
e2e fonoteka.go/parity/fonoteka_flows_test.go#TestFonotekaNuxtFlows/public-anonymous pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus/coverage (157 ported, 157 passing, 14 pending) pass
kind ref status
other go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes .../routes.php --require-recorded --check-secrets pass
false
34min 2026-10-03 complete c540102eea33d68733ee4a5366be28c035954255 3c8b7cd25ac996921a745fa7e98e3a81414c7072

Phase 13 Plan 05: Public share routes Summary

Anyone with a shared link can now open, search and browse a collection or a wishlist in Go, with PHP's bodies and exact headers. The six anonymous routes resolve tokens through ResolvePublic. They search with the public text fields only, return facets narrowed to the shared collection, and serialize albums with the twelve-key public allow-list. Link guessing is throttled by the pubfail:<ip> counter shared by all six routes and by the per-route D-14 buckets. The corpus is at 157 ported routes, and the anonymous journey and the lockout flow replay green.

Performance

  • Duration: 34 min
  • Started: 2026-10-03T07:55:27Z
  • Completed: 2026-10-03T08:29:30Z
  • Tasks: 3 of 3
  • Files modified: 53 in fonoteka.go: 15 source and test files, 36 route fixtures and 2 flows

Accomplishments

  • Resolve (Task 1, tracer). GET public/{token} (and, in Task 2, public-wishlist/{token}) runs in this order:

    1. the pubfail check, before any database access;
    2. ResolvePublic for the route's kind;
    3. {"data":{"state":"active","collection":{"name","album_count"}}}, or the one 404 body {"error":"Not found"}.

    A failed resolution counts one failure.

  • Headers. PublicShareHeaders now sends Cache-Control: no-store, private, the value Symfony puts on the wire. It keeps X-Robots-Tag and the JSON 429 rewrite with Retry-After and X-RateLimit-*.

  • PubfailCounter. A fixed window per key starts at the first failure and lasts 60 s.

    • TooMany peeks at the count, Hit adds one failure.
    • Begin reserves a slot for the request in flight, so concurrent failures cannot pass the limit.
    • Each app gets its own counter.
  • Search (Task 2). AlbumSearchParams.Public selects PHP's public path:

    • the shared collection is the only tenant rule;
    • there is no ratings join or rating filter;
    • rating and price are not sorts;
    • the text fields are TextFieldsPublic (query_by name,artist_display,style_names,genre_name,track_titles, weights 10,10,5,5,3, LIKE on name, artist_display, track_titles and artists.name);
    • engine ids are re-gated in SQL to that collection.

    The authenticated set is now TextFieldsAuthenticated, with the same values as before.

  • Index. The index validates the query with PHP's rules in PHP's order. A rating gets the closure message, and the 422 envelope is {"error":"Validation failed","errors":...}. Only the rule keys reach the search. The body is the page, meta, the facets (artists without Various Artists, genres, decades, formats; zero rows dropped, [] when empty) and the header.

  • Show. Show returns {"data":PublicAlbumDTO} for an album of the shared collection. A missing, foreign, soft-deleted or out-of-int4-range id answers 404 and is not counted as a failed resolution.

  • Public payload. PublicAlbumDTO is a type of its own with the twelve keys in PHP order. No rating or reservation is loaded or emitted.

  • Routes (D-14). The group carries public.share-headers only. The resolve routes carry exactly throttle:10,1. The albums routes carry exactly throttle:fonoteka-public-token then throttle:fonoteka-public-ip. No route regex constrains {token}.

  • Parity.

    • The public state exists on both sides (with albums): alice's collection and wishlist are shared, bob's share is disabled, and bob has reserved the wishlist album.
    • 6 routes are re-recorded with 32 cases, so 157 routes are ported and 14 are pending.
    • The flows public-anonymous (12 steps, one authenticated) and public-pubfail (10 × 404, then 429) replay green.
    • The README documents the recipes.

Task Commits

fonoteka.go (master, not pushed):

  1. Task 1: resolve shared collection links anonymously with PHP's headers and lockout - 990e311 (feat)
  2. Task 2: browse, search and open shared collections and wishlists anonymously - 6278e2b (feat)
  3. Task 3: replay the anonymous share journey and the link-guessing lockout - 3c8b7cd (test)

Decisions Made

See key-decisions. One may need the user's attention. The pubfail counter is in memory and per process. PHP shared it through the file cache across the workers of one host, so a deployment with several Go instances multiplies the guessing budget by the instance count. A shared store (Postgres or Redis) would be a later, deliberate change.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] The pubfail counter leaked across apps

  • Found during: Task 2 (full corpus replay: the 6th public route answered 429 on its first case)
  • Issue: Task 1 stored the counter on the Plugin value. That value is registered once per process (party.Register(&Plugin{})), so every test target, and any second app in one process, shared one counter.
  • Fix: pubfailCounter(app) publishes one counter per app in the service registry at Register, and Routes reads it from there. TestPubfailCounterPerApp pins this.
  • Commit: 6278e2b

2. [Rule 2 - Correctness] The check and the count are serialized

  • Issue: The plan's edge requires that concurrent failures never let more than 10 resolutions through. Separate TooMany and Hit calls, as in PHP, cannot guarantee that.
  • Fix: Begin(key) (done func(failed bool), ok bool) reserves a slot. The handlers use it, and TooMany/Hit remain as specified. The concurrent subtest of TestPubfailCounter runs 200 goroutines and lets exactly 10 through.
  • Commit: 990e311

3. [Rule 3 - Blocking] Route inventories named the old state

  • The Phase 8 coverage subtests asserted that the public family was absent. They now assert the real surface through assertPublicShareSurface.
  • TestRequirePasswordChangeExemptSet required inv.must-change-password on every /_fonoteka/api/v1 route. The public share routes are now exempt, guard-free and counted.
  • The parity contract's ported-route allow-list gained the six routes.
  • Commits: 990e311, 6278e2b

4. [Structure] Handler signatures and one extra file

  • The handlers are PublicResolve/PublicAlbumsIndex/PublicAlbumsShow(app, pubfail, kind): the counter is passed in rather than looked up per request.
  • plugin.go (not in the plan's file list) holds pubfailCounter.
  • Commits: 990e311, 6278e2b

5. [Recording] A share:stale var for the rotation step

  • Issue: The flow may carry only one authenticated step, the regenerate, so the old token could not be read back after the regenerate's capture replaced share:collection.
  • Fix: Both seeds write share:stale with the seeded token. Steps before the regenerate are normalized to {{share:collection}} after recording, because the recorder may pick either name for the equal values.
  • Commit: 3c8b7cd

6. [Coverage] X-Robots-Tag is asserted in Go tests, not by the replay

  • tide compares Content-Type and Cache-Control but not X-Robots-Tag. assertPublicHeaders checks it on every public response in the Go tests, 200, 404 and 429 included.

7. [Tests] Tests beyond the plan's list

  • TestPublicAlbumsIndex covers search fields, facets, empty states, validation, show misses, kinds and the shared counter.
  • TestPublicAlbumsEngine checks the public query_by and the re-gate of poisoned engine ids.
  • TestPubfailCounterPerApp pins one counter per app.

Total deviations: 7: 1 bug, 1 correctness hardening, 1 blocking test-inventory update, 1 structural change, 1 recording aid, 1 coverage note and extra tests. Impact: every recorded body, status and compared header matches PHP. The counter is stricter than PHP only under concurrency.

Issues Encountered

  • TestPhase09SecurityRoutes (Phase 12.2 cabana routes, logged in deferred-items.md) still fails in the fonoteka plugin package. Every other suite is green:
    • the root module;
    • parity (TestParityCorpus 157/157, TestFonotekaNuxtFlows with both public flows, TestParityContract);
    • the plugin's subpackages.
  • Tests ran with FORCE_COLOR unset, as in earlier plans.
  • The isolated PHP server (p1305) was started for recording and stopped before returning. No upload was made through PHP, so nothing was written to the PHP checkout's storage.

Known Stubs

None.

Threat Flags

None beyond the plan's register:

  • T-13-01: PubfailCounter, TestPubfailCounter, TestPublicBucketsPerRoute and the public-pubfail flow.
  • T-13-02: PublicAlbumDTO, the 422 on rating, TextFieldsPublic and the collection re-gate; TestPublicAlbumFieldSet and TestPublicAlbumsEngine.
  • T-13-03: the disabled, regenerated and old-token cases in TestPublicResolve and public-anonymous.
  • T-13-33: the wrong-kind cases on both kinds, in the corpus and in TestPublicAlbumsIndex/kinds.

User Setup Required

None.

Next Phase Readiness

  • 13-06 (unit tests) should add the six public routes to the Phase 13 route-table test. TestPublicBucketsPerRoute and wantPublicRoutes already pin them. The public handlers take no request body, so they need no fuzz entry.
  • Phase 14 leaves these routes untouched. If production runs several instances, a shared pubfail store is a follow-up decision.

Self-Check: PASSED

  • Created files exist: public_share.go, serialize_public_album.go, public_share_controller.go, public_share_smoke_test.go, public-anonymous.yaml and public-pubfail.yaml, all checked with test -f.
  • Commits exist in fonoteka.go: 990e311, 6278e2b, 3c8b7cd.
  • Plan verification:
    • go vet ./... is clean.
    • go test ./... is green for the root and parity; the plugin package fails only the pre-existing TestPhase09SecurityRoutes.
    • The plan's tests pass under -race.
    • check_corpus --require-recorded --check-secrets is green, and no recorded share token occurs in any fixture.
  • Acceptance greps:
    • "no-store, private" appears once in the middleware.
    • No fonoteka-public-token remains on the group line.
    • pubfail: is in public_share.go.
    • expectedPortedRoutes is 157.
    • The public query_by string appears once in album_search.go.
    • There are 2 resolve route lines, each with throttle:10,1.
    • created_at is in serialize_public_album.go.
    • public-pubfail has 1 Too many requests and 10 Not found.
    • public-anonymous has 8 share:collection and 1 Authorization.
    • The README mentions pubfail 5 times.