From 85a45dbc80b0f4196b2782a9de1a0b5184ce2b0c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 12:38:40 +0200 Subject: [PATCH] docs(12-02): complete collections, me/context and share link plan --- .../12-02-SUMMARY.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 .planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md diff --git a/.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md b/.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md new file mode 100644 index 0000000..b71acb4 --- /dev/null +++ b/.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md @@ -0,0 +1,315 @@ +--- +phase: 12-p-ytarium-api-collections-and-albums +plan: 02 +subsystem: api +tags: [fonoteka, collections, tenancy, personal-token, uploads, attach, share-token, me-context, realtime, parity, tide] + +requires: + - phase: 12-p-ytarium-api-collections-and-albums + provides: "12-01: lagoon.ValidateRequest + pl/en catalogs, attach.File.URL/PublicURL, tide multipart parts and upload URL masking, user groups (HasGroupCode)" + - phase: 11-jobs-realtime-and-search-infrastructure + provides: lighthouse album binding and memory driver, beachcomber after-commit sync, conga worker +provides: + - classes.Resolve/SwitchTo/DisplayActiveID/ErrCollectionNotFound (token-aware ActiveCollectionResolver port) + - classes.AccessibleBy/AlbumsAccessibleBy (membership plus personal-token pin), ProvisionCollection + - classes.CollectionDTO/CollectionIndexDTO/PhotoDTO, SerializeCollection/SerializePhoto/LoadCollectionDTO + - classes.CollectionKey, gates (IsSiteAdmin, CanManageOrg, AIAllowed, AIInherited, ResolveDiscogsConfig, DiscogsAllowed, MarketCurrency) + - classes share service (GenerateShareToken[From], ShareStateFor, Enable/Disable/Rename/RegenerateShare) + - 23 collections-area routes ported on both auth groups (collections CRUD, photos, image, switch, me/context, realtime/channels, collection/share) + - controllers/api helpers decodeInput, phpInt, laravelBoolean, requestScope, writeWinterHTTPError (winter_.html), writeValidationFailed + - parity seed hook fonoteka (reseeded per case) and parity/fonoteka_reset.php for the PHP side +affects: [12-03, 12-04, 12-05, 13] + +actuals: + tokens: 61230 + tasks: 3 + commits: 3 +plan_head_before: 02e1aa94612fbfe0940d8424ae7efedb00ea0190 +plan_head_after: 6c729a708f96fe941c4791049b89a9cad321461f + +tech-stack: + added: [] + patterns: + - "One handler value per route, mounted on the JWT group and its token twin with exactly one inv.scope per token route" + - "PHP HttpException answers are embedded Winter production pages (winter_.html) with only the app.url stylesheet origin templated" + - "Parity cases that mutate state are recorded from a fresh PHP reset each and replayed from a fresh Go seed each (per-case reseed)" + - "PHP-side recording state is built by a tinker script that mirrors the Go seed hook and bumps id sequences into 8-digit ranges" + +key-files: + created: + - ../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/gates.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.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/collections_smoke_test.go + - ../fonoteka.go/parity/fonoteka_seed_test.go + - ../fonoteka.go/parity/fonoteka_reset.php + - ../fonoteka.go/parity/fixtures/routes/files/collection-photo.png + - ../fonoteka.go/parity/fixtures/routes/files/not-an-image.txt + modified: + - ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/backend_album_collection.go + - ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go + - ../fonoteka.go/plugins/golem15/fonoteka/routes.go + - ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml + - ../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go + - ../fonoteka.go/config/storage.yaml + - ../fonoteka.go/parity/manifest.yaml + - ../fonoteka.go/parity/parity_test.go + - ../fonoteka.go/parity/parity_contract_test.go + - ../fonoteka.go/parity/genres_seed_test.go + - ../fonoteka.go/parity/user_api_seed_test.go + - ../fonoteka.go/parity/README.md + - ../fonoteka.go/README.md + +key-decisions: + - "Collection strings are trimmed on save (Collection.BeforeSave) because Winter's Model::setAttribute trims every string attribute; the recordings show ' New Shelf ' stored as 'New Shelf', so the plan's byte-for-byte encoding edge does not hold for this stack" + - "ProvisionCollection and Resolve's fallback carry no kind filter, as PHP; a lower-id wishlist can become the active context (pinned by TestResolveFallbackHasNoKindFilter)" + - "GET collections sorts names with PHP 8 SORT_REGULAR (two numeric names compare as numbers, else bytes), stable over id order; recorded by the ordering case" + - "The backend admin album binding keeps a non-provisioning resolver (backendActiveCollection); only API callers provision" + - "Uploads use the Winter layout (bucket storage/app/uploads/public, prefix /storage/app/uploads/public); the parity test config uses the same prefix" + - "Recordings come from a tinker reset of a canonical state (parity/fonoteka_reset.php) rather than the bootstrap flow, one reset per case; id sequences are lifted to 8 digits so captured ids are scrubbed and never collide" + - "A body over the router cap answers 413 {\"error\":\"Payload too large\"} from the handler (the router only wraps the body); not recordable against PHP" + +patterns-established: + - "requestScope(w, r, app) returns the gorm handle, the user row and the optional personal token for handlers shared by both auth groups" + - "New Winter error statuses are added as winter_.html files next to http_errors.go; no code change" + +requirements-completed: [API-01] + +coverage: + - id: D1 + description: "Token-aware tenant core: pinned tokens resolve to their single accessible collection, JWT users get the stored or lowest accessible collection under users-then-context row locks, else exactly one provisioned Moja kolekcja" + requirement: API-01 + verification: + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestResolveProvisionsOnce" + status: pass + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestResolvePinnedToken" + status: pass + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestResolveFallbackHasNoKindFilter" + status: pass + human_judgment: false + - id: D2 + description: "GET collections on both groups (pin-only for tokens, owner_name local-part rule, PHP name order) and the token group's one-scope-per-route rule" + requirement: API-01 + verification: + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestCollectionsIndexBothGroups" + status: pass + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go#TestTokenGroupOneScopePerRoute" + status: pass + - kind: e2e + ref: "go -C ../fonoteka.go test ./parity -run TestParityCorpus (GET collections jwt and personal_token cases)" + status: pass + human_judgment: false + - id: D3 + description: "Collections create/show/update/delete, photo and image uploads and deletes, and switch with the Winter 404 page, on both groups where PHP mirrors them; delete removes albums one by one and repairs contexts" + requirement: API-01 + verification: + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestCollectionDeleteRemovesAlbumsOneByOne" + status: pass + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestCollectionPhotoUpload" + status: pass + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestCollectionSwitchRefusals" + status: pass + - kind: e2e + ref: "go -C ../fonoteka.go test ./parity -run TestParityCorpus (16 collections routes, multipart replays)" + status: pass + human_judgment: false + - id: D4 + description: "me/context flags, realtime/channels channel name and the owner-only collection/share surface with crypto/rand share tokens" + requirement: API-01 + verification: + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestMeContextFlags" + status: pass + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestRealtimeChannelsName" + status: pass + - kind: unit + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestShareTokenAlphabet" + status: pass + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestShareOwnerOnly" + status: pass + - kind: e2e + ref: "go -C ../fonoteka.go test ./parity -run TestParityCorpus (56 ported and passing); check_corpus.go --require-recorded --check-secrets" + status: pass + human_judgment: false + - id: D5 + description: "Photo URLs and avatar URLs use the Winter /storage/app/uploads/public layout (D-22)" + requirement: API-01 + verification: + - kind: integration + ref: "../fonoteka.go/parity/avatar_assembled_test.go#TestAvatarAssembled" + status: pass + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestCollectionPhotoUpload" + status: pass + human_judgment: false + +duration: 46min +completed: 2026-10-02 +status: complete +--- + +# Phase 12 Plan 02: Collections, me/context, realtime channel and share link Summary + +**Token-aware tenant core (pinned tokens, row-locked resolve and provisioning, one access scope) and all 23 collections-area routes ported byte-compatibly on both auth groups, with Winter 404 pages, multipart photo/image uploads under the Winter URL layout, per-album collection deletes, me/context gates and crypto/rand share tokens, replayed from 82 fresh PHP recordings.** + +## Performance + +- **Duration:** 46 min +- **Started:** 2026-10-02T09:51:46Z +- **Completed:** 2026-10-02T10:37:30Z +- **Tasks:** 3 (tracer verified end to end before expansion) +- **Files modified:** 119 in fonoteka.go (code, tests, 75 fixture files, docs) + +## Accomplishments + +- `classes.Resolve` ports `ActiveCollectionResolver::resolve`: a personal token needs exactly one accessible pinned id (else `ErrCollectionNotFound`, the Winter 404 page); a JWT user gets the stored context while accessible, else the lowest-id accessible collection, else a provisioned `Moja kolekcja`, under `SELECT ... FOR UPDATE` on the users row then the context row. Four concurrent first resolves create one collection and one context row. +- `classes.AccessibleBy` / `AlbumsAccessibleBy` are the request-facing access rule: grouped owner-or-editor membership on live rows, plus the token pin `(id IN pin OR own wishlist)`. Genres on the token group now resolve the pin, as PHP does. +- Collections CRUD, photos, image, switch, `me/context`, `realtime/channels` and `collection/share` are mounted once each; the token group carries exactly one `inv.scope` per route, so a write-only token updates a collection and is refused on reads with PHP's 403 body (which now carries `Cache-Control: no-cache, private`). +- Uploads write Winter-style disk names under `storage/app/uploads/public`, set `sort_order` to the id, and answer `url` and a 200x200 crop `thumb_url`; photo removal detaches the row as `AttachMany::remove` does; image upload replaces the previous image. +- Deleting a collection soft-deletes its albums one by one (each gets its search removal and `deleted.fonoteka.album` broadcast after commit, none on rollback) and repairs the context of the owner and every editor. +- `me/context` returns exactly the ten recorded flags (site admin from the user's `admin` group, AI org lock and inheritance, Discogs tiers, market currency); `realtime/channels` returns `collection:`; the share surface is owner-only with tokens drawn from `crypto/rand` with rejection at 248. +- 82 parity cases recorded from the isolated PHP (APP_DEBUG=false); `TestParityCorpus` reports 56 ported routes passing; `check_corpus.go --require-recorded --check-secrets` is green. + +## Task Commits + +All commits are in fonoteka.go (summercms.go code is untouched): + +1. **Task 1: tenant core and GET collections on both groups (tracer)** - `574b2f1` (feat) +2. **Task 2: collection CRUD, photos, image and switch** - `b41d5ca` (feat) +3. **Task 3: me/context, realtime/channels and the share link** - `6c729a7` (feat) + +Ledger: fonoteka.go `02e1aa9..6c729a7`, 3 commits (`git rev-list --count`). + +## Files Created/Modified + +- `classes/active_collection.go`, `access.go`, `collection_provisioner.go`: resolver, switch, display id, access scopes, provisioner +- `classes/serialize.go`: CollectionDTO/CollectionIndexDTO/PhotoDTO, album counts and media loaders +- `classes/gates.go`, `share_service.go`, `fingerprint.go`: me/context gates, share tokens and state, collection_key HMAC +- `controllers/api/*`: request decoding helpers, Winter error pages, collections, media, share, me/context and channel handlers +- `models/collection.go`: per-album BeforeDelete and Winter string trimming in BeforeSave +- `routes.go`: JWT routes and token twins with per-route scopes, inline throttles, `{id}`/`{fileId}` constraints +- `config/storage.yaml`, plugin `config/config.yaml`, `README.md`: Winter upload layout and the new `golem15.fonoteka.*` keys +- `parity/*`: fonoteka seed hook (reseeded per case), manifest flips (56 ported), PHP reset script, recording recipe, 75 fixtures + +## Decisions Made + +See `key-decisions` in the frontmatter. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Winter trims string attributes, so names are not stored byte-for-byte** +- **Found during:** Task 1 (first PHP recordings) +- **Issue:** The plan's encoding truth says names keep leading and trailing spaces. Winter's `Model::setAttribute` trims every string (`$trimStringAttributes = true`); the recorded PHP stores `" New Shelf "` as `New Shelf`. +- **Fix:** `Collection.BeforeSave` trims name and description with PHP's trim set; share renames trim too. Recorded cases pin it (POST, PUT, share rename). +- **Commit:** b41d5ca, 6c729a7 + +**2. [Rule 1 - Bug] PHP's provisioner and resolve fallback have no kind filter** +- **Found during:** Task 1 +- **Issue:** The plan said the provisioner reuses the lowest kind=collection collection; `CollectionProvisioner::provision` uses `accessibleByMembership()->orderBy('id')->first()` with no kind filter. +- **Fix:** Followed PHP; `TestResolveFallbackHasNoKindFilter` pins the wishlist case, and the recorded owner-delete case shows the repaired context. +- **Commit:** 574b2f1 + +**3. [Rule 1 - Bug] Display id for a token with other than one pinned id is null** +- **Issue:** The plan said such a token falls back to the stored context; `activeCollectionIdForDisplay` returns null for any token without exactly one id. +- **Fix:** Followed PHP (`TestCollectionsIndexBothGroups` unpinned-token rows have `is_active` null). +- **Commit:** 574b2f1 + +**4. [Rule 1 - Bug] Name order is PHP SORT_REGULAR, not a plain byte compare** +- **Issue:** `sortBy('name')` uses `asort(SORT_REGULAR)`: two numeric names compare as numbers ("9" before "10"). +- **Fix:** `phpCompareStrings` in a stable sort; the recorded `ordering` case covers numeric, duplicate, accented and case-differing names. +- **Commit:** 574b2f1 + +**5. [Rule 1 - Bug] InvScope refusals lacked `Cache-Control: no-cache, private`** +- **Issue:** The recorded PHP 403 carries the header every Laravel response has; the Go middleware omitted it. +- **Fix:** `middleware/token_scope.go` sets it before writing 401/403. +- **Commit:** 574b2f1 + +**6. [Rule 3 - Blocking] Shared parity database across routes** +- **Issue:** Every ported route replays against one Postgres; the fonoteka hook's tokens and alice's organisation broke the tokens, connected-apps and user-api fixtures, and an unpinned `token:mcp-read` now 404s on the token genres route as PHP would. +- **Fix:** The genres hook deletes the fonoteka tokens and serves `token:mcp-read` from a dedicated pinned reader (alice's "Parity MCP" token stays unpinned for the tokens fixtures; all genre counts are zero either way); the user-api hook resets the organisation fields; fonoteka routes reseed before every case. +- **Commit:** 574b2f1 + +**7. [Rule 3 - Blocking] Backend album binding must not provision** +- **Issue:** `ResolveActiveCollection` now has PHP resolve semantics (provisioning, no kind filter), but the admin album binding (a Go-only Phase 10 path) relied on the old non-provisioning read. +- **Fix:** `backendActiveCollection` keeps the old behaviour for the backend binding; API callers (token store, OAuth consent) use the full resolver as PHP does. +- **Commit:** 574b2f1 + +**8. [Rule 3 - Blocking] Upload part files live under `parity/fixtures/routes/files/`** +- **Issue:** The plan named `parity/fixtures/files/`; tide refuses part paths that leave the fixture's directory (`../`). +- **Fix:** Files sit in `parity/fixtures/routes/files/`, pinned by sha256. +- **Commit:** b41d5ca + +**9. [Rule 3 - Blocking] Deferred-scope placeholder tests and the unported-route check** +- **Issue:** `TestOAuthTokenSurfaceIsolationCoverage` subtests fail by design once token collections, write scopes, switch and share routes exist; `assertPortedMismatch` used `me/context` as its unported route. +- **Fix:** Replaced each placeholder with the real assertion (401 then read 200 then write-only PUT 200; read-only token 403 on every write route; switch and share JWT-only); the mismatch check now uses `GET ai-credential` (Phase 14). +- **Commits:** 574b2f1, b41d5ca, 6c729a7 + +**10. [Rule 2 - Missing critical] Replaced and deleted image blobs are removed after commit** +- **Issue:** PHP's `image()->delete()` leaves the old bytes on disk; the plan asked for after-commit deletion. +- **Fix:** Rows are deleted in the request, blobs after the write succeeds; not observable in responses. +- **Commit:** b41d5ca + +**11. [Rule 3 - Blocking] Over-cap uploads answer 413 from the handler** +- **Issue:** surf only wraps the body in `http.MaxBytesReader`; there is no router 413 response, and PHP's `post_max_size` path cannot be recorded. +- **Fix:** `decodeInput` reports the cap and the handler answers 413 `{"error":"Payload too large"}`; `TestCollectionPhotoUpload` asserts it and that no row is stored. +- **Commit:** b41d5ca + +**12. [Recording approach] PHP state from a tinker reset per case** +- The plan suggested the bootstrap seed plus tinker additions. A single reset script (`parity/fonoteka_reset.php`) that mirrors the Go hook was used instead, run before each case, with collection and file id sequences lifted to 8-digit ranges so captured ids are scrubbed and never collide. The README documents the recipe. + +--- + +**Total deviations:** 11 auto-fixed (5 bugs, 1 missing critical, 5 blocking) plus 1 recording-approach change. +**Impact on plan:** All 23 routes ported and replaying; every deviation follows the recorded PHP contract or keeps the shared corpus honest. No scope creep. + +## Issues Encountered + +- The org-lock-on `me/context` case was not recorded (it needs a PHP restart with `FONOTEKA_AI_ORG_LOCK`); `TestMeContextFlags` covers lock on and off. +- A personal-token caller cannot reach `collection/share` or `switch` over HTTP (the routes are absent from the token group), so the in-handler second lock is covered by Go tests rather than recordings. +- Recording uploads against the isolated PHP wrote 16 files into the PHP checkout's `storage/app/uploads/public/6ab/f82/`; they were removed afterwards. +- The pending-invitation 409 guard is not part of `Resolve` yet; it lands with household and invitations in 12-03 (`invitation_acceptance_required` is always false, as PHP returns it). + +## Known Stubs + +None. + +## User Setup Required + +None. Production keeps `storage.uploads` on the Winter layout; the new `golem15.fonoteka.*` keys default to PHP's values. + +## Next Phase Readiness + +- 12-03 adds the pending-invitation guard to `Resolve`, more Winter pages (409/410 as new `winter_.html` files) and reuses `ProvisionCollection` for member removal. +- 12-04 builds album handlers on `AlbumsAccessibleBy`, `requestScope`, `decodeInput` and the media helpers; `models.marketCurrency()` still returns EUR and should read `classes.MarketCurrency`. +- 12-05 can reuse `collectionsHarness`, the fonoteka seed hook and `GenerateShareTokenFrom` for coverage and fuzzing. + +--- +*Phase: 12-p-ytarium-api-collections-and-albums* +*Completed: 2026-10-02*