Files
summercms/.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-PLAN.md
Jakub Zych 947aabee93 docs: move golem15.user to sm-user-plugin and supersede the Phase 1 extraction deferral
- new note .planning/notes/core-plugins-own-repos.md: shared core plugins live in sm-<name>-plugin repos mounted as submodules
- 01-CONTEXT deferral points to the note; PROJECT constraint and Key Decisions row
- ROADMAP Phase 12 repos and the 12-01 entry, and Phase 12 plans 12-01, 12-02, 12-05 name sm-user-plugin and the submodule commit workflow
2026-10-02 11:02:07 +02:00

51 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
12-p-ytarium-api-collections-and-albums 02 execute 2
12-01
../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go
../fonoteka.go/plugins/golem15/fonoteka/classes/access.go
../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go
../fonoteka.go/plugins/golem15/fonoteka/classes/fingerprint.go
../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go
../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go
../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go
../fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go
../fonoteka.go/plugins/golem15/fonoteka/models/collection.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_404.html
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_share_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_context_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/realtime_channels_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml
../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go
../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go
../fonoteka.go/config/storage.yaml
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/fixtures/routes/
../fonoteka.go/parity/fixtures/files/
../fonoteka.go/parity/fonoteka_seed_test.go
../fonoteka.go/parity/parity_test.go
../fonoteka.go/parity/README.md
../fonoteka.go/README.md
true
API-01
tokens raw_tokens tasks confidence
300000 300000 3 low
truths artifacts key_links prohibitions
Per C-01 and RESEARCH Pattern 1, `classes.Resolve` ports ActiveCollectionResolver::resolve: a personal token must carry exactly one collection id >= 1 that is accessible (else the Winter HTML 404 page), a JWT caller gets the stored context if still accessible, else the lowest-id accessible collection with no kind filter (as PHP), else a provisioned `Moja kolekcja`, under `SELECT ... FOR UPDATE` on the users row then the context row.
Per RESEARCH Pattern 2, every request-facing collection and album query goes through `classes.AccessibleBy(userID, token)`: grouped owner-or-editor membership AND, for a token with collection_ids, `(id IN pin OR (owner_id = user AND kind = 'wishlist'))`.
Per D-01 and D-10, the collections routes (index, store, show, update, destroy, photos upload/delete, image upload/delete, switch with throttle:10,1), `me/context`, `realtime/channels` and `collection/share` show/update/regenerate (regenerate with throttle:10,1) are mounted once each and reused on the token group exactly where routes.php mirrors them: GET collections, GET/PUT/DELETE collections/{id}, collection photos and image.
Per D-26, the token group applies no group-level scope; every token route carries exactly one `inv.scope:read` or `inv.scope:write` as in routes.php, so a write-only token reaches PUT collections/{id} and gets 403 on GET collections, and `/genres` and `/me` keep `inv.scope:read`.
Per D-26, deleting a collection loads its albums and deletes them one by one inside the transaction, so each album gets its Typesense removal and its `deleted.fonoteka.album` broadcast, then every owner and editor who lost access has their context repaired by the collection provisioner.
Per C-03, collection responses come from the full `SerializeCollection` port (id, name, description, album_count, is_owner, is_active, photos, image, created_at, updated_at, plus owner_name on index only with the owner/local-part rule) and photos from `SerializePhoto` (id, url, thumb_url 200x200 crop).
Per D-22, `config/storage.yaml` roots the bucket at `storage/app/uploads/public` with prefix `/storage/app/uploads/public`, so photo URLs equal PHP's and the user avatar route still passes its recorded fixtures.
Per D-20, `GET realtime/channels` (JWT only) returns `{"data":{"collection":"collection:<resolved id>"}}`, and `me/context` returns exactly its recorded flags (can_use_ai, can_manage_org, has_organisation, ai_org_lock, ai_inherited, is_collection_owner, invitation_acceptance_required false, can_import_discogs, market_currency_default, market_currencies) and never a collection id or channel.
Per D-25, the site-admin predicate in `me/context` gates reads the user's group codes (`admin`), and AiGate's site-admin branch falls through because no global vision model exists in the Go port (documented).
Per D-06, only the owner surface of `collection/share` exists: a token caller or an editor gets 404 `{"error":"Collection not found"}`; regenerate draws 16 characters from the 62-symbol alphabet with crypto/rand rejection sampling (reject bytes >= 248) under a row lock.
Per D-21, `POST collections/{id}/switch` for a foreign, missing or wishlist id, and any token call that would switch, answers with the Winter production 404 HTML page (status 404, `text/html; charset=UTF-8`, stylesheet origin from app.url), re-recorded under APP_DEBUG=false.
Per D-08 and D-11, every route above has recorded cases for its success and each distinct error (404 foreign or missing id, 404 editor on owner-only actions, 422 validation, multipart photo and image uploads with url/thumb_url), each case a self-contained flow from the Go seed hook state, and all are `ported` and pass `TestParityCorpus` with `expectedPortedRoutes` raised by 23.
Edge (API-01 empty): a user with no accessible collection gets exactly one `Moja kolekcja` on the first resolve, even under two concurrent requests (users row lock), and `GET collections` for that user lists it; an empty pin list on a token is unrestricted only for listing (activeCollectionIdForDisplay null) and is a 404 for resolve.
Edge (API-01 ordering): `GET collections` orders by name with PHP's stable byte comparison over id-ordered rows (equal names keep ascending id); members and other lists keep their PHP orders.
Edge (API-01 adjacency): two collections with the same name are distinct rows with distinct ids and both are listed; switching to the already active collection succeeds and rewrites the same context row.
statement verification
Edge (API-01 encoding): collection names are stored and returned byte-for-byte as sent (Winter has no TrimStrings or ConvertEmptyStringsToNull), including leading and trailing spaces. backstop
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go Resolve, SwitchTo, DisplayActiveID, ErrCollectionNotFound FOR UPDATE
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/access.go AccessibleBy, AlbumsAccessibleBy func AccessibleBy(
path provides
../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go ShareState, EnableShare, DisableShare, RenameShare, RegenerateShare, GenerateShareToken
path provides
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go writeWinterHTTPError, writeValidationFailed
path provides
../fonoteka.go/parity/fonoteka_seed_test.go fonoteka seed hook: alice, bob (editor), outsider, collections, tokens
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/routes.go ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go one handler value mounted on the JWT and token groups CollectionsIndex
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go ../fonoteka.go/plugins/golem15/fonoteka/classes/access.go Scopes(classes.AccessibleBy(user.ID, token)) AccessibleBy(
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/models/collection.go golem15_fonoteka_albums BeforeDelete loads albums and deletes each row BeforeDelete
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_context_controller.go ../fonoteka.go/plugins/golem15/user/classes/user_groups.go IsSiteAdmin reads HasGroupCode(admin) HasGroupCode
requirement_id category statement status verification
API-01 privacy me/context MUST NOT return a raw collection id or a channel name; only realtime/channels returns the channel string resolved test
requirement_id category statement status verification
API-01 privacy GET collections MUST NOT expose another user's full email address; owner_name falls back only to the local part before @ resolved test
requirement_id category statement status verification
API-01 safety Deleting a collection MUST NOT leave albums visible in search or remove the owner's or editors' ability to use the app; every affected user gets a valid active collection afterwards resolved test

Phase Goal

ROADMAP Phase 12 goal (verbatim, not in user-story form): Collections and Albums endpoints are ported with byte-compatible request/response shapes, including active-context switching, editor invitations, ratings, reservations, cover handling and search. (12-01 Task 4 rewords it per D-03/D-04/D-06.)

This plan's slice: the Nuxt app can list, create, edit, delete and switch collections, upload collection photos and the switcher image, read its me/context flags and Centrifugo channel name, and manage the owner's public share link, all byte-compatible with PHP on both auth groups where PHP mirrors them (API-01; ROADMAP SC-1).

Port the tenant core (token-aware resolver, provisioning, access scope, gates, fingerprint), the collection serializer, and every collections, me/context, realtime/channels and collection/share route to fonoteka.go, with recordings and replays.

Purpose: every later handler (household, albums, search) resolves and gates the tenant through this code; me/context and realtime/channels are the first calls the Nuxt app makes. Decisions implemented: C-01, C-02, C-03, D-01, D-06, D-08, D-10, D-11, D-20, D-21, D-22, D-25 (consumer), D-26. Output: classes and controllers listed in files_modified, routes and config, recorded fixtures and manifest flips, a fonoteka parity seed hook.

Repo: fonoteka.go only (summercms.go untouched). No Nuxt or fonoteka-mcp change. 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/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md @.planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md @.planning/phases/12-p-ytarium-api-collections-and-albums/12-01-SUMMARY.md @../fonoteka.go/plugins/golem15/fonoteka/routes.go @../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go @../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go @../fonoteka.go/parity/README.md - From 12-01: `lagoon.ValidateRequest(ctx, tx, input, []lagoon.RequestRule{{Field, Rules: lagoon.ParseRules("...")}}, tr)`, `lagoon.UploadedFileFromHeader`, `attach.PublicURL`, `(*attach.File).URL()`, tide `Request.Parts`, user plugin `classes.HasGroupCode(ctx, db, userID, "admin")` (import path `git.golem15.com/golem15/sm-user-plugin/classes`). - fonoteka today: `classes.AccessibleByMembership(userID)`, `classes.ResolveActiveCollection(ctx, gdb, userID)` (JWT default path only; callers: controllers/genre_controller.go ListGenres and controllers/api/oauth_consent_controller.go), `persistContext` (INSERT ... ON CONFLICT (user_id)), `classes.SaveCollection(ctx, gdb, c, requested)` with `CollectionFillFields = {name, description}`, `classes.FieldErrors`, `classes.SerializeCollection` (4-key stub), `models.Collection{ID, OwnerID, Owner, Name, Description, Kind, PublicToken, PublicEnabled, PublicTokenGeneratedAt, ReservationsAllowed, Editors, DeletedAt, CreatedAt, UpdatedAt}` with `BeforeDelete` doing a bulk album delete, `models.ApiToken{CollectionIDs lagoon.Jsonable[[]uint], Scopes}`, `models.UserCollectionContext`, `models.OrgAiCredential`, `models.UserAiCredential`, `models.UserDiscogsCredential`, `models.OrgDiscogsCredential`, `models.AlbumMarketCurrencies`. - Request context: `bouncer.User(ctx) (*bouncer.Principal, bool)`; the personal token is `cred, _ := bouncer.Credential(ctx); tok, _ := cred.(*models.ApiToken)` (middleware/token_scope.go); `wire.WriteJSON` (no trailing newline), `wire.WriteOpaque500`; responses carry `Cache-Control: no-cache, private` and `Content-Type: application/json`. - surf: `g.Get/Post/Put/Delete(path, h, mw...)`, `g.Where(param, regex)` constrains only the immediately preceding route (Pitfall 2), inline `throttle:N,M`, `body.limit:`. - attach: `system_files` rows via attach.File (AttachmentType = model MorphName, Field photos/image, IsPublic true), `(*File).Thumb(ctx, bucket, 200, 200, "crop")`, bucket from `app.Lookup[*blob.Bucket]()`, after-commit blob deletion via `DeleteForOwner`/`DeleteKeys`. - lighthouse: deleted album broadcasts are automatic per row (Album binding in realtime.go); beachcomber removes the document per row after commit. - Parity: `seedHooks` map in parity/parity_test.go, `expectedPortedRoutes = 33`, `newConfiguredTarget`, subtest names from `corpusSubtestName` (spaces and slashes become underscores); seed patterns in parity/genres_seed_test.go (upsertParityAlice, mintTestJWT, mintTestInvToken), parity/realtime_seed_test.go (upsertParityOutsider), parity/user_api_seed_test.go; PHP isolation and recording in parity/php_parity.sh and parity/README.md. - PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{routes.php (lines 74-140 and 450-475), controllers/api/CollectionApiController.php, controllers/api/CollectionShareController.php, controllers/api/MeContextController.php, classes/ActiveCollectionResolver.php, classes/CollectionProvisioner.php, classes/CollectionFingerprint.php, classes/CollectionShareService.php, classes/OrgAccess.php, classes/AiGate.php, classes/DiscogsGate.php, classes/DiscogsConfigResolver.php, models/Collection.php (lines 120-230), traits/SerializesFonoteka.php, config/fonoteka.php}; /media/nvme/dev/golem15/fonoteka/modules/system/models/File.php.

Artifacts this phase produces

(This plan's share.)

  • classes: Resolve(ctx, gdb, user *usermodels.User, token *models.ApiToken) (*models.Collection, error), SwitchTo(ctx, gdb, user, token, collectionID uint) (*models.Collection, error), DisplayActiveID(ctx, gdb, userID uint, token *models.ApiToken) (*uint, error), ErrCollectionNotFound, AccessibleBy(userID uint, token *models.ApiToken) func(*gorm.DB) *gorm.DB, AlbumsAccessibleBy(userID uint, token *models.ApiToken) func(*gorm.DB) *gorm.DB, ProvisionCollection(ctx, tx, user) (*models.Collection, error), CollectionKey(appKey string, id uint) string, ShareState, ShareStateFor(c *models.Collection) ShareState, EnableShare, DisableShare, RenameShare, RegenerateShare, GenerateShareToken() (string, error), IsSiteAdmin, CanManageOrg, AIAllowed, ResolveDiscogsConfig, DiscogsAllowed, CollectionDTO, PhotoDTO, SerializeCollection, SerializePhoto.
  • controllers/api: CollectionsIndex, CollectionsStore, CollectionsShow, CollectionsUpdate, CollectionsDestroy, CollectionPhotoUpload, CollectionPhotoDelete, CollectionImageUpload, CollectionImageDelete, CollectionSwitch, MeContext, RealtimeChannels, CollectionShareShow, CollectionShareUpdate, CollectionShareRegenerate; helpers decodeInput, phpInt, laravelBoolean, writeWinterHTTPError, writeValidationFailed.
  • Routes: JWT GET/POST /_fonoteka/api/v1/collections, GET/PUT/DELETE .../collections/{id}, POST .../collections/{id}/photos, DELETE .../collections/{id}/photos/{fileId}, POST/DELETE .../collections/{id}/image, POST .../collections/{id}/switch (throttle:10,1), GET .../me/context, GET .../realtime/channels, GET/PUT .../collection/share, POST .../collection/share/regenerate (throttle:10,1); token group twins of GET collections, GET/PUT/DELETE collections/{id}, photos and image.
  • Config keys (plugin): golem15.fonoteka.ai_org_lock (false), golem15.fonoteka.discogs.token (""), golem15.fonoteka.discogs.market_currency ("EUR"); app storage storage.uploads.bucket_url and public_path_prefix set to the Winter layout.
  • Parity: seed hook fonoteka; vars jwt:alice, jwt:bob, jwt:outsider, token:pinned, token:read-only, token:write-only, id:collection, id:bob-collection, id:foreign-collection.
Task 1: The Nuxt collection switcher and an MCP personal token both list the caller's collections through one tenant core Handlers and classes are new; the old ResolveActiveCollection callers move to Resolve with their fixtures kept green. Plan 12-01 is executed: `go doc ./modules/lagoon ValidateRequest` exits 0 in summercms.go and `go -C ../fonoteka.go doc ./plugins/golem15/user/classes HasGroupCode` exits 0. ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/access.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go, ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go, ../fonoteka.go/config/storage.yaml, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/parity_test.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ActiveCollectionResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/CollectionProvisioner.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php (scopeAccessibleByMembership, scopeAccessibleBy, tokenCollectionRestriction), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CollectionApiController.php (index, activeCollectionIdForDisplay), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php (serializeCollection, serializePhoto, relativeMediaUrl), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 74-110 and 450-470), ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go, ../fonoteka.go/config/storage.yaml, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/genres_seed_test.go, ../fonoteka.go/parity/realtime_seed_test.go, ../fonoteka.go/parity/README.md, ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_collections_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/GET__api_v1_fonoteka_collections_personal_token.yaml, ../fonoteka.go/parity/fixtures/seed/bootstrap.yaml (1) access.go (RESEARCH Pattern 2): move AccessibleByMembership here unchanged and add `AccessibleBy(userID, token)` (membership AND, when the token has a non-empty CollectionIDs, the grouped `(golem15_fonoteka_collections.id IN pin OR (owner_id = user AND kind = 'wishlist'))`) and `AlbumsAccessibleBy(userID, token)` as an EXISTS over golem15_fonoteka_collections (deleted_at IS NULL) with that same predicate, ANDed with albums.deleted_at IS NULL.

(2) active_collection.go (C-01, RESEARCH Pattern 1, T-12-12): Resolve(ctx, gdb, user, token): token present: require exactly one id that is a positive integer, load through AccessibleBy, else return ErrCollectionNotFound; no token: one lagoon.Transaction that runs SELECT id FROM users WHERE id = ? FOR UPDATE, then the context row FOR UPDATE, then stored-if-accessible (AccessibleBy with a nil token), else AccessibleBy(user, nil) ordered by id with NO kind filter (A2: PHP resolve() has none; add a Go test where a lower-id wishlist owned by the user is picked, exactly as PHP), else ProvisionCollection. Persist the fallback with persistContext. SwitchTo(ctx, gdb, user, token, id): a token returns ErrCollectionNotFound; otherwise the same two locks, target must be accessible AND kind = collection, then upsert the context. DisplayActiveID ports activeCollectionIdForDisplay (token with exactly one id: that id; else stored context id; else lowest accessible kind=collection id; never provisions, never locks). Keep ResolveActiveCollection(ctx, gdb, userID) only as a thin wrapper that loads the user and calls Resolve with a nil token, or delete it after moving both callers; ListGenres must pass the request's personal token so the token-group genres route honours the pin (as PHP GenreApiController does). The pending-invitation guard is added in 12-03 (user split), not here.

(3) collection_provisioner.go: port CollectionProvisioner::provision (inside the caller's tx: lock users row, reuse the lowest-id accessible kind=collection collection by membership, else create Moja kolekcja owned by the user with kind collection, then persist the context); returns the collection.

(4) serialize.go: replace the collection stub with an ordered CollectionDTO struct (PHP key order: id, name, description, album_count, is_owner, is_active, photos, image, created_at, updated_at, and an owner_name field that is emitted only by index) and PhotoDTO{ID, URL, ThumbURL}. SerializeCollection(c, userID *uint, activeID *uint, albumCount int64, photos []attach.File, image *attach.File, bucket) or an equivalent loader-friendly signature; is_owner is null when userID is nil, is_active null when activeID is nil; photos []PhotoDTO never null; times as wire.Time Carbon +00:00; SerializePhoto uses (*attach.File).URL() and Thumb(200, 200, "crop"). Album serializer stays for 12-04.

(5) storage.yaml, per D-22: bucket_url: "file://./storage/app/uploads/public", public_path_prefix: "/storage/app/uploads/public", with a comment citing Winter cms.php and File::getPublicPath. Run the user avatar parity cases and the user plugin avatar tests to prove avatars still pass.

(6) request.go: decodeInput(r) (map[string]any, error) (JSON via json.Decoder UseNumber; form and multipart with files as lagoon.UploadedFile; an empty body is an empty map, as Laravel's request->all()), phpInt(raw string, def int) int (leading numeric prefix like PHP (int): "12abc" is 12, "abc" is 0), laravelBoolean(v any) bool (true for 1, "1", true, "true", "on", "yes"), and a small requestScope(w, r, app) returning the gorm handle, the user model and the optional *models.ApiToken (opaque 500 when the DB or user is missing).

(7) collections_controller.go: CollectionsIndex ports index: AccessibleBy(user, token) AND kind = collection, album counts in one grouped query (albums not soft-deleted), photos and image loaded per collection from system_files (field photos ordered by sort_order then id; field image), owner rows for owner_name, then a stable sort by name bytes over rows already ordered by id (sort.SliceStable with strings.Compare), DisplayActiveID for is_active, owner_name null for own rows else trimmed owner name else email local part else null. Response {"data":[...]}, 200, Cache-Control: no-cache, private.

(8) routes.go: JWT group mounts GET /collections with CollectionsIndex; the token group becomes surf.Use("inv_token", "throttle:fonoteka-api-token") with per-route scopes per D-26: /genres read, /me read, /collections read. Update routes_group_test.go expectations that assumed the group-level read scope and add one assertion that a write-only token is refused on GET collections with the exact InvScope 403 body.

(9) Parity: write parity/fonoteka_seed_test.go with seed hook fonoteka (register it in seedHooks) creating, in seed order: alice (activated, organisation owner as the PHP bootstrap leaves her), bob, outsider; alice's Parity Collection with alice's context on it; bob's own collection; an outsider collection; bob as editor of alice's collection (golem15_fonoteka_collection_editors row, role editor, granted_by alice); JWTs for all three (mintTestJWT); personal tokens for alice: pinned (read, write, ai; collection_ids [alice collection]), read-only and write-only. Store every id and credential in the tide store under the var names listed in Artifacts. Record the PHP side the same way: run the PHP bootstrap seed, create bob and outsider with php_parity.sh artisan tinker as the README does for outsider, add the editor row and the three tokens through tinker or the ported routes, and put their values only in the 0600 vars file. Add manifest cases for GET collections (jwt: alice sees own and shared collections with owner_name; token: pinned token sees only the pin with is_active from the pin; write-only token 403) and re-record both route fixtures; flip both routes to ported with seed_hook: fonoteka; raise expectedPortedRoutes by 2 in this task (the remaining 21 flips come in Tasks 2 and 3).

(10) Smoke test collections_smoke_test.go TestCollectionsIndexBothGroups through the assembled router: JWT alice sees two rows with correct is_owner/owner_name; the pinned token sees one; a token pinned to a foreign collection sees none; TestResolveProvisionsOnce runs two concurrent Resolve calls for a user with no collection and finds exactly one Moja kolekcja and one context row. go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCollectionsIndexBothGroups|TestResolveProvisionsOnce|TestGenresSharedHandler|TestFullRouteTableAuthGroupMutualExclusivity)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run 'TestParityCorpus' -count=1 -v <fails_when>Any command exits non-zero; the plugin run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestCollectionsIndexBothGroups"; the parity run lacks "--- PASS: TestParityCorpus/coverage" or any TestParityCorpus subtest for the genres, collections, me, avatar or oauth routes reports FAIL.</fails_when> <acceptance_criteria> - grep -c 'FOR UPDATE' ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go prints at least 2. - grep -n 'inv.scope:read' ../fonoteka.go/plugins/golem15/fonoteka/routes.go shows the scope only as a per-route argument (no surf.Use(... "inv.scope:read") group declaration remains). - grep -n 'bucket_url' ../fonoteka.go/config/storage.yaml shows file://./storage/app/uploads/public and grep -n 'public_path_prefix' ../fonoteka.go/config/storage.yaml shows /storage/app/uploads/public. - grep -A3 'id: "GET /_fonoteka/api/v1/collections jwt"' ../fonoteka.go/parity/manifest.yaml | grep -c 'status: ported' prints 1, and the same for GET /api/v1/fonoteka/collections personal_token. - grep -c '"fonoteka":' ../fonoteka.go/parity/parity_test.go prints 1 (seed hook registered). - 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 exits 0. </acceptance_criteria> A JWT user and a pinned personal token each get PHP's exact collection list from one handler through the token-aware resolver, access scope and serializer, and the token group enforces one scope per route.

Task 2: The Nuxt app creates, edits, deletes and switches collections and manages their photos and switcher image D-26 replaces the bulk album delete in Collection.BeforeDelete with a per-row delete; admin deletes (cabana) go through the same hook, so it is flagged. ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/fingerprint.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_404.html, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/files/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/parity_test.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CollectionApiController.php (store, show, update, destroy, uploadPhoto, deletePhoto, uploadImage, deleteImage, switchTo), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/CollectionFingerprint.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php (beforeDelete, attachMany photos, attachOne image), /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Database/Relations/AttachMany.php and AttachOne.php (remove and delete semantics), ../fonoteka.go/parity/fixtures/routes/DELETE___fonoteka_api_v1_household_members_{id}_jwt.yaml (recorded Winter 404 page bytes), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go (winter page embed precedent), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (UploadAvatar: multipart, attach row, blob write, P7 D-04), ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go, ../fonoteka.go/plugins/golem15/fonoteka/search.go, summercms.go modules/lagoon/attach/file.go (DeleteForOwner, DeleteKeys), summercms.go modules/lagoon/transaction.go (Transaction, AfterCommit), ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_collections_{id}_switch_jwt.yaml (1) Store/show/update/destroy (C-02, C-03): validate with lagoon.ValidateRequest using PHP's rules verbatim (store: `name` required|string|max:255, `description` nullable|string; update: name nullable|string|max:255, description nullable|string); 422 body through writeValidationFailed `{"error":"Validation failed","errors":{...}}`. Fill through SaveCollection (CollectionFillFields only; owner_id and kind server-set on create to the caller and `collection`); strings persisted exactly as sent. Store returns 201 `{"data":CollectionDTO}` with is_owner true and is_active null; show and update 200 with is_active null; a foreign or missing id is 404 `{"error":"Collection not found"}` (JSON, not HTML). Destroy: owner-only (an editor gets the same 404), then in one lagoon.Transaction delete the collection and run ProvisionCollection for the owner and every editor; respond 200 `{"message":"Collection deleted"}`.

(2) Collection.BeforeDelete, per D-26 and the RESEARCH anti-pattern: load the collection's non-deleted albums through the hook's tx and delete each loaded album value so GORM callbacks see a primary key (beachcomber after-commit removal and the lighthouse deleted broadcast fire per album), inside WithSoftDeleteCascade. Add a smoke test with the memory realtime driver and a recording beachcomber engine: deleting a collection with two albums yields two deleted.fonoteka.album publications and two index deletions after commit, none on rollback.

(3) Photos and image (D-11, D-22): POST collections/{id}/photos (accessible by member) and POST collections/{id}/image (owner only) parse multipart, validate file with required|image|mimes:jpg,jpeg,png,gif,webp|max:10240, write the blob under attach's disk-name and partition scheme, insert the system_files row (attachment_type Collection MorphName, field photos or image, is_public true, sort_order as Winter assigns), replace an existing image on image upload (delete the old row and blobs after commit), and answer 201 {"data":PhotoDTO}. DELETE photos/{fileId}: 404 Collection not found, then 404 {"error":"Photo not found"} when the file is not one of this collection's photos, else remove per Winter AttachMany remove semantics read from vendor and answer {"message":"Photo removed"}. DELETE image: owner-only, delete when present, {"message":"Image removed"}. Route constraints: g.Where("id", "[0-9]+") and g.Where("fileId", "[0-9]+") immediately after each route.

(4) Switch (D-01, D-21): POST collections/{id}/switch with throttle:10,1, calls SwitchTo; success 200 {"data":{"switched":true,"collection_key":CollectionKey(app.key, id)}}. fingerprint.go ports CollectionFingerprint: hex HMAC-SHA256 of fonoteka:collection:<id> keyed by the raw app.key config string (including any base64: prefix), first 32 characters. ErrCollectionNotFound becomes writeWinterHTTPError(w, app, 404). http_errors.go: embed winter_404.html as a template whose only variable is the stylesheet href {app.url}/modules/system/assets/css/styles.css (trailing slash of app.url trimmed); bytes otherwise identical to the page recorded under APP_DEBUG=false (copy them from the re-recorded fixture, not from the old debug recording); status 404, Content-Type: text/html; charset=UTF-8, Cache-Control: no-cache, private. Design the helper so 12-03 adds 409 and 410 pages by adding files only.

(5) Token twins (D-10, D-26): GET collections/{id} read; PUT and DELETE collections/{id}, POST photos, DELETE photos/{fileId}, POST and DELETE image write. Not on the token group: POST collections, switch.

(6) Recordings (D-08, D-21): under APP_DEBUG=false (php_parity.sh pins it) add and record cases per route as self-contained multi-step flows from the seed state: POST collections 201 and 422 (missing name, 256-character name, numeric name); GET collections/{id} 200 own, 200 shared (bob on alice's collection), 404 foreign, 404 missing; PUT 200 and 422 and 404; DELETE 200 owner, 404 editor, 404 foreign; photos POST 201 (multipart with tide Parts and a small PNG under parity/fixtures/files/), 422 missing file, 422 text file; photos DELETE 200 and 404 photo; image POST 201 owner, 404 editor, 422; image DELETE 200 and 404 editor; switch 200, 404 HTML foreign, 404 HTML wishlist id; each token twin's own case including a read-only token refused on a write twin and a write-only token accepted. Flip all ten JWT and eight token routes of this task to ported (minus the two Task 1 already flipped) and raise expectedPortedRoutes accordingly.

(7) Smoke tests in collections_smoke_test.go: TestCollectionDeleteRemovesAlbumsOneByOne (step 2), TestCollectionPhotoUpload (step 3), and TestCollectionSwitchRefusals (a foreign id, a wishlist id and a personal-token caller each get the Winter 404 page bytes with the app.url origin; the eleventh switch within a minute gets the throttle response; a successful switch returns the 32-character collection_key equal to a php -r computed HMAC for a fixed test key). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCollectionsIndexBothGroups|TestCollectionDeleteRemovesAlbumsOneByOne|TestCollectionPhotoUpload|TestCollectionSwitchRefusals)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run 'TestParityCorpus' -count=1 -v <fails_when>Any command exits non-zero; the plugin run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestCollectionDeleteRemovesAlbumsOneByOne, TestCollectionPhotoUpload and TestCollectionSwitchRefusals; the parity run reports a FAIL for any collections subtest or lacks "--- PASS: TestParityCorpus/coverage".</fails_when> <acceptance_criteria> - grep -c 'Delete(&Album{})' ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go prints 0 and BeforeDelete iterates over loaded albums. - grep -c 'throttle:10,1' ../fonoteka.go/plugins/golem15/fonoteka/routes.go prints at least 1 on the switch route line. - grep -c '/modules/system/assets/css/styles.css' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_404.html prints 1 and the file holds a template action for the origin instead of a literal 127.0.0.1. - The recorded switch 404 fixture has Content-Type: "text/html; charset=UTF-8" and no PHP file path or line debug text (grep -c 'vendor/' <fixture> prints 0). - TestCollectionPhotoUpload asserts the system_files row, the blob and the 200x200 crop thumb; a file of 10240 KB plus one byte gets 422 with the pl max.file catalog message for file; a body above a test config's http.body_limits.upload_bytes gets the router's 413; a .txt payload gets the 422 image/mimes messages. - A recorded upload fixture carries parts: with a sha256: and its file exists under parity/fixtures/files/. </acceptance_criteria> Collections can be created, edited, deleted (with per-album cleanup and context repair), switched with PHP's HTML 404 for refused switches, and given photos and a switcher image whose URLs match PHP, on both auth groups.

Task 3: The Nuxt app reads its context flags and channel name, and the owner manages the public share link ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_context_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/realtime_channels_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_share_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/README.md, ../fonoteka.go/README.md /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/MeContextController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/OrgAccess.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AiGate.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/DiscogsGate.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/DiscogsConfigResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/config/fonoteka.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php (marketCurrency, MARKET_CURRENCIES), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (realtime/channels closure, lines 80-100), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CollectionShareController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/CollectionShareService.php, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_me_context_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_realtime_channels_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_collection_share_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/PUT___fonoteka_api_v1_collection_share_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_collection_share_regenerate_jwt.yaml, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/album.go (marketCurrency), ../fonoteka.go/plugins/golem15/user/classes/user_groups.go, ../fonoteka.go/README.md (1) gates.go (D-25, RESEARCH Finding 2): `IsSiteAdmin(ctx, db, user)` = user-plugin `HasGroupCode(ctx, db, user.ID, "admin")`; `CanManageOrg` = site admin OR organisation_role in {owner, admin}; `AIAllowed(ctx, db, cfg, user)` ports AiGate: the site-admin branch needs a configured global vision model, which does not exist in the Go port, so it falls through exactly as PHP does when Golem15.Golem is absent (comment this); org lock from `golem15.fonoteka.ai_org_lock` applies to org members only; otherwise a UserAiCredential row or the org's OrgAiCredential row. `ResolveDiscogsConfig` ports DiscogsConfigResolver (site admin: `golem15.fonoteka.discogs.token`; else the user's credential; else the org's) returning the token and source; `DiscogsAllowed` is a non-blank token. These read only existence or the token through the encrypted cast; never log a token.

(2) config.yaml: add ai_org_lock: false, discogs.token: "", discogs.market_currency: "EUR" (plus a comment naming SUMMER_GOLEM15__FONOTEKA__... env overrides) beside the oauth block, and document them in ../fonoteka.go/README.md's configuration section.

(3) me_context_controller.go MeContext: resolve the active collection (Resolve with the request token), then emit the ordered struct with exactly the ten keys of the recorded body: can_use_ai, can_manage_org, has_organisation (organisation_id non-null), ai_org_lock, ai_inherited (has organisation AND org AI credential exists AND no user AI credential), is_collection_owner (active owner_id == user), invitation_acceptance_required (always false), can_import_discogs, market_currency_default (models' marketCurrency(): configured value upper-cased if it is in the list, else EUR, as Album::marketCurrency), market_currencies (models.AlbumMarketCurrencies, never null). ErrCollectionNotFound maps to the Winter 404 page.

(4) realtime_channels_controller.go RealtimeChannels (D-20): resolve, then 200 {"data":{"collection":"collection:<id>"}}; JWT group only. The raw id in the channel name matches PHP (the collection authorizer re-validates membership on every subscribe, T-12-13).

(5) share_service.go and collection_share_controller.go (D-06, T-12-08): GenerateShareToken (16 symbols from 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ via crypto/rand, skipping bytes >= 248, index byte mod 62), unique-index retry loop as PHP; ShareStateFor(c) = {enabled, name, token, path ("/k/" + token for a collection, "/w/" for a wishlist, null when no token), generated_at} exactly as stateFor; Enable (generate a token when none exists, set enabled, stamp generated_at), Disable, Rename (name column), Regenerate (new token and generated_at) each in a lagoon.Transaction that reloads the row FOR UPDATE and saves only the share columns. Controller: owner surface only (token caller or non-owner gets 404 {"error":"Collection not found"} JSON); update validates enabled sometimes|boolean and name sometimes|required|string|min:1|max:255, applies name before enabled as PHP, and returns the envelope {"data":ShareState}; regenerate carries throttle:10,1. Never log a share token.

(6) Routes (JWT only): GET /me/context, GET /realtime/channels, GET /collection/share, PUT /collection/share, POST /collection/share/regenerate with throttle:10,1.

(7) Recordings and flips: me/context for alice (owner), bob (editor, is_collection_owner false), a user whose org has an AI credential and no user credential (ai_inherited true), and a site admin (alice linked to an admin group on both sides via tinker and the seed hook); realtime/channels for alice and for a freshly provisioned user; share show 200 and editor 404; share update enable, rename, disable, 422 (name empty string, enabled "abc") and editor 404; regenerate 200 (token masked or captured into the vars file so no live token sits in git: add a capture rule or manifest capture with category share for $.data.token). Flip me/context, realtime/channels and the three share routes to ported (the full 23 for this plan), set expectedPortedRoutes to 56, and update parity/README.md with the Phase 12 recording recipe (bob, outsider, editor row, tokens, admin group, APP_DEBUG=false).

(8) Smoke tests: TestMeContextFlags (site admin via the user group, org lock on and off, inherited AI), TestRealtimeChannelsName, TestShareTokenAlphabet (10000 tokens: length 16, alphabet only, no byte >= 248 accepted) and TestShareOwnerOnly (editor and token get 404). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestMeContextFlags|TestRealtimeChannelsName|TestShareTokenAlphabet|TestShareOwnerOnly|TestCollectionsIndexBothGroups)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '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 plugin run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestMeContextFlags and TestShareOwnerOnly; the parity run reports FAIL for a me/context, realtime/channels or collection/share subtest or lacks "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret.</fails_when> <acceptance_criteria> - grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows the value 56 (33 before Phase 12 plus 23). - grep -c 'collection_id' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_context_controller.go prints 0 (no raw id key). - grep -c 'HasGroupCode' ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go prints at least 1. - grep -c 'REJECT\|248' ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go prints at least 1. - grep -c 'ai_org_lock' ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml prints 1 and grep -c 'ai_org_lock' ../fonoteka.go/README.md prints at least 1. - TestRealtimeChannelsName also walks surf.BuildRouter(...).Routes() and asserts that /_fonoteka/api/v1/realtime/channels, /_fonoteka/api/v1/me/context and the three /_fonoteka/api/v1/collection/share patterns exist and that no /api/v1/fonoteka pattern ends in realtime/channels, me/context, collection/share, collection/share/regenerate or /switch. </acceptance_criteria> The Nuxt app's first calls (context flags and channel name) and the owner's share-link screen are byte-compatible with PHP, gated by the same admin, org and credential rules, and every collections-area route of Phase 12 is ported.

<threat_model>

Trust Boundaries

Boundary Description
JWT or personal-token caller → collection routes The caller names collection ids in paths; the server must decide tenancy
Personal token (MCP, agents) → tenant resolution A token is pinned to one collection and narrower scopes
Multipart upload → blob storage Untrusted bytes are written to disk and thumbnailed
Share token → anonymous viewers (Phase 13) The token minted here is the bearer secret of public views

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-12-01 Information Disclosure collections/{id}, photos/{fileId}, image high mitigate Every lookup runs through AccessibleBy(user, token); file lookups are scoped to the collection's own attachment rows; foreign and missing ids share one 404 body (Tasks 1-2).
T-12-03 Elevation of Privilege personal token pin high mitigate Resolve requires exactly one accessible pinned id; AccessibleBy narrows every query to the pin plus the owner's wishlist; SwitchTo refuses tokens (Tasks 1-2).
T-12-04 Elevation of Privilege owner-only interactive routes on the token group high mitigate switch, share, me/context and realtime/channels are not mounted on the token group; share also refuses a token in-handler (Tasks 2-3; route-table test in 12-05).
T-12-05 Elevation of Privilege editor acting as owner high mitigate Explicit owner_id checks on destroy, image upload/delete and share; editors get 404 (Tasks 2-3).
T-12-08 Spoofing share token high mitigate crypto/rand with rejection sampling over a 62-symbol alphabet, unique retry, FOR UPDATE mutation, never logged (Task 3).
T-12-29 Denial of Service collection photo/image upload medium mitigate body.limit caps, Laravel max:10240 file rule, image and mimes sniff before the blob write (Task 2).
T-12-30 Tampering mass assignment on collections high mitigate SaveCollection fills only name and description; owner_id and kind are server-set (Task 2; fuzz in 12-05).
T-12-12 Tampering concurrent resolve provisioning medium mitigate users row FOR UPDATE before the context row (PHP lock order) and the unique user_id context key; TestResolveProvisionsOnce (Task 1).
T-12-13 Elevation of Privilege realtime/channels raw id low accept Matches PHP; the id is the caller's own resolved collection, never accepted as a tenant selector on any write, and the collection authorizer re-validates membership on every subscribe.
T-12-19 Information Disclosure Winter error pages low mitigate Pages are the APP_DEBUG=false production bytes with only the stylesheet origin templated from app.url; no paths, lines or stack traces (Task 2).
T-12-SC Tampering package installs low accept No new dependency in this plan.
</threat_model>
- `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green (including the user plugin and its avatar tests after the storage change). - `go -C ../fonoteka.go test ./parity -run TestParityCorpus -count=1` reports 56 ported and passing. - `check_corpus.go --require-recorded --check-secrets` green.

<success_criteria>

  • 23 collections-area routes are ported and replay their recorded PHP fixtures, including HTML 404 pages and multipart uploads.
  • The token group enforces one scope per route; collection delete cleans albums one by one and repairs contexts.
  • me/context, realtime/channels and the owner share surface match PHP bodies. </success_criteria>
Create `.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md` when done.