- 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
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 |
|
|
true |
|
|
|
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).
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>
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; helpersdecodeInput,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 storagestorage.uploads.bucket_urlandpublic_path_prefixset to the Winter layout. - Parity: seed hook
fonoteka; varsjwt:alice,jwt:bob,jwt:outsider,token:pinned,token:read-only,token:write-only,id:collection,id:bob-collection,id:foreign-collection.
(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.
(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.
(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> |
<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>