32 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 14-domain-jobs-and-external-integrations | 03 | execute | 3 |
|
|
true |
|
|
|
Phase Goal
ROADMAP Phase 14 goal (verbatim, not in user-story form): The domain-specific River jobs (CSV import write, Discogs match, wishlist digest), the reindex command, the Discogs client and AI cover recognition are ported on top of the Phase 11 jobs/realtime/search infrastructure and the Phase 13 API surface they serve.
This plan's slice: a collector matches an album or a wishlist item against Discogs, applies a release, imports an album from a Discogs link or barcode, tests their Discogs token, and the MCP client fetches a cover and market price, all with PHP's responses (INTG-01; ROADMAP SC4 as reworded by D-07).
Mount the eight Discogs routes on top of the 14-02 client and domain classes, with PHP's check order, the in-controller limiters and the host-locked cover fetch; record their cases with upstream sidecars and flip them to ported.Purpose: these are the Nuxt match dialog, the import box and the MCP fetch_album_cover_and_price tool. Decisions: D-07, D-11, D-15, D-19.
Output: controllers, routes, AlbumCoverFetcher, route-table test, recordings; ported count 165.
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/discogs:
CoverFetcher(AlbumCoverFetcher port),NewCoverFetcher,(*CoverFetcher).Fetch(ctx, album, opts) (CoverFetchOutcome, error),CoverFetchOutcome. - controllers/api:
AlbumReleaseMatchDraft,AlbumReleaseMatch,AlbumApplyRelease,WishlistReleaseMatch,WishlistApplyRelease,DiscogsImport,AlbumCoverPriceFetch,DiscogsCredentialTest,InboundLimits,NewInboundLimits. - Routes: JWT
POST /albums/match,POST /albums/{id}/match,POST /albums/{id}/apply-release,POST /wishlist/albums/{id}/match,POST /wishlist/albums/{id}/apply-release,POST /albums/import/discogs,POST /discogs-credential/test; tokenPOST /albums/{id}/cover-price/discogs(inv.scope:write,throttle:12,1). - Tests:
TestRouteTablePhase14,TestDiscogsMatchRoute,TestDiscogsInboundLimits,TestApplyReleaseModes,TestDiscogsImportRoute,TestCoverPriceRoute,TestDiscogsCredentialTestRoute,TestCoverFetcherHostLock. - Parity: recorded cases 4-15 of the research D-11 table with sidecars; manifest flips (8 routes).
Assumptions
- Research Open Question 5, resolved by the orchestrator default: the Phase 10.1 admin
discogsLookupanddiscogsSyncstubs stay as they are; PHP has no such admin actions, and no task here touches them. - The inbound limiters live in process memory (surf.MemoryStore, the PubfailCounter precedent); several app instances multiply the inbound budget, as already accepted for pubfail in Phase 13. The outbound Discogs budget stays shared in Postgres (14-02).
- Inbound-limit 429 cases (60 or 20 calls) are proven by Go tests, not by recording dozens of PHP requests.
(1) controllers/api/inbound_limits.go InboundLimits{store *surf.MemoryStore}, NewInboundLimits() (one sweep interval of 1 minute), methods DiscogsMissing(key string) (ok bool, retryAfter int) (key prefix fonoteka-discogs-missing: plus user id, 60 per 60 s) and DiscogsImport(key string) bool (fonoteka-discogs-import:, 20 per 60 s); retry_after is the seconds rounded up from Attempt's retryAfter (Laravel availableIn). plugin.go holds one InboundLimits created at Register, beside pubfail, and passes it to the handlers.
(2) release_match_controller.go AlbumReleaseMatch(app, limits): requestScope; album lookup through the existing accessible-album and active-collection scope (missing or foreign → 404 {"error":"Album not found"}); DiscogsAllowed false → PHP's 503 discogs_disabled body; limiter → 429 body with retry_after; validation exactly as PHP (q required, the year and medium rules) → PHP's 422 body; then discogs.ForUser, SearchByQuery, MapSearchResult plus ScoreRelease, answer PHP's candidate list with q, year, year_delta, medium; *RateLimitError → 429 discogs_rate_limited, ErrTokenRejected → 502 discogs_token_rejected, other errors → PHP's mapped body. Typed response structs or *csv.Map keep PHP key order. routes.go mounts POST /albums/{id}/match on the JWT group with the [0-9]+ constraint.
(3) Route tables: routes_table_phase14_test.go declares phase14Routes (method, path, group, scope, throttle, routes.php line) starting with this route and TestRouteTablePhase14 (each entry mounted once on its group with its middleware, constraint refuses abc and 1x); phase12Universe and phase13Universe skip any key in phase14Routes so each route is pinned once; phase14Absent is untouched until a listed route lands.
(4) Parity: seed alice's Discogs credential and an album with a known title in both seeds (14-02 seeded the token); script files for /database/search?q=…&type=release 200, a 401 and a 429 with Retry-After: 30; record with the proxy: 200 candidates, 422 missing q (no upstream), 502 token rejected, 429 discogs_rate_limited (budget exhausted); flip the route; expectedPortedRoutes 158. discogs_routes_test.go TestDiscogsMatchRoute (foreign album 404 makes no upstream call, gate off 503 makes none) and TestDiscogsInboundLimits (the 60th call passes, the 61st answers 429 with retry_after; the key is shared with the wishlist routes added in Task 2).
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestDiscogsMatchRoute|TestDiscogsInboundLimits|TestRouteTablePhase14|TestRouteTablePhase13|TestRouteTablePhase12)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' && 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; a verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestDiscogsMatchRoute, TestDiscogsInboundLimits, TestRouteTablePhase14 and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret, an unrecorded route or a case-status mismatch.</fails_when>
<acceptance_criteria>
- grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 158.
- grep -c 'fonoteka-discogs-missing:' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.go prints 1.
- The manifest entry POST /_fonoteka/api/v1/albums/{id}/match jwt has status: ported and at least four cases, three with a sidecar file on disk.
</acceptance_criteria>
The album match route works end to end through the inbound limiter, the Discogs client and the scorer, and replays PHP's recorded exchanges offline.
(1) release_match_controller.go: AlbumReleaseMatchDraft(app, limits) for POST /albums/match (no album; candidates scored against the posted draft) and AlbumApplyRelease(app, limits) for POST /albums/{id}/apply-release: scope 404 → gate 503 → limiter → validation (release id, overwrite_all, cover_only, dry_run booleans as PHP) → GetRelease (nil → 200 discogs_no_match body) → MapRelease → Applicator.Apply(ctx, album, mapped, overwriteAll, coverOnly, dryRun) inside one lagoon.Transaction with the album broadcast emitted as Phase 12 album updates emit → 200 {data, filled, remaining, draft} in PHP order. Rate-limit and token errors map as in Task 1.
(2) wishlist_release_match_controller.go: WishlistReleaseMatch and WishlistApplyRelease scope the album through the active wishlist resolver (PHP's ActiveWishlistResolver), share the fonoteka-discogs-missing: key, reuse the album handlers' core, and never return a draft key or accept dry_run where PHP does not. routes.go mounts POST /albums/match, POST /albums/{id}/apply-release, POST /wishlist/albums/{id}/match, POST /wishlist/albums/{id}/apply-release on the JWT group ([0-9]+ on id); add them to phase14Routes and remove the two wishlist entries from phase14Absent.
(3) Parity: script files for /releases/{id} 200 and 404 and the cover GET on i.discogs.com (a small JPEG, served by the scripted proxy); record cases 9-12 of the research D-11 table: albums/match 200; apply-release 200 fill-empty, dry_run:true, cover_only:true, overwrite_all:true, and 200 discogs_no_match; wishlist match and apply-release success twins; flip the four routes; expectedPortedRoutes 162. Seed albums in both seeds with some fields filled so fill-empty and overwrite differ.
(4) Tests in discogs_routes_test.go: TestApplyReleaseModes (fill-empty leaves a filled field, overwrite_all replaces it, cover_only changes only the cover, dry_run writes no row and imports no cover, a second identical apply fills nothing and imports no second cover), and the wishlist twin answers without draft.
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestApplyReleaseModes|TestDiscogsMatchRoute|TestDiscogsInboundLimits|TestRouteTablePhase14|TestRouteTablePhase13)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' && 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; a verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestApplyReleaseModes, TestRouteTablePhase14 and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret, an unrecorded route or a case-status mismatch.</fails_when>
<acceptance_criteria>
- grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 162.
- grep -c '"POST /wishlist/albums/{id}/match"' ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go prints 0.
- The apply-release manifest entry carries cases for fill-empty, dry_run, cover_only, overwrite_all and discogs_no_match, each recorded with a sidecar.
</acceptance_criteria>
Applying, previewing and draft-matching releases behave as PHP for albums and wishlist items, with covers fetched only from Discogs hosts.
(1) discogs_import_controller.go DiscogsImport(app, limits) for POST /albums/import/discogs: gate 503 → validation (input as PHP) → fonoteka-discogs-import: limiter (429 {"result":"error","code":"too_many_requests"}) → ImportResolver: a release URL or id answers the draft, a barcode with many hits answers candidates, zero hits answers 200 no_match with barcode, a master answers its versions; errors map as PHP.
(2) classes/discogs/cover_fetcher.go ports AlbumCoverFetcher: NewCoverFetcher(client, importer *classes.CoverImporter, db, clock) and Fetch(ctx, album, CoverFetchOptions{RefreshPrice bool}) (CoverFetchOutcome, error) covering the discogs_id path (release, cover import, price suggestion through ResolvePriceSuggestion and the market currency), nothing_missing, ambiguous, rate_limited, no_source and refresh_price, persisting what PHP persists. Cover bytes only through CoverImporter (AllowHosts discogs.com and .discogs.com). album_cover_fetch_controller.go AlbumCoverPriceFetch(app) answers PHP's body with null values dropped (validation 422 {"errors":…}, album 404 {"error":"Album not found"}). routes.go mounts it on the token group with inv.scope:write and throttle:12,1.
(3) credentials_controller.go DiscogsCredentialTest(app) for POST /discogs-credential/test: gate off answers the disabled body with no upstream call; an inline token or the stored credential calls GetIdentity; 200 → {"ok":true}, ErrTokenRejected → {"ok":false,"error":"Token Discogs jest nieprawidłowy lub wygasł."}, a rate limit → PHP's rate-limited message; the token never appears in the body or logs. routes.go mounts it; remove it from phase14Absent and add all three routes to phase14Routes.
(4) Parity: script files for barcode search (many and zero hits), master versions, price suggestions (and the empty object), identity 200, 401 and 429; record the research D-11 cases 13-15 (import draft, candidates, no_match, 422, cover-price fetched, nothing_missing, ambiguous, rate_limited, refresh_price, credential ok, rejected, rate limited, disabled), re-recording the existing live-vendor discogs-credential/test case through the proxy (D-19); fix any manifest status that differs from its fixture; flip the three routes; expectedPortedRoutes 165, pending 6. README notes the Discogs script files.
(5) Tests: TestDiscogsImportRoute (limiter after validation: an invalid body never counts; the 21st valid call is 429), TestCoverPriceRoute (scope write required, throttle 12 per minute, nulls dropped), TestDiscogsCredentialTestRoute (no upstream when disabled; token absent from every body), cover_fetcher_test.go TestCoverFetcherHostLock (a release whose image URL points at another host imports nothing and makes no request to it).
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes/discogs/... -count=1 -race -v -run '^(TestCoverFetcherHostLock)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestDiscogsImportRoute|TestCoverPriceRoute|TestDiscogsCredentialTestRoute|TestRouteTablePhase14|TestRouteTablePhase13|TestRouteTablePhase12)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' && 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; a verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestCoverFetcherHostLock, TestDiscogsImportRoute, TestCoverPriceRoute, TestDiscogsCredentialTestRoute, TestRouteTablePhase14 and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret, an unrecorded route or a case-status mismatch.</fails_when>
<acceptance_criteria>
- grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 165.
- grep -c 'cover-price/discogs' ../fonoteka.go/plugins/golem15/fonoteka/routes.go prints 1 and that line carries inv.scope:write and throttle:12,1.
- grep -A3 'var phase14Absent' ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go | grep -c 'discogs-credential/test' prints 0.
- The re-recorded POST /_fonoteka/api/v1/discogs-credential/test jwt fixture has a sidecar and no live Discogs token.
</acceptance_criteria>
All eight Discogs routes pass the parity diff offline, the cover fetch stays host-locked, and only the AI routes remain absent from the router.
Canon referrals (not minted as prohibitions)
- IDOR on album ids is canon (OWASP access control) — covered by the scope-first ordering truth and /gsd-secure-phase.
- SSRF through cover URLs is canon — covered by CoverImporter's host lock and /gsd-secure-phase.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Client → album and wishlist ids | Ids are user input; albums belong to collections |
| Client → Discogs-backed routes | Each call can spend the shared Discogs budget |
| Discogs release JSON → cover URL | Image URLs come from an outside service |
| Personal token → cover-price route | MCP clients act with a scoped token |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-14-16 | Elevation of Privilege | album and wishlist match/apply | high | mitigate | Scope check first (404 before gate, limiter or upstream); TestDiscogsMatchRoute foreign-album case (Task 1). |
| T-14-17 | Denial of Service | Discogs routes | medium | mitigate | fonoteka-discogs-missing 60/60 s shared, fonoteka-discogs-import 20/60 s, throttle:12,1 on cover-price; TestDiscogsInboundLimits, TestDiscogsImportRoute, TestCoverPriceRoute (Tasks 1, 3). |
| T-14-18 | Tampering | cover download | high | mitigate | Only CoverImporter AllowHosts discogs.com / .discogs.com; TestCoverFetcherHostLock (Task 3). |
| T-14-19 | Tampering | apply-release | medium | mitigate | Fill-empty default, overwrite only with overwrite_all, dry_run writes nothing; TestApplyReleaseModes (Task 2). |
| T-14-20 | Elevation of Privilege | token cover-price | high | mitigate | inv.scope:write on the route; TestRouteTablePhase14 and TestCoverPriceRoute (Task 3). |
| T-14-21 | Information Disclosure | discogs-credential/test | medium | mitigate | Body is ok/error only, token never echoed or logged; TestDiscogsCredentialTestRoute (Task 3). |
| T-14-SC | Tampering | package installs | low | accept | No new module or package. |
| </threat_model> |
<success_criteria>
- Eight Discogs routes ported with PHP bodies, check order and limiters.
- Every Discogs and cover exchange replays offline from PHP-recorded sidecars. </success_criteria>