16 KiB
Phase 12: Płytarium API — Collections and Albums - Context
Gathered: 2026-09-28 Status: Ready for planning
## Phase BoundaryThe collection and album endpoints of golem15.fonoteka, ported to fonoteka.go with byte-compatible request and response shapes. They are mounted on the JWT group (/_fonoteka/api/v1) and, where PHP mirrors them, on the personal-token group (/api/v1/fonoteka, inv.scope:*).
In scope:
- Collections:
collectionsCRUD,collections/{id}/photos(upload and delete),collections/{id}/image(upload and delete),collections/{id}/switch,me/context(returns an opaque channel name), andcollection/share(GET, PUT, regenerate). - Household:
household/invitations(index, store, cancel, resend),household/members(index, destroy), andinvitations/{token}/accept. - Albums:
albumsCRUD,albums/{id}/rating(PUT, DELETE),albums/{id}/photos(upload, including the manualcover_urlbranch, and delete), andalbums/search.- The side routes
albums/sync,stats,value,missingandbulk. - The
cover_urlsimport on create and bulk.
- Lookups:
artists,styles(GET, POST) andgenres(POST; GET was ported in Phase 3). - Fuzz: a request-DTO-level fuzz over every write endpoint of this phase.
Moved out of this phase (roadmap and requirement wording to be corrected at plan time, see D-03, D-04 and D-06):
wishlist/albums/{id}/reserve|reveal(reservations) → Phase 13.- All anonymous public routes (
public/{token},public/{token}/albums,public/{token}/albums/{id}) → Phase 13. albums/{id}/cover-price/discogs,albums/match,albums/{id}/match,albums/{id}/apply-release,albums/recognize,albums/import/discogs→ Phase 14 (INTG-01/02).- Register-via-invitation (PendingInvitationRegistration consumed on register) and public invitation inspection → Phase 13 (API-07).
Repo: fonoteka.go. Framework changes in summercms.go only where a gap is found; no Nuxt or fonoteka-mcp change.
Route boundary
- D-01: Collections, household and album routes listed in the domain are ported whole. Each handler is mounted once and reused on both groups wherever PHP mirrors it (the P6 D-15 genres pattern), with the exact
->where()constraints and inline throttles (throttle:10,1on switch, share regenerate, invitation store and resend;throttle:20,1on album photo upload;throttle:60,1on albums/sync). - D-02:
albums/sync,stats,value,missingandbulkare ported here: same controllers, no external service, and the Nuxt Albums UI calls them.bulkis where the singlecollection.bulk_updatedbroadcast underWithoutBroadcasting[Album](P11 D-08) is exercised on a real endpoint. - D-03: Reservations move to Phase 13. In PHP they only exist as wishlist routes (
AlbumReservationService,WishlistAlbumReservationController) and need the wishlist resolver. Fix the Phase 12 success criterion 2 and API-02 wording, and add them to Phase 13 / API-03. - D-04: The Discogs cover-price route moves to Phase 14 with INTG-01. The Discogs routes that match or use AI stay in Phase 14. Their manifest entries stay
pending(P6 D-15: no 501 shells). Fix success criterion 2 and API-02 wording. - D-05:
CoverImporter(the host-locked cover fetch behindcover_urlson album create and bulk) is ported now on the existing fetchguardAllowHostsGET helper (P6 D-11), soPOST albumswithcover_urlsis parity-true in this phase. The pending todofetchguard-guarded-http-client.md(POST, multipart, bearer) is not folded. It stays for Phase 14. If research finds thatCoverImporterdepends onDiscogsClientor needs more than a guarded GET, it reports that before planning. - D-06: All anonymous public-share routes move to Phase 13. Phase 12 ports only the owner-only JWT
collection/sharesurface: show, update and regenerate, which emits thepublic_token. API-01 and success criterion 1 ("public token views") are reworded at plan time, andpublic/{token}*added to Phase 13 alongsidepublic-wishlist/{token}*. - D-07: The manual cover URL branch of album photo upload ("exactly one of file or cover_url") uses fetchguard
PublicOnly, as in PHP (P6 D-11/D-13).
Parity evidence
-
D-08: New recordings against the isolated PHP instance, using the Phase 2
tidecapture rules (private 0600 vars, no live tokens in git):nuxt-collectionsflow: create → switch →me/context→ share show/update/regenerate → invite → accept as a second user → members → remove member.nuxt-albumsflow: create (with and withoutcover_urls) → update → rate / unrate → photo upload and delete → search → stats/value/missing/sync → bulk → delete.- Plus every distinct error status and body per route: 404 for foreign or missing ids, 403/404 non-owner, 410 on an unavailable invitation, 422 validation, and the personal-token 404 on owner-only household actions.
Envelopes are reproduced per endpoint (P7 D-13).
-
D-09: Centrifugo publications are recorded from PHP during the album flows (create, update, delete, bulk) with the P11 D-10 capture tooling. The Go side, on the memory driver or a fake Centrifugo, is diffed against them with
timestamp/actornormalised. -
D-10: Token-group twins run the same handler as the JWT route and each replays its own recorded fixture. A route-table test asserts that every twin carries the right
inv.scope:read|writeand that JWT-only routes (switch, share, household, invitations) are absent from the token group. -
D-11: Uploads get both recorded multipart replays (same file bytes, body diff including
thumb_url) and Go tests. The Go tests assert thesystem_filesrow, the blob and the thumb exist; that theImageContentGuardport rejects non-images; and that the upload-groupMaxBytesReadercap holds (P7 D-04 pattern). -
D-12: Search replays run with search disabled, as PHP was recorded (
SCOUT_DRIVER=null). Typesense behaviour is covered by Go tests (D-16..D-18).
Invitations and mail
- D-13: Invitation mail is sent by a River job enqueued in the same transaction as the invitation write, using the P11 job machinery. It goes out only if the invitation commits, which matches PHP's commit-before-side-effect ordering without the inline send.
- The raw token (64 hex chars, sha256 at rest, 7-day expiry, as PHP) is present only in the job args and the mail. It is never logged and never stored in
summer_jobs.metadata. - Locale and link follow PHP: inviter
preferred_localepickscollection_invitation/collection_invitation-enand/zaproszenia/{token}//en/invitations/{token}underapp.url. - Asserted through postcard's
memorydriver.
- The raw token (64 hex chars, sha256 at rest, 7-day expiry, as PHP) is present only in the job args and the mail. It is never logged and never stored in
- D-14: Accepting an invitation ports
NotificationService's write path now: thenotifyInvitationAcceptedrow, plus any mail it sends, through the same job mechanism. The recorded accept flow then matches PHP DB state. Phase 13 adds the notifications list, read and prune API on top. - D-15: Only the existing-account path is ported. That means invite, resend and cancel for any email; accept by a logged-in user; and removing an editor with PHP's
CollectionProvisionercontext repair and organisation-membership rules. PendingInvitationRegistration rows are cleared on accept as PHP does, but consuming them on register belongs to Phase 13.
Search and the security test
-
D-16: Scout semantics are mirrored exactly. When Typesense is on and the query takes the Typesense path:
- Typesense returns one page of candidate ids.
- SQL applies
whereIn(ids)+accessibleBy(user)+ active collection (or token-frozen collection) + filters. meta.total/last_pagecome from Typesense, as Scout's paginator does. A stale document therefore yields a short page, never a leaked row. The inflated total is a documented, tested quirk, not a bug to fix.
Rating, name and artist sorts force the SQL path, as PHP does. A Typesense error logs a warning and falls back to the SQL search.
-
D-17: The SQL text search uses
ILIKEwith%and_escaped in user input, over PHP's column lists (TEXT_FIELDS_AUTHENTICATED: name, notes, artist_display, track_titles, label, catalog_number, plus artists.name). Escaping is a small, deliberate hardening: PHP passes%/_through, so results differ only for queries containing them. Polish ordering relies on thepl-PLICU database (P3 D-06). -
D-18: The leak security test (success criterion 3) proves, with a scripted fake engine driver returning poisoned ids through the P11 search interface, that none of these return a row:
- A stale document whose album moved to a collection the caller cannot access.
- A mis-scoped document carrying the caller's
collection_idfor a foreign album. - A soft-deleted album still in the index.
- An editor removed from a collection whose documents remain.
- A personal token frozen to its creation-time collection receiving hits from the user's other collections.
No Typesense container is used.
Carried forward (locked earlier)
- C-01:
ActiveCollection(P3 D-03) is extended with switching and editor membership.me/contextnever exposes a rawcollection_id, and the channel name is opaque (P11 D-11 naming rules). - C-02: Write paths go through the ported
AlbumWriteService/CollectionWriteServicefill boundaries (P5 D-05..D-07). The new request-DTO fuzz covers every write endpoint of this phase (unknown and server-owned keys never persisted). This inherits P5 criterion 3's HTTP half. - C-03: Response DTOs come from ported
Serialize*functions, never from marshalled models. Use[]notnull, Carbon+00:00times, and the pagination envelope withoutlinks(P5 D-08, P6 D-17, DATA-10). - C-04: Album broadcasts and Typesense sync are already wired by Phase 11 (after-commit inline sync, broadcast job in the write transaction). This phase only calls them.
Claude's Discretion
- Handler file layout under
controllers/apiand how the ~800-lineAlbumApiControlleris split across Go files. - DTO structs and how the fuzz enumerates write endpoints (route-table driven preferred).
- Job kind names and queue for invitation and notification mail.
- Fake engine driver shape for tests.
- Which errors per route need a recording and which a Go test with bodies from PHP source, where recording a case is impractical (e.g. a mid-transaction race). Recording is the default.
- Plan count and split, subject to the plan-count checkpoint, "unit tests are the last plan" and the security-review agent (this phase touches authorization and public tokens).
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Scope
.planning/ROADMAP.md§ Phase 12 (criteria 1 and 2 reworded per D-03, D-04, D-06), § Phase 13, § Phase 14.planning/REQUIREMENTS.md: API-01, API-02 (wording fixes), API-03, API-07, INTG-01.planning/PROJECT.md: API parity is the acceptance test; two-repo split
PHP reference (contract source)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php: JWT group (lines ~74–330) and personal-token group (~450–520)/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/:CollectionApiController,CollectionShareController,MeContextController,InvitationApiController,CollectionMemberController,AlbumApiController,AlbumSyncController,RatingApiController,ArtistApiController,StyleApiController,GenreApiController/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/:ActiveCollectionResolver,AlbumSearchService,AlbumSyncService,CollectionFingerprint,CollectionShareService,CollectionProvisioner,InvitationService,NotificationService(write path),ImageContentGuard,ManualCoverUrlFetcher,AlbumWriteService,ArtistResolver,OrgAccess,discogs/CoverImporter.php/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/collection_invitation.htm,collection_invitation-en.htm/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/:useFonoteka.ts,useAlbumsQuery.ts,useCollectionSwitcher.ts,useCollectionSync.ts,useBulkAlbumTape.ts,useRealtimeSync.ts(client contract)
Prior phase decisions that apply
.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md: D-03 ActiveCollection, D-06 Polish ordering, D-14/D-15 routing and typed params.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md: D-05..D-09 fill/hidden/rules, D-14..D-19 attachments and thumbs.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md: D-05 parameterized middleware, D-11..D-14 fetchguard, D-15 no 501 shells, D-17 response helpers, D-18 body caps.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md: D-04 upload pattern, D-11..D-14 recording rules and envelopes.planning/phases/11-jobs-realtime-and-search-infrastructure/11-CONTEXT.md: D-02 job dispatch in transaction, D-06..D-10 broadcasts and capture, D-11 channel naming, D-19/D-20 search driver and sync.planning/phases/02-api-parity-harness-bootstrap/02-CONTEXT.md: tide capture rules
Research
.planning/research/PITFALLS.mdPitfall 3 (DTOs, not models), Pitfall 4 ([]vsnull), Pitfall 15 (Typesense as pre-filter)
Parity corpus
fonoteka.go/parity/manifest.yaml,fonoteka.go/parity/README.md,fonoteka.go/parity/fixtures/{routes,nuxt,mcp}/
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go: default-path resolver to extend with switch and membership.classes/album_write_service.go,collection_write_service.go: fill boundaries with service-level fuzz tests already in place.classes/artist_resolver.go,serialize.go,join_tables.go(album_artists ordering, CollectionEditor pivot),backend_album_collection.go.controllers/genre_controller.go: the Phase 3 handler shape.controllers/api/token_api_controller.go,me_token_controller.go: JWT/token-group handler precedents.routes.go: both group builders already exist. Only genres, tokens, me, me/locale and OAuth are mounted.- Framework:
fetchguard(AllowHosts/PublicOnly), lagoon attachments andThumb, postcard memory driver, P11 job manager, realtime memory driver and search driver interface.
Established Patterns
- One handler on both groups. Route-table tests for scope and isolation (
routes_group_test.go,routes_isolation_test.go). - Manifest entries flip from
pendingtoportedonly with a real handler and a passing replay. - Security-relevant phases carry
T-12-xxthreats with failing-when-broken tests. Candidates: cross-collection id access, invitation token reuse or expiry, owner-only enforcement under a personal token, search leak, upload content guard.
Integration Points
fonoteka.go/plugins/golem15/fonoteka/routes.go(both groups),parity/manifest.yaml, newparity/fixtures/nuxt/nuxt-collections.yamlandnuxt-albums.yaml, and the Centrifugo publication goldens.- The P11 job manager (invitation and notification mail jobs) and the search driver (fake engine for tests).
</code_context>
## Specific Ideas- The user moved all anonymous public routes to Phase 13 so the public surface (collection and wishlist, stacked public buckets,
pubfail:<ip>) is ported and security-reviewed in one place. - Cover import is wanted now so album create is parity-true, while the broader guarded-client work waits for Phase 14.
- Reservations (
wishlist/albums/{id}/reserve|reveal) → Phase 13. - Anonymous public-share routes
public/{token}*→ Phase 13. - Discogs cover-price, release match, apply-release, recognize, Discogs import → Phase 14.
- Register-via-invitation and public invitation inspection → Phase 13 (API-07).
- Notifications list, read and prune API → Phase 13 (API-04).
Reviewed Todos (not folded)
fetchguard-guarded-http-client.md: not folded. Discogs cover-price moved to Phase 14, andCoverImporteronly needs the existing guarded GET.backend-admin-api-tokens.md: unrelated to this API surface (admin auth). Stays deferred past v1.redacting-slog-handler.md: Phase 14 (credentials). Worth noting that invitation tokens must not reach logs (D-13), but no handler port is needed here.
Phase: 12-p-ytarium-api-collections-and-albums Context gathered: 2026-09-28