diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 1444589..eb7abe8 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -709,7 +709,27 @@ Plans: 4. Per-user and per-org Discogs/AI credentials CRUD store encrypted values, honor the org-lock flag, and resolve env-to-org-to-user correctly. 5. Onboarding, public and invitation-inspection routes, including the anonymous collection views `public/{token}`, `public/{token}/albums` and `public/{token}/albums/{id}`, are reachable without auth and enforce their own public rate-limit buckets. -**Plans**: TBD +**Plans:** 6 plans + +Plans: + +**Wave 1** +- [ ] 13-01-PLAN.md — Framework gaps (summercms.go): surf overlap dispatch, conga workerless job kinds, lagoon prohibited, tide date and publication masks; the pinned job contract; parity scaffolding (queue override, rows dump, share:wishlist, D-15, ported case-status check); ROADMAP/REQUIREMENTS rewording + +**Wave 2** *(blocked on Wave 1 completion)* +- [ ] 13-02-PLAN.md — Notifications, credentials CRUD with the AI/Discogs resolution order, onboarding bootstrap, invitation inspection, sm-user-plugin RegisterEvent.Payload and registration exports, the register listener, onboarding flow (14 routes) + +**Wave 3** *(blocked on Wave 2 completion)* +- [ ] 13-03-PLAN.md — Wishlist (30 routes): resolver, visibility, subscriptions, secret reservations, share/settings/household, purchase with transactional mail, item-added bell and digest queue, nuxt-wishlist and mcp-wishlist flows, publication goldens + +**Wave 4** *(blocked on Wave 3 completion)* +- [ ] 13-04-PLAN.md — CSV (8 routes): fputcsv export on both groups, parser package with PHP truth tables, private uploads, import session with commit CAS, queued import/match jobs, D-05 seam, nuxt-csv flow + +**Wave 5** *(blocked on Wave 4 completion)* +- [ ] 13-05-PLAN.md — Public views (6 routes): token resolution, pubfail lockout, public search and facets, public album field set, D-14 per-route buckets, header fix, public flows + +**Wave 6** *(blocked on Wave 5 completion)* +- [ ] 13-06-PLAN.md — Unit tests last: route-table test, request-DTO fuzz, T-13 threat tests, coverage, check-phase13.sh, security review and validation sign-off ### Phase 14: Domain jobs and external integrations @@ -767,7 +787,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → | 11.1. SummerCMS documentation for humans and AI agents | 7/7 | In Progress| | | 11.2. summercms.io Alpha 0.1 landing page on SummerCMS | 3/3 | In Progress| | | 12. Płytarium API — Collections and Albums | 5/5 | Complete | 2026-10-02 | -| 13. Płytarium API — wishlist, notifications, CSV, credentials, public routes | 0/TBD | Not started | - | +| 13. Płytarium API — wishlist, notifications, CSV, credentials, public routes | 0/6 | Planned | - | | 14. Domain jobs and external integrations | 0/TBD | Not started | - | | 15. Cutover | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index aa4938b..d483459 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,18 +1,18 @@ --- gsd_state_version: "1.0" milestone: v1.0 -current_phase: "12.2" -current_phase_name: "Admin form fields: date, file upload, relation editing with deferred binding (INSERTED)" +current_phase: 13 +current_phase_name: Płytarium API — wishlist, notifications, CSV, credentials, public routes status: executing stopped_at: Completed 12.2-04-PLAN.md -last_updated: "2026-10-02T17:58:33.905Z" +last_updated: "2026-10-02T18:12:26.851Z" last_activity: 2026-10-02 last_activity_desc: Phase 12.2 execution started -state_head: 92d665f1b210648254f5bc3b4e7115302c3c3669 +state_head: 9d2b1c1848b641034dbbd807f8730074a3573cf1 progress: total_phases: 21 completed_phases: 11 - total_plans: 108 + total_plans: 112 completed_plans: 105 milestone_name: milestone --- @@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-09-16) ## Current Position -Phase: 12.2 (Admin form fields: date, file upload, relation editing with deferred binding (INSERTED)) — EXECUTING +Phase: 13 (Płytarium API — wishlist, notifications, CSV, credentials, public routes) — READY TO EXECUTE Plan: 5 of 5 Status: Ready to execute Last activity: 2026-10-02 — Phase 12.2 execution started diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-PLAN.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-PLAN.md new file mode 100644 index 0000000..585d7f3 --- /dev/null +++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-PLAN.md @@ -0,0 +1,350 @@ +--- +phase: 13-p-ytarium-api-wishlist-notifications-csv-credentials-public +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - modules/surf/router.go + - modules/surf/overlap.go + - modules/surf/overlap_test.go + - modules/surf/README.md + - docs/services/routing.md + - modules/conga/conga.go + - modules/conga/unregistered_kind_test.go + - modules/conga/README.md + - docs/services/jobs.md + - modules/lagoon/validate_rules.go + - modules/lagoon/validate_request.go + - modules/lagoon/validate_request_test.go + - modules/lagoon/README.md + - docs/database/casts-and-validation.md + - modules/tide/diff.go + - modules/tide/centrifugo_golden.go + - modules/tide/normalize_phase13_test.go + - modules/tide/README.md + - docs/services/parity-testing.md + - ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/routes_overlap_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go + - ../fonoteka.go/parity/php_parity.sh + - ../fonoteka.go/parity/capture-rules.yaml + - ../fonoteka.go/parity/check_corpus.go + - ../fonoteka.go/parity/check_corpus_test.go + - ../fonoteka.go/parity/manifest.yaml + - ../fonoteka.go/parity/README.md + - .planning/ROADMAP.md + - .planning/REQUIREMENTS.md +autonomous: true +requirements: [API-03, API-04, API-05, API-06, API-07] +estimate: + tokens: 240000 + raw_tokens: 240000 + tasks: 4 + confidence: low +must_haves: + truths: + - "Per RESEARCH Finding 1 (user-approved framework change), a surf router holding two same-method routes that ServeMux rejects as conflicting (for example `GET /shelves/items/{id}` with `id [0-9]+` and `GET /shelves/{shelfId}/items` with `shelfId [0-9]+`) boots without `surf: route conflict` and sends each request to the first route in registration order whose literal segments and Where constraints match, with that route's own path values, middleware, body limit and recovery." + - "A request whose path only the generated family pattern matches (no member's literals and constraints match) answers the same bare 404 the router gives any unknown path, for every method; a method mismatch on a path some member or other route matches answers the 405 and Allow header ServeMux gives for the same table without the conflict." + - "`(*surf.Router).Routes()` and `summer route:list` still list every member of an overlap family as its own route with its own pattern and middleware; routes outside a family register on ServeMux exactly as before." + - "The four routes.php wishlist pairs (`GET wishlist/token/{token}/subscribe` vs `GET wishlist/{collectionId}/albums/{albumId}`, `GET wishlist/{collectionId}/subscribe` vs `GET wishlist/albums/{id}`, `DELETE wishlist/{collectionId}/subscribe` vs `DELETE wishlist/albums/{id}`, `GET wishlist/albums/{id}` vs `GET wishlist/{collectionId}/albums`) assemble on one surf router in fonoteka.go and dispatch numeric and literal segments to the PHP-equivalent route." + - "Per RESEARCH Finding 2 (user-approved framework change), while an in-process conga worker runs, `Manager.Dispatch` and `Manager.Enqueue` of a job kind that no plugin registered succeed (they insert through the insert-only River client) and the job stays `available` (or `scheduled` with a delay) because its queue is not served; a registered kind still inserts exactly as before." + - "conga refuses, with `conga.ErrUnregisteredKindQueue`, to insert an unregistered kind with an empty queue or onto a queue the worker set serves (default, `scheduled`, every configured queue and every registered job's queue), so a workerless job can never be fetched and discarded." + - "Per D-03 and D-08 (costly, signed off 2026-10-02) the job contract lives in one file, `classes/job_contract.go`, and `TestJobContract` pins every string: kinds `golem15.fonoteka.csv_import`, `golem15.fonoteka.csv_match`, `golem15.fonoteka.wishlist_digest`, `golem15.fonoteka.wishlist_purchased_mail`; queues `fonoteka.csv.import`, `fonoteka.csv.match`, `fonoteka.wishlist.digest`, `mail`; labels `fonoteka.csv.import`, `fonoteka.csv.match`, `wishlist_digest`; digest delay 1800 s; args JSON `{\"csv_import_id\":N}` and `{\"subscriber_id\":U,\"wishlist_collection_id\":C}` and `{\"subscriber_id\":U,\"album_name\":S,\"wishlist_name\":S}`." + - "With the fonoteka plugins' jobs registered and a worker running, dispatching `CsvImportArgs` on `fonoteka.csv.import` with label `fonoteka.csv.import` succeeds, writes the summer_jobs row and leaves the River job unworked (D-04: no worker, no fake success)." + - "`lagoon.ValidateRequest` supports Laravel 9 `prohibited`: non-implicit, it fails only for a present, non-blank value that `required` would accept (a string, a number including 0, a boolean including false, a non-empty array), passes for an absent key, null, an empty string and an empty array, and its message is the literal `validation.prohibited` because neither Winter catalog defines the key." + - "tide compares `Content-Disposition` with every `YYYY-MM-DD` date masked on both sides after asserting each side's date is a real calendar date, so a later replay of `attachment; filename=export-2026-09-17.csv` passes while a different stem, a missing date or `2026-13-45` is still a Diff." + - "`tide.NormalizePublications` masks a Carbon `+00:00` value at `$.data.payload.created_at` as `{{datetime}}` and a positive integer at `$.data.payload.id` that no captured id variable names as `{{id}}`; a `Z` date, a non-integer id and every other path stay visible." + - "`parity/php_parity.sh` honours `QUEUE_CONNECTION` from the environment (default `sync`) and gains a `rows` subcommand that prints the parity SQLite result of one SELECT as JSON for row goldens (D-13); `capture-rules.yaml` captures the wishlist share token as `share:wishlist` (category share) on `GET/PUT wishlist/share` and `POST wishlist/share/regenerate`." + - "Per D-15 and RESEARCH Finding 5, the manifest case of `GET /_fonoteka/api/v1/invitations/{token}` expects the recorded 200, and check_corpus fails a `status: ported` route whose case status differs from its fixture's recorded status." + - "Per D-01, D-02 and D-06, ROADMAP Phase 13 criteria 1-4, its repos line, Phase 14 criteria 4-5, and REQUIREMENTS API-03, API-04, API-06, INTG-01 and INTG-02 say what Phase 13 ships and what moves to Phase 14, in a planning-docs-only commit." + - "Edge (API-03 adjacency): overlap families are computed transitively, so the three-way GET family (`wishlist/albums/{id}`, `wishlist/{collectionId}/subscribe`, `wishlist/{collectionId}/albums`) is one family and `GET wishlist/albums/similar` keeps its own more specific pattern." + - "Edge (API-03 boundary): `GET wishlist/albums/subscribe` and `GET wishlist/0x1/albums` reach no member (both fail the numeric constraints) and answer 404, as Laravel does." + - statement: "Edge (API-05 concurrency): a Dispatch of an unregistered kind racing a worker start never routes through the worker client; River's unknown-kind check cannot fire for it." + verification: backstop + artifacts: + - path: "modules/surf/overlap.go" + provides: "overlap family detection and the constraint-aware dispatcher used by compile" + contains: "SetPathValue" + - path: "modules/conga/conga.go" + provides: "ErrUnregisteredKindQueue and insert-only routing for unregistered kinds" + contains: "ErrUnregisteredKindQueue" + - path: "modules/lagoon/validate_rules.go" + provides: "prohibited rule" + contains: "\"prohibited\"" + - path: "modules/tide/centrifugo_golden.go" + provides: "payload created_at and id masks" + contains: "$.data.payload.created_at" + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go" + provides: "the Phase 13 job kinds, queues, labels and args types" + contains: "fonoteka.wishlist.digest" + - path: "../fonoteka.go/parity/php_parity.sh" + provides: "QUEUE_CONNECTION override and rows subcommand" + contains: "QUEUE_CONNECTION:-sync" + key_links: + - from: "modules/surf/router.go" + to: "modules/surf/overlap.go" + via: "compile registers overlap families through one dispatcher pattern instead of one mux.Handle per member" + pattern: "overlap" + - from: "modules/conga/conga.go" + to: "modules/conga/client.go" + via: "insertClient picks the insert-only client for a kind with no registered job; knownQueues decides the served-queue refusal" + pattern: "knownQueues" + - from: "../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go" + to: "modules/conga/worker.go" + via: "StartWorker with the app jobs, then Dispatch of CsvImportArgs" + pattern: "StartWorker" + prohibitions: + - requirement_id: API-05 + category: transparency + statement: "A Phase 13 job with no worker MUST NOT be worked, completed or discarded by any stub; it stays queued until Phase 14 registers its worker (D-04)" + status: resolved + verification: test + - requirement_id: API-03 + category: transparency + statement: "The overlap dispatcher MUST NOT collapse the wishlist routes into one app-level handler or drop them from the route table; every PHP route stays its own route (C-01)" + status: resolved + verification: test + - requirement_id: API-07 + category: safety + statement: "Framework code, tests, READMEs and docs changed here MUST NOT name the consuming application; examples use neutral names (acme, shelves, items)" + status: resolved + verification: test +--- + +## Phase Goal + +ROADMAP Phase 13 goal (verbatim, not in user-story form; the MVP precedent of Phases 11, 11.2 and 12 is to quote it): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets. + +This plan's slice: the framework can register PHP's overlapping constrained routes, queue a job whose worker ships later without failing or losing it, validate `prohibited`, and compare dated download names and notification publications; the app has one pinned job contract; the parity harness can record with a real queue and dump rows; the roadmap says what Phase 13 ships. Plans 13-02 to 13-05 build every route on these pieces. + + +Close the framework gaps of RESEARCH Findings 1, 2, 6 (date mask), 8 (publication masks) and 9 (`prohibited`) in summercms.go, pin the D-03/D-08 job contract in fonoteka.go, add the parity scaffolding (Finding 4 queue override, the D-13 row dump, the wishlist share capture, the D-15 manifest fix and a ported-case status check), and reword the planning docs per D-01, D-02 and D-06. + +Purpose: without the surf change the app panics at boot once the wishlist routes exist; without the conga change commit, mapping and wishlist item-add return 500 in production (`work_in_serve: true`). Decisions implemented: D-01, D-02, D-03, D-04, D-06, D-08 (contract), D-13 (row dump tooling), D-15, C-01, C-07. +Output: framework features with README and docs updates, `classes/job_contract.go`, parity tooling, reworded ROADMAP/REQUIREMENTS. + +Repos: summercms.go (framework, planning docs) and fonoteka.go (job contract, tests, parity tooling). Another session is committing Phase 12.2 work in summercms.go concurrently: stage only the paths of the task at hand (`git add `), never `git add -A` or `git add .`, and rebase on HEAD before committing if needed. Framework code, tests, READMEs and docs never name the application (CLAUDE.md). Planning docs and code go in separate commits; never add co-author tags. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/REQUIREMENTS.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-PATTERNS.md +@modules/surf/router.go +@modules/conga/conga.go + + +- surf (today): `Router.add` appends `route{pluginID, method, path, handler, middleware, raw, constraints}`; `Router.compile` loops `r.wrap(rt)` then `handleRoute(mux, rt, h)` which calls `mux.Handle(method+" "+path, h)` and turns a ServeMux panic into `surf: route conflict for ...`; `wrap` builds `constrain(handler, constraints)` (a failed constraint answers `http.NotFound`), body limit, named and factory middleware, locale, recovery; `pathHasParam`; `Constraint{param, re, enum}` with `match`; `RouteInfo{Method, Pattern, PluginID, Middleware, Raw}` from `Routes()`; `summer route:list` in routelist_command.go. +- conga (today): `Manager.Dispatch(ctx, db, args pact.JobArgs, DispatchOpts{Label, Count, Metadata, Queue, Delay, MaxAttempts}) (uint, error)` writes a summer_jobs row (status in progress, metadata `""` when nil, user from the bouncer principal) and `client.InsertTx`s on the same `*sql.Tx`; `Manager.Enqueue(ctx, db, args, EnqueueOpts{Queue, Delay, MaxAttempts})`; `insertOpts` takes queue and attempts from a registered job when the opts leave them empty (an unregistered kind with no queue lands on River's `default`); `insertClient()` returns `m.worker` whenever a worker runs (River then rejects an unregistered kind with `UnknownJobKindError`), else a lazily built insert-only client (`newInsertClient`, no Workers, no kind check); `knownQueues(settings, jobs)` = `default` + registered job queues + configured `queue.queues`; StartWorker also serves `QueueScheduled`; `CancelJob(ctx, id)` sets is_canceled and StatusStopped and cancels the River job. +- lagoon: `requestRuleArity` map, `implicitRuleNames`, `ParseRules` panics on an unknown rule at init; `(*requestValidator).message` falls back to `"validation." + rule` when the catalog has no key. +- tide: `compareHeaders(want, got, extra)` compares `Content-Disposition` byte for byte (extraCompareHeaders); `NormalizePublications(pubs, store)` and `(*normalizer).walk` mask `$.data.timestamp`, `$.data.payload.timestamp`, `$.data.payload.actor`, `*_at` under `$.data.payload.album.` and captured ids; `carbonOffsetRe`, `isoOffsetRe`, `isIDKey`. +- fonoteka.go parity: `php_parity.sh` `export_env` sets `QUEUE_CONNECTION=sync` (line 41) and the SQLite file `$DB`; capture-rules.yaml lines 108-123 capture `share:collection`; check_corpus.go `--require-recorded --check-secrets`; manifest entry `GET /_fonoteka/api/v1/invitations/{token} public_invitation` (case status 404, fixture 200). +- fonoteka app jobs: `jobs.go` `Jobs()` registers the invitation mail job on `classes.InvitationMailQueue` ("mail"); `classes.InvitationMailKind = "golem15.fonoteka.invitation_mail"`. + + + +## Artifacts this phase produces + +(This plan's share.) + +- surf: constraint-aware overlap dispatch inside `compile` (unexported `overlapFamilies`, family dispatcher in modules/surf/overlap.go); no exported API change; README and docs/services/routing.md section "Overlapping constrained routes". +- conga: `ErrUnregisteredKindQueue`; unregistered kinds insert through the insert-only client; README and docs/services/jobs.md section "Jobs whose worker ships later". +- lagoon: request rule `prohibited`. +- tide: Content-Disposition date masking in header comparison; `$.data.payload.created_at` and `$.data.payload.id` publication masks. +- fonoteka classes (job contract): constants `CsvImportKind`, `CsvImportQueue`, `CsvImportLabel`, `CsvMatchKind`, `CsvMatchQueue`, `CsvMatchLabel`, `WishlistDigestKind`, `WishlistDigestJobQueue`, `WishlistDigestLabel`, `WishlistDigestDelay`, `WishlistPurchasedMailKind`, `WishlistPurchasedMailQueue`, `WishlistPurchasedMailAttempts`; types `CsvImportArgs{CsvImportID}`, `CsvMatchArgs{CsvImportID}`, `WishlistDigestArgs{SubscriberID, WishlistCollectionID}`, `WishlistPurchasedMailArgs{SubscriberID, AlbumName, WishlistName}`, each with `Kind() string`. +- Tests: `TestOverlappingConstrainedRoutes`, `TestUnregisteredKindWithWorker`, `TestValidateRequestProhibited`, `TestNormalizeContentDispositionDate`, `TestNormalizeNotificationPublication`, `TestWishlistOverlapPatternsDispatch`, `TestJobContract`, `TestJobContractDispatchWhileWorkerRuns`, `TestCheckCorpusPortedCaseStatus`. +- Parity tooling: `php_parity.sh rows `, `QUEUE_CONNECTION` override, capture `share:wishlist`. + +## Job contract (D-03, D-08) — the one place the names are stated + +Phase 14 workers consume these kinds and args, and queued river_job rows in a live database carry them; renaming later needs a coordinated worker change and a row migration. The user signed this table off at the plan-count checkpoint on 2026-10-02, so it is recorded here without a new checkpoint. + +| Job | Kind | Queue | summer_jobs label | Args JSON | Insert | Worker | +|---|---|---|---|---|---|---| +| CSV import (PHP AlbumCsvImportJob) | `golem15.fonoteka.csv_import` | `fonoteka.csv.import` (unserved until Phase 14) | `fonoteka.csv.import` | `{"csv_import_id":N}` | `conga.Dispatch`, Count = row_count, Metadata `{"csv_import_id":N}` | Phase 14 (JOBS-02) | +| CSV match (PHP AlbumCsvMatchJob) | `golem15.fonoteka.csv_match` | `fonoteka.csv.match` (unserved) | `fonoteka.csv.match` | `{"csv_import_id":N}` | `conga.Dispatch`, Count = row_count, Metadata `{"csv_import_id":N}` | Phase 14 (JOBS-02) | +| Wishlist digest (PHP WishlistDigestJob) | `golem15.fonoteka.wishlist_digest` | `fonoteka.wishlist.digest` (unserved) | `wishlist_digest` | `{"subscriber_id":U,"wishlist_collection_id":C}` | `conga.Dispatch`, Delay 1800 s, Count 0, Metadata nil (stores `""`) | Phase 14 (JOBS-03) | +| Wishlist purchase mail (D-07) | `golem15.fonoteka.wishlist_purchased_mail` | `mail` | none (Enqueue writes no summer_jobs row) | `{"subscriber_id":U,"album_name":S,"wishlist_name":S}` | `conga.Enqueue`, 3 attempts | Phase 13 (13-03) | + +Known difference (RESEARCH Finding 3): PHP's JobManager rows get `user_id = NULL` on JWT requests; `conga.Dispatch` stores the request principal. No client reads the column; it is recorded in parity/README.md. + +## Assumption-delta decision + + +Detector run on the Phase 13 ROADMAP section: detected=false. Considered by hand: public-wishlist mirrors the public collection view with `kind='wishlist'` (a second kind of shared collection, not a second identity); the share token stays on the collection row and the collection id stays the identity. Noun primary: Collection. Decision: no-change. Rationale: `kind` already models both; no anchor moves. + + +## Flagged assumptions + +- A2 (RESEARCH): the `prohibited` message is the literal `validation.prohibited`; plan 13-03 records a wishlist store with `condition` set to settle it. +- The overlap dispatcher's 405 contract is defined as "what ServeMux answers for the same table without the conflict"; PHP answers 404 for paths no route matches, which the 404 truth covers. + + + + + Task 1: The app's four overlapping wishlist route pairs boot on one router and each request reaches the PHP-equivalent route + Internal to surf.compile; no exported API changes and non-conflicting routes register exactly as before. + modules/surf/router.go, modules/surf/overlap.go, modules/surf/overlap_test.go, modules/surf/README.md, docs/services/routing.md, ../fonoteka.go/plugins/golem15/fonoteka/routes_overlap_test.go + modules/surf/router.go (whole file: route, add, compile, handleRoute, wrap), modules/surf/params.go (Constraint, match, constrain, pathHasParam), modules/surf/routetable.go, modules/surf/routelist_command.go, modules/surf/router_test.go, modules/surf/README.md, docs/services/routing.md, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 134-225 and 507-510), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Finding 1), ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go (phase12RouteTable boot recipe), ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go + Per RESEARCH Finding 1 (user-approved) and C-01, make surf register overlapping constrained routes with Laravel's registration-order semantics. + +(1) modules/surf/overlap.go: before `compile` registers anything, compute overlap families over `r.routes`. Two routes overlap-conflict when ServeMux would panic for them: same method (a GET route also answers HEAD as ServeMux does), the same segment count, every segment pair either equal literals or containing a single-segment wildcard, and neither pattern matching a strict subset of the other's paths. Families are the transitive closure of that relation. Routes outside every family keep the current one-pattern-per-route registration through handleRoute. A family member whose pattern uses a multi-segment wildcard, the end anchor or a host fails compile with an error naming both routes (unsupported, never silently misrouted). + +(2) Register each family under one generated pattern whose segments are the members' shared literal where they all agree and a generated wildcard elsewhere. The family handler walks members in registration order; the first member whose literal segments equal the request's segments and whose constraints all match gets its own named path values set with `Request.SetPathValue`, then runs its own fully wrapped handler (the same `wrap` output compile builds today, so middleware, body limit, locale and recovery stay per member). No member matching answers `http.NotFound`. The registration must not create a 405 ServeMux would not have answered: a request whose path only the generated pattern matches answers 404 for every method, and a method mismatch on a path a member or another route matches answers ServeMux's 405 with the same Allow header as a mux holding the same table without the conflict (one way: register the family pattern without a method and resolve method, 405 and Allow inside the family handler from the router's full route list; families of different methods that generalise to the same shape share one registration). + +(3) `Routes()` and route:list stay one entry per route (no family entries). Add a short paragraph to the surf README (Features, and a Usage note) and a section "Overlapping constrained routes" to docs/services/routing.md in prose: when it applies, registration-order dispatch, 404/405 behaviour, unsupported shapes. Neutral examples only (shelves, items). + +(4) modules/surf/overlap_test.go `TestOverlappingConstrainedRoutes` with neutral names mirroring the four app shapes (for example `GET /shelves/token/{token}/follow` vs `GET /shelves/{shelfId}/items/{itemId}`, `GET|DELETE /shelves/{shelfId}/follow` vs `GET|DELETE /shelves/items/{id}`, `GET /shelves/items/{id}` vs `GET /shelves/{shelfId}/items`, plus a literal `GET /shelves/items/similar`): subtests boot (no compile error), dispatch (numeric and literal segments reach the right handler with the right PathValue names), constraint-404 (`/shelves/items/follow`, `/shelves/0x1/items` answer 404 text/plain), method-405 (status and Allow equal a reference ServeMux built from the same table minus the conflicting partner), unknown-method-404 (POST to a path only the family pattern matches answers 404), middleware (each member's own middleware runs, the other member's does not), route-table (Routes() lists every member once), unsupported (a family with `{rest...}` is a compile error). + +(5) fonoteka.go routes_overlap_test.go `TestWishlistOverlapPatternsDispatch`: build a surf router with stub handlers that record which route ran, registered with the exact routes.php wishlist patterns and constraints under `/_fonoteka/api/v1` (lines 171-176, 197-206, 224-225), and assert each of the four conflicting pairs plus `GET wishlist/albums/similar` dispatch as PHP would. This proves the real shapes before plan 13-03 mounts the real handlers. + + go vet ./... && go test ./modules/surf -count=1 -v -run '^(TestOverlappingConstrainedRoutes)$' && go test ./modules/surf -count=1 && go test ./cmd/summer -count=1 -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -v -run '^(TestWishlistOverlapPatternsDispatch|TestRouteTablePhase12|TestFullRouteTableAuthGroupMutualExclusivity)$' + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestOverlappingConstrainedRoutes" and "--- PASS: TestWishlistOverlapPatternsDispatch"; docs:build --check or TestDocsTree reports an unknown identifier or broken link. + + + - `grep -c 'SetPathValue' modules/surf/overlap.go` prints at least 1. + - `grep -c 't.Run(' modules/surf/overlap_test.go` prints at least 8. + - `grep -ci 'overlapping constrained routes' docs/services/routing.md` prints at least 1 and `grep -ci 'overlap' modules/surf/README.md` prints at least 1. + - `grep -rniE 'fonoteka|plytarium|płytarium|wishlist' modules/surf/overlap.go modules/surf/overlap_test.go modules/surf/README.md docs/services/routing.md` prints nothing (framework stays app-agnostic). + - `grep -c 'wishlist/{collectionId}/albums/{albumId}' ../fonoteka.go/plugins/golem15/fonoteka/routes_overlap_test.go` prints at least 1. + + The framework registers PHP-style overlapping constrained routes and the app's exact wishlist shapes dispatch correctly, so plan 13-03 can mount the real routes without a boot panic. + + + + Task 2: A job whose worker ships in Phase 14 can be queued from a request while the in-process worker runs, under one pinned job contract + The job contract table above (D-03/D-08) is the costly decision this task writes into code; the conga change itself is reversible and additive. + modules/conga/conga.go, modules/conga/unregistered_kind_test.go, modules/conga/README.md, docs/services/jobs.md, ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract_test.go, ../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go + modules/conga/conga.go (Dispatch, Enqueue, CancelJob, insertOpts, insertClient), modules/conga/client.go (settingsFromApp, knownQueues, newInsertClient, baseConfig), modules/conga/worker.go (StartWorker, WorkerOptions, QueueScheduled), modules/conga/manager_test.go and worker_test.go (worker start recipe, poll-only option, Postgres harness), modules/conga/README.md, docs/services/jobs.md, ../fonoteka.go/plugins/golem15/fonoteka/jobs.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go (InvitationMailKind, InvitationMailQueue, InvitationMailArgs), ../fonoteka.go/config/queue.yaml, ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go (river_job assertions), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/AlbumCsvImportJob.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/AlbumCsvMatchJob.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/WishlistDigestJob.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/WishlistDigestQueue.php, .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Findings 2 and 3, Jobs table) + Per RESEARCH Finding 2 (user-approved), D-03, D-04 and D-08. + +(1) conga.go: add exported `ErrUnregisteredKindQueue`. In Dispatch and Enqueue, when `args.Kind()` has no registered job: require a non-empty queue in the opts and refuse a queue that the known-queue set contains (knownQueues plus QueueScheduled), returning an error that wraps ErrUnregisteredKindQueue and names the kind and queue; then insert through the insert-only client (build it lazily even while a worker runs; keep it separate from `m.worker`). A registered kind keeps today's client choice and behaviour. CancelJob, Get and the status helpers keep working for workerless jobs (they act on summer_jobs and cancel through a client that never checks kinds). + +(2) Tests modules/conga/unregistered_kind_test.go `TestUnregisteredKindWithWorker` on the conga Postgres harness with neutral names: register one real job (queue `acme.mail`), start a worker (poll-only, short poll interval as the existing worker tests do), then subtests dispatch-unregistered (Dispatch of kind `acme.pending_import` on queue `acme.pending` returns an id, the summer_jobs row has the label and river_job_id, the river_job row is `available` with attempt 0 and still is after three poll intervals), enqueue-delayed (Enqueue with a delay gives `scheduled`), refuse-served (onto `default`, `scheduled` and `acme.mail` errors with ErrUnregisteredKindQueue and inserts nothing), refuse-empty (empty queue errors), cancel (CancelJob stops the summer_jobs row and cancels the River job), registered-unchanged (the registered kind still runs on the worker). + +(3) Docs in the same change: conga README (API reference entry for ErrUnregisteredKindQueue, a Usage note) and a docs/services/jobs.md section "Jobs whose worker ships later" in prose: dispatch onto a queue nothing serves, the refusal rules, rows wait until a worker for the kind is registered and its queue becomes served. Neutral names. + +(4) fonoteka.go classes/job_contract.go: the constants and args types listed in the Job contract table and the Artifacts section, with JSON tags exactly `csv_import_id`, `subscriber_id`, `wishlist_collection_id`, `album_name`, `wishlist_name`, `WishlistDigestDelay = 1800 * time.Second`, `WishlistPurchasedMailQueue = "mail"`, `WishlistPurchasedMailAttempts = 3`, and a doc comment pointing at the PHP job classes and stating that only the purchase mail kind gets a worker in Phase 13. classes/job_contract_test.go `TestJobContract` pins every kind, queue, label and the marshalled args JSON byte for byte, and asserts no Phase 13 queue name appears in ../fonoteka.go/config/queue.yaml (read the file). + +(5) fonoteka.go job_contract_worker_test.go `TestJobContractDispatchWhileWorkerRuns`: boot the app plugins' jobs into a conga Manager on the plugin test database, start a worker, Dispatch `classes.CsvImportArgs{CsvImportID: 1}` with label CsvImportLabel, queue CsvImportQueue, Count 3 and Metadata `{"csv_import_id":1}`; assert success, the summer_jobs row (label, status in progress, progress_max 3, metadata JSON) and an unworked river_job row of kind CsvImportKind; then Dispatch WishlistDigestArgs with WishlistDigestDelay and assert a scheduled row. + + go vet ./... && go test ./modules/conga -count=1 -v -run '^(TestUnregisteredKindWithWorker)$' && go test ./modules/conga -count=1 && go test ./cmd/summer -count=1 -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka ./plugins/golem15/fonoteka/classes -count=1 -v -run '^(TestJobContract|TestJobContractDispatchWhileWorkerRuns)$' + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestUnregisteredKindWithWorker", "--- PASS: TestJobContract" and "--- PASS: TestJobContractDispatchWhileWorkerRuns". + + + - `go doc ./modules/conga ErrUnregisteredKindQueue` exits 0 and `grep -c 'ErrUnregisteredKindQueue' modules/conga/README.md` prints at least 1. + - `grep -ci 'worker ships later' docs/services/jobs.md` prints at least 1. + - `grep -c '"fonoteka.wishlist.digest"' ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go` prints 1 and `grep -c 'golem15.fonoteka.csv_match' ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go` prints 1. + - `grep -cE 'fonoteka\.(csv|wishlist)\.' ../fonoteka.go/config/queue.yaml` prints 0. + - `grep -rniE 'fonoteka|plytarium|wishlist' modules/conga/conga.go modules/conga/unregistered_kind_test.go modules/conga/README.md docs/services/jobs.md` prints nothing. + + A request can queue a Phase 14 job in its write transaction while the server's worker runs, the job waits unworked, and every name Phase 14 will consume is pinned by a test. + + + + Task 3: A wishlist item request with a forbidden field gets Laravel's prohibited error, and dated CSV downloads and notification publications replay on later days + modules/lagoon/validate_rules.go, modules/lagoon/validate_request.go, modules/lagoon/validate_request_test.go, modules/lagoon/README.md, docs/database/casts-and-validation.md, modules/tide/diff.go, modules/tide/centrifugo_golden.go, modules/tide/normalize_phase13_test.go, modules/tide/README.md, docs/services/parity-testing.md + modules/lagoon/validate_rules.go (requestRuleArity, implicitRuleNames), modules/lagoon/validate_request.go (rule dispatch, message, presence and blank handling), modules/lagoon/validate_request_test.go, modules/lagoon/README.md, docs/database/casts-and-validation.md, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Concerns/ValidatesAttributes.php (validateProhibited, validateRequired), /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Validator.php (implicitRules, presentOrRuleIsImplicit), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistAlbumApiController.php (lines 280-310 rules), modules/tide/diff.go (compareHeaders), modules/tide/centrifugo_golden.go (NormalizePublications, normalizer.walk), modules/tide/normalize.go (maskDate, carbonOffsetRe), modules/tide/README.md, docs/services/parity-testing.md, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_export_csv_jwt.yaml (Content-Disposition), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (lines 265-284, publication payload) + (1) lagoon, per RESEARCH Finding 9: add `prohibited` (arity 0, not implicit) to the request validator. It runs only when the attribute is validatable (present and not a blank string, as for any non-implicit rule) and fails when Laravel's validateRequired would pass for the value: a non-empty string, any number (0 included), any boolean (false included), a non-empty array or map. null and an empty array pass. Its message goes through the existing lookup and therefore stays the literal `validation.prohibited` (no catalog key exists in Winter's pl or en files; do not add one). `TestValidateRequestProhibited` in validate_request_test.go covers absent, null, empty string, empty array, "VG", 0, false, a non-empty array, and the message text in pl and en. README rule list and the casts-and-validation docs page list `prohibited` in prose. + +(2) tide, per RESEARCH Finding 6 point 2: in header comparison, compare `Content-Disposition` after replacing every `YYYY-MM-DD` token on both sides with one placeholder, but only after checking each replaced token parses as a real calendar date on its side; a token that does not parse, or a date present on one side only, keeps the byte comparison so the Diff stays visible. No application name or filename stem is hard-coded. + +(3) tide, per RESEARCH Finding 8 (masking only): in `normalizer.walk`, mask `$.data.payload.created_at` holding a Carbon `+00:00` value as `{{datetime}}` with the same shape check used for album dates, and mask `$.data.payload.id` holding a positive integer as `{{id}}` only when no captured id variable names it (a captured value keeps its `{{id:name}}` placeholder). Leave every other path unchanged. + +(4) Tests modules/tide/normalize_phase13_test.go: `TestNormalizeContentDispositionDate` (same stem on different days: no Diff; different stem: Diff; `2026-13-45`: Diff; date only on one side: Diff; header absent on got: Diff) and `TestNormalizeNotificationPublication` (a `notification:new` body with id and created_at normalizes equal across two runs with different ids and times; a `Z` created_at and a string id stay visible; an album publication's existing masks are unchanged). README and docs/services/parity-testing.md describe both masks in prose with neutral examples. + + go vet ./... && go test ./modules/lagoon ./modules/tide -count=1 -v -run '^(TestValidateRequestProhibited|TestNormalizeContentDispositionDate|TestNormalizeNotificationPublication)$' && go test ./modules/lagoon ./modules/tide -count=1 && go test ./cmd/summer -count=1 -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go test ./parity -count=1 -run '^(TestUserAPINuxtFlows|TestBroadcastGoldens)$' + Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks a "--- PASS" line for each of the three named tests; TestBroadcastGoldens or TestUserAPINuxtFlows fails (an existing golden or recorded body regressed). + + + - `grep -c '"prohibited"' modules/lagoon/validate_rules.go` prints at least 1 and `grep -c 'prohibited' modules/phrasebook/lang/pl/validation.yaml` prints 0. + - `grep -c 'prohibited' modules/lagoon/README.md` and `grep -c 'prohibited' docs/database/casts-and-validation.md` each print at least 1. + - `grep -c 'payload.created_at' modules/tide/centrifugo_golden.go` prints at least 1. + - `grep -ci 'content-disposition' modules/tide/README.md` and `grep -ci 'content-disposition' docs/services/parity-testing.md` each print at least 1. + - `grep -rniE 'fonoteka|plytarium' modules/tide/diff.go modules/tide/centrifugo_golden.go modules/tide/normalize_phase13_test.go modules/lagoon/validate_rules.go` prints nothing. + + The validator speaks Laravel's prohibited rule, and the parity diff can compare a dated download and a notification publication recorded on another day without hiding a real difference. + + + + Task 4: Recordings can run with a real queue and dump rows, ported cases cannot drift from their fixtures, and the roadmap says what Phase 13 ships + `bash -n ../fonoteka.go/parity/php_parity.sh` exits 0 before the edit and `command -v sqlite3` succeeds (the rows subcommand shells out to it). + ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/capture-rules.yaml, ../fonoteka.go/parity/check_corpus.go, ../fonoteka.go/parity/check_corpus_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/README.md, .planning/ROADMAP.md, .planning/REQUIREMENTS.md + ../fonoteka.go/parity/php_parity.sh (whole file: export_env, refuse_db, subcommands), ../fonoteka.go/parity/capture-rules.yaml (lines 100-125), ../fonoteka.go/parity/check_corpus.go, ../fonoteka.go/parity/check_corpus_test.go, ../fonoteka.go/parity/manifest.yaml (the `GET /_fonoteka/api/v1/invitations/{token} public_invitation` entry), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_invitations_{token}_public_invitation.yaml, ../fonoteka.go/parity/README.md (Phase 12 recording sections), .planning/ROADMAP.md (Phase 13 and 14 sections), .planning/REQUIREMENTS.md (API-03, API-04, API-06, INTG-01, INTG-02), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md (D-01, D-02, D-06, D-13, D-15), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Findings 4, 5 and 8) + (1) php_parity.sh, per RESEARCH Finding 4: `export_env` exports `QUEUE_CONNECTION="${QUEUE_CONNECTION:-sync}"` so `QUEUE_CONNECTION=database php_parity.sh serve` keeps jobs queued (Winter's jobs migrations already create the table on reset). Add a `rows` subcommand, per D-13: it takes one read-only SELECT (refuse anything that does not start with SELECT after trimming, and refuse a semicolon), runs it against the parity SQLite with the existing refuse_db guard, and prints a JSON array of row objects to stdout (sqlite3 JSON mode). It never writes under parity/fixtures by itself. + +(2) capture-rules.yaml: copy the collection share rule for the wishlist share surface: `GET` and `PUT /_fonoteka/api/v1/wishlist/share` and `POST /_fonoteka/api/v1/wishlist/share/regenerate`, capturing the token path PHP returns as `share:wishlist`, category share. + +(3) check_corpus.go, per RESEARCH Finding 5: for a route with `status: ported`, fail when any case's manifest `status` differs from the recorded status in its fixture, naming route, case and both numbers; pending routes are reported only as a count line (they are re-recorded by plans 13-02 to 13-05). check_corpus_test.go `TestCheckCorpusPortedCaseStatus` with a planted mismatch on a ported route (fails) and on a pending route (passes with the count line). + +(4) manifest.yaml, per D-15: set the case status of `GET /_fonoteka/api/v1/invitations/{token} public_invitation` to 200 to match its fixture (the route stays pending until 13-02 re-records and ports it). + +(5) parity/README.md: a "Phase 13 recording" section: `QUEUE_CONNECTION=database` for the nuxt-wishlist and nuxt-csv recordings, `php_parity.sh rows` for row goldens (digest queue, apparatus job rows), `share:wishlist`, the shared anonymous `throttle:10,1` budget (reset before each anonymous recording, at most 10 inline-throttled cases per route), and the known `summer_jobs.user_id` difference from the Job contract table. Commit the fonoteka.go changes path-scoped. + +(6) Planning docs (a separate docs-only commit in summercms.go; use Edit, never a whole-file Write), per D-01, D-02, D-06: ROADMAP Phase 13 `**Repos:**` becomes fonoteka.go, sm-user-plugin (submodule, D-09/D-11 additive exports) and summercms.go (13-01 framework gaps). Criterion 1 adds that the wishlist Discogs `match`/`apply-release` routes move to Phase 14 and the digest job body is Phase 14 (JOBS-03). Criterion 2 becomes notifications list, unread count and mark-read (one and all), with pruning as the Phase 14 `fonoteka:prune-notifications` console command. Criterion 3 adds that commit and mapping enqueue the CSV import and match jobs whose bodies are Phase 14 (JOBS-02). Criterion 4 adds that the live `ai-credential/test` and `discogs-credential/test` routes move to Phase 14. Phase 14 criterion 4 (Discogs) adds the wishlist `match`/`apply-release` routes, `discogs-credential/test` and the CSV row-edit Discogs pick; criterion 5 (AI) adds `ai-credential/test` and the backend global vision model behind the AI resolver's admin tier. REQUIREMENTS: API-03 notes match/apply-release in Phase 14; API-04 reads list, unread count, mark read, with pruning a Phase 14 console command; API-06 notes the live /test routes in Phase 14; INTG-01 and INTG-02 gain the moved routes. Leave every status column and the traceability table untouched. + + bash -n ../fonoteka.go/parity/php_parity.sh && grep -q 'QUEUE_CONNECTION:-sync' ../fonoteka.go/parity/php_parity.sh && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestCheckCorpusPortedCaseStatus|TestParityCorpus)$' && 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 && grep -q 'prune-notifications' .planning/REQUIREMENTS.md && grep -A14 '### Phase 14:' .planning/ROADMAP.md | grep -q 'apply-release' + Non-zero exit: the script has a syntax error or lacks the override; a verbose run prints "--- FAIL" or "no tests to run" or lacks "--- PASS: TestCheckCorpusPortedCaseStatus" and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a ported case-status mismatch, a secret or an unrecorded route; REQUIREMENTS.md lacks the prune wording or the Phase 14 section lacks apply-release. + + + - `grep -c 'share:wishlist' ../fonoteka.go/parity/capture-rules.yaml` prints at least 1. + - `grep -A12 'invitations/{token} public_invitation' ../fonoteka.go/parity/manifest.yaml | grep -c 'status: 200'` prints 1. + - `grep -c 'rows)' ../fonoteka.go/parity/php_parity.sh` prints at least 1 and `grep -c 'QUEUE_CONNECTION=database' ../fonoteka.go/parity/README.md` prints at least 1. + - `grep -A14 '### Phase 13:' .planning/ROADMAP.md | grep -c 'sm-user-plugin'` prints at least 1. + - The REQUIREMENTS.md traceability rows for API-03..API-07 still read `Phase 13 | Pending`. + - `git log -1 --stat` of the planning commit lists only .planning files, and the fonoteka.go commit lists only parity/ files. + + Recordings can show queued-not-run jobs and dump the rows D-13 compares, a ported route can no longer disagree with its fixture, and the roadmap and requirements match the locked Phase 13/14 boundary. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| HTTP request path → surf dispatch | Untrusted path segments select the handler and its middleware chain | +| Request write transaction → River queue | A queued job waits for a worker that ships later; it must neither fail the write nor be lost | +| Recorded fixtures and goldens → tide comparison | Masks decide what the parity diff is allowed to ignore | +| Isolated PHP SQLite → row goldens | The rows subcommand reads the parity database for committed goldens | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-23 | Elevation of Privilege | surf overlap dispatch | high | mitigate | Members dispatch in registration order only after their literals and constraints match; each member runs its own wrapped middleware chain; no match is 404; TestOverlappingConstrainedRoutes covers middleware isolation and 404/405 (Task 1). | +| T-13-22 | Denial of Service / Repudiation | conga unregistered kinds | high | mitigate | Unregistered kinds go through the insert-only client; empty or served queues are refused with ErrUnregisteredKindQueue so a job is never fetched and discarded; TestUnregisteredKindWithWorker and TestJobContractDispatchWhileWorkerRuns (Task 2). | +| T-13-24 | Repudiation | tide date and publication masks | medium | mitigate | Each mask asserts the masked value's shape (real calendar date, Carbon +00:00, positive integer) and leaves every other path visible; negative tests prove a wrong stem, a bad date, a Z date and a string id still diff (Task 3). | +| T-13-25 | Tampering | lagoon prohibited rule | medium | mitigate | Laravel semantics ported with a truth table including 0 and false; plan 13-03 records the wishlist 422 and its fuzz proves condition/shelf never persist (Task 3). | +| T-13-26 | Information Disclosure | php_parity.sh rows and share capture | medium | mitigate | rows accepts one SELECT only and prints to stdout; share tokens are captured as `{{share:wishlist}}`; check_corpus --check-secrets stays in the verify (Task 4). | +| T-13-SC | Tampering | package installs | low | accept | No new dependency in this plan; no npm/pip/cargo installs. | + + + +- summercms.go: `go vet ./... && go test ./... -count=1` green; `go test ./cmd/summer -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' -count=1` and `go run ./cmd/summer docs:build --check` green. +- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus still 99 ported and passing. +- Planning docs commit contains only ROADMAP.md and REQUIREMENTS.md. + + + +- Overlapping constrained routes register and dispatch with registration-order semantics; the route table is unchanged. +- Workerless jobs can be dispatched while a worker runs and wait unworked; the job contract is pinned in one file. +- `prohibited`, the Content-Disposition date mask and the notification publication masks exist with docs. +- Parity tooling supports a database queue, row dumps, the wishlist share capture and the ported case-status check; ROADMAP and REQUIREMENTS carry D-01, D-02 and D-06. + + + +Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-SUMMARY.md` when done. + diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-02-PLAN.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-02-PLAN.md new file mode 100644 index 0000000..6e2077b --- /dev/null +++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-02-PLAN.md @@ -0,0 +1,312 @@ +--- +phase: 13-p-ytarium-api-wishlist-notifications-csv-credentials-public +plan: 02 +type: execute +wave: 2 +depends_on: ["13-01"] +files_modified: + - ../fonoteka.go/plugins/golem15/fonoteka/classes/notifications.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/notifications_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/notifications_smoke_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml + - ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml + - ../fonoteka.go/plugins/golem15/fonoteka/classes/onboarding.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/onboarding_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitation_inspect_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/onboarding_smoke_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/plugin.go + - ../fonoteka.go/plugins/golem15/fonoteka/routes.go + - ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go + - ../fonoteka.go/plugins/golem15/user/classes/events.go + - ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go + - ../fonoteka.go/plugins/golem15/user/controllers/registration.go + - ../fonoteka.go/plugins/golem15/user/register_test.go + - ../fonoteka.go/plugins/golem15/user/README.md + - ../fonoteka.go/plugins/golem15/user + - ../fonoteka.go/parity/manifest.yaml + - ../fonoteka.go/parity/fixtures/routes/ + - ../fonoteka.go/parity/fixtures/nuxt/onboarding.yaml + - ../fonoteka.go/parity/fonoteka_seed_test.go + - ../fonoteka.go/parity/fonoteka_reset.php + - ../fonoteka.go/parity/fonoteka_flows_test.go + - ../fonoteka.go/parity/parity_test.go + - ../fonoteka.go/parity/README.md +autonomous: true +requirements: [API-04, API-06, API-07] +estimate: + tokens: 260000 + raw_tokens: 260000 + tasks: 3 + confidence: low +must_haves: + truths: + - "Per D-06, `GET /_fonoteka/api/v1/notifications` returns the caller's newest 50 notifications by id descending as `{\"data\":[{\"id\",\"type\",\"payload\",\"read_at\",\"created_at\"}]}` with the stored payload bytes in their stored key order, Carbon `+00:00` times or null, and `{\"data\":[]}` when there are none." + - "`GET notifications/unread-count` answers `{\"data\":{\"count\":N}}` over the caller's unread rows; `POST notifications/read-all` and `POST notifications/{id}/read` answer `{\"data\":{\"state\":\"read\"}}`; `{id}/read` of another user's or a missing id answers the Winter production 404 HTML page (C-02), and read-all never touches another user's rows." + - "Per D-02, `GET/POST ai-credential`, `GET/POST/DELETE org-ai-credential` and `GET/POST discogs-credential` answer PHP's bodies: `{\"configured\":false}` or the configured shape with provider, model and base_url only; store answers `{\"configured\":true,\"provider\":P}`; org show/destroy without an organisation answer 404 `{\"error\":\"No organisation\"}`; org store provisions an organisation first and a non-manager gets 403 `{\"error\":\"Forbidden\"}`; org destroy answers `{\"configured\":false}`." + - "Per RESEARCH Finding 7, every credential store `$request->validate()` or `ValidationException::withMessages` failure (bad provider, bad base_url, missing api_key with no stored key, malformed Discogs token) answers Winter's generic 500 HTML page with status 500, as production PHP does." + - "A credential store writes only the validated keys present in the request (absent is not null); api_key and the Discogs token are stored only as `lagoon.Encrypted` ciphertext; a missing api_key reuses the stored key (the org store reuses the caller's own UserAiCredential key, as PHP does); no response body or log line contains a key or token." + - "`POST discogs-credential` trims the token, applies `/^[A-Za-z0-9_\\-]{10,255}$/`, refuses `shared=true` from a non-manager with 403 and the `discogs.shared_forbidden` text, writes the user and org credential in one transaction, and answers `{\"configured\",\"source\",\"shared\"}` from the Discogs resolver (admin → env DISCOGS_TOKEN, then user, then org)." + - "Per D-02, `classes.ResolveAIConfig` walks PHP AiConfigResolver's tiers: site admin → the backend global vision model (through `classes.AdminVisionModel`, which reports none configured until Phase 14 ports the global model, exactly as PHP behaves with no global model), org-lock on and user in an org → the org credential only, a per-user credential, an org credential, else `classes.ErrNoAICredential` (`No AI credential configured.`)." + - "Per D-09 (signed off 2026-10-02, costly) sm-user-plugin's `RegisterEvent` gains `Payload map[string]any` holding a copy of the register input without `password` and `password_confirmation` (user-confirmed at the checkpoint); existing listeners compile unchanged and `/register`'s status codes and bodies replay byte-identically." + - "Per D-11, sm-user-plugin exports `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` (additive; `/register` calls the same functions, no logic duplicated) so `onboarding/bootstrap` hashes, fires and mints exactly as `/register` does." + - "Per D-10, fonoteka's RegisterEvent listener hashes `Payload[\"invitation_token\"]` with sha256; a pending, unexpired, unrevoked invitation whose `LOWER(email)` equals the user's trimmed lowercased email upserts `PendingInvitationRegistration` on `user_id` (ON CONFLICT DO UPDATE invitation_id); any other case provisions the user's collection." + - "Per D-11, `POST onboarding/bootstrap` answers 409 `{\"error\":\"Onboarding already completed\"}` when any non-deleted user exists (before validation); a validation failure answers the Winter 500 page; otherwise under `pg_advisory_xact_lock(hashtext('fonoteka:onboarding:bootstrap'))` and an in-transaction recount it creates the organisation (slug from org_name), registers the activated user as its owner, commits, fires RegisterEvent and answers `{\"token\",\"user\"}`." + - "`GET onboarding/status` answers `{\"needs_onboarding\":B}` with B true exactly when no non-deleted user exists; `GET invitations/{token}` answers `{\"data\":{\"state\":\"pending\",\"collection_name\":N}}` for a pending invitation and `{\"data\":{\"state\":\"unavailable\"}}` otherwise; both run under the inline `throttle:10,1` and no auth (routes.php 351-364)." + - "The 14 routes of this plan are ported with re-recorded fixtures from the fonoteka reset, `expectedPortedRoutes` is 113, the two credential `/test` routes stay pending (D-02), and `TestFonotekaNuxtFlows/onboarding` (status, bootstrap, replay 409, invite, register with invitation_token, 409 guard, accept) replays green." + - "Edge (API-04 boundary): with 52 rows for the caller, `GET notifications` returns exactly the 50 highest ids; marking an already-read notification read again answers 200." + - "Edge (API-04 empty): a user with no notifications gets `{\"data\":[]}` and `{\"data\":{\"count\":0}}`." + - "Edge (API-06 encoding): a Discogs token with surrounding spaces is stored trimmed; 9 characters or a character outside `[A-Za-z0-9_-]` gives the 500 page; exactly 10 and exactly 255 characters are accepted." + - "Edge (API-07 concurrency): two concurrent bootstraps on an empty database create exactly one user, one organisation and one owner; the other answers 409." + - statement: "Edge (API-06 adjacency): an org-less caller of `POST org-ai-credential` becomes owner of the provisioned `plytarium-org-` and is therefore allowed, as PHP OrgProvisioner does." + verification: backstop + artifacts: + - path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/notifications_controller.go" + provides: "NotificationsIndex, NotificationsUnreadCount, NotificationsReadAll, NotificationsMarkRead" + contains: "func NotificationsIndex(" + - path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go" + provides: "the seven credential handlers" + contains: "func DiscogsCredentialStore(" + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go" + provides: "AIConfig, ResolveAIConfig, ErrNoAICredential, AdminVisionModel" + contains: "func ResolveAIConfig(" + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/onboarding.go" + provides: "OnboardingNeeded, BootstrapOwner, ErrOnboardingCompleted, HandleRegisterEvent" + contains: "pg_advisory_xact_lock" + - path: "../fonoteka.go/plugins/golem15/user/classes/events.go" + provides: "RegisterEvent.Payload" + contains: "Payload map[string]any" + - path: "../fonoteka.go/parity/fixtures/nuxt/onboarding.yaml" + provides: "recorded onboarding flow" + key_links: + - from: "../fonoteka.go/plugins/golem15/fonoteka/plugin.go" + to: "../fonoteka.go/plugins/golem15/user/classes/events.go" + via: "app.Events.Listen[*userclasses.RegisterEvent] calling classes.HandleRegisterEvent" + pattern: "RegisterEvent" + - from: "../fonoteka.go/plugins/golem15/fonoteka/classes/onboarding.go" + to: "../fonoteka.go/plugins/golem15/user/controllers/registration.go" + via: "BootstrapOwner calls RegisterUser in the bootstrap transaction, FireRegisterEvent after commit" + pattern: "RegisterUser" + - from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go" + to: "summercms.go modules/lagoon/encrypted.go" + via: "api_key and token stored through lagoon.NewEncrypted, never serialized" + pattern: "NewEncrypted" + prohibitions: + - requirement_id: API-07 + category: privacy + statement: "RegisterEvent.Payload MUST NOT carry the password or password_confirmation, in plaintext or hashed form, to any listener" + status: resolved + verification: test + - requirement_id: API-07 + category: safety + statement: "The sm-user-plugin change MUST NOT alter any existing exported signature, route, status code or response body of the core user plugin; it is additive only (D-09, D-11, core plugin contract)" + status: resolved + verification: test + - requirement_id: API-06 + category: privacy + statement: "No credential response, log line, job argument or committed fixture may contain an API key or Discogs token" + status: resolved + verification: test + - requirement_id: API-06 + category: transparency + statement: "The ai-credential/test and discogs-credential/test routes MUST NOT be mounted (no 501 or fake-success shells); they stay pending for Phase 14 (D-02, C-07)" + status: resolved + verification: test +--- + +## Phase Goal + +ROADMAP Phase 13 goal (verbatim, not in user-story form): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets. + +This plan's slice: a signed-in user reads and clears their bell notifications and manages their own and their organisation's AI and Discogs keys; a fresh install creates its first owner; an invited person sees what they were invited to, registers with the invitation token and is held at the acceptance step, exactly as the Nuxt app experiences PHP (API-04, API-06, API-07). + + +Port the notifications routes, the credentials CRUD with the AI resolver, onboarding status and bootstrap, invitation inspection, the additive sm-user-plugin exports (D-09, D-11) and the fonoteka register listener (D-10), with re-recorded fixtures and the `onboarding` flow. + +Purpose: these are the small surfaces the SPA calls on every screen (bell), on its settings pages (keys) and on first run (onboarding, invite links). Decisions implemented: D-02, D-06, D-09, D-10, D-11, D-12 (onboarding flow), D-15, C-01, C-02, C-03, C-04, C-07. +Output: classes, handlers, routes, user-plugin exports, recordings, the onboarding flow, ported count 113. + +Repos: fonoteka.go and the sm-user-plugin submodule at ../fonoteka.go/plugins/golem15/user. The user-plugin change is committed inside the submodule first (path-scoped `git -C ../fonoteka.go/plugins/golem15/user add `), not pushed (pushing goes through `ssu` when the user asks), then the pointer bump is committed in fonoteka.go. Never add co-author tags. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-PATTERNS.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-SUMMARY.md +@../fonoteka.go/plugins/golem15/fonoteka/routes.go +@../fonoteka.go/parity/README.md + + +- From 13-01: `lagoon.ValidateRequest` with `prohibited`; check_corpus ported case-status rule; `php_parity.sh rows`; job contract constants (not used here). +- Controllers (existing): `requestScope(w, r, app) (*gorm.DB, *usermodels.User, *models.ApiToken, bool)` (request.go:172), `readInput`, `validateInput` (collections_controller.go:217-233), `writeWinterHTTPError(w, app, status)` with winter_404.html, winter_409.html, winter_410.html, winter_500.html (http_errors.go:48), `writeValidationFailed`, `writeOpaque500`, `writeResolveError`, `wire.WriteJSON`. +- Classes (existing): `WriteNotification(ctx, tx, svc, userID, typ, payload)`, notification type constants, `IsSiteAdmin`, `CanManageOrg`, `AIAllowed`, `aiOrgLock`, `ResolveDiscogsConfig`, `DiscogsAllowed`, `ConfigAIOrgLock`, `CredentialFillFields = []string{"provider","model","base_url"}`, `ProvisionOrgFor(ctx, tx, user) (uint, error)`, `ProvisionCollection(ctx, tx, user)`, `invitationTokenHash`, `guardPendingInvitation`, `models.Slug`. +- Models: `models.Notification{ID, UserID, Type, Payload lagoon.Jsonable[map[string]any], ReadAt, CreatedAt, UpdatedAt}`, `UserAiCredential`, `OrgAiCredential`, `UserDiscogsCredential`, `OrgDiscogsCredential` (api_key/token `lagoon.Encrypted`, `json:"-"`), `PendingInvitationRegistration` (unique user_id), `CollectionInvitation`. +- sm-user-plugin: `classes.RegisterEvent{User *models.User}`, `classes.GetApiArrayEvent`; controllers `Register` (api_controller.go:602-703: readFields, rules, lagoon.Validate, bouncer.HashPassword with golem15.user.password.bcrypt_cost, lagoon.Fill, Create, `_ = app.Events.Fire(...)`, mintFor, apiArray), `mintFor(app, secret, user, issuer)`, `apiArray(ctx, app, user)`, `requestURL(r)`, `sessionDeps`. +- Plugin listener precedent: plugin.go Boot `app.Events.Listen[*userclasses.GetApiArrayEvent]("golem15.fonoteka", ...)`. +- PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{controllers/api/NotificationApiController.php, controllers/api/AiCredentialController.php, controllers/api/OrgAiCredentialController.php, controllers/api/DiscogsCredentialController.php, controllers/api/OnboardingController.php, controllers/api/InvitationApiController.php (inspect), classes/InvitationService.php (inspect, lines 26-37), classes/AiConfigResolver.php, classes/AiGate.php, classes/DiscogsConfigResolver.php, classes/DiscogsGate.php, classes/OrgAccess.php, classes/OrgProvisioner.php, Plugin.php (lines 175-205), routes.php (lines 103-106, 291-310, 351-364), lang/pl/lang.php and lang/en/lang.php (discogs.*)}. + + + +## Artifacts this phase produces + +(This plan's share.) + +- controllers/api: `NotificationsIndex`, `NotificationsUnreadCount`, `NotificationsReadAll`, `NotificationsMarkRead`, `AiCredentialShow`, `AiCredentialStore`, `OrgAiCredentialShow`, `OrgAiCredentialStore`, `OrgAiCredentialDestroy`, `DiscogsCredentialShow`, `DiscogsCredentialStore`, `OnboardingStatus`, `OnboardingBootstrap`, `InvitationInspect`. +- classes: `ListNotifications`, `UnreadNotificationCount`, `MarkNotificationRead`, `MarkAllNotificationsRead`, `ErrNotificationNotFound`, `SaveUserAICredential`, `SaveOrgAICredential`, `DeleteOrgAICredential`, `SaveDiscogsCredential`, `DiscogsStatus`, `AIConfig{Adapter, APIKey, BaseURL, Model}`, `ResolveAIConfig`, `ErrNoAICredential`, `AdminVisionModel` (package-level func seam), `OnboardingNeeded`, `BootstrapOwner`, `ErrOnboardingCompleted`, `HandleRegisterEvent`, `InspectInvitation`. +- sm-user-plugin: `RegisterEvent.Payload map[string]any`, `RegisterUser`, `RegisterOptions`, `FireRegisterEvent`, `IssueToken`, `APIArray` (README updated). +- Routes (JWT group): `GET /_fonoteka/api/v1/notifications`, `GET .../notifications/unread-count`, `POST .../notifications/read-all`, `POST .../notifications/{id}/read` (`[0-9]+`), `GET|POST .../ai-credential`, `GET|POST|DELETE .../org-ai-credential`, `GET|POST .../discogs-credential`. Onboarding group (`throttle:10,1`): `GET .../onboarding/status`, `POST .../onboarding/bootstrap`. Public invitation (`throttle:10,1`): `GET .../invitations/{token}`. +- Parity: seed states `notifications`, `credentials`, `empty`, `invite-for-register` (Go seed and fonoteka_reset.php), `fixtures/nuxt/onboarding.yaml`, `TestFonotekaNuxtFlows/onboarding`. +- Tests: `TestNotificationsRoutes`, `TestCredentialsCRUD`, `TestCredentialSecretsNeverSerialized`, `TestResolveAIConfigPrecedence`, `TestDiscogsSharedMirror`, `TestBootstrapConcurrent`, `TestRegisterInvitationListener`, `TestInspectInvitation`, `TestRegisterEventPayload` (user plugin). + +## Flagged assumptions + +- The admin tier of D-02's AI order reads the backend global vision model, which lives in the unported Golem15.Golem plugin. Go exposes it through `classes.AdminVisionModel`, which answers "none configured" until Phase 14 (INTG-02, reworded in 13-01 Task 4). With no global model PHP behaves identically, so no Phase 13 response differs; this is a dependency boundary, not a reduced decision. +- PHP fires `golem15.user.register` inside the bootstrap transaction. Go fires it after the bootstrap commit, the same point `/register` fires it, because the event carries no transaction handle (D-09 adds only Payload). A listener failure is logged and ignored as in `/register`; the active-collection resolver still provisions lazily. Response bodies and the final DB state match. + + + + + Task 1: A signed-in user sees their bell list exactly as PHP returns it + `php -v` exits 0 and `../fonoteka.go/parity/php_parity.sh reset` can rebuild the isolated PHP instance (needed for the re-recording). + ../fonoteka.go/plugins/golem15/fonoteka/classes/notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/notifications_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/notifications_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/NotificationApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 100-106), ../fonoteka.go/plugins/golem15/fonoteka/models/notification.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_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/invitations_controller.go (handler shape), ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/parity/manifest.yaml (the four notifications entries), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_notifications_jwt.yaml, ../fonoteka.go/parity/fonoteka_seed_test.go (seedFonotekaCase extras), ../fonoteka.go/parity/fonoteka_reset.php (PARITY_CASE extras), ../fonoteka.go/parity/parity_test.go (expectedPortedRoutes), ../fonoteka.go/parity/README.md (Phase 12 collections recording recipe) + Per D-06, C-03 and D-12. + +(1) classes/notifications.go `ListNotifications(ctx, db, userID uint, limit int) ([]models.Notification, error)`: `user_id = ?`, `ORDER BY id DESC`, `LIMIT 50`. The payload is read so its stored JSON bytes are emitted unchanged (raw message, not a re-marshalled map) to keep PHP's key order. + +(2) controllers/api/notifications_controller.go `NotificationsIndex(app)`: JWT caller via requestScope; body `{"data":[{"id","type","payload","read_at","created_at"}]}` in that key order, times through the existing Carbon `+00:00` formatter, null read_at as null, `[]` (never null) for no rows; DB errors are writeOpaque500. Route `g.Get("/notifications", ...)` in the JWT group only. + +(3) Parity seed state `notifications` in both fonoteka_reset.php and seedFonotekaCase: 52 rows for alice with fixed ids, types and created_at (two read, payload keys in PHP's order such as album_id, album_name, collection_id, actor_name), one row for bob. Re-record `routes/GET___fonoteka_api_v1_notifications_jwt.yaml` from the fonoteka reset with `seed_hook: fonoteka` and cases `case` (alice, 50 newest) and `empty` (outsider), following the Phase 12 recipe in parity/README.md. Set the route `status: ported`, fix every case status from the new fixture, raise expectedPortedRoutes to 100. + +(4) Smoke test notifications_smoke_test.go `TestNotificationsRoutes` (extended in Task 2): through the assembled handler, alice gets 50 rows newest first with payload bytes equal to the stored JSON, outsider gets `{"data":[]}`. + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestNotificationsRoutes)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v + Any command exits non-zero; the plugin run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestNotificationsRoutes"; the parity run reports a FAIL subtest for the notifications route or lacks "--- PASS: TestParityCorpus/coverage". + + + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 100. + - `grep -A4 'id: "GET /_fonoteka/api/v1/notifications jwt"' ../fonoteka.go/parity/manifest.yaml | grep -c 'status: ported'` prints 1. + - `grep -c '"/notifications"' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints 1 and the line sits inside the `/_fonoteka/api/v1` JWT group. + - The re-recorded fixture has no `{{id:token}}`, `{{id:genre}}` or `{{id:wishlist-album}}` placeholder (RESEARCH Finding 5 collisions are gone). + + The bell list is served by Go with PHP's bytes, recorded from the fonoteka reset and replayed in the corpus. + + + + Task 2: A user clears notifications and manages personal and organisation AI and Discogs keys without any secret leaving the server + The isolated PHP parity instance can be reset (`php -v` exits 0). + ../fonoteka.go/plugins/golem15/fonoteka/classes/notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/notifications_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/notifications_smoke_test.go, ../fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/NotificationApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AiCredentialController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OrgAiCredentialController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/DiscogsCredentialController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AiConfigResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AiGate.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/UserAiConfig.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/OrgAiConfig.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/DiscogsConfigResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/OrgAccess.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/pl/lang.php (discogs.*), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/en/lang.php (discogs.*), ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service_fuzz_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/org_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go, ../fonoteka.go/plugins/golem15/fonoteka/models/org_ai_credential.go, ../fonoteka.go/plugins/golem15/fonoteka/models/user_discogs_credential.go, ../fonoteka.go/plugins/golem15/fonoteka/models/org_discogs_credential.go, summercms.go modules/lagoon/encrypted.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go, ../fonoteka.go/parity/fixtures/routes/ (the nine credential and three notification fixtures) + (1) Notifications (D-06, C-02): `UnreadNotificationCount`, `MarkAllNotificationsRead` (only the caller's rows with read_at null; set read_at and updated_at now), `MarkNotificationRead` (update scoped by user_id and id; zero rows → `ErrNotificationNotFound`); handlers `NotificationsUnreadCount` `{"data":{"count":N}}`, `NotificationsReadAll` and `NotificationsMarkRead` `{"data":{"state":"read"}}`, ErrNotificationNotFound → `writeWinterHTTPError(w, app, 404)`. Routes `GET /notifications/unread-count`, `POST /notifications/read-all`, `POST /notifications/{id}/read` with `g.Where("id","[0-9]+")`, JWT group only. The two literal routes are registered before `{id}` as in routes.php. + +(2) Credentials (D-02, Finding 7): handlers in credentials_controller.go porting each PHP controller's order of checks and exact bodies. Validation goes through lagoon.ValidateRequest with PHP's rules (`provider required|in:claude,openai`, `api_key nullable|string`, `model nullable|string|max:255`, `base_url nullable|url`; Discogs `token required|string` then the trimmed regex `^[A-Za-z0-9_\-]{10,255}$`, `shared sometimes|boolean`); any failure, and the missing-key case, answer `writeWinterHTTPError(w, app, 500)`. Writes go through classes `SaveUserAICredential`, `SaveOrgAICredential`, `DeleteOrgAICredential`, `SaveDiscogsCredential` in credential_write_service.go: upsert on user_id or organisation_id, fill only present validated keys through CredentialFillFields, store api_key or token with lagoon.NewEncrypted, never read a key back except to reuse it (the org store reuses the caller's UserAiCredential key, as PHP does). Org store order: ProvisionOrgFor, then CanManageOrg → 403 `{"error":"Forbidden"}`, then validation. Discogs `shared=true` from a non-manager → 403 `{"error": }` (port the pl and en strings to lang.yaml); the user and org rows write in one lagoon.Transaction; `DiscogsStatus` builds `{"configured","source","shared"}` from DiscogsAllowed, ResolveDiscogsConfig's source and the org credential's existence. Routes in the JWT group: `GET|POST /ai-credential`, `GET|POST|DELETE /org-ai-credential`, `GET|POST /discogs-credential`; do not mount the two `/test` routes. + +(3) AI resolver (D-02): ai_config_resolver.go `AIConfig{Adapter, APIKey, BaseURL, Model string}`, `ResolveAIConfig(ctx, db, cfg, user) (*AIConfig, error)` porting AiConfigResolver's tiers with UserAiConfig/OrgAiConfig defaults (read AiDefaults.php), `ErrNoAICredential` with PHP's message, and `AdminVisionModel` as a package-level `func(ctx) (*AIConfig, error)` defaulting to "none configured" (nil, nil) with a doc comment naming Phase 14 INTG-02. AIAllowed's site-admin branch allows when AdminVisionModel returns a config, otherwise falls through as today. No route calls ResolveAIConfig in Phase 13. + +(4) Recordings (D-12): seed state `credentials` (alice user AI and Discogs credentials, the org's AI and Discogs credentials; bob a plain org member) in both seeds; re-record every case per route from the reset: show configured and not, store (user, org owner, org member 403, org-less caller), destroy (200, no-org 404, member 403), discogs store (user, shared by owner, shared by member 403), at least one 500 page per store route (bad provider, malformed token, missing key), notifications unread-count, read-all, read (own 200, foreign 404 page, missing 404 page). Flip the ten routes to ported; expectedPortedRoutes 110. + +(5) Smoke tests: extend `TestNotificationsRoutes` (count, read-all scope, read own and foreign); credentials_smoke_test.go `TestCredentialsCRUD` (every body above), `TestCredentialSecretsNeverSerialized` (stored columns are ciphertext; no response body or captured slog line contains the plaintext key or token), `TestResolveAIConfigPrecedence` (admin with and without a vision model, org-lock on with a per-user key, per-user, org fallback, nothing → ErrNoAICredential), `TestDiscogsSharedMirror` (owner shared mirrors to the org row in one transaction; member shared 403 writes nothing). + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestNotificationsRoutes|TestCredentialsCRUD|TestCredentialSecretsNeverSerialized|TestResolveAIConfigPrecedence|TestDiscogsSharedMirror)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v + Any command exits non-zero; the plugin run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks a "--- PASS" line for each of the five named tests; the parity run reports FAIL for a notifications or credential subtest or lacks "--- PASS: TestParityCorpus/coverage". + + + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 110. + - `grep -cE '"/(ai|discogs)-credential/test"' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints 0 and both `/test` manifest entries still read `status: pending`. + - `grep -c 'NewEncrypted' ../fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service.go` prints at least 1. + - `grep -c 'shared_forbidden' ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml` prints at least 1. + - Each credential store route has at least one recorded case with `status: 500` whose fixture Content-Type is `text/html; charset=UTF-8`. + - `grep -c 'func ResolveAIConfig(' ../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go` prints 1. + + Notifications can be counted and cleared, and personal and organisation keys can be saved, mirrored and removed with PHP's bodies, error pages and resolution order, with every secret encrypted and never echoed. + + + + Task 3: A fresh install creates its first owner, and an invited person registers with the link's token and is held until they accept + D-09: once other plugins read RegisterEvent.Payload, removing or reshaping it breaks them in every project using the user plugin. Signed off by the user on 2026-10-02, including the password-key stripping; recorded without a new checkpoint. + The isolated PHP parity instance can be reset and its log mailer is available (`php_parity.sh serve-mail`, Phase 12 household recipe). + ../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/register_test.go, ../fonoteka.go/plugins/golem15/user/README.md, ../fonoteka.go/plugins/golem15/user, ../fonoteka.go/plugins/golem15/fonoteka/classes/onboarding.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/onboarding_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitation_inspect_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go, ../fonoteka.go/plugins/golem15/fonoteka/onboarding_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/nuxt/onboarding.yaml, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/README.md + ../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Register lines 602-703, apiArray, mintFor, requestURL, sessionDeps, registrationSettings), ../fonoteka.go/plugins/golem15/user/register_test.go, ../fonoteka.go/plugins/golem15/user/README.md, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OnboardingController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php (lines 175-205), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/InvitationService.php (inspect, invitationByToken), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/InvitationApiController.php (inspect), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 351-364), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php (register: where golem15.user.register is fired and with what payload), ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Boot listener), ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go (guardPendingInvitation), ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go (invitationTokenHash), ../fonoteka.go/plugins/golem15/fonoteka/models/slug.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go (phase12Universe), ../fonoteka.go/parity/fonoteka_flows_test.go (isolatedFlowDB, replayNuxtCollections), ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml (secret:invite two-run recipe), ../fonoteka.go/parity/README.md (Phase 12 household recording) + (1) sm-user-plugin, per D-09 and D-11 (additive only, core plugin contract): add `Payload map[string]any` to RegisterEvent with a doc comment (a copy of the register input; `password` and `password_confirmation` are never included). In controllers/registration.go export `RegisterOptions{Activate bool; IP string}`, `RegisterUser(ctx, app, db *gorm.DB, fields map[string]any, opts RegisterOptions) (*models.User, map[string][]string, error)` (the existing validate, hash with golem15.user.password.bcrypt_cost, fill, create steps of Register, unchanged; validation errors returned, not written), `FireRegisterEvent(ctx, app, user, fields)` (builds Payload from a copy of fields minus the two password keys and fires; returns the listener error), `IssueToken(app, user, r *http.Request) (string, error)` (mintFor with the secret and requestURL issuer exactly as Register uses) and `APIArray(ctx, app, user)` (apiArray). Register calls these in its existing order and still ignores the fire error; every status and body stays the same. register_test.go `TestRegisterEventPayload`: a listener sees email and an extra `invitation_token` key, never either password key; a listener that does not read Payload still compiles (the test registers one). README: API reference entries for the five exports and Payload, Extension events wording. Commit inside the submodule (path-scoped), then bump the pointer in fonoteka.go in its own commit. + +(2) fonoteka listener, per D-10: classes/onboarding.go `HandleRegisterEvent(ctx, db, e *userclasses.RegisterEvent) error`: a string `invitation_token` in Payload hashed with invitationTokenHash matches an invitation with accepted_at and revoked_at null, expires_at in the future and `LOWER(email)` equal to the user's trimmed lowercased email → `INSERT ... ON CONFLICT (user_id) DO UPDATE SET invitation_id` into pending_invitation_registrations; otherwise ProvisionCollection for the user. plugin.go Boot registers it with `app.Events.Listen[*userclasses.RegisterEvent]("golem15.fonoteka", ...)` using the lazily published *gorm.DB. + +(3) Onboarding, per D-11: `OnboardingNeeded(ctx, db)` counts users with deleted_at null; `BootstrapOwner(ctx, app, db, input, r)`: a count before anything → `ErrOnboardingCompleted` (409 `{"error":"Onboarding already completed"}`); lagoon.ValidateRequest with `org_name required|string|max:255`, `email required|email`, `password required|min:8`, plus an existence check for the email (unique:users) → any failure the Winter 500 page; then one lagoon.Transaction taking `SELECT pg_advisory_xact_lock(hashtext('fonoteka:onboarding:bootstrap'))`, recounting (non-zero → ErrOnboardingCompleted), creating the organisation (name, slug from models.Slug of org_name), calling RegisterUser with password_confirmation equal to password and Activate true (validation errors → 500 page), setting organisation_id and organisation_role `owner`; after commit FireRegisterEvent with the input (error logged, not returned), IssueToken and APIArray → 200 `{"token","user"}`. Handlers `OnboardingStatus` `{"needs_onboarding":B}` and `OnboardingBootstrap`; fill the routes.go onboarding group (`/onboarding/status`, `/onboarding/bootstrap`). + +(4) Inspection: invitation_service.go `InspectInvitation(ctx, db, rawToken) (state, collectionName string, err error)` porting InvitationService::inspect (pending only when the invitation is pending by its Status rules); handler `InvitationInspect` answers `{"data":{"state":"unavailable"}}` or `{"data":{"state":"pending","collection_name":N}}`; route `GET /{token}` in the public_invitation group (prefix `/_fonoteka/api/v1/invitations`, `throttle:10,1`, no constraint). + +(5) routes_table_phase12_test.go: narrow phase12Universe's `/invitations/` entry so only `/invitations/{token}/accept` counts as Phase 12 (the public inspection route belongs to Phase 13); every Phase 12 assertion stays. + +(6) Recordings and flow (D-12, D-15): seed states `empty` (no users) and `invite-for-register` (a pending invitation from alice to a new address) in both seeds. Re-record route cases: status on empty and seeded, bootstrap 200 (empty), 409 (seeded), 500 page (empty with a short password), inspect pending and unavailable; keep at most 10 inline-throttled anonymous cases per route and reset PHP before each anonymous recording. Record `fixtures/nuxt/onboarding.yaml` with the two-run secret:invite recipe: status, bootstrap, status, bootstrap 409, owner invites a new address, inspect, register that address with `invitation_token`, me/context 409 page (guard), accept, me/context 200. Add `TestFonotekaNuxtFlows/onboarding` replaying it on an isolated database and asserting one owner, one organisation and the cleared pending registration. Flip the three routes; expectedPortedRoutes 113. + +(7) Smoke tests onboarding_smoke_test.go: `TestBootstrapConcurrent` (two goroutines on an empty DB: one 200, one 409, one user, one org), `TestRegisterInvitationListener` (valid token → pending registration and no collection; expired, revoked, foreign-email and missing token → collection provisioned), `TestInspectInvitation` (pending, accepted, expired, revoked, unknown). + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestRegisterEventPayload)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestBootstrapConcurrent|TestRegisterInvitationListener|TestInspectInvitation|TestRouteTablePhase12)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus|TestFonotekaNuxtFlows|TestUserAPINuxtFlows)$' -count=1 -v + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE"; output lacks "--- PASS: TestRegisterEventPayload", "--- PASS: TestBootstrapConcurrent", "--- PASS: TestRegisterInvitationListener", "--- PASS: TestFonotekaNuxtFlows/onboarding", "--- PASS: TestUserAPINuxtFlows" or "--- PASS: TestParityCorpus/coverage". + + + - `grep -c 'Payload map\[string\]any' ../fonoteka.go/plugins/golem15/user/classes/events.go` prints 1. + - `grep -rnE '^func (RegisterUser|FireRegisterEvent|IssueToken|APIArray)\(' ../fonoteka.go/plugins/golem15/user --include=*.go | wc -l` prints 4. + - `grep -cE 'RegisterUser|FireRegisterEvent|IssueToken|APIArray|Payload' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 5. + - `git -C ../fonoteka.go/plugins/golem15/user log -1 --name-only` lists no file outside classes/events.go, controllers/, register_test.go and README.md. + - `grep -c 'pg_advisory_xact_lock' ../fonoteka.go/plugins/golem15/fonoteka/classes/onboarding.go` prints 1. + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 113 and `test -f ../fonoteka.go/parity/fixtures/nuxt/onboarding.yaml` succeeds with `grep -cE '[0-9a-f]{64}' ../fonoteka.go/parity/fixtures/nuxt/onboarding.yaml` printing 0. + + A new site gets exactly one first owner, invitation links show what they invite to, and registering with an invitation token holds the newcomer at acceptance, all through the unchanged-contract user plugin. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Anonymous client → onboarding and inspection routes | Unauthenticated input creates the first owner or probes invitation tokens | +| JWT client → notifications and credentials | A user reads and writes only their own rows; org writes need owner/admin | +| Credential at rest → DB and logs | API keys and Discogs tokens must exist only as ciphertext | +| User plugin event → fonoteka listener | Register input crosses a plugin boundary | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-08 | Information Disclosure | credential responses, logs, marshal | high | mitigate | lagoon.Encrypted with redacting marshal, `json:"-"` models, show returns provider/model/base_url only; TestCredentialSecretsNeverSerialized scans bodies and captured logs (Task 2). | +| T-13-09 | Elevation of Privilege | org credential store/destroy, Discogs shared | high | mitigate | CanManageOrg after ProvisionOrgFor → 403 Forbidden or shared_forbidden, writing nothing; TestCredentialsCRUD and TestDiscogsSharedMirror member cases (Task 2). | +| T-13-10 | Tampering | credential owner FK mass assignment | high | mitigate | Writes fill only CredentialFillFields; user_id/organisation_id come from the session; the existing credential fuzz plus the 13-06 request fuzz (Task 2). | +| T-13-11 | Tampering | base_url as SSRF target | medium | accept | Phase 13 only stores base_url (`nullable|url`); no outbound call exists until Phase 14, which owns the guarded client (INTG-02). | +| T-13-18 | Elevation of Privilege | onboarding bootstrap | high | mitigate | 409 before validation when any user exists; advisory lock plus in-transaction recount; users.email unique backstop; TestBootstrapConcurrent (Task 3). | +| T-13-19 | Spoofing | register listener invitation match | high | mitigate | sha256 match, not accepted, not revoked, expires_at in the future, LOWER(trim(email)) equality; TestRegisterInvitationListener expired/revoked/foreign cases (Task 3). | +| T-13-20 | Information Disclosure | RegisterEvent.Payload | high | mitigate | Payload copies input minus password and password_confirmation; TestRegisterEventPayload (Task 3). | +| T-13-21 | Information Disclosure / Tampering | notifications read and mark-read | medium | mitigate | Every query scoped by user_id; zero updated rows → Winter 404 page; TestNotificationsRoutes foreign-id case (Task 2). | +| T-13-27 | Information Disclosure | invitation inspection | medium | mitigate | Only a sha256-matched pending invitation reveals its collection name; 64-hex tokens; throttle:10,1 shared anonymous budget; TestInspectInvitation (Task 3). | +| T-13-SC | Tampering | package installs | low | accept | No new dependency in this plan. | + + + +- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus at 113 ported and passing; `TestFonotekaNuxtFlows/onboarding` and `TestUserAPINuxtFlows` pass; check_corpus `--require-recorded --check-secrets` green. +- The sm-user-plugin commit touches only the additive files; its pointer bump is its own fonoteka.go commit. + + + +- Four notification routes, seven credential routes, two onboarding routes and invitation inspection ported with PHP bodies and error pages. +- The D-02 resolution order exists for both AI and Discogs; secrets never leave the server. +- The register hook works through an additive, documented user-plugin change. + + + +Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-02-SUMMARY.md` when done. + diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-03-PLAN.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-03-PLAN.md new file mode 100644 index 0000000..f1dd197 --- /dev/null +++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-03-PLAN.md @@ -0,0 +1,339 @@ +--- +phase: 13-p-ytarium-api-wishlist-notifications-csv-credentials-public +plan: 03 +type: execute +wave: 3 +depends_on: ["13-02"] +files_modified: + - ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_subscriptions.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/reservations.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/similarity_finder.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_share_settings_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_subscriptions_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_reservations_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_peer_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/jobs.go + - ../fonoteka.go/plugins/golem15/fonoteka/mail.go + - ../fonoteka.go/plugins/golem15/fonoteka/realtime.go + - ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm + - ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased-en.htm + - ../fonoteka.go/plugins/golem15/fonoteka/routes.go + - ../fonoteka.go/plugins/golem15/fonoteka/wishlist_smoke_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/wishlist_reservations_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go + - ../fonoteka.go/parity/manifest.yaml + - ../fonoteka.go/parity/fixtures/routes/ + - ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml + - ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.rows.json + - ../fonoteka.go/parity/fixtures/mcp/mcp-wishlist.yaml + - ../fonoteka.go/parity/fixtures/broadcasts/wishlist-item-added.yaml + - ../fonoteka.go/parity/fixtures/broadcasts/wishlist-purchased.yaml + - ../fonoteka.go/parity/fixtures/broadcasts/reservation-revealed.yaml + - ../fonoteka.go/parity/fonoteka_seed_test.go + - ../fonoteka.go/parity/fonoteka_reset.php + - ../fonoteka.go/parity/fonoteka_flows_test.go + - ../fonoteka.go/parity/broadcast_goldens_test.go + - ../fonoteka.go/parity/parity_test.go + - ../fonoteka.go/parity/README.md +autonomous: true +requirements: [API-03] +estimate: + tokens: 340000 + raw_tokens: 340000 + tasks: 4 + confidence: low +must_haves: + truths: + - "`GET wishlist/albums` on both groups (token group with `inv.scope:read`) lists the caller's own wishlist (provisioned once as `Moja lista życzeń` under a users-row lock when missing) with PHP's pagination envelope and, per reservation, the `reservation` key masked for the owner (`reserved_by` omitted before reveal); `GET wishlist/albums/{id}` (JWT only) shows one item the same way and answers PHP's 404 JSON for a foreign or missing id." + - "`POST wishlist/albums` (both groups, 201 with `data` and `duplicate`), `PUT` and `DELETE wishlist/albums/{id}` (both groups) go through the Phase 12 album write path into the own wishlist; `condition` and `shelf` are `prohibited` and a Validator::make failure answers 422 `{\"error\":\"Validation failed\",\"errors\":...}`; `GET wishlist/albums/similar` answers `{\"wishlist\":[...],\"collection\":[...]}` from a case-insensitive escaped name/artist match (limit 6, by name)." + - "Per D-08 and the planner's discretion, the item-added branch extends the existing `albumAddedCallback`: every insert of an album into a wishlist (JWT store, token-group store, any bulk or later CSV path) runs it exactly once on the insert's transaction; each subscriber except the actor gets a `wishlist_item_added` bell row `{album_id, album_name, collection_id, owner_name}` when ws_enabled, and for email_enabled subscribers the `golem15_fonoteka_wishlist_digest_queue` row is upserted atomically (`ON CONFLICT (user_id, collection_id)`, item_count + 1)." + - "Per D-08 and the 13-01 job contract, only the insert that creates a digest-queue row dispatches `WishlistDigestArgs{SubscriberID, WishlistCollectionID}` on `fonoteka.wishlist.digest` with label `wishlist_digest` and a 1800 s delay through conga.Dispatch on the same transaction; later inserts in the window only bump item_count; no digest worker or mail exists until Phase 14 (D-04)." + - "`GET|PUT wishlist/share`, `POST wishlist/share/regenerate` (`throttle:10,1`, token rotated under a row lock), `GET wishlist/household` (household wishlists with 8-album previews by name), `GET|PUT wishlist/settings` (`reservations_allowed required|boolean`, 422 JSON on failure) and `GET wishlist/subscriptions` answer PHP's bodies, JWT group only." + - "Subscriptions by token (`wishlist/token/{token}/subscribe` GET/POST 201/DELETE) and by collection (`wishlist/{collectionId}/subscribe` GET/POST 201/DELETE, `[0-9]+`) and `GET wishlist/subscribers` follow WishlistSubscriptionService: subscribe is an upsert stamping subscribed_at, household peers appear as synthetic subscribers with both channels on and a `forced` state, a collection subscription needs a wishlist visible to the caller, and an absent subscription or invisible wishlist answers the Winter 404 page." + - "Reservations: `POST wishlist/albums/{id}/reserve` answers 201 for a reservable album, 422 `cannot_reserve_own_wishlist` on the caller's own wishlist and 409 `reservations_disabled` when the wishlist disallows it; a second reserver loses under the album row lock and unique album_id; `DELETE .../reserve` works only for the reserver (Winter 404 page otherwise); `POST .../reveal` is owner-only, one-way and idempotent, writes only a `reservation_revealed` bell row and answers `{\"data\":{\"reserved\":false}}` when nothing is reserved." + - "Per D-07, `POST wishlist/albums/{id}/purchase` moves the album to the caller's active collection by updating collection_id, releases its reservation, writes `wishlist_item_purchased` bell rows to every ws-enabled subscriber (the actor included) and to the reserver if not already notified, and enqueues one `golem15.fonoteka.wishlist_purchased_mail` job per email-enabled subscriber on `mail` in the same transaction; mail leaves only after commit and a rolled-back purchase leaves no row, job or mail; the response body and DB state equal PHP's." + - "The purchase mail worker sends `golem15.fonoteka::mail.wishlist_item_purchased` (or `-en` for a subscriber whose preferred_locale is en) with vars albumName and wishlistName, PHP's subject and the `plytarium` layout, to the subscriber's address, observed through postcard's memory driver; it skips a subscriber that no longer exists and never logs the args." + - "`GET wishlist/{collectionId}/albums` and `GET wishlist/{collectionId}/albums/{albumId}` (JWT, both `[0-9]+`) show a visible peer's wishlist through the reservable-wishlist scope with the viewer's reservation state; a foreign, invisible or missing wishlist answers the Winter 404 page; none of reserve, reveal, purchase, similar, share, settings, subscriptions or peer routes is mounted on the token group." + - "Through the assembled router the four overlap pairs from 13-01 dispatch to the real handlers, and the 30 wishlist routes are ported with re-recorded fixtures; `expectedPortedRoutes` is 143 and `wishlist/albums/{id}/match` and `apply-release` stay pending (D-01)." + - "Per D-12 and D-13, `TestFonotekaNuxtFlows/nuxt-wishlist` (recorded with QUEUE_CONNECTION=database: create, share, subscribe by token and by collection, reserve as a second user, reveal, purchase, settings, household, peer albums, with GET notifications steps as each recipient) and `TestFonotekaNuxtFlows/mcp-wishlist` replay green, the recorded digest-queue rows equal Go's, and the `notification:new`/`notification:count` publications match their goldens." + - "Edge (API-03 concurrency): two users reserving one album at once produce exactly one reservation; the other gets the 409 PHP answers." + - "Edge (API-03 adjacency): three wishlist items added within one window by the owner produce one digest-queue row with item_count 3 and exactly one dispatched digest job per email-enabled subscriber; the owner (actor) gets neither a bell row nor a digest row." + - "Edge (API-03 ordering): household wishlists and subscriptions list in PHP's order (household by owner name, then other subscriptions); previews hold at most 8 albums ordered by name." + - "Edge (API-03 encoding): `similar` treats `%`, `_` and `\\` in the query literally and matches case-insensitively, as MariaDB `_ci` does in production." + - "Edge (API-03 empty): a user without a wishlist gets one provisioned on first read and an empty `data` list; `reveal` with no reservation answers `{\"data\":{\"reserved\":false}}`." + - statement: "Edge (API-03 concurrency): concurrent first reads by one user provision exactly one wishlist." + verification: backstop + artifacts: + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go" + provides: "ActiveWishlist, ProvisionWishlist, WishlistsVisibleTo, ReservableWishlistIDs" + contains: "Moja lista życzeń" + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/reservations.go" + provides: "ReserveAlbum, CancelReservation, RevealReservation, ReleaseForAlbum, ReservationStateForViewer, ReservationDTO" + contains: "FOR UPDATE" + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go" + provides: "NotifyWishlistItemAdded, NotifyWishlistItemPurchased, NotifyReservationRevealed, EnqueueWishlistDigest, JobDispatcher, SetJobDispatcher" + contains: "ON CONFLICT" + - path: "../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm" + provides: "Polish purchase mail template" + - path: "../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml" + provides: "recorded Nuxt wishlist journey" + - path: "../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.rows.json" + provides: "recorded digest-queue rows (D-13)" + key_links: + - from: "../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go" + to: "../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go" + via: "albumAddedCallback's wishlist branch calls NotifyWishlistItemAdded on the insert transaction" + pattern: "NotifyWishlistItemAdded" + - from: "../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go" + to: "../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go" + via: "Dispatch WishlistDigestArgs with WishlistDigestJobQueue, WishlistDigestLabel, WishlistDigestDelay; Enqueue WishlistPurchasedMailArgs" + pattern: "WishlistDigestLabel" + - from: "../fonoteka.go/plugins/golem15/fonoteka/jobs.go" + to: "summercms.go modules/postcard" + via: "purchase mail worker sends the locale-picked template" + pattern: "wishlist_item_purchased" + - from: "../fonoteka.go/plugins/golem15/fonoteka/routes.go" + to: "summercms.go modules/surf/overlap.go" + via: "the four overlapping wishlist pairs register as one family each" + pattern: "wishlist/{collectionId}" + prohibitions: + - requirement_id: API-03 + category: privacy + statement: "A wishlist owner MUST NOT learn who reserved an item before revealing it, through any response, notification or publication" + status: resolved + verification: test + - requirement_id: API-03 + category: transparency + statement: "No digest job may be worked, completed or faked in Phase 13, and no mail may be sent from a rolled-back purchase" + status: resolved + verification: test + - requirement_id: API-03 + category: transparency + statement: "The wishlist match and apply-release routes MUST NOT be mounted in any form; they stay pending for Phase 14 (D-01, C-07)" + status: resolved + verification: test +--- + +## Phase Goal + +ROADMAP Phase 13 goal (verbatim, not in user-story form): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets. + +This plan's slice: a household keeps wishlists: the owner adds, edits and shares items; others follow by link or as household members, get bell notifications, reserve gifts secretly, and the owner marks items bought, exactly as the Nuxt app and fonoteka-mcp see PHP (API-03; ROADMAP SC-1). + + +Port the 30 Phase 13 wishlist routes (26 JWT, 4 token group) with the wishlist resolver, visibility scopes, subscription and reservation services, the reservation mask, the similarity finder, share/settings/household, purchase with its mail job, and the item-added notifications with the digest queue; record the nuxt-wishlist and mcp-wishlist flows, the publication goldens and the digest rows. + +Purpose: the wishlist is Płytarium's second user-facing surface and the main source of notifications. Decisions implemented: D-01 (match/apply-release stay pending), D-07, D-08, D-12, D-13, C-01, C-02, C-03, C-04, C-05, C-07; the item-added discretion is resolved by extending albumAddedCallback (RESEARCH recommendation). +Output: classes, handlers, mail job and templates, routes, recordings, flows, goldens; ported count 143. + +Repo: fonoteka.go only. Commits path-scoped; never add co-author tags. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-PATTERNS.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-SUMMARY.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-02-SUMMARY.md +@../fonoteka.go/plugins/golem15/fonoteka/routes.go +@../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go + + +- From 13-01: surf overlap families (no API change); conga unregistered-kind dispatch; `lagoon` `prohibited`; tide publication masks; job contract `classes.WishlistDigestKind`, `WishlistDigestJobQueue`, `WishlistDigestLabel`, `WishlistDigestDelay`, `WishlistDigestArgs`, `WishlistPurchasedMailKind`, `WishlistPurchasedMailQueue`, `WishlistPurchasedMailAttempts`, `WishlistPurchasedMailArgs`; `php_parity.sh rows`; capture `share:wishlist`; `QUEUE_CONNECTION` override. +- From 13-02: `classes.WriteNotification` usage for new types, `NotificationsIndex` (for flow steps), seed-state pattern. +- Existing classes: `WriteNotification(ctx, tx, svc, userID, typ, payload)` with `NotificationPayload` ordered pairs, `NotificationTypeWishlistItemAdded`, `NotificationTypeWishlistItemPurchased`, `NotificationTypeReservationRevealed`, `albumAddedCallback` (returns early for non-real collections), `realtimeService` atomic pointer and `SetRealtimeService`, `NotificationActor`, `JobQueue` interface (Enqueue) in invitation_service.go, `CreateAlbum`, `UpdateAlbum`, `FindDuplicateAlbum`, `SerializeDuplicate`, `SerializeAlbum`/`AlbumDTO`, `AlbumsAccessibleBy`, `AccessibleBy` (owner's wishlist exempt from a token pin), `ShareStateFor`, `EnableShare`, `DisableShare`, `RegenerateShare`, `RenameShare`, `GenerateShareToken`, `Resolve`, `ProvisionCollection`, `WithoutBroadcasting[models.Album]` plus `Service.Emit` for album writes (Phase 11 note). +- Models: `Collection{Kind, OwnerID, PublicToken, PublicEnabled, ReservationsAllowed?}`, `AlbumReservation` (UNIQUE album_id), `WishlistSubscription` (UNIQUE user_id+collection_id, ws_enabled, email_enabled, subscribed_at), `WishlistDigestQueue` (UNIQUE user_id+collection_id, item_count). +- conga: `(*Manager).Dispatch(ctx, db, args, DispatchOpts{Label, Count, Metadata, Queue, Delay})`, `(*Manager).Enqueue(ctx, db, args, EnqueueOpts{Queue, MaxAttempts})`, `conga.Job(fn, conga.OnQueue(q), conga.MaxAttempts(n))`. +- postcard: `Mailer.Send(ctx, postcard.Message{Template, To, Vars})`, memory driver; plugin templates via `MailTemplates()`/`MailTemplatesFS()` (mail.go). +- PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{controllers/api/WishlistAlbumApiController.php, WishlistAlbumReservationController.php, WishlistHouseholdController.php, WishlistPeerAlbumController.php, WishlistSettingsController.php, WishlistShareController.php, WishlistSubscriptionController.php, classes/ActiveWishlistResolver.php, WishlistProvisioner.php, WishlistSubscriptionService.php, AlbumReservationService.php, AlbumSimilarityFinder.php, AlbumWriteService.php (moveToCollection, lines 270-282), CollectionShareService.php, NotificationService.php (lines 95-284), models/Collection.php (wishlistsVisibleTo, lines 247-264), models/WishlistDigestQueue.php, models/AlbumReservation.php, traits/SerializesFonoteka.php (lines 55-140), views/mail/wishlist_item_purchased.htm and -en.htm, Plugin.php (lines 80-112), routes.php (lines 130-225, 501-510)}; fonoteka-mcp /media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/client.ts (lines 295-307); Nuxt /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/ and stores/fonoteka.ts (wishlist calls). + + + +## Artifacts this phase produces + +(This plan's share.) + +- classes: `ActiveWishlist`, `ProvisionWishlist`, `WishlistsVisibleTo`, `ReservableWishlistIDs`, `Subscribe`, `Unsubscribe`, `SubscribersOf`, `FollowerCountOf`, `SubscriptionStateFor`, `SubscriptionsFor`, `ReserveAlbum`, `CancelReservation`, `RevealReservation`, `ReleaseForAlbum`, `ReservationStateForViewer`, `ReservationDTO`, `ReservationContext`, `FindSimilarAlbums`, `MoveToCollection`, `NotifyWishlistItemAdded`, `NotifyWishlistItemPurchased`, `NotifyReservationRevealed`, `EnqueueWishlistDigest`, `JobDispatcher` (Enqueue and Dispatch), `SetJobDispatcher`, `ErrNoJobDispatcher`, `ErrCannotReserveOwnWishlist`, `ErrReservationsDisabled`, `ErrAlreadyReserved`; `AlbumDTO.Reservation *ReservationDTO` (`json:"reservation,omitempty"`). +- controllers/api: `WishlistAlbumsIndex`, `WishlistAlbumsStore`, `WishlistAlbumsSimilar`, `WishlistAlbumsShow`, `WishlistAlbumsUpdate`, `WishlistAlbumsDestroy`, `WishlistAlbumsPurchase`, `WishlistReserve`, `WishlistReserveCancel`, `WishlistReveal`, `WishlistShareShow`, `WishlistShareUpdate`, `WishlistShareRegenerate`, `WishlistHousehold`, `WishlistSettingsShow`, `WishlistSettingsUpdate`, `WishlistSubscriptionsIndex`, `WishlistSubscribers`, `WishlistTokenSubscribeStatus`, `WishlistTokenSubscribe`, `WishlistTokenUnsubscribe`, `WishlistCollectionSubscribeStatus`, `WishlistCollectionSubscribe`, `WishlistCollectionUnsubscribe`, `WishlistPeerAlbumsIndex`, `WishlistPeerAlbumsShow`. +- Plugin: purchase mail job registered in `Jobs()` (worker `sendWishlistPurchasedMail`), templates `golem15.fonoteka::mail.wishlist_item_purchased` and `-en`; `SetJobDispatcher` wired at boot beside `SetRealtimeService`. +- Routes: the 30 wishlist routes listed in RESEARCH "Wishlist JWT" and "Wishlist on the token group", without match/apply-release. +- Parity: seed state `wishlist`; `fixtures/nuxt/nuxt-wishlist.yaml`, `nuxt-wishlist.rows.json`, `fixtures/mcp/mcp-wishlist.yaml`; broadcast goldens `wishlist-item-added.yaml`, `wishlist-purchased.yaml`, `reservation-revealed.yaml`; subtests `TestFonotekaNuxtFlows/nuxt-wishlist`, `TestFonotekaNuxtFlows/mcp-wishlist`, `TestBroadcastGoldens/wishlist-item-added`, `/wishlist-purchased`, `/reservation-revealed`. +- Tests: `TestWishlistOwnListAndShow`, `TestWishlistItemAddedOncePerPath`, `TestDigestCoalescing`, `TestWishlistShareSettingsHousehold`, `TestWishlistSubscriptions`, `TestReserveConcurrent`, `TestRevealIdempotent`, `TestReservationMask`, `TestPurchaseSideEffects`, `TestPurchaseMailAfterCommit`, `TestWishlistOverlapRoutesAssembled`. + + + + + + + + Task 1: A user opens their own wishlist on the web and through a personal token and sees reservations masked as PHP masks them + The isolated PHP parity instance can be reset (`php -v` exits 0). + ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/reservations.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistAlbumApiController.php (index, show, embedsFor, paginated), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ActiveWishlistResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/WishlistProvisioner.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumReservationService.php (stateForViewer), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php (lines 55-140), ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/access.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go (index and show shape), ../fonoteka.go/plugins/golem15/fonoteka/models/album_reservation.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/parity/manifest.yaml (wishlist entries), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_wishlist_albums_jwt.yaml + Per C-01, C-03 and RESEARCH "Wishlist" analysis. + +(1) wishlist_resolver.go: `ProvisionWishlist(ctx, tx, user)` ports WishlistProvisioner (lock the users row FOR UPDATE, reuse the existing own `kind='wishlist'` collection, else create `Moja lista życzeń`); `ActiveWishlist(ctx, db, user)` ports ActiveWishlistResolver; `WishlistsVisibleTo(db, userID)` ports Collection::wishlistsVisibleTo (kind wishlist and owner among the owners and editors of every collection the user belongs to, the user included); `ReservableWishlistIDs` ports AlbumReservationService::reservableWishlistIds. + +(2) reservations.go read side: `ReservationDTO` and `ReservationStateForViewer(album, reservation, ReservationContext{ViewerID, IsOwner})` port stateForViewer's three-way mask (owner before reveal sees no `reserved_by`). serialize_album.go: SerializeAlbum takes an optional ReservationContext; with none, no `reservation` key is emitted so every existing caller stays byte-identical; conditional keys inside are omitted, not nulled, as PHP. + +(3) wishlist_albums_controller.go `WishlistAlbumsIndex` (PHP index: own wishlist, paginated envelope without links, reservation context is_owner true) and `WishlistAlbumsShow` (PHP show; foreign or missing id → PHP's 404 JSON body as recorded). Routes: JWT `GET /wishlist/albums`, `GET /wishlist/albums/{id}` with `[0-9]+`; token group `GET /wishlist/albums` with `inv.scope:read`, sharing the index handler. + +(4) Seed state `wishlist` in both seeds: alice's wishlist with three items (one reserved by bob, unrevealed; one revealed), share enabled, reservations allowed, bob subscribed by collection, notifications for bob. Re-record the three route fixtures from the reset (owner list, owner show of the reserved item, foreign id 404, token-group list with a read token). Flip; expectedPortedRoutes 116. + +(5) wishlist_smoke_test.go `TestWishlistOwnListAndShow`: first read provisions one wishlist; the owner's list hides `reserved_by` for the unrevealed reservation and shows it for the revealed one; a non-wishlist album id answers 404. + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestWishlistOwnListAndShow|TestRouteTablePhase12)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus|TestFonotekaNuxtFlows)$' -count=1 -v + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestWishlistOwnListAndShow" and "--- PASS: TestParityCorpus/coverage"; an existing album fixture fails (the optional reservation key leaked into existing bodies). + + + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 116. + - `grep -c 'json:"reservation,omitempty"' ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go` prints 1. + - `grep -c 'Moja lista życzeń' ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go` prints at least 1. + - `grep -n '"/wishlist/albums"' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` shows one JWT line and one token-group line carrying `inv.scope:read`. + + The wishlist read path works end to end on both groups with PHP's reservation mask, proving resolver, serializer, routes and recordings before the write and social features land. + + + + Task 2: The owner adds, edits, removes and shares wishlist items, and followers get a bell notification and a queued digest for each new item + The isolated PHP parity instance can be reset (`php -v` exits 0). + ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/similarity_finder.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_subscriptions.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_share_settings_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_smoke_test.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistAlbumApiController.php (store, update, destroy, similar, rules lines 280-310), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistShareController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistSettingsController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistHouseholdController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSimilarityFinder.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (notifyWishlistItemAdded), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/WishlistSubscriptionService.php (subscribersOf), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/WishlistDigestQueue.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php (lines 80-112), ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go (albumAddedCallback, NotifyAlbumAdded, init), ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go (JobQueue), ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_share_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go (store/update/destroy), ../fonoteka.go/plugins/golem15/fonoteka/realtime.go (SetRealtimeService wiring) + (1) Item writes (C-04): `WishlistAlbumsStore` (201 `{"data","duplicate"}`), `WishlistAlbumsUpdate`, `WishlistAlbumsDestroy` port PHP onto the Phase 12 album write path targeting ActiveWishlist; PHP's Validator::make rules including `condition prohibited` and `shelf prohibited` through lagoon.ValidateRequest, failures via writeValidationFailed (422 `{"error":"Validation failed","errors"}` in PHP key order). Album writes keep the Phase 11 note (WithoutBroadcasting plus Service.Emit). `FindSimilarAlbums` ports AlbumSimilarityFinder with `ILIKE` and `\`, `%`, `_` escaped (Pitfall 7), optional year, photos embed, order by name, limit 6; `WishlistAlbumsSimilar` answers `{"wishlist":[...],"collection":[...]}`. Routes: JWT `POST /wishlist/albums`, `GET /wishlist/albums/similar` (before `{id}`), `PUT|DELETE /wishlist/albums/{id}` (`[0-9]+`); token group `POST /wishlist/albums` and `PUT|DELETE /wishlist/albums/{id}` with `inv.scope:write`. + +(2) Item-added (D-08, discretion resolved): in notification_service.go, albumAddedCallback calls `NotifyWishlistItemAdded` for an album whose collection kind is wishlist (real collections keep NotifyAlbumAdded). wishlist_notifications.go ports notifyWishlistItemAdded with `SubscribersOf` (port of subscribersOf, including synthetic household peers with both channels on; put it in wishlist_subscriptions.go): skip the actor; ws_enabled → WriteNotification type wishlist_item_added with ordered payload album_id, album_name, collection_id, owner_name; email_enabled → `EnqueueWishlistDigest(ctx, tx, subscriberID, wishlistID)`: one `INSERT ... ON CONFLICT (user_id, collection_id) DO UPDATE SET item_count = item_count + 1, updated_at = NOW() RETURNING (xmax = 0)` and, only when the row was inserted, Dispatch of `WishlistDigestArgs` with WishlistDigestJobQueue, WishlistDigestLabel and WishlistDigestDelay on the same transaction. The dispatcher comes from `SetJobDispatcher(JobDispatcher)` (an interface with the existing JobQueue Enqueue plus conga's Dispatch, satisfied by *conga.Manager), wired in realtime.go beside SetRealtimeService; a nil dispatcher when a digest must be dispatched fails the insert with `ErrNoJobDispatcher` (never a silent skip). Use a clean session on the callback's handle (the 12-04 cleanSession precedent). + +(3) Share, settings, household (JWT only): `WishlistShareShow`/`Update`/`Regenerate` port WishlistShareController on ActiveWishlist with the existing share service (`/w/` paths, regenerate under the share row lock, `throttle:10,1` on regenerate); `WishlistSettingsShow`/`Update` (`reservations_allowed required|boolean`, written directly because it is outside the fillable set, 422 JSON on failure); `WishlistHousehold` ports the household index (8-album previews by name). Routes `GET|PUT /wishlist/share`, `POST /wishlist/share/regenerate`, `GET /wishlist/household`, `GET|PUT /wishlist/settings`. + +(4) Recordings from the `wishlist` reset: store 201 (with and without a duplicate), store 422 with `condition` set (settles A2) and with a missing name, update 200 and foreign 404, destroy 200 and 404, similar, share show/update/regenerate (`{{share:wishlist}}`), settings show/update/422, household; token-group store/update/destroy with write and read-only tokens. Flip these 13 routes; expectedPortedRoutes 129. + +(5) Tests: wishlist_notifications_test.go `TestWishlistItemAddedOncePerPath` (JWT store, token-group store and a direct CreateAlbum into a wishlist each produce exactly one bell row per ws-enabled non-actor subscriber and one digest row bump; nothing is written to the actor; a real-collection insert still produces album_added only) and `TestDigestCoalescing` (three inserts → item_count 3, one summer_jobs row with label wishlist_digest and one scheduled river_job about 1800 s ahead on fonoteka.wishlist.digest; a rolled-back insert leaves no row and no job); wishlist_smoke_test.go `TestWishlistShareSettingsHousehold`. + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestWishlistItemAddedOncePerPath|TestDigestCoalescing|TestWishlistShareSettingsHousehold|TestWishlistOwnListAndShow|TestInvitationAcceptAddsEditor)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus|TestFonotekaNuxtFlows|TestBroadcastGoldens)$' -count=1 -v + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestWishlistItemAddedOncePerPath, TestDigestCoalescing and TestWishlistShareSettingsHousehold; nuxt-albums or an existing broadcast golden regresses (album_added path changed). + + + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 129. + - `grep -c 'NotifyWishlistItemAdded' ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go` prints at least 1. + - `grep -c 'xmax = 0' ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go` prints 1 and `grep -c 'WishlistDigestLabel' ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go` prints at least 1. + - `grep -c 'ILIKE' ../fonoteka.go/plugins/golem15/fonoteka/classes/similarity_finder.go` prints at least 1. + - The recorded 422 fixture for `condition` exists and its message text equals what Go emits (A2 settled by recording). + - `grep -c 'wishlist_digest\|fonoteka.wishlist.digest' ../fonoteka.go/plugins/golem15/fonoteka/jobs.go` prints 0 (no digest worker registered). + + Owners curate and share their wishlist, every new item reaches followers' bells immediately and joins one queued digest per follower window, with no worker pretending to send it. + + + + Task 3: Followers subscribe, reserve gifts secretly and see peer wishlists, and the owner marks an item bought with mail sent only after commit + The isolated PHP parity instance can be reset (`php -v` exits 0). + ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_subscriptions.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/reservations.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_subscriptions_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_reservations_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_peer_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_share_settings_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/jobs.go, ../fonoteka.go/plugins/golem15/fonoteka/mail.go, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased-en.htm, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_reservations_test.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistSubscriptionController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistSettingsController.php (subscriptions), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistAlbumReservationController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistPeerAlbumController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistAlbumApiController.php (purchase), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/WishlistSubscriptionService.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumReservationService.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumWriteService.php (moveToCollection lines 270-282), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (notifyWishlistItemPurchased, notifyReservationRevealed, mailWishlistPurchased lines 237-262), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/CollectionShareService.php (resolvePublic used by subscribe-by-token), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased-en.htm, ../fonoteka.go/plugins/golem15/fonoteka/jobs.go (invitation mail worker and deliverInvitationMail pattern), ../fonoteka.go/plugins/golem15/fonoteka/mail.go, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/collection_invitation.htm (template conversion precedent), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go + (1) Subscriptions: wishlist_subscriptions.go ports `Subscribe` (upsert stamping subscribed_at), `Unsubscribe`, `FollowerCountOf`, `SubscriptionStateFor` (with `forced` for household peers) and `SubscriptionsFor` (household wishlists by owner name, then other subscriptions). The token routes resolve the wishlist by its public token with PHP's checks (the 16-character shape, `LOWER(public_token)` lookup, public_enabled, kind wishlist, constant-time compare; put the shared resolver in share_service.go as `ResolvePublic(ctx, db, token, kind)` so plan 13-05 reuses it); the collection routes require a wishlist from WishlistsVisibleTo. Handlers `WishlistTokenSubscribeStatus`/`WishlistTokenSubscribe` (201)/`WishlistTokenUnsubscribe`, `WishlistCollectionSubscribeStatus`/`WishlistCollectionSubscribe` (201)/`WishlistCollectionUnsubscribe`, `WishlistSubscribers`, `WishlistSubscriptionsIndex`; every PHP HttpException is the Winter 404 page. + +(2) Reservations: `ReserveAlbum` (album row FOR UPDATE, reservable-wishlist check, own wishlist → ErrCannotReserveOwnWishlist 422 `cannot_reserve_own_wishlist`, disallowed → ErrReservationsDisabled 409 `reservations_disabled`, an existing reservation → ErrAlreadyReserved with PHP's 409, create → 201 with PHP's body), `CancelReservation` (reserver only, else the Winter 404 page), `RevealReservation` (owner only, idempotent, stamps revealed_at once and calls `NotifyReservationRevealed`, bell only, `{"data":{"reserved":false}}` without a reservation), `ReleaseForAlbum`. Handlers `WishlistReserve`, `WishlistReserveCancel`, `WishlistReveal`. + +(3) Purchase (D-07): `MoveToCollection` ports AlbumWriteService::moveToCollection inside one lagoon.Transaction: update collection_id to the caller's active collection, ReleaseForAlbum, then `NotifyWishlistItemPurchased` (bell rows for every ws-enabled subscriber including the actor, the reserver once if not already notified, payload album_id, album_name, collection_id) and, per email-enabled subscriber, Enqueue `WishlistPurchasedMailArgs{SubscriberID, AlbumName, WishlistName}` on WishlistPurchasedMailQueue with WishlistPurchasedMailAttempts on the same transaction. `WishlistAlbumsPurchase` answers PHP's 200 body. The album update broadcast follows the Phase 11 note. + +(4) Mail: register the purchase mail job in `Jobs()` with conga.Job, OnQueue(WishlistPurchasedMailQueue) and MaxAttempts; the worker loads the subscriber (skip with an id-only info log when gone), picks `golem15.fonoteka::mail.wishlist_item_purchased-en` for preferred_locale en and the Polish template otherwise, and sends vars albumName and wishlistName; port both templates with PHP's front matter, subject and the plytarium layout; add them to MailTemplates. Never log args. + +(5) Peer albums: `WishlistPeerAlbumsIndex` and `WishlistPeerAlbumsShow` port WishlistPeerAlbumController through ReservableWishlistIDs with the viewer's reservation context; invisible, foreign or missing → Winter 404 page. + +(6) Routes, JWT group only, in routes.php order: `GET /wishlist/subscribers`, `GET /wishlist/subscriptions`, `GET|POST|DELETE /wishlist/token/{token}/subscribe`, `GET|POST|DELETE /wishlist/{collectionId}/subscribe` (`[0-9]+`), `POST /wishlist/albums/{id}/purchase`, `POST|DELETE /wishlist/albums/{id}/reserve`, `POST /wishlist/albums/{id}/reveal` (each `[0-9]+`), `GET /wishlist/{collectionId}/albums`, `GET /wishlist/{collectionId}/albums/{albumId}` (both `[0-9]+`). Never mount match or apply-release. + +(7) Recordings from the `wishlist` reset with PHP's error pages: every route's success case plus its distinct errors (not subscribed, invisible wishlist, foreign reserver cancel, reserve own 422, reserve disabled 409, double reserve 409, reveal by non-owner, reveal without reservation, purchase foreign 404, peer invisible). Flip these 14 routes; expectedPortedRoutes 143. + +(8) Tests: wishlist_reservations_test.go `TestReserveConcurrent` (two goroutines: one 201, one 409, one row), `TestRevealIdempotent` (second reveal writes nothing new), `TestReservationMask` (owner, reserver and third party views before and after reveal), `TestWishlistSubscriptions` (forced household state, follower count, subscribe by disabled token 404); wishlist_notifications_test.go `TestPurchaseSideEffects` (move, release, recipients including actor and reserver once) and `TestPurchaseMailAfterCommit` (commit: one river_job per email subscriber, worker run sends the right template, recipient and locale to the postcard memory driver; rollback: no job, no mail); `TestWishlistOverlapRoutesAssembled` (the assembled router sends each of the four 13-01 overlap pairs to the real handler). + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestReserveConcurrent|TestRevealIdempotent|TestReservationMask|TestWishlistSubscriptions|TestPurchaseSideEffects|TestPurchaseMailAfterCommit|TestWishlistOverlapRoutesAssembled|TestRouteTablePhase12)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks a "--- PASS" line for each of the seven new named tests; the parity run reports FAIL for a wishlist subtest or lacks "--- PASS: TestParityCorpus/coverage". + + + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 143. + - `grep -cE '/wishlist/albums/\{id\}/(match|apply-release)' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints 0 and both manifest entries read `status: pending`. + - `awk '/r.Group\("\/api\/v1\/fonoteka"/,/^\t}\)/' ../fonoteka.go/plugins/golem15/fonoteka/routes.go | grep -cE 'wishlist/(share|settings|subscri|household|token|\{collectionId\})|/reserve|/reveal|/purchase|/similar'` prints 0 (none of these is on the token group). + - `grep -c 'wishlist_item_purchased' ../fonoteka.go/plugins/golem15/fonoteka/mail.go` prints at least 2 and `grep -c 'layout = "plytarium"' ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm` prints 1. + - `grep -c 'func ResolvePublic(' ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go` prints 1. + + Following, secret reservations, reveal, peer browsing and purchase all behave as in PHP, with purchase mail delivered by a transactional job and every other wishlist route ported. + + + + Task 4: The Nuxt wishlist journey and the MCP wishlist tools replay end to end, with notifications, publications and digest rows matching PHP + The isolated PHP instance can serve with `QUEUE_CONNECTION=database` and the tide fake Centrifugo recorder (`summer parity:broadcasts`) can listen on 127.0.0.1:8424. + ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml, ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.rows.json, ../fonoteka.go/parity/fixtures/mcp/mcp-wishlist.yaml, ../fonoteka.go/parity/fixtures/broadcasts/wishlist-item-added.yaml, ../fonoteka.go/parity/fixtures/broadcasts/wishlist-purchased.yaml, ../fonoteka.go/parity/fixtures/broadcasts/reservation-revealed.yaml, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/README.md + /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/ (wishlist composables), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/fonoteka.ts (wishlist calls), /media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/client.ts (lines 295-307), ../fonoteka.go/parity/fonoteka_flows_test.go (replayNuxtCollections, replayNuxtAlbums, isolatedFlowDB), ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml, ../fonoteka.go/parity/fixtures/mcp/mcp-tools.yaml, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/fixtures/broadcasts/created.yaml, ../fonoteka.go/parity/README.md (Broadcast goldens, Phase 13 recording), ../fonoteka.go/parity/php_parity.sh (rows), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Findings 4 and 8) + Per D-12 and D-13 (RESEARCH Findings 4 and 8). + +(1) nuxt-wishlist: author the request spec in the order the Nuxt composables issue it (alice creates two items, opens share and enables it, bob subscribes by token and by collection, GET notifications as bob, bob reserves an item, alice lists (masked), alice reveals, GET notifications as bob, alice purchases, GET notifications as bob and as alice, settings show/update, household, bob lists alice's wishlist as a peer) with the app's headers, record it with `QUEUE_CONNECTION=database php_parity.sh serve` so the digest and purchase-mail jobs stay queued, captures for every id and `share:wishlist`. After the recording, dump the digest-queue rows with `php_parity.sh rows` using a SELECT that joins users.email and collections.name (no ids) into nuxt-wishlist.rows.json. `TestFonotekaNuxtFlows/nuxt-wishlist` replays it on an isolated database from the `wishlist` seed and compares the same SELECT on Postgres with the rows golden (email, collection name, item_count), plus asserts one summer_jobs row per digest window with label wishlist_digest. + +(2) mcp-wishlist: record the fonoteka-mcp wishlist calls (list, create, update, delete through `/api/v1/fonoteka/wishlist/albums`) with a pinned personal token from the vars store; `TestFonotekaNuxtFlows/mcp-wishlist` replays them. + +(3) Publications: with `summer parity:broadcasts` record the `notification:new` and `notification:count` publications for item-added, purchase (under sync with no email-enabled subscriber for the album `updated` broadcast, as Finding 4 describes) and reveal into the three goldens; add `TestBroadcastGoldens` subtests `wishlist-item-added`, `wishlist-purchased`, `reservation-revealed` using the 13-01 payload id and created_at masks. + +(4) parity/README.md: the nuxt-wishlist recipe (database queue, rows dump, broadcasts) and the mcp-wishlist recipe. Fixtures carry only `{{...}}` references for tokens (check_corpus --check-secrets). + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows|TestBroadcastGoldens|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 + Any command exits non-zero; the run prints "--- FAIL", "--- SKIP" or "no tests to run", or lacks "--- PASS: TestFonotekaNuxtFlows/nuxt-wishlist", "--- PASS: TestFonotekaNuxtFlows/mcp-wishlist", "--- PASS: TestBroadcastGoldens/wishlist-item-added", "--- PASS: TestBroadcastGoldens/wishlist-purchased" and "--- PASS: TestBroadcastGoldens/reservation-revealed"; check_corpus reports a secret, an unrecorded route or a ported case-status mismatch. + + + - `test -f ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.rows.json` succeeds and the file holds at least one row with an `item_count` key. + - `grep -c 'share:wishlist' ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml` prints at least 1 and `grep -cE 'inv_[A-Za-z0-9]{20,}' ../fonoteka.go/parity/fixtures/mcp/mcp-wishlist.yaml` prints 0. + - `grep -c '/notifications' ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml` prints at least 3. + - `grep -c 'QUEUE_CONNECTION=database' ../fonoteka.go/parity/README.md` prints at least 1. + + The whole wishlist journey of both real clients replays against Go, and its side effects (bell rows via the API, publications, digest rows) are proven equal to PHP's. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| JWT client → wishlist routes | Users act on their own wishlist and on peers' wishlists only through visibility scopes | +| Personal token → token-group wishlist routes | MCP clients reach only list/create/update/delete of the token owner's wishlist | +| Write transaction → notifications, publications, jobs, mail | Side effects must follow the transaction outcome | +| Reservation data → wishlist owner | The giver's identity is hidden until reveal | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-04 | Elevation of Privilege | reserve, cancel, reveal | high | mitigate | Own wishlist → 422; reveal only through the caller's own wishlist; cancel only by the reserver (Winter 404 otherwise); TestReservationMask and TestReserveConcurrent (Task 3). | +| T-13-05 | Information Disclosure | reservation mask in every serializer path | high | mitigate | ReservationStateForViewer omits reserved_by for the owner before reveal on index, show and peer routes; TestReservationMask and TestWishlistOwnListAndShow (Tasks 1, 3). | +| T-13-06 | Information Disclosure / Tampering | peer wishlists, subscriptions, wishlist album ids | high | mitigate | ReservableWishlistIDs / WishlistsVisibleTo / ActiveWishlist scoping; identical Winter 404 pages for foreign and missing; TestWishlistSubscriptions and peer cases (Tasks 2, 3). | +| T-13-07 | Elevation of Privilege | token group | high | mitigate | Only list/create/update/delete are mounted on /api/v1/fonoteka with one inv.scope each; acceptance grep plus TestRouteTablePhase13 in 13-06 (Task 3). | +| T-13-28 | Tampering / Repudiation | item-added and purchase side effects | medium | mitigate | Bell rows, digest upsert, Dispatch and Enqueue run on the write transaction; publications go out after commit; TestDigestCoalescing and TestPurchaseMailAfterCommit rollback cases (Tasks 2, 3). | +| T-13-29 | Information Disclosure | subscribe by token | medium | mitigate | ResolvePublic requires public_enabled, kind wishlist and a constant-time exact token match; a disabled or regenerated token answers 404 (Task 3). | +| T-13-30 | Information Disclosure | purchase mail job args and logs | medium | mitigate | Args carry subscriber id and the two names only (no address, no token); the worker never logs args (Task 3). | +| T-13-SC | Tampering | package installs | low | accept | No new dependency in this plan. | + + + +- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus at 143 ported and passing; nuxt-wishlist, mcp-wishlist and the three new broadcast goldens pass; check_corpus `--require-recorded --check-secrets` green. + + + +- 30 wishlist routes ported with PHP bodies and error pages; match/apply-release still pending. +- Item-added, purchase and reveal side effects match PHP (rows, publications, digest rows), mail only after commit, no digest worker. +- Both real clients' wishlist journeys replay green. + + + +Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-03-SUMMARY.md` when done. + diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-04-PLAN.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-04-PLAN.md new file mode 100644 index 0000000..2ad9f78 --- /dev/null +++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-04-PLAN.md @@ -0,0 +1,292 @@ +--- +phase: 13-p-ytarium-api-wishlist-notifications-csv-credentials-public +plan: 04 +type: execute +wave: 4 +depends_on: ["13-03"] +files_modified: + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/contract.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/writer.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/pipe_codec.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/parser.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/detector.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/mapper.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/canonical_id.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/errors.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/csv_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/testdata/ + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_export.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_export_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_import_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go + - ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml + - ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml + - ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml + - ../fonoteka.go/plugins/golem15/fonoteka/routes.go + - ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/go.mod + - ../fonoteka.go/plugins/golem15/fonoteka/go.sum + - ../fonoteka.go/go.mod + - ../fonoteka.go/go.sum + - ../fonoteka.go/go.work.sum + - ../fonoteka.go/parity/manifest.yaml + - ../fonoteka.go/parity/fixtures/routes/ + - ../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.yaml + - ../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.rows.json + - ../fonoteka.go/parity/fixtures/nuxt/files/ + - ../fonoteka.go/parity/fonoteka_seed_test.go + - ../fonoteka.go/parity/fonoteka_reset.php + - ../fonoteka.go/parity/fonoteka_flows_test.go + - ../fonoteka.go/parity/parity_test.go + - ../fonoteka.go/parity/README.md +autonomous: true +requirements: [API-05] +estimate: + tokens: 300000 + raw_tokens: 300000 + tasks: 3 + confidence: low +must_haves: + truths: + - "`GET export/csv` on the JWT group and on the token group (`inv.scope:read`) streams `text/csv; charset=UTF-8` with `Content-Disposition: attachment; filename=plytarium-kolekcja-.csv`, a UTF-8 BOM, the CsvAlbumContract HEADERS row and one row per album from the same SQL filters as the authenticated album index, written by a port of PHP `fputcsv(..., ',', '\"', '')`: fields containing a comma, quote, CR, LF, tab or space are quoted, quotes doubled, null and false empty, true `1`, and formula-leading cells neutralised by the FORMULA_PATTERN guard." + - "Per RESEARCH Finding 6, the export never uses `encoding/csv.Writer`, and its recorded fixture replays on any later day through the 13-01 Content-Disposition date mask." + - "The `classes/csv` package ports CsvAlbumParser, CsvColumnDetector, CsvAlbumContract, CsvPipeCodec, CsvColumnMapper and CsvCanonicalIdMatcher: BOM strip, UTF-8 then Windows-1250 then ISO-8859-2 decoding (golang.org/x/text/encoding/charmap, approved at the checkpoint), the `\\p{L}` check, delimiter guess, Polish and English header aliases, combined `Artist - Title`, MAX_ROWS 5000 and NUL rejection, each pinned by a truth table whose expected values were produced by the PHP classes." + - "`POST import/csv` (JWT, `throttle:10,1`) refuses a missing, non-csv/txt or unreadable file with 422 `{\"result\":\"error\",\"code\":\"csv_unreadable\",\"message\":...}`, a file over 5242880 bytes with `csv_too_large`, a parse failure with the exception's errorCode and its English message, invalid canonical rows with `validation_failed`; otherwise it stores the upload in a private bucket at `fonoteka-csv//.csv` (never under the public uploads prefix), creates the import in status `preview` (canonical) or `mapping` with mode `fill_empty`, writes the rows and answers 202 with the serialized import." + - "`GET import/csv/{id}` returns the import with rows paginated by `page` and `per_page` clamped to 1..50, `summary` as an object of status counts or `[]` when empty, and progress from row counts (uploaded, mapping, matching) or from the summer_jobs row; imports are visible only when `user_id` is the caller and the target collection is still accessible, otherwise PHP's not-found body." + - "Per D-03 and the 13-01 job contract, a non-canonical `PATCH import/csv/{id}/mapping` (only before commit: mapping, preview or failed; else `csv_already_committed`) cancels the previous match job, re-parses and replaces the rows, sets status `uploaded` and dispatches `CsvMatchArgs` on `fonoteka.csv.match` with label `fonoteka.csv.match`, Count row_count and Metadata `{\"csv_import_id\":N}`, storing match_job_id; a canonical mapping sets `preview` without a job." + - "Per D-03, `POST import/csv/{id}/commit` compare-and-swaps `preview` → `importing` (with the requested import_mode) and dispatches `CsvImportArgs` on `fonoteka.csv.import` with label `fonoteka.csv.import`, storing import_job_id and answering 202 `{\"data\":{\"import_job_id\",\"status\",\"import_mode\"}}`; a replay while importing or done answers 202 with the current job; any other status answers `csv_match_not_ready`." + - "Per D-04, no worker exists for either kind: the queued River jobs stay unworked, and `show` reports the status and progress PHP reports before its worker runs; `POST import/csv/{id}/cancel` calls conga.CancelJob for both job ids (is_canceled, status stopped 4) and sets the import `canceled`." + - "Per D-05, `PATCH import/csv/{id}/rows/{rowId}` ports `skip` (status skipped) and `accept_csv` (status matched_csv); the `selected_discogs_id` branch validates a digit string from the row's `candidates_json` allow-list (else `validation_failed`), checks DiscogsAllowed (else `discogs_unavailable`) and then asks the `ReleaseFetcher` seam, whose Phase 13 implementation always fails, so the route answers 422 `discogs_unavailable` with `Nie udało się pobrać tego wydania z Discogs.` and never writes release data; the successful-pick case stays pending for Phase 14." + - "The 8 CSV routes are ported with re-recorded fixtures (`expectedPortedRoutes` 151), and `TestFonotekaNuxtFlows/nuxt-csv` (recorded with QUEUE_CONNECTION=database: store, show, mapping, row edit, commit to its 202, show, cancel, export on both groups) replays green with its job rows (label, status, progress_max, metadata, is_canceled) equal to the recorded PHP `golem15_apparatus_jobs` rows." + - "Edge (API-05 boundary): a 5242880-byte file is accepted and 5242881 bytes answer `csv_too_large`; 5000 data rows are accepted and 5001 are refused as PHP refuses them; `per_page=0` reads 1 and `per_page=51` reads 50." + - "Edge (API-05 encoding): a Windows-1250 file and an ISO-8859-2 file containing `Łódź` import with the same row values as their UTF-8 twin; a file with a NUL byte answers `csv_unreadable`." + - "Edge (API-05 concurrency): two concurrent commits of one preview import dispatch exactly one import job; the loser answers 202 with the same import_job_id." + - "Edge (API-05 empty): an import whose rows carry no status has `summary` `[]`, and an export of an empty collection is the BOM plus the header row." + - statement: "Edge (API-05 ordering): exported rows follow the album index's default order and the header columns follow CsvAlbumContract::HEADERS exactly." + verification: backstop + artifacts: + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/csv/writer.go" + provides: "WriteRecord, the fputcsv port" + contains: "func WriteRecord(" + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/csv/parser.go" + provides: "Parse, ParseResult, MaxRows, MaxBytes" + contains: "charmap" + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go" + provides: "StoreCsvImport, CsvImportFor, UpdateCsvMapping, UpdateCsvRow, CommitCsvImport, CancelCsvImport, SerializeCsvImport, ReleaseFetcher, SetReleaseFetcher, ErrDiscogsUnavailable" + contains: "CsvMatchLabel" + - path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_import_controller.go" + provides: "CsvImportStore, CsvImportShow, CsvImportMapping, CsvImportRow, CsvImportCommit, CsvImportCancel" + - path: "../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.yaml" + provides: "recorded Nuxt CSV journey" + key_links: + - from: "../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go" + to: "../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go" + via: "Dispatch CsvImportArgs and CsvMatchArgs with their queues and labels; CancelJob on cancel and remap" + pattern: "CsvImportQueue" + - from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_export_controller.go" + to: "../fonoteka.go/plugins/golem15/fonoteka/classes/csv/writer.go" + via: "BOM, HEADERS, then WriteRecord per album row" + pattern: "WriteRecord" + - from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go" + to: "../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml" + via: "private CSV bucket opened from golem15.fonoteka.csv.bucket_url" + pattern: "csv.bucket_url" + prohibitions: + - requirement_id: API-05 + category: transparency + statement: "The selected_discogs_id branch MUST NOT fabricate, guess or copy release data; until Phase 14 it answers discogs_unavailable (D-05)" + status: resolved + verification: test + - requirement_id: API-05 + category: transparency + statement: "No CSV import or match job may be worked, marked done or faked in Phase 13; show reports the queued state PHP reports (D-04)" + status: resolved + verification: test + - requirement_id: API-05 + category: privacy + statement: "An uploaded CSV MUST NOT be stored where the static uploads handler or any public URL can serve it" + status: resolved + verification: test +--- + +## Phase Goal + +ROADMAP Phase 13 goal (verbatim, not in user-story form): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets. + +This plan's slice: a collector downloads their collection as a spreadsheet-safe CSV, uploads a CSV from another app in any of the encodings Polish users have, maps its columns, fixes rows, commits it and sees it waiting for the importer, exactly as the Nuxt import wizard sees PHP before its worker runs (API-05; ROADMAP SC-3). + + +Port CSV export on both groups with an `fputcsv` writer, the `classes/csv` parser package with PHP truth tables, the private upload bucket, and the six import-session routes with the commit compare-and-swap, the job dispatch and cancellation of the 13-01 contract and the D-05 seam; record the routes and the nuxt-csv flow with job-row goldens. + +Purpose: the import wizard is how users bring collections into Płytarium; the jobs it queues are the contract Phase 14 workers fulfil. Decisions implemented: D-03, D-04, D-05, D-12 (nuxt-csv), D-13 (row goldens), C-01, C-02, C-03, C-07; RESEARCH Findings 3, 4 and 6. +Output: csv package, services, handlers, routes, config key, recordings, flow; ported count 151. + +Repo: fonoteka.go only. golang.org/x/text becomes a direct requirement of the fonoteka plugin module (user-approved at the plan-count checkpoint; already v0.42.0 in the graph). Commits path-scoped; never add co-author tags. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-SUMMARY.md +@../fonoteka.go/plugins/golem15/fonoteka/routes.go + + +- From 13-01: job contract `classes.CsvImportKind`, `CsvImportQueue`, `CsvImportLabel`, `CsvMatchKind`, `CsvMatchQueue`, `CsvMatchLabel`, `CsvImportArgs{CsvImportID}`, `CsvMatchArgs{CsvImportID}`; conga unregistered-kind dispatch; tide Content-Disposition date mask; `php_parity.sh rows`; `QUEUE_CONNECTION` override. +- From 13-03: `classes.JobDispatcher`, `SetJobDispatcher` (Dispatch and Enqueue on a transaction). +- conga: `(*Manager).Dispatch(ctx, db, args, DispatchOpts{Label, Count, Metadata, Queue})`, `(*Manager).CancelJob(ctx, id)`, `(*Manager).Get(ctx, id) (Record, error)` with Record{Status, Progress, ProgressMax, IsCanceled, Metadata}; statuses equal Apparatus constants (record.go). +- Existing: `classes/album_search.go` unexported `searchSQL` and filter parsing used by the authenticated album index; `classes.DiscogsAllowed`; `uploadBucket(app)` (request.go:192) for the public bucket; tide multipart `Request.Parts` with sha256 part files; models `CsvImport{UserID, CollectionID, Status, ImportMode, StoragePath, RowCount, ColumnMap Jsonable, MatchJobID *uint, ImportJobID *uint}`, `CsvImportRow{Status, Aggregate, CandidatesJSON, ...}`. +- PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{controllers/api/CsvImportApiController.php (whole file), controllers/api/CsvExportApiController.php, classes/csv/*.php, models/CsvImport.php, models/CsvImportRow.php, lang/pl/lang.php (csv_import keys, lines 138-147), lang/en/lang.php, routes.php (lines 78, 324-329, 455)}; /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/JobManager.php (dispatch, cancel); Nuxt /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/ (CSV import wizard calls). + + + +## Artifacts this phase produces + +(This plan's share.) + +- Package `classes/csv` (package name `csv`): `Headers`, `ExportRow`, `FormulaSafe`, `WriteRecord`, `Parse`, `ParseResult`, `ParseError{Code, Message}`, `MaxRows`, `MaxBytes`, `DetectColumns`, `MapColumns`, `PipeEncode`, `PipeDecode`, `MatchCanonicalID`. +- classes: `ExportAlbums` (csv_export.go), `StoreCsvImport`, `CsvImportFor`, `UpdateCsvMapping`, `UpdateCsvRow`, `CommitCsvImport`, `CancelCsvImport`, `SerializeCsvImport`, `ReleaseFetcher` (interface), `SetReleaseFetcher`, `ErrDiscogsUnavailable`, `ErrCsvAlreadyCommitted`, `ErrCsvMatchNotReady`. +- controllers/api: `CsvExport`, `CsvImportStore`, `CsvImportShow`, `CsvImportMapping`, `CsvImportRow`, `CsvImportCommit`, `CsvImportCancel`; `csvBucket(app)` (private bucket helper in request.go). +- Config key: `golem15.fonoteka.csv.bucket_url` (default `file://./storage/app`). +- Routes: JWT `POST /import/csv` (`throttle:10,1`), `GET /import/csv/{id}`, `PATCH /import/csv/{id}/mapping`, `PATCH /import/csv/{id}/rows/{rowId}`, `POST /import/csv/{id}/commit`, `POST /import/csv/{id}/cancel` (all `[0-9]+`), `GET /export/csv`; token `GET /export/csv` (`inv.scope:read`). +- Parity: seed state `csv`; `fixtures/nuxt/nuxt-csv.yaml`, `nuxt-csv.rows.json`, part files under `fixtures/nuxt/files/`; `TestFonotekaNuxtFlows/nuxt-csv`. +- Tests: `TestPHPFputcsv`, `TestCsvParserTruthTable`, `TestCsvDetectorTruthTable`, `TestCsvExport`, `TestCsvStoreAndShow`, `TestCsvImportScope`, `TestCsvCommitCAS`, `TestCsvJobRows`, `TestCsvCancel`, `TestCsvRowPickSeam`. + + + + + Task 1: A collector downloads their collection as a CSV that matches PHP's file byte for byte + The isolated PHP parity instance can be reset (`php -v` exits 0). + ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/contract.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/writer.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/pipe_codec.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/csv_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_export.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_export_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CsvExportApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvAlbumContract.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvPipeCodec.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSearchService.php (authenticatedDatabaseQuery), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go (searchSQL, filters), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go (index filter parsing), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_export_csv_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/GET__api_v1_fonoteka_export_csv_personal_token.yaml, .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Finding 6 point 3, fputcsv probe) + Per RESEARCH Finding 6 and C-01. + +(1) classes/csv: contract.go ports CsvAlbumContract's `HEADERS`, `exportRow` and the FORMULA_PATTERN guard (`^[\x09\x0A\x0D ]*[=+\-@]`) as `Headers`, `ExportRow`, `FormulaSafe`; pipe_codec.go ports CsvPipeCodec as `PipeEncode`/`PipeDecode`; writer.go `WriteRecord(w io.Writer, fields []any) error` ports PHP `fputcsv` with delimiter `,`, enclosure `"` and an empty escape: quote a field containing comma, quote, CR, LF, tab or space, double embedded quotes, write nil and false as empty, true as `1`, numbers in PHP's string form, and end the line with LF as PHP does. Do not use encoding/csv.Writer. `TestPHPFputcsv` table includes the research probe line (`Kind of Blue`, `LP`, a tab, `x"y`, empty, `=SUM(1)`, `plain`, `semi;colon`, `café`, and null/false/true/0) with PHP's exact bytes. + +(2) classes/csv_export.go `ExportAlbums(ctx, db, user, token, filters, fn func(row []any) error)`: the authenticated album query (port authenticatedDatabaseQuery over the existing searchSQL and filter parsing, SQL path only, exporting what is needed from album_search.go without changing its callers) streamed in batches of 100 in the index's default order. controllers/api/csv_export_controller.go `CsvExport(app)`: validate filters as PHP does, set `Content-Type: text/csv; charset=UTF-8` and `Content-Disposition: attachment; filename=plytarium-kolekcja-.csv`, write the BOM, the Headers row and each ExportRow through WriteRecord. Route: JWT `GET /export/csv`. + +(3) Seed state `csv` (alice's collection with albums whose fields exercise quoting, formula cells, Polish letters and empty values; imports for Task 2-3 added there later) in both seeds. Re-record the JWT export fixture from the reset (the old one carries the `{{id:token}}` collision); flip it; expectedPortedRoutes 144. + +(4) csv_smoke_test.go `TestCsvExport`: the body starts with the BOM and the header row, a formula cell is neutralised, a field with a space is quoted, an empty collection yields only BOM and header. + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes/csv -run '^(TestPHPFputcsv)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCsvExport)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestPHPFputcsv" and "--- PASS: TestCsvExport"; the parity run reports FAIL for the export route or lacks "--- PASS: TestParityCorpus/coverage". + + + - `grep -rc 'encoding/csv' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/writer.go` prints 0. + - `grep -c 'plytarium-kolekcja-' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_export_controller.go` prints 1. + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 144. + - The re-recorded JWT export fixture contains no `{{id:token}}` placeholder. + + The export works end to end with PHP's exact quoting, BOM, header and filename, proving the csv package, the query and the recording path before the import session lands. + + + + Task 2: A collector uploads a CSV in any Polish encoding and sees the parsed preview or the mapping step, stored privately + The isolated PHP parity instance can be reset and `php` can run the PHP csv classes for truth-table generation. + ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/parser.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/detector.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/mapper.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/canonical_id.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/errors.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/csv_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/testdata/, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_import_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go, ../fonoteka.go/plugins/golem15/fonoteka/go.mod, ../fonoteka.go/plugins/golem15/fonoteka/go.sum, ../fonoteka.go/go.mod, ../fonoteka.go/go.sum, ../fonoteka.go/go.work.sum, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/nuxt/files/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvAlbumParser.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvColumnDetector.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvColumnMapper.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvCanonicalIdMatcher.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvParseException.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CsvImportApiController.php (store, show, replaceRows, hasInvalidCanonicalRows, findVisible, serializeImport, serializeRow, jobProgress, notFound, error), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/CsvImport.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/CsvImportRow.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/pl/lang.php (lines 138-147), ../fonoteka.go/plugins/golem15/fonoteka/models/csv_import.go, ../fonoteka.go/plugins/golem15/fonoteka/models/csv_import_row.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go (multipart handling), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go (uploadBucket), ../fonoteka.go/config/storage.yaml, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/classes/access.go + (1) Parser package, per RESEARCH "Parser classes to port": parser.go `Parse(content []byte, columnMap map[string]any) (ParseResult, error)` ports CsvAlbumParser (BOM strip, UTF-8 validity then Windows-1250 then ISO-8859-2 via golang.org/x/text/encoding/charmap, the `\p{L}` plausibility check, `fgetcsv` logical records with enclosure `"` and an empty escape, MaxRows 5000, MaxBytes 5242880, NUL rejection), detector.go, mapper.go, canonical_id.go and errors.go (`ParseError{Code, Message}` carrying PHP's error codes and English messages). Promote golang.org/x/text to a direct requirement (`go get` in the plugin module, `go mod tidy`, `go work sync`). Truth tables in csv_test.go `TestCsvParserTruthTable` and `TestCsvDetectorTruthTable` read fixture CSVs from classes/csv/testdata/ and compare against expected JSON produced once by running the PHP classes (`php artisan tinker` against the isolated instance or a small php script under parity/, documented in a testdata README line); cover canonical exports, combined `Artist - Title`, semicolon and tab delimiters, Polish and English header aliases, the three encodings with `Łódź`, 5000 versus 5001 rows, a NUL byte and an empty file. + +(2) Private bucket: config.yaml key `csv.bucket_url` (merged as `golem15.fonoteka.csv.bucket_url`, default `file://./storage/app`); request.go `csvBucket(app)` opens it separately from uploadBucket; keys are `fonoteka-csv//.csv`, generated server-side only. + +(3) csv_import_service.go: `StoreCsvImport` (size and extension checks, Parse, canonical invalid rows → validation_failed, ActiveCollection resolve, write the blob, create the import (status preview when canonical, mapping otherwise, mode fill_empty), replace rows with PHP's per-row status), `CsvImportFor` (findVisible: user_id is the caller and the collection is still AccessibleBy the caller), `SerializeCsvImport` (PHP key order; `summary` an object of counts or `[]`; progress per jobProgress, reading the summer_jobs row through conga when a job id is set; `can_retry`). Port the csv_import lang keys (pl and en) to lang.yaml for the error messages. controllers/api/csv_import_controller.go `CsvImportStore` (202 `{"data":...}`; errors `{"result":"error","code","message"}` with PHP's statuses; a CSV parse error uses the English exception message as PHP does) and `CsvImportShow` (`page`, `per_page` clamped 1..50, `rows_per_page`; not visible → PHP's not-found body). Routes: JWT `POST /import/csv` with `throttle:10,1`, `GET /import/csv/{id}` with `[0-9]+`. + +(4) Recordings via tide multipart parts (part files under parity/fixtures/nuxt/files/ pinned by sha256): store canonical 202, store non-canonical 202, unreadable 422 (binary), missing file 422 (Polish message), too large 422, parse error 422 (English message); show of each import, a foreign import (bob) and a missing id. Flip both routes; expectedPortedRoutes 146. + +(5) csv_smoke_test.go `TestCsvStoreAndShow` (canonical → preview with rows, non-canonical → mapping, blob key under fonoteka-csv/ in the private bucket and absent from the public bucket) and `TestCsvImportScope` (another user's import and an import whose collection the caller lost access to both answer not found). + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes/csv -count=1 -v -run '^(TestPHPFputcsv|TestCsvParserTruthTable|TestCsvDetectorTruthTable)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCsvStoreAndShow|TestCsvImportScope|TestCsvExport)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestCsvParserTruthTable, TestCsvDetectorTruthTable, TestCsvStoreAndShow and TestCsvImportScope; the parity run reports FAIL for an import route or lacks "--- PASS: TestParityCorpus/coverage". + + + - `grep -c 'golang.org/x/text' ../fonoteka.go/plugins/golem15/fonoteka/go.mod` prints 1 and that line has no `// indirect` marker. + - `grep -c 'charmap' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/parser.go` prints at least 1. + - `grep -c 'csv.bucket_url\|bucket_url' ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml` prints at least 1 and `grep -c 'uploads/public' ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml` prints 0. + - `ls ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/testdata/` lists at least one Windows-1250 and one ISO-8859-2 sample. + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 146. + + Uploads in UTF-8, Windows-1250 and ISO-8859-2 parse exactly as PHP parses them, land in private storage, and show the preview or mapping state with PHP's bodies and errors. + + + + Task 3: A collector maps columns, fixes rows, commits and cancels an import, sees it queued for the Phase 14 importer, and exports through a personal token + Writes the D-03 job kinds, queues, labels and args (13-01 contract) into live summer_jobs and river_job rows; signed off 2026-10-02, recorded without a new checkpoint. + The isolated PHP instance can serve with `QUEUE_CONNECTION=database` (13-01 override) and `php_parity.sh rows` works. + ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_import_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.yaml, ../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.rows.json, ../fonoteka.go/parity/fixtures/nuxt/files/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/README.md + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CsvImportApiController.php (updateMapping, updateRow, commit, cancel, cancelJob, allowedCandidateIds, isBeforeCommit), /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/JobManager.php (dispatch, cancel lines 59-97 and 222-233), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/DiscogsGate.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 324-329, 455), ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go (JobDispatcher), summercms.go modules/conga/conga.go (Dispatch, CancelJob, Get), summercms.go modules/conga/record.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go (DiscogsAllowed), ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/README.md (Phase 13 recording) + Per D-03, D-04 and D-05. + +(1) Mapping: `UpdateCsvMapping` requires isBeforeCommit (mapping, preview, failed; else ErrCsvAlreadyCommitted → `csv_already_committed`), reads `column_map` or the whole body, re-parses the stored blob, cancels the previous match job (conga.CancelJob when match_job_id is set), replaces rows, sets preview (canonical) or uploaded (non-canonical) and, when non-canonical, Dispatches `CsvMatchArgs{CsvImportID}` with CsvMatchQueue, CsvMatchLabel, Count row_count and Metadata `{"csv_import_id":N}` on the same transaction, storing match_job_id. Handler `CsvImportMapping`. + +(2) Row edit: `UpdateCsvRow` ports `skip` and `accept_csv`; for `selected_discogs_id` it validates a digit string present in the row's candidates (else validation_failed), checks DiscogsAllowed (else ErrDiscogsUnavailable), then calls the `ReleaseFetcher` interface (`FetchRelease(ctx, user, discogsID string) (map[string]any, error)`) installed with `SetReleaseFetcher`; the Phase 13 default returns ErrDiscogsUnavailable with a doc comment naming Phase 14 (INTG-01), so the handler answers 422 `{"result":"error","code":"discogs_unavailable","message":"Nie udało się pobrać tego wydania z Discogs."}` and writes nothing. Handler `CsvImportRow`. + +(3) Commit: `CommitCsvImport` runs an `UPDATE ... SET status = 'importing', import_mode = ? WHERE id = ? AND status = 'preview'`; one updated row → Dispatch `CsvImportArgs{CsvImportID}` with CsvImportQueue, CsvImportLabel, Count row_count, Metadata `{"csv_import_id":N}` in the same transaction and store import_job_id; zero rows and status importing or done → 202 with the current import_job_id (replay); otherwise ErrCsvMatchNotReady → `csv_match_not_ready`. Handler `CsvImportCommit` answers 202 `{"data":{"import_job_id","status","import_mode"}}`. + +(4) Cancel: `CancelCsvImport` cancels both job ids through conga.CancelJob and sets status canceled; handler `CsvImportCancel` answers PHP's body. Routes JWT `PATCH /import/csv/{id}/mapping`, `PATCH /import/csv/{id}/rows/{rowId}` (both `[0-9]+`), `POST /import/csv/{id}/commit`, `POST /import/csv/{id}/cancel`; token group `GET /export/csv` with `inv.scope:read` sharing the Task 1 handler. + +(5) Recordings with `QUEUE_CONNECTION=database`: route cases for mapping (canonical, non-canonical with job, committed → csv_already_committed), row edit (skip, accept_csv, validation_failed pick, discogs_unavailable pick with the gate off), commit (202, replay 202, csv_match_not_ready), cancel (200, foreign 404), token export. The successful-pick case is not recorded (Phase 14). nuxt-csv flow: store → show → mapping → row edit → commit (202) → show (importing, progress from the job row) → cancel → export JWT → export token; after recording, dump `golem15_apparatus_jobs` (label, status, progress, progress_max, metadata, is_canceled ordered by id) with `php_parity.sh rows` into nuxt-csv.rows.json. Align the csv_imports id sequence on both sides as Phase 12 did for collections so metadata ids match. `TestFonotekaNuxtFlows/nuxt-csv` replays and compares the same columns of Go's summer_jobs with the golden. Flip the remaining 5 routes; expectedPortedRoutes 151. README: the nuxt-csv recipe. + +(6) Tests in csv_smoke_test.go: `TestCsvCommitCAS` (two concurrent commits: one dispatch, both 202 with the same import_job_id), `TestCsvJobRows` (match and import dispatches write the contract's label, count and metadata and leave river jobs unworked on unserved queues), `TestCsvCancel` (both jobs stopped, is_canceled, River jobs cancelled, import canceled), `TestCsvRowPickSeam` (a candidate pick answers discogs_unavailable and changes nothing; a non-candidate answers validation_failed; a test ReleaseFetcher returning data is never installed outside the test). + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCsvCommitCAS|TestCsvJobRows|TestCsvCancel|TestCsvRowPickSeam|TestCsvStoreAndShow)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus|TestFonotekaNuxtFlows)$' -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 + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestCsvCommitCAS, TestCsvJobRows, TestCsvCancel, TestCsvRowPickSeam and "--- PASS: TestFonotekaNuxtFlows/nuxt-csv"; check_corpus reports a secret, an unrecorded route or a ported case-status mismatch. + + + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 151. + - `grep -c 'CsvImportLabel' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go` and `grep -c 'CsvMatchLabel' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go` each print at least 1. + - `grep -cE 'csv_import|csv_match|CsvImportKind|CsvMatchKind' ../fonoteka.go/plugins/golem15/fonoteka/jobs.go` prints 0 (no CSV worker in Phase 13). + - `test -f ../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.rows.json` succeeds and it contains the label `fonoteka.csv.import`. + - `grep -c 'discogs_unavailable' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go` prints at least 1. + + The whole import wizard runs against Go up to a queued import job that waits for Phase 14, cancel and commit replays behave as in PHP, the Discogs pick never invents data, and both groups export. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Uploaded file → parser | Untrusted bytes of up to 5 MiB are decoded and parsed | +| Upload → storage | The file is kept for re-parsing and must never be web-served | +| Import id in URL → import row | Imports are per user and per accessible collection | +| Album data → exported CSV opened in spreadsheets | Cells can carry formulas | +| Request → queued jobs | Commit and mapping write rows Phase 14 workers will act on | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-12 | Elevation of Privilege | import routes by id | high | mitigate | CsvImportFor requires user_id = caller and AccessibleBy(collection_id); not visible → PHP not-found body; TestCsvImportScope (Task 2). | +| T-13-13 | Information Disclosure | CSV upload storage | high | mitigate | Private bucket from `golem15.fonoteka.csv.bucket_url`, server-generated `fonoteka-csv//.csv` keys, never the public uploads prefix; TestCsvStoreAndShow asserts absence from the public bucket (Task 2). | +| T-13-14 | Tampering | exported cells | medium | mitigate | FormulaSafe port of FORMULA_PATTERN on every exported cell; TestCsvExport and TestPHPFputcsv (Task 1). | +| T-13-15 | Denial of Service | CSV parse | medium | mitigate | 5 MiB cap, MaxRows 5000, NUL rejection, `throttle:10,1` on store; truth-table boundary cases (Task 2). | +| T-13-16 | Tampering | row edit Discogs pick | high | mitigate | Candidate allow-list; ReleaseFetcher seam always fails in Phase 13 → discogs_unavailable, nothing written; TestCsvRowPickSeam (Task 3). | +| T-13-17 | Tampering | double commit / double writer | high | mitigate | Single-statement compare-and-swap preview → importing; one Dispatch per swap; replay answers the existing job; TestCsvCommitCAS (Task 3). | +| T-13-31 | Repudiation | queued jobs without workers | medium | mitigate | Unserved queues from the 13-01 contract; cancel stops summer_jobs and River rows; TestCsvJobRows and TestCsvCancel (Task 3). | +| T-13-SC | Tampering | golang.org/x/text direct import | medium | mitigate | Go-team module already in the graph (v0.42.0 via go-i18n), go.sum pins the hash, approved by the user at the plan-count checkpoint; no npm/pip/cargo installs. | + + + +- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus at 151 ported and passing; `TestFonotekaNuxtFlows/nuxt-csv` passes; check_corpus `--require-recorded --check-secrets` green. + + + +- 8 CSV routes ported with PHP bodies, quoting and error envelopes. +- Parser, detector and writer match PHP truth tables across encodings and limits. +- Commit and mapping queue the contract's jobs, unworked until Phase 14; cancel and the D-05 seam behave as decided. + + + +Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-04-SUMMARY.md` when done. + diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-05-PLAN.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-05-PLAN.md new file mode 100644 index 0000000..18f7e4a --- /dev/null +++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-05-PLAN.md @@ -0,0 +1,253 @@ +--- +phase: 13-p-ytarium-api-wishlist-notifications-csv-credentials-public +plan: 05 +type: execute +wave: 5 +depends_on: ["13-04"] +files_modified: + - ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_public_album.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/public_share_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go + - ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/routes.go + - ../fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go + - ../fonoteka.go/parity/manifest.yaml + - ../fonoteka.go/parity/fixtures/routes/ + - ../fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml + - ../fonoteka.go/parity/fixtures/nuxt/public-pubfail.yaml + - ../fonoteka.go/parity/fonoteka_seed_test.go + - ../fonoteka.go/parity/fonoteka_reset.php + - ../fonoteka.go/parity/fonoteka_flows_test.go + - ../fonoteka.go/parity/parity_test.go + - ../fonoteka.go/parity/README.md +autonomous: true +requirements: [API-03, API-07] +estimate: + tokens: 220000 + raw_tokens: 220000 + tasks: 3 + confidence: low +must_haves: + truths: + - "Per D-14, the public group carries only `public.share-headers`; `GET public/{token}` and `GET public-wishlist/{token}` carry the inline `throttle:10,1` only, and the four albums index/show routes carry `throttle:fonoteka-public-token` then `throttle:fonoteka-public-ip` only; every public response, 429s included, has `X-Robots-Tag: noindex, nofollow` and `Cache-Control: no-store, private` exactly as PHP sends them on the wire." + - "Per C-06, a public token resolves only when it is 16 characters of `[0-9a-zA-Z]`, a `LOWER(public_token)` lookup finds a collection with public_enabled true and the route's kind (`collection` or `wishlist`), and a constant-time compare of the exact token succeeds; anything else answers 404 `{\"error\":\"Not found\"}`; no route regex constrains `{token}`." + - "The `pubfail:` counter ports PHP's anti-enumeration: before resolving, 10 failures within the 60 s window started by the first failure answer 429 `{\"error\":\"Too many requests\"}` without counting; each failed resolution counts one; the client IP is surf's trusted-proxy ClientIP; the counter is shared by all six public routes." + - "`GET public/{token}` and `GET public-wishlist/{token}` answer PHP's collection header body; `GET .../albums` validates its query with Validator::make semantics (422 `{\"error\":\"Validation failed\",\"errors\":...}`, a `rating` parameter refused by PHP's closure rule), searches only the shared collection with `TEXT_FIELDS_PUBLIC` (query_by `name,artist_display,style_names,genre_name,track_titles`, weights `10,10,5,5,3`, SQL LIKE on name, artist_display, track_titles and artists.name) re-gated in SQL to that collection, and answers the list, the header and facets (artist, genre, decade, format, zero-count rows dropped) in PHP's shape." + - "`GET .../albums/{id}` (`[0-9]+`) answers `{\"data\": serializePublicAlbum}` for an album of the shared collection and 404 `{\"error\":\"Not found\"}` otherwise; the public album carries exactly SerializesPublicAlbum's twelve keys in order (id, name, artists, artist_display, year, format, medium, genre, styles, tracklist, photos, created_at) and nothing else, and public-wishlist mirrors the public collection with `kind='wishlist'` and no reservation data." + - "The six public routes are ported with re-recorded fixtures (`expectedPortedRoutes` 157, pending 14: the four Phase 13→14 routes and the ten routes outside this phase), and `TestFonotekaNuxtFlows/public-anonymous` and `TestFonotekaNuxtFlows/public-pubfail` (10 failures then the 429) replay green." + - "Edge (API-07 boundary): the 10th failed resolution within a minute still answers 404 and the 11th answers 429; after the window the counter starts again." + - "Edge (API-07 encoding): a token of 15 or 17 characters or with a non-alphanumeric character never reaches the database and counts as a failure; an uppercase/lowercase variant of a valid token finds the row through LOWER() but fails the exact compare." + - "Edge (API-07 adjacency): after the owner regenerates the share token (13-03), the old token answers 404 and the new one resolves; after the owner disables sharing, the token answers 404." + - "Edge (API-07 empty): an albums query matching nothing answers an empty list with the header and facets whose rows are all non-zero (empty arrays when the collection has no albums)." + - statement: "Edge (API-07 concurrency): concurrent failures from one IP never let more than 10 failed resolutions through in one window (the counter's check and hit are serialized)." + verification: backstop + artifacts: + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go" + provides: "PubfailCounter (TooMany, Hit), PublicAlbums, PublicFacets, PublicHeader" + contains: "pubfail:" + - path: "../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_public_album.go" + provides: "SerializePublicAlbum" + contains: "func SerializePublicAlbum(" + - path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/public_share_controller.go" + provides: "PublicResolve, PublicAlbumsIndex, PublicAlbumsShow" + - path: "../fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml" + provides: "recorded anonymous public journey" + key_links: + - from: "../fonoteka.go/plugins/golem15/fonoteka/routes.go" + to: "../fonoteka.go/plugins/golem15/fonoteka/plugin.go" + via: "per-route throttle:fonoteka-public-token and throttle:fonoteka-public-ip bucket names" + pattern: "fonoteka-public-ip" + - from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/public_share_controller.go" + to: "../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go" + via: "ResolvePublic(ctx, db, token, kind) after the pubfail check" + pattern: "ResolvePublic" + - from: "../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go" + to: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go" + via: "public search mode with TEXT_FIELDS_PUBLIC and a collection-only SQL re-gate" + pattern: "track_titles" + prohibitions: + - requirement_id: API-07 + category: privacy + statement: "An anonymous public view MUST NOT expose ratings, prices, notes, shelf, condition, reservations or any owner account data, for either kind" + status: resolved + verification: test + - requirement_id: API-07 + category: privacy + statement: "A disabled, regenerated or wrong-kind share token MUST NOT resolve any collection" + status: resolved + verification: test +--- + +## Phase Goal + +ROADMAP Phase 13 goal (verbatim, not in user-story form): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets. + +This plan's slice: anyone holding a shared link can browse a collection or a wishlist without an account, searching and filtering it, while guessing links is throttled exactly as PHP throttles it (API-07 and the public-wishlist part of API-03; ROADMAP SC-1 and SC-5). + + +Port the six anonymous public routes with token resolution, the anti-enumeration counter, the public search mode, facets and the public album serializer; correct the public headers and move the rate buckets per route (D-14); record the public flows. + +Purpose: shared links are Płytarium's only anonymous surface and the most exposed one; it gets its own security-focused slice. Decisions implemented: D-12 (public-anonymous), D-14, C-01, C-02, C-03, C-06, C-07; RESEARCH Findings 6 (header) and 9 (public search mode). +Output: classes, handlers, header fix, route layout, recordings and flows; ported count 157. + +Repo: fonoteka.go only. Commits path-scoped; never add co-author tags. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-03-SUMMARY.md +@../fonoteka.go/plugins/golem15/fonoteka/routes.go + + +- From 13-03: `classes.ResolvePublic(ctx, db, token, kind) (*models.Collection, error)` (shape check, LOWER() lookup, public_enabled, kind, constant-time compare), share token capture `share:collection` and `share:wishlist`. +- Existing: routes.go public group `r.Group("/_fonoteka/api/v1", surf.Use("public.share-headers", "throttle:fonoteka-public-token", "throttle:fonoteka-public-ip"), ...)` (empty); plugin.go buckets `fonoteka-public-token` (60/min, key `pubtok:` + PathValue token) and `fonoteka-public-ip` (120/min, ClientIP); middleware/public_share_headers.go (sets X-Robots-Tag and Cache-Control, rewrites 429 to JSON); `surf.ClientIP(r, surf.TrustedProxies(cfg))`; inline throttle key `inline:domainless|` shared by every inline-throttled anonymous route; classes/album_search.go `SearchAlbums` (user/token scoped, authenticated TEXT_FIELDS), beachcomber `SearchPage` re-gated in SQL (Phase 12); routes_bucket_test.go boot-probe groups; `writeValidationFailed`. +- PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{controllers/api/PublicShareApiController.php (whole file), middleware/PublicShareHeaders.php, traits/SerializesPublicAlbum.php, classes/AlbumSearchService.php (TEXT_FIELDS_PUBLIC lines 60-65, public mode), classes/CollectionShareService.php (resolvePublic), routes.php (lines 399-428)}; Nuxt /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/pages and composables for `/k/` and `/w/` public pages. + + + +## Artifacts this phase produces + +(This plan's share.) + +- classes: `PubfailCounter` with `TooMany(key string) bool` and `Hit(key string)`, `PublicAlbums`, `PublicFacets`, `PublicHeader`, `SerializePublicAlbum`, public search mode in album_search.go (`TextFieldsPublic`). +- controllers/api: `PublicResolve`, `PublicAlbumsIndex`, `PublicAlbumsShow` (one handler per route, parameterised by kind). +- Routes (group `public.share-headers` only): `GET /_fonoteka/api/v1/public/{token}` and `GET .../public-wishlist/{token}` (`throttle:10,1`); `GET .../public/{token}/albums`, `GET .../public/{token}/albums/{id}`, `GET .../public-wishlist/{token}/albums`, `GET .../public-wishlist/{token}/albums/{id}` (`throttle:fonoteka-public-token`, `throttle:fonoteka-public-ip`; `{id}` `[0-9]+`). +- Parity: seed state `public`; `fixtures/nuxt/public-anonymous.yaml`, `fixtures/nuxt/public-pubfail.yaml`; `TestFonotekaNuxtFlows/public-anonymous`, `TestFonotekaNuxtFlows/public-pubfail`. +- Tests: `TestPublicResolve`, `TestPubfailCounter`, `TestPublicBucketsPerRoute`, `TestPublicAlbumFieldSet`. + + + + + Task 1: An anonymous visitor opens a shared collection link and gets PHP's header, its exact cache headers and its throttle + The isolated PHP parity instance can be reset (`php -v` exits 0); `php_parity.sh reset` clears the file cache before each anonymous recording. + ../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/public_share_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go, ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/PublicShareApiController.php (resolve, collectionHeader, tooManyFailedResolves, failureKey, unavailable, tokenUnavailable, throttled), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/PublicShareHeaders.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 399-428), ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go, ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go (ResolvePublic from 13-03), ../fonoteka.go/plugins/golem15/fonoteka/routes.go (public group), ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (public buckets), summercms.go modules/surf/clientip.go, summercms.go modules/surf/limiter.go (inline key), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_public_{token}_public_share.yaml (header bytes), ../fonoteka.go/parity/README.md (Phase 13 recording: anonymous throttle budget) + Per D-14, C-06 and RESEARCH Finding 6 point 1. + +(1) Header fix: public_share_headers.go sets `Cache-Control` to the wire value PHP sends, `no-store, private` (Symfony re-normalises PHP's directive order), keeping `X-Robots-Tag: noindex, nofollow` and the JSON 429 rewrite with Retry-After and X-RateLimit-* kept; update its test to the recorded bytes. + +(2) classes/public_share.go `PubfailCounter`: an in-memory fixed window per key (window starts at the first hit, 60 s, mutex-serialized), `TooMany(key) bool` peeks against the limit 10 without counting, `Hit(key)` counts one; one process-wide instance owned by the plugin; keys `pubfail:` plus surf's trusted-proxy ClientIP. `PublicHeader(collection)` ports collectionHeader. + +(3) controllers/api/public_share_controller.go `PublicResolve(app, kind)`: TooMany → 429 `{"error":"Too many requests"}`; ResolvePublic(token, kind) failure → Hit then 404 `{"error":"Not found"}`; success → PHP's resolve body. + +(4) routes.go, per D-14: the public group becomes `surf.Use("public.share-headers")` only; register `GET /public/{token}` with `throttle:10,1` (the albums routes and the wishlist twin follow in Task 2). Do not add a route regex for `{token}`. + +(5) Seed state `public` in both seeds: alice's collection shared (token from the store's `share:collection` value, never committed), alice's wishlist shared (`share:wishlist`), a disabled share on another collection. Re-record `GET public/{token}` cases from the reset: valid, malformed token, well-formed unknown token, disabled share (each ≤ 10 inline-throttled cases per route). Flip; expectedPortedRoutes 152. + +(6) public_share_smoke_test.go `TestPublicResolve` (valid, malformed, wrong kind, disabled, case-variant token fails the exact compare, headers on 200, 404 and 429) and `TestPubfailCounter` (10th failure 404, 11th 429 without a lookup, window expiry with an injected clock, concurrent hits never exceed the limit). + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/middleware -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestPublicResolve|TestPubfailCounter)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestPublicResolve" and "--- PASS: TestPubfailCounter"; the parity run reports FAIL for the public resolve route (for example a header.Cache-Control diff) or lacks "--- PASS: TestParityCorpus/coverage". + + + - `grep -c '"no-store, private"' ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go` prints 1. + - `awk '/public.share-headers/' ../fonoteka.go/plugins/golem15/fonoteka/routes.go | grep -c 'fonoteka-public-token'` prints 0 (the buckets left the group line). + - `grep -c 'pubfail:' ../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go` prints at least 1. + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 152. + + A shared collection link resolves anonymously with PHP's body and exact headers, guessing is throttled by the shared failure counter, and the D-14 route layout is in place. + + + + Task 2: The visitor searches and opens albums of a shared collection or wishlist and sees only public fields, each route under its own buckets + The isolated PHP parity instance can be reset (`php -v` exits 0). + ../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_public_album.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/public_share_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go, ../fonoteka.go/plugins/golem15/fonoteka/public_share_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/PublicShareApiController.php (index, show, facetsFor, albumIdsFor, artistFacet, genreFacet, decadeFacet, formatFacet, the Validator::make rules and the rating closure), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesPublicAlbum.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSearchService.php (lines 40-80 and the public search path), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go (SearchAlbums, searchSQL, ScopedAlbums, text fields), ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (public buckets), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_public_{token}_albums_public_share.yaml, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_public-wishlist_{token}_public_share.yaml + Per RESEARCH Finding 9 (public search mode), C-06 and D-14. + +(1) album_search.go: a public search mode used only by the public index: `TextFieldsPublic` (query_by `name,artist_display,style_names,genre_name,track_titles`, weights `10,10,5,5,3`, SQL LIKE on `name`, `artist_display`, `track_titles` and the `artists.name` relation), scoped to one collection id with no user, token or ratings join; engine ids are re-gated in SQL to that collection exactly as the Phase 12 recount does. Existing authenticated callers are unchanged. + +(2) classes/public_share.go `PublicAlbums` and `PublicFacets` port index's query and facetsFor (artist, genre, decade, format; zero-count rows dropped; PHP order); serialize_public_album.go `SerializePublicAlbum` ports SerializesPublicAlbum's twelve keys in PHP order (id, name, artists, artist_display, year, format, medium, genre, styles, tracklist, photos, created_at) and nothing else. + +(3) Handlers: `PublicAlbumsIndex(app, kind)` runs the pubfail check and ResolvePublic as resolve does, validates the query with lagoon.ValidateRequest using PHP's rules (the rating closure as a CustomRule with PHP's message) and answers 422 `{"error":"Validation failed","errors"}` on failure, else the list with header and facets in PHP's shape; `PublicAlbumsShow(app, kind)` answers `{"data":...}` for an album of the shared collection, else 404 `{"error":"Not found"}` (with the same pubfail accounting PHP applies). Routes: `GET /public/{token}/albums`, `GET /public/{token}/albums/{id}` (`[0-9]+`), `GET /public-wishlist/{token}` (`throttle:10,1`), `GET /public-wishlist/{token}/albums`, `GET /public-wishlist/{token}/albums/{id}` (`[0-9]+`); every albums route carries `throttle:fonoteka-public-token` then `throttle:fonoteka-public-ip`. + +(4) routes_bucket_test.go: update the public boot probe to the D-14 layout and add `TestPublicBucketsPerRoute` over the assembled route table (resolve routes: exactly `throttle:10,1`; albums routes: exactly the two named buckets in that order; group: only public.share-headers; no other public middleware). + +(5) Re-record from the `public` reset: index (no query, text query, filter, rating 422, invalid filter 422), show (shared album, album of another collection 404, missing 404), the wishlist twins (resolve, index, show). Keep the inline budget rule. Flip the five routes; expectedPortedRoutes 157. + +(6) public_share_smoke_test.go `TestPublicAlbumFieldSet` (for both kinds the serialized object's keys, in order, equal the twelve PHP keys for an album that has a rating, a market price, notes, a shelf, a condition, a barcode and a reservation). + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestPublicBucketsPerRoute|TestPublicAlbumFieldSet|TestPublicResolve|TestSearchLeak)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v + Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestPublicBucketsPerRoute, TestPublicAlbumFieldSet and TestSearchLeak (authenticated search unchanged); the parity run reports FAIL for a public route or lacks "--- PASS: TestParityCorpus/coverage". + + + - `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 157. + - `grep -c 'name,artist_display,style_names,genre_name,track_titles' ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go` prints 1. + - `grep -cE '"/public(-wishlist)?/\{token\}"' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints 2 and both lines carry `throttle:10,1`. + - `grep -c 'created_at' ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_public_album.go` prints at least 1 and TestPublicAlbumFieldSet compares the exact ordered key list. + + Both kinds of shared link can be browsed, searched and opened anonymously with PHP's bodies, facets and per-route buckets, exposing nothing beyond the public field set. + + + + Task 3: The anonymous journey and the link-guessing lockout replay against Go exactly as recorded from PHP + The isolated PHP parity instance can be reset between the two anonymous recordings (file cache cleared). + ../fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml, ../fonoteka.go/parity/fixtures/nuxt/public-pubfail.yaml, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/README.md + /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/pages (the `/k/` and `/w/` public pages) and their composables, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml (flow shape), ../fonoteka.go/parity/README.md (Phase 13 recording), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Parity Harness Notes: throttle state, the pubfail sequence) + Per D-12 (public-anonymous) and RESEARCH "The pubfail sequence". + +(1) public-anonymous: author the request spec in the order the Nuxt public pages issue it, with no Authorization header: resolve the collection link, albums (first page, a text query, a filter), one album, resolve the wishlist link, its albums and one album, a bad token, then (as alice over JWT inside the same flow) regenerate the collection share and resolve the old token (404) and the new one (200). Record it from a fresh reset with `{{share:collection}}` and `{{share:wishlist}}` captures. `TestFonotekaNuxtFlows/public-anonymous` replays it on an isolated database from the `public` seed. + +(2) public-pubfail: from a fresh reset, 10 requests to `GET public//albums` (404 `{"error":"Not found"}`) then the 11th (429 `{"error":"Too many requests"}`), using the albums route because it has no inline throttle. `TestFonotekaNuxtFlows/public-pubfail` replays it against a fresh Go target so the counter starts empty. + +(3) parity/README.md: both recipes, the reset-before-anonymous rule and why the pubfail flow uses the albums route. + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows|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 + Any command exits non-zero; the run prints "--- FAIL", "--- SKIP" or "no tests to run", or lacks "--- PASS: TestFonotekaNuxtFlows/public-anonymous", "--- PASS: TestFonotekaNuxtFlows/public-pubfail" and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret (a raw share token), an unrecorded route or a ported case-status mismatch. + + + - `grep -c 'Too many requests' ../fonoteka.go/parity/fixtures/nuxt/public-pubfail.yaml` prints 1 and `grep -c '"error":"Not found"\|Not found' ../fonoteka.go/parity/fixtures/nuxt/public-pubfail.yaml` prints at least 10. + - `grep -c 'share:collection' ../fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml` prints at least 1 and `grep -c 'Authorization' ../fonoteka.go/parity/fixtures/nuxt/public-anonymous.yaml` prints 1 (only the JWT regenerate step is authenticated). + - `grep -ci 'pubfail' ../fonoteka.go/parity/README.md` prints at least 1. + + The anonymous journey, link rotation and the link-guessing lockout are proven equal to PHP through recorded flows. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Anonymous internet client → public routes | No authentication; the share token is the only credential | +| Public token → collection row | Token lookup must not leak existence of other collections or allow enumeration | +| Shared collection → public serializer | Private album fields must not cross into the anonymous response | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-01 | Information Disclosure | token guessing / enumeration | high | mitigate | 16-character shape check before any query, LOWER() lookup plus constant-time compare, pubfail 10 per 60 s per trusted-proxy IP (peek before, hit on failure), inline 10/min on resolve, per-token 60 and per-IP 120 on albums; TestPubfailCounter, TestPublicBucketsPerRoute and the public-pubfail flow (Tasks 1-3). | +| T-13-02 | Information Disclosure | public serializer and facets | high | mitigate | SerializePublicAlbum's fixed field set, rating parameter refused with 422, collection-only search scope re-gated in SQL, zero-count facets dropped, no reservations on public-wishlist; TestPublicAlbumFieldSet (Task 2). | +| T-13-03 | Spoofing | disabled or regenerated share | high | mitigate | ResolvePublic requires public_enabled, the route's kind and the exact current token; the public-anonymous flow proves the old token 404 after regenerate (Tasks 1, 3). | +| T-13-32 | Denial of Service | shared anonymous inline budget | low | accept | PHP shares one `throttle:10,1` guest key across onboarding, inspection and public resolves; Go mirrors it (`inline:domainless|ClientIP`) for parity; documented in parity/README.md. | +| T-13-33 | Elevation of Privilege | public route kind confusion | medium | mitigate | Every handler passes its route's kind to ResolvePublic; a wishlist token on `public/{token}` and a collection token on `public-wishlist/{token}` answer 404; TestPublicResolve wrong-kind case (Task 1). | +| T-13-SC | Tampering | package installs | low | accept | No new dependency in this plan. | + + + +- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus at 157 ported and passing with 14 pending; the two public flows pass; check_corpus `--require-recorded --check-secrets` green. + + + +- Six anonymous public routes ported with PHP bodies and exact headers. +- D-14 bucket layout per route; pubfail lockout identical to PHP. +- No private album field reachable anonymously. + + + +Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-05-SUMMARY.md` when done. + diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-06-PLAN.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-06-PLAN.md new file mode 100644 index 0000000..ac1f026 --- /dev/null +++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-06-PLAN.md @@ -0,0 +1,235 @@ +--- +phase: 13-p-ytarium-api-wishlist-notifications-csv-credentials-public +plan: 06 +type: execute +wave: 6 +depends_on: ["13-05"] +files_modified: + - ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/testdata/fuzz/FuzzWriteEndpoints/ + - ../fonoteka.go/plugins/golem15/fonoteka/phase13_security_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/phase13_classes_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/csv_edges_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/phase13_controllers_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go + - ../fonoteka.go/plugins/golem15/user/registration_test.go + - ../fonoteka.go/plugins/golem15/user + - modules/surf/overlap_edges_test.go + - modules/conga/unregistered_kind_test.go + - modules/lagoon/validate_request_test.go + - modules/tide/normalize_phase13_test.go + - scripts/check-phase13.sh + - .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-SECURITY-REVIEW.md + - .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md + - .planning/REQUIREMENTS.md +autonomous: true +requirements: [API-03, API-04, API-05, API-06, API-07] +estimate: + tokens: 300000 + raw_tokens: 300000 + tasks: 3 + confidence: low +must_haves: + truths: + - "Per C-01, `TestRouteTablePhase13` over the assembled router asserts the 58 Phase 13 routes exist exactly once on their routes.php group (JWT, personal token, onboarding, public_invitation, public_share) with a routes.php line number per entry, every personal-token route carries exactly one `inv.scope` equal to routes.php (wishlist list read, create/update/delete write, export read), every `{id}`, `{rowId}`, `{collectionId}` and `{albumId}` is constrained to `[0-9]+` (checked by request), no `{token}` is constrained, the inline throttles sit exactly on share regenerate, CSV store, the onboarding group, inspection and the two public resolves, the public buckets sit exactly on the four albums routes, and the four Phase 14 routes (wishlist match, apply-release, ai-credential/test, discogs-credential/test) are absent." + - "Through the same assembled router the four overlap pairs dispatch numeric and literal segments to the right handler, an unmatched path answers 404 and a method mismatch answers ServeMux's 405 (T-13-23)." + - "Per C-04, `FuzzWriteEndpoints` enumerates every Phase 13 write route from the route table (JSON and multipart) and, for random extra keys and every server-owned key (id, user_id, owner_id, collection_id, organisation_id, kind, public_token, public_enabled, token_hash, status, import_job_id, match_job_id, storage_path, reservations_allowed outside settings, revealed_at, subscribed_at, item_count, api_key outside credential stores, created_at, updated_at, deleted_at), asserts no column outside the endpoint's allow-list changes in Postgres and no response is a 500 that PHP does not answer; its committed seed corpus runs in plain `go test`." + - "Each mitigated T-13 threat of plans 13-01 to 13-05 has a named test that fails when its protection is removed; `scripts/check-phase13.sh --removal` applies anchor-exact mutations to the protecting code, requires the named test to fail on an assertion and restores each file byte for byte (cmp)." + - "Every Go package created or changed in Phase 13 reaches at least 80% statement coverage: summercms.go surf, conga, lagoon, tide; fonoteka classes, classes/csv, controllers/api and middleware (measured with -coverpkg as check-phase12.sh does); in sm-user-plugin the classes package reaches 80% and every function in controllers/registration.go reaches 80% (go tool cover -func) without lowering the controllers package below its pre-phase value; the numbers are recorded in 13-VALIDATION.md." + - "`scripts/check-phase13.sh --all` (summercms.go/scripts, per the user's checkpoint note) runs vet and tests in both repos, the parity corpus (157 ported, 0 failing, 14 pending), TestBroadcastGoldens, every TestFonotekaNuxtFlows subtest (nuxt-collections, nuxt-albums, onboarding, nuxt-wishlist, mcp-wishlist, nuxt-csv, public-anonymous, public-pubfail), check_corpus --require-recorded --check-secrets, TestDocsTree and docs:build --check, the named tests by exact name (refusing skips and 'no tests to run'), the coverage floors and the evidence files; `--self-test` proves each detector fails closed." + - "13-SECURITY-REVIEW.md maps every T-13 id of the six plans to its category, severity, disposition, protecting file and the named test that was run and seen failing under --removal; 13-VALIDATION.md has real task ids in its Per-Task Verification Map, no pending row, the gate path `scripts/check-phase13.sh` in summercms.go, `nyquist_compliant: true` and `wave_0_complete: true`; REQUIREMENTS.md marks API-03..API-07 Complete." + - "Edge (all requirements, empty): table tests cover an empty JSON body and an empty multipart body on every Phase 13 write route with PHP's status for each." + - "Edge (API-05, API-07 boundary): table tests cover CSV size and row limits, per_page clamps, Discogs token length bounds, notification list cap and the pubfail threshold one step either side." + - statement: "Edge (API-03, API-07 concurrency): fuzz and race runs over the Phase 13 write routes under -race report no data race in handlers, the pubfail counter or the job dispatcher holder." + verification: backstop + artifacts: + - path: "../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go" + provides: "TestRouteTablePhase13" + contains: "TestRouteTablePhase13" + - path: "../fonoteka.go/plugins/golem15/fonoteka/phase13_security_test.go" + provides: "TestPhase13Threats with one subtest per mitigated T-13 id" + contains: "TestPhase13Threats" + - path: "scripts/check-phase13.sh" + provides: "fail-closed Phase 13 gate with --self-test, --go, --parity, --named, --removal, --coverage, --evidence, --all" + contains: "EXPECTED_PORTED=157" + - path: ".planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-SECURITY-REVIEW.md" + provides: "threat-to-test evidence" + key_links: + - from: "../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go" + to: "summercms.go modules/surf/routetable.go" + via: "surf.BuildRouter(...).Routes() enumerates the Phase 13 write routes" + pattern: "Routes\\(\\)" + - from: "scripts/check-phase13.sh" + to: ".planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md" + via: "--named reads every test the validation map names; --evidence refuses pending or TBD rows" + pattern: "13-VALIDATION.md" + prohibitions: + - requirement_id: API-07 + category: transparency + statement: "A threat MUST NOT be marked mitigated in 13-SECURITY-REVIEW.md without a named test that was run and seen to fail when the protection is removed" + status: resolved + verification: test + - requirement_id: API-05 + category: transparency + statement: "Pending routes MUST NOT be counted as passing by the gate; the four Phase 14 routes stay pending and absent from the router" + status: resolved + verification: test +--- + +## Phase Goal + +ROADMAP Phase 13 goal (verbatim, not in user-story form): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets. + +This plan's slice: the project rule's last plan. It proves the Phase 13 route table, the mass-assignment boundary and every T-13 protection, brings the phase's packages to full unit coverage in both repos, adds the fail-closed `check-phase13.sh` gate, and signs off security and validation (API-03..API-07). + + +Write TestRouteTablePhase13, extend FuzzWriteEndpoints to every Phase 13 write route, add TestPhase13Threats, fill coverage gaps in summercms.go and fonoteka.go (including sm-user-plugin), write `scripts/check-phase13.sh`, run the security review, and validate 13-VALIDATION.md. + +Purpose: CLAUDE.md lean rule 3 (unit tests are always the last plan) and the security review this phase needs (public tokens, credentials encryption, authorization). Decisions covered: C-01, C-04, C-07 directly; every other D-NN through the named tests the gate requires. +Output: tests in both repos and the submodule, the gate script, 13-SECURITY-REVIEW.md, the validated 13-VALIDATION.md, REQUIREMENTS traceability. + +Repos: summercms.go (framework tests, gate script, planning docs), fonoteka.go (app tests), sm-user-plugin (registration tests; committed in the submodule, not pushed, then the pointer bump in fonoteka.go). Production code changes only when a test exposes a real bug; each such fix is its own commit naming the threat or decision. Path-scoped staging only (another session works in summercms.go). Planning docs and code in separate commits; never add co-author tags. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-SUMMARY.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-02-SUMMARY.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-03-SUMMARY.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-04-SUMMARY.md +@.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-05-SUMMARY.md +@scripts/check-phase12.sh +@.planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md + + +- Route-table recipe: `phase12RouteTable(t)` in routes_table_phase12_test.go (backpack.New(bootConfig), bouncer.NewRegistry, party.Activate golem15.user and golem15.fonoteka, stubInvToken, surf.BuildRouter, Routes()), `jwtPrefix`/`tokenPrefix` constants, `splitAPIPattern`, `middlewareMatching`, `inlineThrottle` (reuse, do not redeclare in the package). +- Fuzz recipe: write_endpoints_fuzz_test.go `writeSpec{path, base, carol, upload, change, when, create, remove}`, table-name constants, `seedFuzzWorld`, `fuzzWriteSpecs` keyed "METHOD /path". +- Gate recipe: scripts/check-phase12.sh (env-overridable ROOT/APP/PHASE_DIR, `phase12_detect` go-test-json detector with exit codes, `run_go`, `run_parity`, `run_named`, `coverage_report`/`cover_profile` with -coverpkg, `removal_table`/`removal_harness` with cmp restore and a dirty-tree refusal, `evidence_check`, `run_self_test`). +- Final test and function names: the Artifacts sections of 13-01..13-05 and their SUMMARY files. +- Threat registers: the `` blocks of 13-01..13-05 (T-13-01..T-13-33 plus T-13-SC). + + + +## Artifacts this phase produces + +(This plan's share.) + +- Tests: `TestRouteTablePhase13` (with `phase13Route` table and `phase13Universe`), `FuzzWriteEndpoints` extended with every Phase 13 write route and a committed seed corpus, `TestPhase13Threats` (one subtest per mitigated T-13 id), `TestPhase13EmptyBodies`, `TestPhase13Boundaries`, package unit tests in both repos and sm-user-plugin (`TestRegisterUserExports`). +- Gate: `scripts/check-phase13.sh` with `--self-test`, `--go`, `--parity`, `--named`, `--removal`, `--coverage`, `--evidence`, `--all`; `EXPECTED_PORTED=157`, `COVERAGE_FLOOR=80`. +- Docs: 13-SECURITY-REVIEW.md, validated 13-VALIDATION.md, REQUIREMENTS.md rows API-03..API-07 Complete. + + + + + Task 1: The assembled router proves every Phase 13 route sits on PHP's group with PHP's scope, constraints and throttles, and the Phase 14 routes are absent + Plan 13-05 is executed: `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 157. + ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go + /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 74-330, 351-428, 449-520), ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go (whole file), ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go, .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Route Inventory) + Per C-01 and the Route Inventory: add `phase13Route{method, path, group, scope, throttle, buckets, line}` and the explicit 58-entry table transcribed from routes.php with the line number per entry (groups jwt, token, onboarding, public_invitation, public_share), plus `phase13Universe` (the Phase 13 path prefixes: notifications, ai-credential, org-ai-credential, discogs-credential, onboarding, invitations/{token} without /accept, wishlist, import/csv, export/csv, public, public-wishlist) and `phase14Absent` (the four routes). `TestRouteTablePhase13` boots the assembled router as phase12RouteTable does and asserts: every table entry present exactly once with its group's middleware; no unexpected route inside phase13Universe; exactly one `inv.scope:` per token route equal to the table; the inline throttle and public bucket placement of the D-14 layout; each numeric parameter refuses `abc` and `1x` and accepts `12` (authenticated requests through the real handler stack, distinguishing a constraint 404 from a handler 404 as the Phase 12 test does); `{token}` parameters have no constraint; the four phase14Absent routes answer the router's 404; and the four overlap pairs dispatch to the right real handler. TestRouteTablePhase12 stays green unchanged (13-02 narrowed its universe). + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestRouteTablePhase13|TestRouteTablePhase12|TestFullRouteTableAuthGroupMutualExclusivity)$' -count=1 -race -v + Non-zero exit; output prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestRouteTablePhase13" and "--- PASS: TestRouteTablePhase12". + + + - `grep -c 'routes.php' ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go` prints at least 1 and the table has 58 entries (`grep -cE '^\s*\{"(GET|POST|PUT|PATCH|DELETE)"' ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go` prints 58). + - `grep -cE 'apply-release|ai-credential/test|discogs-credential/test' ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go` prints at least 3 (the absent list). + - Temporarily adding `inv.scope:read` to a second middleware slot of the token wishlist list route makes TestRouteTablePhase13 fail (checked by --removal in Task 3). + + The whole Phase 13 route surface is pinned against routes.php through the real router, so a misplaced scope, throttle, constraint or Phase 14 route fails a test. + + + + Task 2: No Phase 13 write endpoint persists a server-owned key, and every Phase 13 protection has a test that breaks without it + ../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go, ../fonoteka.go/plugins/golem15/fonoteka/testdata/fuzz/FuzzWriteEndpoints/, ../fonoteka.go/plugins/golem15/fonoteka/phase13_security_test.go + ../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go (whole file), ../fonoteka.go/plugins/golem15/fonoteka/phase12_security_test.go (subtest-per-threat shape), the `` blocks of 13-01-PLAN.md to 13-05-PLAN.md, the 13-01..13-05 SUMMARY files (final names), ../fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/reservations.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/public_share.go + + - FuzzWriteEndpoints: for each Phase 13 write route (notifications read-all and {id}/read, the three credential stores and org destroy, onboarding bootstrap on an empty world, wishlist albums store/update/destroy on both groups, share update and regenerate, settings update, token and collection subscribe/unsubscribe, purchase, reserve, cancel, reveal, CSV store (multipart), mapping, row edit, commit, cancel), a valid base body plus fuzzed extra keys and every server-owned key never changes a column outside the endpoint's allow-list and never yields an unexpected 500. + - TestPhase13Threats: one subtest per mitigated T-13 id (T-13-01..T-13-33 except accepted ones) asserting the protection: enumeration lockout, public field set, disabled/regenerated token, reserve/reveal/cancel authority, owner mask, peer IDOR 404 parity, token-group absence, credential secret absence in bodies and logs, org manager checks, credential FK fixed, bootstrap single owner, invitation match rules, payload without passwords, notification user scope, inspection, item-added/purchase rollback, subscribe-by-token checks, mail args content, CSV import scope, private storage, formula guard, parse limits, Discogs seam, commit CAS, workerless jobs, overlap dispatch isolation, tide mask negatives (framework subtests live in the framework packages and are referenced by name). + + Per C-04 and each plan's threat register. (1) Extend `fuzzWriteSpecs` with one writeSpec per Phase 13 write route (table names for notifications, credentials, wishlist subscriptions, reservations, digest queue, csv_imports, csv_import_rows, users and organisations for bootstrap), seed what each needs in seedFuzzWorld, and assert by before/after row snapshots that only allowed columns change; reset state per iteration; commit seed files under testdata/fuzz/FuzzWriteEndpoints/ so plain `go test` exercises every Phase 13 route with the server-owned keys (synthetic values only). (2) phase13_security_test.go `TestPhase13Threats` with subtests named `T-13-NN` implementing the behavior list through the assembled handler, the postcard memory driver, a fake realtime driver and a capturing slog handler; each subtest is small and names the protecting function in a comment. Any genuine bug found is fixed in production code in its own commit naming the threat. + + go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(FuzzWriteEndpoints|TestPhase13Threats)$' -count=1 -race -v && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^$' -fuzz '^FuzzWriteEndpoints$' -fuzztime 60s + Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: FuzzWriteEndpoints" and "--- PASS: TestPhase13Threats"; the fuzz run prints "Failing input written to". + + + - `grep -c 't.Run("T-13-' ../fonoteka.go/plugins/golem15/fonoteka/phase13_security_test.go` prints at least 20. + - `grep -cE '"(POST|PUT|PATCH|DELETE) /(_fonoteka/api/v1|api/v1/fonoteka)/(notifications|ai-credential|org-ai-credential|discogs-credential|onboarding|wishlist|import/csv)' ../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go` prints at least 25. + - `ls ../fonoteka.go/plugins/golem15/fonoteka/testdata/fuzz/FuzzWriteEndpoints/ | wc -l` is at least the number of Phase 12 seeds plus 25. + - The fuzz target fails when CredentialFillFields temporarily gains `user_id` (checked by --removal in Task 3). + + Mass assignment is closed on every Phase 13 write endpoint and every Phase 13 threat is pinned by a test that fails without its protection. + + + + Task 3: Phase 13 code is fully unit tested in both repos, and a fail-closed gate, the security review and the validation file sign it off + modules/surf/overlap_edges_test.go, modules/conga/unregistered_kind_test.go, modules/lagoon/validate_request_test.go, modules/tide/normalize_phase13_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/phase13_classes_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/csv_edges_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/phase13_controllers_test.go, ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go, ../fonoteka.go/plugins/golem15/user/registration_test.go, ../fonoteka.go/plugins/golem15/user, scripts/check-phase13.sh, .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-SECURITY-REVIEW.md, .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md, .planning/REQUIREMENTS.md + scripts/check-phase12.sh (whole script), .planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md, .planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md (validated map format), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md, the five Phase 13 SUMMARY files, the five Phase 13 PLAN threat registers, ../fonoteka.go/plugins/golem15/user/controllers/registration.go + (1) Framework coverage (summercms.go, neutral names): overlap_edges_test.go (families of three, HEAD on a GET family, Allow header ordering, mixed-method families sharing one shape, unsupported-shape errors, families across plugins), plus edge cases appended to the conga, lagoon and tide Phase 13 tests (refusal messages, delayed insert, prohibited with nested arrays and wildcards, date masks with several dates and quoted filenames, publication masks beside captured ids). + +(2) App coverage (fonoteka.go): phase13_classes_test.go (resolver and provisioner branches, subscription states, reservation state matrix, digest upsert, similarity escaping, onboarding count, invitation listener branches, AI resolver tiers, Discogs status, pubfail window, public facets and header), classes/csv csv_edges_test.go (pipe codec, mapper, canonical id matcher, detector aliases, encodings, limits), phase13_controllers_test.go (`TestPhase13EmptyBodies` empty JSON and multipart bodies on every Phase 13 write route with PHP's status; `TestPhase13Boundaries` CSV size and rows, per_page clamps, Discogs token bounds, notification cap, pubfail threshold), middleware header bytes on 200/404/429. sm-user-plugin registration_test.go `TestRegisterUserExports` (RegisterUser validation errors, activation option, IP recording, FireRegisterEvent listener error propagation, IssueToken issuer, APIArray listener merge); commit it in the submodule (not pushed), then the pointer bump in fonoteka.go. + +(3) scripts/check-phase13.sh modelled on check-phase12.sh (PHASE13_ROOT/PHASE13_APP/PHASE13_PHASE_DIR overrides, `phase13_detect`, `EXPECTED_PORTED=157`, `COVERAGE_FLOOR=80`): `--go` vet and test both repos; `--parity` TestParityCorpus with 157 ported and 0 failing parsed from the coverage line, TestBroadcastGoldens including the three wishlist goldens, every TestFonotekaNuxtFlows subtest listed in the truths, TestUserAPINuxtFlows, check_corpus --require-recorded --check-secrets, TestDocsTree and `go run ./cmd/summer docs:build --check`; `--named` every test 13-VALIDATION.md names, run by exact name, refusing skip, fail and "no tests to run"; `--removal` anchor-exact mutations restored byte for byte with cmp and refusing a dirty file: the overlap dispatcher skipping constraints, unregistered kinds sent through the worker client, the owner mask removed, ResolvePublic without public_enabled, the constant-time compare replaced by the LOWER match alone, TooMany always false, the payload keeping password_confirmation, bootstrap without the in-transaction recount, CsvImportFor without AccessibleBy, commit without the status condition, CredentialFillFields gaining user_id, mark-read without the user_id scope, the release fetcher returning data, the digest upsert dispatching on every insert, the public serializer gaining shelf, the credential show echoing api_key, a duplicated inv.scope on the token wishlist list route; each must make its named test fail on an assertion; `--coverage` per listed package with -coverpkg (and the go tool cover -func check for controllers/registration.go); `--evidence` 13-SECURITY-REVIEW.md lists every T-13 id with a passing test and 13-VALIDATION.md has no pending or TBD row and nyquist_compliant true; `--all`; `--self-test` proving each detector fails closed on planted inputs. No application name in framework-facing output beyond the paths it must call. + +(4) Planning docs (separate commit): 13-SECURITY-REVIEW.md written by the security-review agent if one can be spawned, otherwise self-performed and disclosed as in the 08-10 precedent; table of every T-13 id and T-13-SC with category, severity, disposition, protecting file, named test and the --removal result. 13-VALIDATION.md: replace the seeded Per-Task Verification Map with final rows (13-01-T1 .. 13-06-T3, exact commands, file ticks, statuses), set the phase gate command to `scripts/check-phase13.sh --self-test && scripts/check-phase13.sh --all` run from summercms.go, tick Wave 0, status validated, nyquist_compliant true, wave_0_complete true. REQUIREMENTS.md: API-03..API-07 checkboxes ticked and traceability rows Complete. + + scripts/check-phase13.sh --self-test && scripts/check-phase13.sh --all && scripts/check-phase13.sh --removal + Non-zero exit; output contains "refuse:" or "FAIL", a package coverage line below 80%, a named test reported skipped or with "no tests to run", a --removal mutation whose named test still passes or a file that cmp reports changed after restore, or the evidence stage reporting a pending row or a T-13 id without a test. + + + - `test -x scripts/check-phase13.sh` succeeds and `grep -c 'EXPECTED_PORTED=157' scripts/check-phase13.sh` prints 1. + - `grep -c 'nyquist_compliant: true' .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md` prints 1 and `grep -c '⬜ pending' .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md` prints 0. + - Every T-13 id in the six plans' threat registers appears in 13-SECURITY-REVIEW.md (`grep -ho 'T-13-[0-9A-Z]*' .planning/phases/13-*/13-0*-PLAN.md | sort -u` is a subset of the same scan over 13-SECURITY-REVIEW.md). + - `grep -cE 'API-0[3-7] \| Phase 13 \| Complete' .planning/REQUIREMENTS.md` prints 5. + - The coverage stage prints one line per listed package at 80% or more. + + Phase 13 is closed by evidence: full unit coverage in both repos, a gate that fails closed on any regression, a security review tying each threat to a removal-proven test, and a validated validation file. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Test harness → production code | Tests and the gate must not weaken or bypass the protections they check | +| Gate script → tracked source | --removal edits tracked files temporarily and must restore them exactly | +| Fuzz corpus → git | Seed inputs are committed and must hold no secrets | + +## STRIDE Threat Register + +This plan verifies every threat registered by plans 13-01 to 13-05 (T-13-01 to T-13-33): TestRouteTablePhase13 covers T-13-07 and T-13-23 at the assembled router, FuzzWriteEndpoints covers T-13-10 and the C-04 boundary, TestPhase13Threats has one subtest per remaining mitigated id, and --removal proves each. The rows below are the threats this plan itself introduces. + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-13-34 | Tampering | gate --removal leaving mutated source | medium | mitigate | Anchor-exact mutation, cmp byte-identical restore, trap on exit, refusal on a dirty target file; --removal is its own mode outside --all because it edits tracked source (Task 3). | +| T-13-35 | Repudiation | security review claims without evidence | medium | mitigate | The evidence stage refuses a T-13 id without a named, executed, removal-proven test (Task 3). | +| T-13-36 | Information Disclosure | fuzz seed corpus | low | mitigate | Synthetic values only; the gate's secret scan covers testdata/fuzz (Tasks 2-3). | +| T-13-SC | Tampering | package installs | low | accept | No new dependency in this plan. | + + + +- `scripts/check-phase13.sh --self-test`, `--all` and `--removal` exit 0. +- Both repos: `go vet ./... && go test ./... -count=1` green; `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` green. +- 13-VALIDATION.md validated; 13-SECURITY-REVIEW.md complete; API-03..API-07 Complete in REQUIREMENTS.md. + + + +- The Phase 13 route table, write-endpoint boundary and every threat are pinned by named, removal-proven tests. +- Every Phase 13 package in both repos meets the coverage floor; the gate fails closed. +- Security review and validation sign-off are complete with real task ids. + + + +Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-06-SUMMARY.md` when done. + diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md index 0a246eb..9dbe51f 100644 --- a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md +++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md @@ -901,20 +901,27 @@ GORM's soft-delete scope applies, as Winter's does. The `PublicToken` field type | A7 | The export `Content-Disposition` format is `attachment; filename=plytarium-kolekcja-YYYY-MM-DD.csv` | CSV export | Low: it is recorded verbatim in the fixture | | A8 | The digest queue name `fonoteka.wishlist.digest` and the kind and arg names in the jobs table are acceptable stable names | Jobs | Medium: costly to change later (D-03), so confirm at the checkpoint | -## Open Questions +## Open Questions (RESOLVED) + +All five were resolved at the plan-count checkpoint on 2026-10-02. 1. **Approve the framework changes (Findings 1 and 2) for plan 13-01?** - Known: both are blocking in production. `surf` and `conga` are framework modules with READMEs and docs. - Unclear: the user's preference for the conga fix style (route unknown kinds to the insert-only client, or `SkipUnknownJobCheck`). - Recommendation: route unknown kinds through the insert-only client, keep the check for registered kinds, and document it. + - RESOLVED: the user approved the surf overlap dispatch and the conga unregistered-kind insert onto queues nothing serves until Phase 14 (plan 13-01). 2. **Confirm the job naming contract** (the jobs table: labels per PHP, D-03's queues, the kinds, the digest queue). - Recommendation: confirm at the plan-count checkpoint, since D-03 calls the choice costly. + - RESOLVED: labels `fonoteka.csv.import`, `fonoteka.csv.match` and `wishlist_digest` kept verbatim; the contract is pinned in `classes/job_contract.go` (plan 13-01), rated costly and signed off. 3. **D-13's "diff recorded `wishlist_digest_queue` rows":** is the Go-test assertion enough, or build a SQLite row dump? - Recommendation: notifications through API flow steps, digest rows and `summer_jobs` rows in Go tests (Finding 8). + - RESOLVED: real row goldens via a `php_parity.sh rows` dump (digest-queue rows in 13-03, job rows in 13-04), plus Go tests. 4. **The `RegisterEvent.Payload` contents (A3).** - Recommendation: a copy of the input without `password` and `password_confirmation`. + - RESOLVED: Payload strips `password` and `password_confirmation` (user decision). 5. **x/text for CSV decoding (A5).** - Recommendation: approve the direct import. It is already in the module graph. + - RESOLVED: the direct `golang.org/x/text` import is approved. ## Environment Availability diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md index 9ad7874..5d25ae3 100644 --- a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md +++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md @@ -24,7 +24,7 @@ created: "2026-10-02" | **Quick run command** | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... -short -count=1` | | **Full suite command** | `go vet ./... && go test ./... -count=1 && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` | | **Parity command** | `go -C ../fonoteka.go test ./parity -run 'TestParityCorpus|TestBroadcastGoldens|TestFonotekaNuxtFlows' -count=1` | -| **Phase gate** | `../fonoteka.go/scripts/check-phase13.sh --self-test && ../fonoteka.go/scripts/check-phase13.sh --all` | +| **Phase gate** | `scripts/check-phase13.sh --self-test && scripts/check-phase13.sh --all` (run from summercms.go; the script lives in summercms.go/scripts like check-phase12.sh) | | **Estimated runtime** | ~180 seconds (full suite with testcontainers) | --- @@ -44,7 +44,26 @@ Filled by the planner per task; the requirement → test map lives in `13-RESEAR | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| -| 13-01-01 | 01 | 1 | (framework) | T-13-23 | Overlapping constrained routes dispatch to the right handler | unit | `go test ./modules/surf -run TestOverlappingConstrainedRoutes -count=1` | ❌ W0 | ⬜ pending | +| 13-01-01 | 01 | 1 | API-03 (framework) | T-13-23 | Overlapping constrained routes dispatch to the right handler; app wishlist shapes dispatch | unit | `go test ./modules/surf -run '^(TestOverlappingConstrainedRoutes)$' -count=1 -v` ; `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestWishlistOverlapPatternsDispatch)$' -count=1 -v` | ❌ W0 | ⬜ pending | +| 13-01-02 | 01 | 1 | API-03, API-05 (framework) | T-13-22 | Unregistered job kinds queue while a worker runs and are never discarded; job contract pinned | integration | `go test ./modules/conga -run '^(TestUnregisteredKindWithWorker)$' -count=1 -v` ; `go -C ../fonoteka.go test ./plugins/golem15/fonoteka ./plugins/golem15/fonoteka/classes -run '^(TestJobContract|TestJobContractDispatchWhileWorkerRuns)$' -count=1 -v` | ❌ W0 | ⬜ pending | +| 13-01-03 | 01 | 1 | API-03, API-05 (framework) | T-13-24, T-13-25 | prohibited rule; Content-Disposition date and publication masks never hide a real diff | unit | `go test ./modules/lagoon ./modules/tide -run '^(TestValidateRequestProhibited|TestNormalizeContentDispositionDate|TestNormalizeNotificationPublication)$' -count=1 -v` | ❌ W0 | ⬜ pending | +| 13-01-04 | 01 | 1 | API-03..API-07 | T-13-26 | Queue override, rows dump, share:wishlist capture, ported case-status check, planning rewording | unit | `go -C ../fonoteka.go test ./parity -run '^(TestCheckCorpusPortedCaseStatus|TestParityCorpus)$' -count=1 -v` | ❌ W0 | ⬜ pending | +| 13-02-01 | 02 | 2 | API-04 | T-13-21 | Bell list newest 50, caller's rows only | parity + smoke | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestNotificationsRoutes)$' -count=1 -race -v` | ❌ | ⬜ pending | +| 13-02-02 | 02 | 2 | API-04, API-06 | T-13-08, T-13-09, T-13-10, T-13-21 | Notifications read; credentials CRUD encrypted, secret-free, org checks, AI/Discogs resolution order | parity + integration | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestNotificationsRoutes|TestCredentialsCRUD|TestCredentialSecretsNeverSerialized|TestResolveAIConfigPrecedence|TestDiscogsSharedMirror)$' -count=1 -race -v` | ❌ | ⬜ pending | +| 13-02-03 | 02 | 2 | API-07 | T-13-18, T-13-19, T-13-20, T-13-27 | Single first owner; register hook; payload without passwords; inspection | parity flow + integration | `go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestRegisterEventPayload)$' -count=1 -v` ; `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestBootstrapConcurrent|TestRegisterInvitationListener|TestInspectInvitation)$' -count=1 -race -v` ; `TestFonotekaNuxtFlows/onboarding` | ❌ | ⬜ pending | +| 13-03-01 | 03 | 3 | API-03 | T-13-05 | Own wishlist list/show on both groups with the reservation mask | parity + smoke | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestWishlistOwnListAndShow)$' -count=1 -race -v` | ❌ | ⬜ pending | +| 13-03-02 | 03 | 3 | API-03 | T-13-28 | Item writes, prohibited 422, item-added once per path, digest coalescing, share/settings/household | parity + integration | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestWishlistItemAddedOncePerPath|TestDigestCoalescing|TestWishlistShareSettingsHousehold)$' -count=1 -race -v` | ❌ | ⬜ pending | +| 13-03-03 | 03 | 3 | API-03 | T-13-04, T-13-05, T-13-06, T-13-07, T-13-29, T-13-30 | Subscriptions, secret reservations, reveal, purchase with mail after commit, peers, overlap routes assembled | parity + integration | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestReserveConcurrent|TestRevealIdempotent|TestReservationMask|TestWishlistSubscriptions|TestPurchaseSideEffects|TestPurchaseMailAfterCommit|TestWishlistOverlapRoutesAssembled)$' -count=1 -race -v` | ❌ | ⬜ pending | +| 13-03-04 | 03 | 3 | API-03 | T-13-28 | nuxt-wishlist and mcp-wishlist flows, digest rows, publication goldens | parity flow | `go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows|TestBroadcastGoldens)$' -count=1 -v` | ❌ | ⬜ pending | +| 13-04-01 | 04 | 4 | API-05 | T-13-14 | Export with PHP fputcsv quoting, BOM, header, formula guard | parity + unit | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes/csv -run '^(TestPHPFputcsv)$' -count=1 -v` ; `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCsvExport)$' -count=1 -race -v` | ❌ | ⬜ pending | +| 13-04-02 | 04 | 4 | API-05 | T-13-12, T-13-13, T-13-15 | Parser truth tables across encodings and limits; private storage; import scope | unit + integration | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes/csv -run '^(TestCsvParserTruthTable|TestCsvDetectorTruthTable)$' -count=1 -v` ; `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCsvStoreAndShow|TestCsvImportScope)$' -count=1 -race -v` | ❌ | ⬜ pending | +| 13-04-03 | 04 | 4 | API-05 | T-13-16, T-13-17, T-13-31 | Commit CAS, job rows, cancel, Discogs seam, nuxt-csv flow | parity flow + integration | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCsvCommitCAS|TestCsvJobRows|TestCsvCancel|TestCsvRowPickSeam)$' -count=1 -race -v` ; `TestFonotekaNuxtFlows/nuxt-csv` | ❌ | ⬜ pending | +| 13-05-01 | 05 | 5 | API-07 | T-13-01, T-13-03, T-13-33 | Public resolve, exact headers, pubfail counter, D-14 layout | parity + smoke | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestPublicResolve|TestPubfailCounter)$' -count=1 -race -v` | ❌ | ⬜ pending | +| 13-05-02 | 05 | 5 | API-03, API-07 | T-13-01, T-13-02 | Public albums, facets, field set, per-route buckets | parity + smoke | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestPublicBucketsPerRoute|TestPublicAlbumFieldSet)$' -count=1 -race -v` | ❌ | ⬜ pending | +| 13-05-03 | 05 | 5 | API-07 | T-13-01, T-13-03 | public-anonymous and public-pubfail flows | parity flow | `go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows)$' -count=1 -v` | ❌ | ⬜ pending | +| 13-06-01 | 06 | 6 | API-03..API-07 (C-01) | T-13-07, T-13-23 | Route table: groups, scopes, constraints, throttles, Phase 14 routes absent | unit | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestRouteTablePhase13)$' -count=1 -race -v` | ❌ | ⬜ pending | +| 13-06-02 | 06 | 6 | API-03..API-07 (C-04) | all mitigated T-13 | Request-DTO fuzz over every write route; one test per threat | fuzz + integration | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(FuzzWriteEndpoints|TestPhase13Threats)$' -count=1 -race -v` | ✅ extend | ⬜ pending | +| 13-06-03 | 06 | 6 | API-03..API-07 | T-13-34, T-13-35, T-13-36 | Coverage, fail-closed gate, security review, validation sign-off | gate | `scripts/check-phase13.sh --self-test && scripts/check-phase13.sh --all && scripts/check-phase13.sh --removal` | ❌ | ⬜ pending | *Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/COVERAGE.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/COVERAGE.md new file mode 100644 index 0000000..a1c3f36 --- /dev/null +++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/COVERAGE.md @@ -0,0 +1,3 @@ +# Phase 13 API coverage + +No external API integration: ports the app's own HTTP API; reuses Phase 11 Centrifugo and Typesense; live Discogs and AI calls stay pending for Phase 14. diff --git a/.planning/state.json b/.planning/state.json index 3d79cc4..d64f550 100644 --- a/.planning/state.json +++ b/.planning/state.json @@ -92,7 +92,7 @@ "next": { "command": "/gsd:progress --next", "label": "Advance to the next step", - "reason": "Phase 12.2 of 21 · executing" + "reason": "Phase 13 of 21 · executing" }, - "updated_at": "2026-10-02T17:58:23.800Z" + "updated_at": "2026-10-02T18:12:26.876Z" }