docs(12): create phase plan
This commit is contained in:
@@ -613,7 +613,24 @@ Plans:
|
|||||||
4. Artists/genres/styles lookup endpoints used by the Albums UI pass the parity diff.
|
4. Artists/genres/styles lookup endpoints used by the Albums UI pass the parity diff.
|
||||||
5. A request-DTO-level fuzz over every write endpoint asserts unknown and server-owned keys are never persisted (inherits the HTTP half of Phase 5 criterion 3; the HTTP layer does not exist until Phase 6/12).
|
5. A request-DTO-level fuzz over every write endpoint asserts unknown and server-owned keys are never persisted (inherits the HTTP half of Phase 5 criterion 3; the HTTP layer does not exist until Phase 6/12).
|
||||||
|
|
||||||
**Plans**: TBD
|
**Plans**: 5 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
**Wave 1**
|
||||||
|
- [ ] 12-01-PLAN.md — Framework gaps (summercms.go): Laravel-semantics request validator with pl/en catalogs, attach URL/webp, tide multipart and upload masks, beachcomber found/weights; user groups in the Go user plugin (D-25); ROADMAP/REQUIREMENTS rewording
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
- [ ] 12-02-PLAN.md — Active context, collections and share: token-aware resolver, AccessibleBy, provisioning, gates, collection serializer, collections routes, me/context, realtime/channels, collection/share, per-route scopes and per-album delete (D-26)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
- [ ] 12-03-PLAN.md — Household and invitations: InvitationService, transactional mail job with encrypted token, notifications write path, members, accept, 409 guard, nuxt-collections flow
|
||||||
|
|
||||||
|
**Wave 4** *(blocked on Wave 3 completion)*
|
||||||
|
- [ ] 12-04-PLAN.md — Albums, search and lookups: full album write path, covers through fetchguard, uploads, bulk/stats/value/missing/sync, Scout-exact search recount, lookups, nuxt-albums flow, created/updated goldens
|
||||||
|
|
||||||
|
**Wave 5** *(blocked on Wave 4 completion)*
|
||||||
|
- [ ] 12-05-PLAN.md — Unit tests last: D-18 leak test with D-19 totals, route-table scope test, request-DTO fuzz, T-12 threat tests, coverage, check-phase12.sh, security review and validation sign-off
|
||||||
|
|
||||||
### Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes
|
### Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes
|
||||||
|
|
||||||
@@ -687,7 +704,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
|||||||
| 11. Jobs, realtime and search infrastructure | 8/8 | In Progress| |
|
| 11. Jobs, realtime and search infrastructure | 8/8 | In Progress| |
|
||||||
| 11.1. SummerCMS documentation for humans and AI agents | 7/7 | In Progress| |
|
| 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| |
|
| 11.2. summercms.io Alpha 0.1 landing page on SummerCMS | 3/3 | In Progress| |
|
||||||
| 12. Płytarium API — Collections and Albums | 0/TBD | Not started | - |
|
| 12. Płytarium API — Collections and Albums | 0/5 | Planned | - |
|
||||||
| 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/TBD | Not started | - |
|
||||||
| 14. Domain jobs and external integrations | 0/TBD | Not started | - |
|
| 14. Domain jobs and external integrations | 0/TBD | Not started | - |
|
||||||
| 15. Cutover | 0/TBD | Not started | - |
|
| 15. Cutover | 0/TBD | Not started | - |
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
---
|
---
|
||||||
gsd_state_version: "1.0"
|
gsd_state_version: "1.0"
|
||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
current_phase: "11.2"
|
current_phase: 12
|
||||||
current_phase_name: summercms.io Alpha 0.1 landing page on SummerCMS (INSERTED)
|
current_phase_name: Płytarium API — Collections and Albums
|
||||||
status: verifying
|
status: executing
|
||||||
stopped_at: Completed 11.2-03-PLAN.md
|
stopped_at: Completed 11.2-03-PLAN.md
|
||||||
last_updated: "2026-10-01T14:40:47.065Z"
|
last_updated: "2026-10-02T06:40:05.378Z"
|
||||||
last_activity: 2026-10-01
|
last_activity: 2026-10-01
|
||||||
last_activity_desc: Phase 09 re-verified and marked complete (review fixes applied)
|
last_activity_desc: Phase 09 re-verified and marked complete (review fixes applied)
|
||||||
state_head: c07db9a321d44d655179cf2e50a00afe0ababdb3
|
state_head: d97b4292a3473b1b83e45ac15425a27320db43ff
|
||||||
progress:
|
progress:
|
||||||
total_phases: 20
|
total_phases: 19
|
||||||
completed_phases: 10
|
completed_phases: 10
|
||||||
total_plans: 96
|
total_plans: 101
|
||||||
completed_plans: 96
|
completed_plans: 96
|
||||||
milestone_name: milestone
|
milestone_name: milestone
|
||||||
---
|
---
|
||||||
@@ -28,9 +28,9 @@ See: .planning/PROJECT.md (updated 2026-09-16)
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 11.2 (summercms.io Alpha 0.1 landing page on SummerCMS (INSERTED)) — EXECUTING
|
Phase: 12 (Płytarium API — Collections and Albums) — READY TO EXECUTE
|
||||||
Plan: 3 of 3
|
Plan: 3 of 3
|
||||||
Status: Phase complete — ready for verification
|
Status: Ready to execute
|
||||||
Last activity: 2026-10-01 - Completed quick task 261001-qoa: Add a docs page on performance and scaling compared to PHP/WinterCMS
|
Last activity: 2026-10-01 - Completed quick task 261001-qoa: Add a docs page on performance and scaling compared to PHP/WinterCMS
|
||||||
|
|
||||||
Progress: [██████░░░░] 60%
|
Progress: [██████░░░░] 60%
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
---
|
||||||
|
phase: 12-p-ytarium-api-collections-and-albums
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- modules/lagoon/validate.go
|
||||||
|
- modules/lagoon/validate_request.go
|
||||||
|
- modules/lagoon/validate_rules.go
|
||||||
|
- modules/lagoon/validate_request_test.go
|
||||||
|
- modules/lagoon/validate_test.go
|
||||||
|
- modules/lagoon/README.md
|
||||||
|
- modules/phrasebook/lang/pl/validation.yaml
|
||||||
|
- modules/phrasebook/lang/en/validation.yaml
|
||||||
|
- modules/phrasebook/README.md
|
||||||
|
- docs/database/casts-and-validation.md
|
||||||
|
- modules/lagoon/attach/bucket.go
|
||||||
|
- modules/lagoon/attach/thumb.go
|
||||||
|
- modules/lagoon/attach/file.go
|
||||||
|
- modules/lagoon/attach/url_test.go
|
||||||
|
- docs/database/attachments.md
|
||||||
|
- docs/services/storage.md
|
||||||
|
- modules/tide/flow.go
|
||||||
|
- modules/tide/record.go
|
||||||
|
- modules/tide/replay.go
|
||||||
|
- modules/tide/normalize.go
|
||||||
|
- modules/tide/centrifugo_golden.go
|
||||||
|
- modules/tide/multipart.go
|
||||||
|
- modules/tide/multipart_test.go
|
||||||
|
- modules/tide/README.md
|
||||||
|
- docs/services/parity-testing.md
|
||||||
|
- modules/beachcomber/searchable.go
|
||||||
|
- modules/beachcomber/engines.go
|
||||||
|
- modules/beachcomber/typesense/engine.go
|
||||||
|
- modules/beachcomber/searchpage_test.go
|
||||||
|
- modules/beachcomber/typesense/searchpage_test.go
|
||||||
|
- modules/beachcomber/README.md
|
||||||
|
- docs/services/search.md
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
- ../fonoteka.go/plugins/golem15/user/models/user_group.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/user/models/user.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/user/updates/user_groups_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/user/classes/user_groups.go
|
||||||
|
- ../fonoteka.go/parity/schema_diff_test.go
|
||||||
|
- ../fonoteka.go/go.mod
|
||||||
|
- ../fonoteka.go/go.sum
|
||||||
|
- ../fonoteka.go/go.work.sum
|
||||||
|
- ../fonoteka.go/plugins/golem15/user/go.mod
|
||||||
|
- ../fonoteka.go/plugins/golem15/user/go.sum
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/go.mod
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/go.sum
|
||||||
|
- .planning/ROADMAP.md
|
||||||
|
- .planning/REQUIREMENTS.md
|
||||||
|
- .planning/todos/pending/lagoon-validate-min-message.md
|
||||||
|
- .planning/todos/done/lagoon-validate-min-message.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [API-01, API-02]
|
||||||
|
estimate:
|
||||||
|
tokens: 260000
|
||||||
|
raw_tokens: 260000
|
||||||
|
tasks: 4
|
||||||
|
confidence: low
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Per D-21 and RESEARCH Finding 5, `lagoon.ValidateRequest` reproduces Laravel 9 request-validation semantics: an attribute stops after a failed implicit rule (required), non-implicit rules skip absent, null-with-nullable and blank-string values, `sometimes` skips an absent key, and `{\"albums\":[]}` against `required|array|min:1` yields exactly one message, `Pole albums jest wymagane.` in pl."
|
||||||
|
- "`lagoon.ValidateRequest` supports array, `*` wildcard expansion with attributes named like `albums.0.name`, string, integer, numeric, boolean, url, date, after_or_equal, before_or_equal (including `tomorrow`), exists:table,column, regex, image, mimes, in, email, min, max, between and closure rules (`lagoon.CustomRule`), and picks the size message by value type (`max.string`, `max.numeric`, `max.array`, `max.file`)."
|
||||||
|
- "The pl and en catalogs `lagoon::validation.*` are ports of Winter's `modules/system/lang/{pl,en}/validation.php`; a key absent from pl (after_or_equal, before_or_equal) falls back to en exactly as Winter does."
|
||||||
|
- "The `lagoon-validate-min-message` todo is folded: `lagoon.Validate` answers a min failure with the min message and a numeric between failure with the between message, and every recorded user-plugin 422 body still replays byte-identically (core plugin contract unchanged)."
|
||||||
|
- "Per D-22, `(*attach.File).URL()` returns the public URL of the original file and `attach.PublicURL(key)` the URL of any blob key; with `storage.uploads.bucket_url: file://./storage/app/uploads/public` and `public_path_prefix: /storage/app/uploads/public` an original resolves to `/storage/app/uploads/public/<partition>/<disk_name>` and a 200x200 crop thumb to `/storage/app/uploads/public/<partition>/thumb_<id>_200_200_0_0_crop.<ext>`, matching Winter's `File::getPath()`/`getThumb()`."
|
||||||
|
- "Per D-24, `golang.org/x/image` is a direct dependency at v0.46.0 and `golang.org/x/image/webp` is registered, so `image.DecodeConfig` and `(*File).Thumb` accept a .webp original; a webp thumb is JPEG bytes under the original extension (imaging cannot encode webp) and that is documented."
|
||||||
|
- "Per D-11, a tide request can carry a multipart body described by parts (field values and files stored beside the fixture with a sha256), recorded and replayed with one fixed boundary so PHP and Go receive byte-identical bodies; a part file whose sha256 differs fails the load."
|
||||||
|
- "tide masks the random partition and disk name of upload URLs under `url`/`thumb_url` keys while still failing on a wrong prefix, partition shape or thumb suffix, and `NormalizePublications` masks Carbon `created_at`/`updated_at` (and other `*_at`) values inside the published album subtree, so the created/updated broadcast goldens can become assertions."
|
||||||
|
- "Per D-19, beachcomber exposes the engine's `found` count through the optional `beachcomber.PageSearcher` interface (`SearchPage`) without changing `Engine.SearchIDs`, and `beachcomber.Query.QueryByWeights` is sent to Typesense as `query_by_weights`; an engine without SearchPage falls back to `Found = len(ids)`."
|
||||||
|
- "Per D-25, the Go user plugin gains Winter's `user_groups` and `users_groups` tables (with the Guest and Registered seed rows), `models.UserGroup`, a `Groups` relation on `models.User` that is never serialized, and `classes.UserGroupCodes`; the user API payloads and every user-api parity fixture stay byte-identical."
|
||||||
|
- "Per D-03, D-04, D-06 and D-20, ROADMAP Phase 12 criteria 1-3 and goal, Phase 13, Phase 14, and REQUIREMENTS API-01, API-02, API-03, API-07 and INTG-01 are reworded in a planning-docs-only commit."
|
||||||
|
- "Edge (API-01 encoding): string size rules count characters (Unicode code points, as PHP mb_strlen), not bytes: a 255-character Polish name passes max:255 and 256 characters fail with the max.string message."
|
||||||
|
- "Edge (API-02 boundary): between:1889,2100 on an integer accepts 1889 and 2100 and rejects 1888 and 2101 with the between.numeric message; max:10240 on a file accepts exactly 10240 KB and rejects 10240 KB plus one byte with the max.file message."
|
||||||
|
- "Edge (API-02 precision): numeric min:0 and max:999999.9999 compare decimal strings exactly (big.Rat), so 999999.9999 passes and 1000000 fails; no float64 rounding decides a bound."
|
||||||
|
artifacts:
|
||||||
|
- path: "modules/lagoon/validate_request.go"
|
||||||
|
provides: "ValidateRequest, RequestRule, Rule, ParseRules, CustomRule, UploadedFile"
|
||||||
|
contains: "func ValidateRequest("
|
||||||
|
- path: "modules/phrasebook/lang/pl/validation.yaml"
|
||||||
|
provides: "Polish Laravel/Winter validation catalog"
|
||||||
|
contains: "Pole :attribute jest wymagane."
|
||||||
|
- path: "modules/lagoon/attach/thumb.go"
|
||||||
|
provides: "PublicURL, File.URL, webp decoder registration"
|
||||||
|
contains: "golang.org/x/image/webp"
|
||||||
|
- path: "modules/tide/multipart.go"
|
||||||
|
provides: "multipart request parts, fixed boundary encoder"
|
||||||
|
- path: "modules/beachcomber/searchable.go"
|
||||||
|
provides: "PageSearcher, SearchResult, Query.QueryByWeights"
|
||||||
|
contains: "PageSearcher"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go"
|
||||||
|
provides: "user_groups and users_groups migration with seed rows"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/user/classes/user_groups.go"
|
||||||
|
provides: "UserGroupCodes, HasGroupCode"
|
||||||
|
key_links:
|
||||||
|
- from: "modules/lagoon/validate_request.go"
|
||||||
|
to: "modules/phrasebook/lang/pl/validation.yaml"
|
||||||
|
via: "translator lookup lagoon::validation.<rule>[.<type>] in the request locale"
|
||||||
|
pattern: "lagoon::validation\\."
|
||||||
|
- from: "modules/tide/replay.go"
|
||||||
|
to: "modules/tide/multipart.go"
|
||||||
|
via: "Request.Parts encoded with the fixed boundary before sending"
|
||||||
|
pattern: "Parts"
|
||||||
|
- from: "modules/beachcomber/typesense/engine.go"
|
||||||
|
to: "modules/beachcomber/searchable.go"
|
||||||
|
via: "Engine implements PageSearcher and sends query_by_weights"
|
||||||
|
pattern: "query_by_weights"
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/user/classes/user_groups.go"
|
||||||
|
to: "../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go"
|
||||||
|
via: "joins users_groups to user_groups by user_group_id"
|
||||||
|
pattern: "users_groups"
|
||||||
|
prohibitions:
|
||||||
|
- requirement_id: API-01
|
||||||
|
category: safety
|
||||||
|
statement: "The user-groups change MUST NOT alter any existing user-plugin response, route, rule or column; every recorded user-api fixture replays unchanged"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
- requirement_id: API-02
|
||||||
|
category: transparency
|
||||||
|
statement: "A validation message MUST NOT be invented or paraphrased; every message text comes from the ported Winter catalog or verbatim from the PHP closure rule"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Goal
|
||||||
|
|
||||||
|
ROADMAP Phase 12 goal (verbatim, not in user-story form; MVP precedent of Phases 11 and 11.2 is to quote it): Collections and Albums endpoints are ported with byte-compatible request/response shapes, including active-context switching, editor invitations, ratings, reservations, cover handling and search.
|
||||||
|
|
||||||
|
This plan's slice: the framework can produce every Laravel 422 body, record and replay multipart uploads, emit Winter-shaped upload URLs, decode webp, report the search engine's `found` count and rank fields by weight; the user plugin knows user groups; and the roadmap and requirements say what Phase 12 actually ships. Nothing here is user-visible on its own: plans 12-02 to 12-04 build every endpoint on these pieces.
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Close the framework gaps RESEARCH Finding 5 lists (summercms.go), add user groups to the Go user plugin (fonoteka.go, the user plugin lives at `../fonoteka.go/plugins/golem15/user`), and correct the planning-doc wording.
|
||||||
|
|
||||||
|
Purpose: every Phase 12 endpoint needs Laravel-exact 422 bodies (D-21), multipart recordings (D-11), PHP upload URLs (D-22), webp (D-24), a re-gated search total (D-19) and the site-admin predicate (D-25).
|
||||||
|
Output: `lagoon.ValidateRequest` with pl/en catalogs; attach `URL`/`PublicURL` and webp; tide multipart and upload/publication masks; beachcomber `PageSearcher` and weights; user groups; README and docs updates; reworded ROADMAP/REQUIREMENTS.
|
||||||
|
|
||||||
|
Repos: summercms.go (framework and planning docs) and fonoteka.go (user plugin). Framework code, tests, READMEs and docs use neutral names (acme, blog, posts) and never name the application. Planning docs and code go in separate commits; never add co-author tags.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/REQUIREMENTS.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md
|
||||||
|
@.planning/todos/pending/lagoon-validate-min-message.md
|
||||||
|
@modules/lagoon/validate.go
|
||||||
|
@modules/lagoon/attach/bucket.go
|
||||||
|
@modules/lagoon/attach/thumb.go
|
||||||
|
@modules/tide/flow.go
|
||||||
|
@modules/tide/normalize.go
|
||||||
|
@modules/beachcomber/searchable.go
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
- lagoon (today): `Validate(ctx, tx *gorm.DB, model any, rules map[string]string, values map[string]any, tr *phrasebook.Translator) (map[string][]string, error)`; messages via `validateMessage` with key `lagoon::validate.<rule>` and English fallbacks; `laravelAttribute(field)`, `isLaravelBoolean`, `isEmptyValue`, `numericString`, `moneyInRange` (big.Rat), `uniqueOK`. Callers: modules/cabana/crud.go:342, modules/cabana/settings.go:158, the user plugin controllers (seven calls in ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go) and ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go:94. Their output must not change except the folded min/between message fix.
|
||||||
|
- phrasebook: files `modules/phrasebook/lang/<locale>/<group>.yaml` load as `lagoon::<group>.*`; nested YAML maps flatten to dotted keys; `(*Translator).Get(ctx, key, params)` uses the ctx locale with fallback requested, parent, app.fallback_locale, raw key.
|
||||||
|
- attach: `const defaultPublicPathPrefix = "/storage/uploads"`, `PublicPathPrefix()`, unexported `publicURL(key string) string`, `BlobKey(diskName)`, `PartitionDirectory(diskName)`, `ThumbFilename(id, w, h, offX, offY, mode, ext)`, `(*File).Thumb(ctx, bucket, w, h, mode) (string, error)`; thumb.go blank-imports image/gif, image/jpeg, image/png; `defaultEncodeImage` writes JPEG for unknown extensions.
|
||||||
|
- tide: `Request{Method, Path, Query, Headers, Body Body}`, `Response{Status, Headers, Body, BodyFile, SHA256}`, `Step`, `Flow`, `RecordFlow`, `ReplayFlow(ctx, flow, ReplayConfig{Target, Store, BaseDir})`, normalizer `maskLeaf` (masks id/date/collection_key/client_id), `NormalizePublications` (masks data.timestamp, payload.timestamp, actor, captured ids).
|
||||||
|
- beachcomber: `Engine{Name, Configured, Upsert, Delete, Flush, SearchIDs(ctx, index, Query) ([]string, error)}`, `Query{Q, QueryBy []string, FilterBy, SortBy, Page, PerPage}`, `RegisterEngine(name, EngineFactory)`, nullEngine in engines.go, typesense `(*Engine).SearchIDs` at typesense/engine.go:231 decoding `searchAnswer`.
|
||||||
|
- User plugin: `models.User` (users table, no groups), migrations registered through `updates.Register(...)` in timestamp-named files (`202609220007_create_jwt_blacklist.go` is the latest), `classes/` holds queries (models stay a leaf). PHP source: /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v1.1.1/create_user_groups_table.php, seed_user_groups_table.php, v2.8.0/add_permissions_to_user_groups.php, models/UserGroup.php, models/User.php:50.
|
||||||
|
- Laravel 9 validator source (contract): /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Validator.php (isValidatable, presentOrRuleIsImplicit, shouldStopValidating, implicitRules), Concerns/ValidatesAttributes.php, Concerns/FormatsMessages.php (getSizeMessage, getDisplayableAttribute), Concerns/ReplacesAttributes.php; catalogs /media/nvme/dev/golem15/fonoteka/modules/system/lang/{pl,en}/validation.php.
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
(This plan's share.)
|
||||||
|
|
||||||
|
- lagoon: `ValidateRequest(ctx context.Context, tx *gorm.DB, input map[string]any, rules []RequestRule, tr *phrasebook.Translator) (map[string][]string, error)`, `RequestRule{Field string; Rules []Rule}`, `Rule` (a parsed token or a custom func), `ParseRules(spec string) []Rule`, `CustomRule(fn func(attribute string, value any) (message string, failed bool)) Rule`, `UploadedFile{Filename string; Size int64; Header textproto.MIMEHeader; Open func() (io.ReadCloser, error)}` with `UploadedFileFromHeader(*multipart.FileHeader) UploadedFile`, `ErrorKeys(errs map[string][]string) []string` (rule-declaration order); catalog namespace `lagoon::validation.*` (pl, en).
|
||||||
|
- attach: `PublicURL(key string) string`, `(*File).URL() string`, webp decoding.
|
||||||
|
- tide: `Request.Parts []Part`, `Part{Name, Value, File, Filename, ContentType, SHA256}`, `MultipartBoundary` (fixed), upload-URL masking for `url`/`thumb_url`, album-date masking in `NormalizePublications`.
|
||||||
|
- beachcomber: `PageSearcher{SearchPage(ctx, index string, q Query) (SearchResult, error)}`, `SearchResult{IDs []string; Found int}`, `SearchPage(ctx, e Engine, index string, q Query) (SearchResult, error)` (fallback helper), `Query.QueryByWeights []int`.
|
||||||
|
- User plugin (fonoteka.go): tables `user_groups`, `users_groups`; `models.UserGroup`; `models.User.Groups`; `classes.UserGroupCodes(ctx, db, userID) ([]string, error)`, `classes.HasGroupCode(ctx, db, userID, code) (bool, error)`.
|
||||||
|
- Dependency: `golang.org/x/image v0.46.0` (direct, summercms.go).
|
||||||
|
|
||||||
|
## Assumption-delta decision
|
||||||
|
|
||||||
|
<assumption_delta_decision>
|
||||||
|
Detector run on the Phase 12 ROADMAP section: detected=false. Considered D-25 (user groups) by hand: it adds a membership relation that a single predicate (site admin = group code `admin`) reads; the user identity, its primary key and the JWT principal stay the only identity model. Noun primary: User. Decision: no-change. Rationale: groups are an attribute of a user, not a second identity or tenant.
|
||||||
|
</assumption_delta_decision>
|
||||||
|
|
||||||
|
## Flagged assumptions (edge probe)
|
||||||
|
|
||||||
|
- API-01 adjacency/empty/ordering and API-02 concurrency are not framework concerns; plans 12-02 to 12-04 resolve them. This plan resolves API-01 encoding (character counting) and API-02 boundary and precision for the validator.
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1: A Laravel-shaped Polish 422 body comes out of one request validator for a wildcard array request</name>
|
||||||
|
<reversibility rating="reversible">New entry point beside lagoon.Validate; existing callers keep their function and catalog keys.</reversibility>
|
||||||
|
<files>modules/lagoon/validate_request.go, modules/lagoon/validate_rules.go, modules/lagoon/validate.go, modules/lagoon/validate_request_test.go, modules/lagoon/validate_test.go, modules/phrasebook/lang/pl/validation.yaml, modules/phrasebook/lang/en/validation.yaml, modules/lagoon/README.md, modules/phrasebook/README.md, docs/database/casts-and-validation.md</files>
|
||||||
|
<read_first>modules/lagoon/validate.go (whole file), modules/phrasebook/lang.go, modules/phrasebook/loader.go (flatten), modules/phrasebook/translator.go (Get, fallbackChain), modules/phrasebook/lang/pl/validate.yaml, modules/phrasebook/lang/en/validate.yaml, .planning/todos/pending/lagoon-validate-min-message.md, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Validator.php, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Concerns/ValidatesAttributes.php, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Concerns/FormatsMessages.php, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Concerns/ReplacesAttributes.php, /media/nvme/dev/golem15/fonoteka/modules/system/lang/pl/validation.php, /media/nvme/dev/golem15/fonoteka/modules/system/lang/en/validation.php, ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_albums_bulk_jwt.yaml (recorded 422 body), docs/database/casts-and-validation.md, modules/lagoon/README.md</read_first>
|
||||||
|
<action>Per D-21 and RESEARCH Finding 5 / Pitfall 3, add a request validator with Laravel 9 semantics next to the existing model validator.
|
||||||
|
|
||||||
|
(1) Catalogs: create modules/phrasebook/lang/pl/validation.yaml and en/validation.yaml as faithful ports of Winter's system lang validation.php files (every key, nested size maps `between`, `gt`, `gte`, `lt`, `lte`, `max`, `min`, `size` with `numeric`, `file`, `string`, `array` children, plus `custom` and `attributes` maps if present). Text is copied verbatim; only PHP quoting is converted. They load as `lagoon::validation.*` beside the untouched `lagoon::validate.*` group. Keys missing from pl (after_or_equal, before_or_equal and any other) are left missing so the translator falls back to en, as Winter does.
|
||||||
|
|
||||||
|
(2) validate_request.go and validate_rules.go: exported `RequestRule{Field string; Rules []Rule}`, `Rule` (token name, args, implicit flag, or a custom func), `ParseRules(spec string) []Rule` (pipe split, `regex:` arguments kept whole even when they contain a pipe or comma, `in:` args split on commas), `CustomRule(fn)` (non-implicit, runs in declaration position, its returned message is used verbatim), `UploadedFile` with `UploadedFileFromHeader`, and `ValidateRequest(ctx, tx, input, rules, tr)`. Semantics to port from Validator.php: expand `*` segments against the decoded input (each array index becomes `.0`, `.1` ...; an absent parent expands to the literal attribute so `required` still fires); per attribute run rules in order; a rule is validatable only when the value is present or the rule is implicit (required, required_* family, accepted, present), a blank string after trim counts as absent for non-implicit rules, `nullable` with a null value skips the rest, `sometimes` skips an absent key; stop the attribute after a failed implicit rule (shouldStopValidating) and after `bail`. Rules: required, nullable, sometimes, bail, array, string, integer (int types, json.Number without fraction, numeric strings without fraction as filter_var FILTER_VALIDATE_INT), numeric, boolean (true, false, 0, 1, "0", "1"), email (port FILTER_VALIDATE_EMAIL-compatible check used by Laravel's default `email` rule, record the boundary cases you choose in the test), url (Laravel 9 validateUrl regex), date (strtotime-compatible: accept the ISO and Y-m-d shapes the recorded fixtures use; document the accepted set), after_or_equal and before_or_equal (argument is a date or a relative word: today, tomorrow, yesterday, now), exists:table,column (identName-checked identifiers, deleted_at IS NULL is NOT added because Laravel's exists does not add it), regex:/pattern/ (PCRE delimiters stripped; reject patterns Go RE2 cannot compile with a boot-time error, never at request time), in, mimes and image (sniff with http.DetectContentType plus extension mapping as Laravel's guessExtension; image = jpg, jpeg, png, gif, bmp, svg, webp), min, max, between, size, same as Laravel getSize: string length in Unicode code points (utf8.RuneCountInString, PHP mb_strlen), array element count, numeric value when the attribute also has numeric or integer, file size in kilobytes (bytes/1024). Messages: key `lagoon::validation.<rule>`, or `lagoon::validation.<rule>.<numeric|file|string|array>` for size rules picked by the same type order Laravel uses; replacements :attribute (Laravel getDisplayableAttribute: custom attribute name if the catalog defines one, else snake case with underscores turned into spaces; wildcard attributes keep their dotted index), :min, :max, :size, :values (comma-joined), :date, :other, :format. Return the Laravel errors object (attribute to ordered messages) plus `ErrorKeys` giving attribute order by first rule failure so callers can emit keys in PHP order where order is ever compared byte-wise.
|
||||||
|
|
||||||
|
(3) Fold the min-message todo in validate.go: pick the message by which bound failed (min below the lower bound, max above the upper, between when both bounds came from between) for integer and numeric fields; keep the existing `lagoon::validate.*` texts and every other branch byte-identical. Before changing, grep the user-api fixtures for `may not be greater than` and `must be at least` and keep any recorded text green.
|
||||||
|
|
||||||
|
(4) Smoke tests (full coverage comes in 12-05): validate_request_test.go with neutral posts/tags examples including `{"posts":[]}` under `required|array|min:1` producing exactly `Pole posts jest wymagane.` in pl and `The posts field is required.` in en; `posts.*.title` wildcard naming `posts.0.title`; a 255 versus 256 character Polish string under max:255; between:1889,2100 at 1888, 1889, 2100, 2101; numeric max:999999.9999 at 999999.9999 and 1000000; a pl-missing key falling back to en; validate_test.go cases for the min/between message fix.
|
||||||
|
|
||||||
|
(5) Docs in the same change (CLAUDE.md): modules/lagoon/README.md (API reference for ValidateRequest, RequestRule, Rule, ParseRules, CustomRule, UploadedFile, the supported rule list, the implicit-stop semantics), modules/phrasebook/README.md (the new `lagoon::validation.*` namespace beside `lagoon::validate.*`), docs/database/casts-and-validation.md (a "Request validation" section in prose; no hand-written Go fences, follow the 11.1 docs rules, use src= only for compiled examples). Every identifier named must exist.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go vet ./... && go test ./modules/lagoon -run '^(TestValidateRequest.*|TestValidate.*Message.*)$' -count=1 -v && go test ./modules/phrasebook -count=1 && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1</automated>
|
||||||
|
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestValidateRequest"; TestDocsTree reports an unknown identifier or broken link; any user plugin test fails (core plugin regression).</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go doc ./modules/lagoon ValidateRequest`, `go doc ./modules/lagoon ParseRules`, `go doc ./modules/lagoon CustomRule` and `go doc ./modules/lagoon UploadedFileFromHeader` exit 0.
|
||||||
|
- `grep -c 'Pole :attribute jest wymagane.' modules/phrasebook/lang/pl/validation.yaml` prints 1 and `grep -c 'max:' modules/phrasebook/lang/pl/validation.yaml` prints at least 1.
|
||||||
|
- The en catalog holds `after_or_equal` and the pl catalog does not (`grep -c '^after_or_equal' modules/phrasebook/lang/pl/validation.yaml` prints 0), matching Winter's pl file.
|
||||||
|
- `grep -c 'ValidateRequest' modules/lagoon/README.md` and `grep -c 'lagoon::validation' modules/phrasebook/README.md` each print at least 1.
|
||||||
|
- A test asserts the exact pl string `Pole posts jest wymagane.` as the only message for `{"posts":[]}`.
|
||||||
|
- A validate_test.go case asserts that `min:0` with -1 on an integer field answers with the min message carrying `0`, not the max message.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Any handler can hand decoded request input and PHP-ordered rules to one validator and get Laravel's exact errors object in the request locale; the old model validator keeps its contract with the min/between message fixed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: An uploaded photo can be recorded, replayed and addressed exactly as Winter does, webp included</name>
|
||||||
|
<reversibility rating="reversible">Additive exports and masks; the framework default public_path_prefix stays /storage/uploads, so only an app that opts in by config changes its URLs.</reversibility>
|
||||||
|
<files>modules/lagoon/attach/bucket.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/file.go, modules/lagoon/attach/url_test.go, modules/tide/flow.go, modules/tide/record.go, modules/tide/replay.go, modules/tide/multipart.go, modules/tide/multipart_test.go, modules/tide/normalize.go, modules/tide/centrifugo_golden.go, modules/tide/README.md, modules/lagoon/README.md, docs/database/attachments.md, docs/services/storage.md, docs/services/parity-testing.md, go.mod, go.sum, ../fonoteka.go/go.mod, ../fonoteka.go/go.sum, ../fonoteka.go/go.work.sum, ../fonoteka.go/plugins/golem15/user/go.mod, ../fonoteka.go/plugins/golem15/user/go.sum, ../fonoteka.go/plugins/golem15/fonoteka/go.mod, ../fonoteka.go/plugins/golem15/fonoteka/go.sum</files>
|
||||||
|
<read_first>modules/lagoon/attach/bucket.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/file.go, modules/lagoon/attach/static.go, modules/lagoon/attach/thumb_test.go, /media/nvme/dev/golem15/fonoteka/modules/system/models/File.php (getPublicPath, getDiskName, getPartitionDirectory), /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Database/Attach/File.php (getThumb, getThumbFilename, getDiskName), /media/nvme/dev/golem15/fonoteka/config/cms.php (storage.uploads), modules/tide/flow.go, modules/tide/record.go, modules/tide/replay.go, modules/tide/fixture.go, modules/tide/normalize.go, modules/tide/centrifugo_golden.go, modules/tide/README.md, ../fonoteka.go/parity/fixtures/broadcasts/created.yaml (album subtree dates), ../fonoteka.go/parity/README.md (Broadcast goldens), docs/services/parity-testing.md, docs/services/storage.md, docs/database/attachments.md</read_first>
|
||||||
|
<action>(1) attach, per D-22: export `PublicURL(key string) string` (the current publicURL body: PublicPathPrefix joined with the key by exactly one slash) and keep a lowercase alias only if other files need it; add `(*File).URL() string` returning `PublicURL(BlobKey(f.DiskName))`. Do not change defaultPublicPathPrefix. Add url_test.go pinning, under a config with bucket `mem://` and public_path_prefix `/storage/app/uploads/public`, that URL() is `/storage/app/uploads/public/<PartitionDirectory(disk)>/<disk>` and a Thumb(200, 200, crop) URL is `/storage/app/uploads/public/<partition>/thumb_<id>_200_200_0_0_crop.<ext>` (compare with Winter getThumbFilename read from the vendor source).
|
||||||
|
|
||||||
|
(2) webp, per D-24: `go get golang.org/x/image@v0.46.0` in summercms.go (becomes a direct requirement), blank-import `golang.org/x/image/webp` in thumb.go beside the gif/jpeg/png decoders, and add a test decoding a small webp fixture with image.DecodeConfig and producing a crop thumb whose bytes are JPEG under the .webp name (document this in the attach section of modules/lagoon/README.md and docs/database/attachments.md). Then in ../fonoteka.go run `go work sync` and `go mod tidy` in the root module and both plugin modules so all go.sum and go.work.sum files list v0.46.0; `go -C ../fonoteka.go build ./...` must pass.
|
||||||
|
|
||||||
|
(3) tide multipart, per D-11: add `Parts []Part` to Request (yaml `parts,omitempty`), `Part{Name, Value, File, Filename, ContentType, SHA256 string}` where File is a path relative to the fixture directory (fixtures store upload bytes as files, e.g. `files/cover.png`), and a fixed exported boundary constant `MultipartBoundary`. multipart.go encodes parts in declaration order with mime/multipart using that boundary and sets `Content-Type: multipart/form-data; boundary=...` (overriding any recorded Content-Type header value only when it is a multipart type). Loading a flow checks every part file exists under BaseDir and its sha256 matches (mismatch is an error naming the part); a request with both Body and Parts is an error. RecordFlow and ReplayFlow send the same encoded bytes; variable substitution applies to Value and Path, never to file bytes. The recorder never inlines file bytes into YAML.
|
||||||
|
|
||||||
|
(4) Upload URL masking: in normalize.go, for leaf keys `url` and `thumb_url` whose string value starts with an uploads prefix, assert the shape `<prefix>/<3 hex>/<3 hex>/<3 hex>/<disk>` for originals and `.../thumb_<digits>_<w>_<h>_0_0_<mode>.<ext>` for thumbs (derive the partition and disk-name pattern from attach.PartitionDirectory and Winter getDiskName as read from vendor), then mask the partition and disk stem (keeping prefix, size, mode and extension visible); a wrong prefix, a missing partition or a different thumb size reports a Diff at the path, like the Carbon date check. Values that are not upload-shaped stay untouched. The prefix to accept is configurable on the normalizer (default `/storage/app/uploads/public`); never hard-code an application name.
|
||||||
|
|
||||||
|
(5) Publications: extend NormalizePublications so Carbon `+00:00` values under `*_at` keys anywhere inside `data.payload.album` are masked with the same shape assertion as response bodies (A5 in RESEARCH: confirm first whether they are already masked; if they are, add only the test).
|
||||||
|
|
||||||
|
(6) Tests: multipart_test.go (round trip against an httptest server capturing the raw body: two recordings of the same flow produce identical bytes; a tampered part file fails load; Body plus Parts fails), normalize tests for upload URLs and publication dates. Docs in the same change: modules/tide/README.md (parts, boundary, upload masks, publication date masks), docs/services/parity-testing.md, docs/services/storage.md (the Winter layout recipe: bucket rooted at uploads/public with prefix /storage/app/uploads/public, and URL/PublicURL), docs/database/attachments.md (URL, webp).</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go vet ./... && go test ./modules/lagoon/attach ./modules/tide -count=1 -v -run '^(TestFileURLWinterLayout|TestThumbWebP|TestMultipart.*|TestNormalizeUploadURL.*|TestNormalizePublication.*)$' && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./...</automated>
|
||||||
|
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks a "--- PASS" line for TestFileURLWinterLayout, TestThumbWebP and a TestMultipart test; the fonoteka.go build fails on a go.sum mismatch for golang.org/x/image.</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c 'golang.org/x/image v0.46.0' go.mod` prints 1 and the line has no `// indirect` comment.
|
||||||
|
- `grep -c 'golang.org/x/image/webp' modules/lagoon/attach/thumb.go` prints 1.
|
||||||
|
- `go doc ./modules/lagoon/attach PublicURL` and `go doc ./modules/lagoon/attach File.URL` exit 0.
|
||||||
|
- `go doc ./modules/tide Part` and `go doc ./modules/tide MultipartBoundary` exit 0.
|
||||||
|
- `grep -c 'Parts' modules/tide/README.md` prints at least 1 and `grep -c 'storage/app/uploads/public' docs/services/storage.md` prints at least 1.
|
||||||
|
- A tide test asserts that a thumb_url with a 100x100 size against a 200x200 expectation is reported as a Diff (masking never hides a wrong size).
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>A multipart upload can be recorded from PHP and replayed against Go byte for byte, upload URLs are compared by shape without their random parts, the attach package emits Winter's URLs under the Winter layout config, and webp images decode.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Search reports how many documents matched with PHP's field weights, and the user plugin knows which groups a user is in</name>
|
||||||
|
<reversibility rating="costly">D-25 changes the core user plugin schema (two new tables). It is additive with a down migration and the user signed off on 2026-10-02, so it is flagged without a checkpoint.</reversibility>
|
||||||
|
<files>modules/beachcomber/searchable.go, modules/beachcomber/engines.go, modules/beachcomber/typesense/engine.go, modules/beachcomber/searchpage_test.go, modules/beachcomber/typesense/searchpage_test.go, modules/beachcomber/README.md, docs/services/search.md, ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go, ../fonoteka.go/plugins/golem15/user/updates/user_groups_test.go, ../fonoteka.go/plugins/golem15/user/classes/user_groups.go, ../fonoteka.go/parity/schema_diff_test.go</files>
|
||||||
|
<read_first>modules/beachcomber/searchable.go, modules/beachcomber/engines.go, modules/beachcomber/beachcomber.go, modules/beachcomber/typesense/engine.go (SearchIDs, searchAnswer), modules/beachcomber/README.md, docs/services/search.md, /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Engines/TypesenseEngine.php (search params, found, maxPerPage), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSearchService.php (lines 40-60 and 170-200), ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/registry.go, ../fonoteka.go/plugins/golem15/user/updates/202609220007_create_jwt_blacklist.go, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, ../fonoteka.go/plugins/golem15/user/classes/user_lookup.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (user payload around line 800-830: groups stays []), ../fonoteka.go/parity/schema_diff_test.go (allowedDiffs), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v1.1.1/create_user_groups_table.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v1.1.1/seed_user_groups_table.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v2.8.0/add_permissions_to_user_groups.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/UserGroup.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/OrgAccess.php</read_first>
|
||||||
|
<action>(1) beachcomber, per D-19: add `QueryByWeights []int` to Query; add `SearchResult{IDs []string; Found int}`, the optional interface `PageSearcher` with `SearchPage(ctx, index string, q Query) (SearchResult, error)`, and the helper `SearchPage(ctx, e Engine, index string, q Query) (SearchResult, error)` that type-asserts PageSearcher and otherwise calls SearchIDs and sets Found to len(ids). Engine.SearchIDs keeps its signature (other engines and test fakes stay valid). nullEngine implements SearchPage returning an empty result. typesense Engine implements SearchPage on the same `/collections/{index}/documents/search` request as SearchIDs (share one internal function), decoding `found`, and sends `query_by_weights` as the comma-joined weights when QueryByWeights is non-empty; a QueryByWeights length different from QueryBy is an error before any request. Reject PerPage above 250 with an error (Typesense's limit; callers page). Test with an httptest Typesense double: found and ids decode, the weights parameter arrives, the fallback helper works on an engine without SearchPage.
|
||||||
|
|
||||||
|
(2) Docs in the same change: modules/beachcomber/README.md and docs/services/search.md (PageSearcher, SearchResult, the helper, QueryByWeights, the 250 per-page limit, and that ids remain candidates only that callers re-gate in SQL).
|
||||||
|
|
||||||
|
(3) User groups, per D-25 (fonoteka.go user plugin, additive): models/user_group.go `UserGroup{ID uint; Name string; Code *string; Description *string; Permissions *string; CreatedAt, UpdatedAt *time.Time}` with TableName `user_groups`, registered in the models registry like the other models; add `Groups []UserGroup` to models.User with tag `gorm:"many2many:users_groups;joinForeignKey:user_id;joinReferences:user_group_id"` and `json:"-"` (GORM never writes it unless a caller associates groups; nothing in the user plugin does). Migration file updates/202610020001_create_user_groups.go (ID `202610020001_create_user_groups`): create `user_groups` (id serial primary key, name varchar(255) not null, code varchar(255) null with an index, description text null, permissions text null, created_at and updated_at timestamp null) and `users_groups` (user_id integer not null, user_group_id integer not null, primary key (user_id, user_group_id) named user_group), then insert the Guest/guest and Registered/registered rows with PHP's descriptions; Rollback drops both tables. classes/user_groups.go: `UserGroupCodes(ctx, db, userID uint) ([]string, error)` (codes of the user's groups ordered by user_groups.id, null codes skipped) and `HasGroupCode(ctx, db, userID uint, code string) (bool, error)`. The user API payload keeps `"groups":[]` exactly as today (D-25: contract unchanged); do not read the new table in any user-plugin handler.
|
||||||
|
|
||||||
|
(4) parity/schema_diff_test.go: add allowedDiffs entries `user_groups` and `users_groups` with a reason naming D-25 and that the frozen PHP snapshot predates the user-plugin dump (the same justification as user_throttle). Keep every other entry.
|
||||||
|
|
||||||
|
(5) Tests: updates/user_groups_test.go on the plugin's Postgres harness: migrate up creates both tables and the two seed rows, RollbackLast drops them, re-up works; UserGroupCodes returns `admin` for a user linked to a group with code admin and an empty slice for a user with no groups.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go vet ./... && go test ./modules/beachcomber/... -count=1 -v -run '^(TestSearchPage.*|TestTypesenseSearchPage.*)$' && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/user/updates -count=1 -v -run '^(TestUserGroups.*)$' && go -C ../fonoteka.go test ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestUserAPINuxtFlows)$' -count=1</automated>
|
||||||
|
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS" for a TestSearchPage test and a TestUserGroups test; TestSchemaMatchesPHPSnapshot reports an extra Go table; TestUserAPINuxtFlows fails (user payload changed).</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go doc ./modules/beachcomber PageSearcher`, `go doc ./modules/beachcomber SearchPage` and `go doc ./modules/beachcomber Query.QueryByWeights` exit 0.
|
||||||
|
- `grep -c 'query_by_weights' modules/beachcomber/typesense/engine.go` prints at least 1.
|
||||||
|
- `grep -c 'PageSearcher' modules/beachcomber/README.md` and `grep -c 'PageSearcher' docs/services/search.md` each print at least 1.
|
||||||
|
- `grep -c '"user_groups"' ../fonoteka.go/parity/schema_diff_test.go` and `grep -c '"users_groups"' ../fonoteka.go/parity/schema_diff_test.go` each print 1.
|
||||||
|
- `grep -c 'json:"-"' ../fonoteka.go/plugins/golem15/user/models/user.go` increases by one relative to HEAD (the Groups field is never serialized).
|
||||||
|
- `grep '"groups":' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go | grep -c '\[\]any{}'` prints 1, and this task's fonoteka.go commit touches no file under `plugins/golem15/user/controllers/` (payload untouched).
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Callers can ask the search engine for one page of candidate ids and the total it found, ranked with explicit weights, and any plugin can ask which group codes a user has without the user API changing.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 4: The roadmap and requirements describe what Phase 12 ships (planning docs only)</name>
|
||||||
|
<files>.planning/ROADMAP.md, .planning/REQUIREMENTS.md, .planning/todos/pending/lagoon-validate-min-message.md, .planning/todos/done/lagoon-validate-min-message.md</files>
|
||||||
|
<read_first>.planning/ROADMAP.md (Phase 12, 13, 14 sections), .planning/REQUIREMENTS.md (API-01, API-02, API-03, API-07, INTG-01), .planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md (D-03, D-04, D-06, D-19, D-20), .planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md (Findings 1 and 2)</read_first>
|
||||||
|
<action>Use Edit (scoped replacements), never a whole-file Write. Per D-03, D-04, D-06, D-19 and D-20:
|
||||||
|
- ROADMAP Phase 12 Goal: drop "reservations" and say cover import and manual cover URL instead of "cover handling" where it adds precision; keep one line.
|
||||||
|
- Phase 12 success criterion 1: Collections CRUD with photos and image, the `collections/{id}/switch` and `me/context` flags, the opaque channel name from `GET realtime/channels`, editor invitation/acceptance and members, and the owner-only `collection/share` show/update/regenerate pass the parity diff (anonymous public token views moved to Phase 13).
|
||||||
|
- Criterion 2: Albums CRUD, ratings, photo upload, manual cover URL and Discogs cover import on create/bulk (`cover_urls`), plus sync/stats/value/missing/bulk pass the parity diff (reservations moved to Phase 13, the Discogs cover-price route to Phase 14).
|
||||||
|
- Criterion 3: the search total is the re-gated SQL count over at most 1000 engine ids (Scout v10.25.0), and the leak test also asserts the total never counts a leaked row.
|
||||||
|
- Phase 13: add wishlist reservations (`wishlist/albums/{id}/reserve|reveal`) to the wishlist criterion and the anonymous `public/{token}`, `public/{token}/albums`, `public/{token}/albums/{id}` views to the public routes criterion.
|
||||||
|
- Phase 14: name the `albums/{id}/cover-price/discogs` route in the Discogs criterion.
|
||||||
|
- REQUIREMENTS: API-01 reads "me/context flags plus the opaque channel name from realtime/channels" and drops public token views (owner-only share surface stays); API-02 drops reservations and Discogs cover price and says "Discogs cover import (cover_urls)" and that both search items and the total are re-gated in SQL; API-03 gains reservations; API-07 gains the anonymous collection public-token views; INTG-01 gains the Discogs cover-price route. Leave every status column and the traceability table untouched.
|
||||||
|
Also `git mv .planning/todos/pending/lagoon-validate-min-message.md .planning/todos/done/` (Task 1 folded it), following the `done/` precedent of verify-models-leaf-rule.md. Commit these planning files alone as a docs commit (no code in the same commit).</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q 'realtime/channels' .planning/ROADMAP.md && grep -q 'realtime/channels' .planning/REQUIREMENTS.md && grep -m1 '\*\*API-02\*\*' .planning/REQUIREMENTS.md | grep -vq 'reservations' && grep -m1 '\*\*API-03\*\*' .planning/REQUIREMENTS.md | grep -q 'reserv'</automated>
|
||||||
|
<fails_when>Non-zero exit: realtime/channels is missing from either file, the API-02 requirement line still mentions reservations, or the API-03 line carries no reservation wording.</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -A8 '### Phase 12:' .planning/ROADMAP.md | grep -c 'reservations'` prints 0.
|
||||||
|
- `grep -c 'cover-price/discogs' .planning/ROADMAP.md` prints at least 1.
|
||||||
|
- `grep -c 'public/{token}' .planning/ROADMAP.md` prints at least 1 inside the Phase 13 section.
|
||||||
|
- The REQUIREMENTS.md traceability table rows for API-01 and API-02 still read `Phase 12 | Pending`.
|
||||||
|
- `git log -1 --stat` for this commit lists only .planning files.
|
||||||
|
- `test -f .planning/todos/done/lagoon-validate-min-message.md` succeeds and the pending copy is gone.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Anyone reading the roadmap or requirements sees the Phase 12 boundary the user locked in CONTEXT.md, and the moved surfaces are owned by Phases 13 and 14.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| HTTP request body → request validator | Untrusted JSON and multipart input reaches rule evaluation, regex matching and DB `exists` lookups |
|
||||||
|
| Uploaded bytes → image decoders | Untrusted image bytes reach the gif/jpeg/png/webp decoders and the thumbnailer |
|
||||||
|
| Parity fixtures (git) → tide replay | Committed fixtures and part files drive requests; secrets must stay in the 0600 vars file |
|
||||||
|
| Search engine → application | Engine ids and counts are candidates, not authorization |
|
||||||
|
| Module proxy → go.mod | A dependency bump enters the build |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-12-14 | Tampering | lagoon `exists:` and regex rules | high | mitigate | Table and column names pass identName; regex patterns come only from code (ParseRules at registration), never from input; an RE2-incompatible pattern fails at boot (Task 1). |
|
||||||
|
| T-12-15 | Denial of Service | wildcard expansion and size rules | medium | mitigate | Expansion is bounded by the decoded body, which surf caps by http.body_limits; size counts are O(n) over already-decoded values; no backtracking regex engine (RE2) (Task 1). |
|
||||||
|
| T-12-16 | Denial of Service | webp/png/jpeg decode in Thumb and DecodeConfig | medium | mitigate | DecodeConfig reads headers only; Thumb runs only on files that passed the 10240 KB cap in the callers (12-02/12-04); x/image v0.46.0 is the current upstream with its fixes (Task 2). |
|
||||||
|
| T-12-17 | Information Disclosure | tide multipart part files | medium | mitigate | Part bytes live as committed fixture files that are test images only; check_corpus --check-secrets still scans every YAML; substitution never touches file bytes (Task 2). |
|
||||||
|
| T-12-28 | Information Disclosure | beachcomber SearchPage found count | high | mitigate | Found is exposed to callers only as an input to a SQL recount (D-19, enforced in 12-04 and tested in 12-05); README states ids and counts are candidates (Task 3). |
|
||||||
|
| T-12-18 | Elevation of Privilege | user groups table | medium | mitigate | No route writes users_groups; Groups is never serialized; the site-admin predicate reads group codes server-side only (Task 3, consumed in 12-02). |
|
||||||
|
| T-12-SC | Tampering | Go module installs (golang.org/x/image v0.46.0) | high | mitigate | Official Go sub-repository already in the module graph, verified with `go list -m` against proxy.golang.org (RESEARCH Package Legitimacy Audit: OK); go.sum pins the hash; named by D-24 as the phase decision authorizing the bump. No npm/pip/cargo installs. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- summercms.go: `go vet ./... && go test ./... -count=1` green; `go test ./cmd/summer -run TestDocsTree -count=1` green.
|
||||||
|
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green (user-api fixtures unchanged, schema diff green with the two new allow-list entries).
|
||||||
|
- Planning docs commit contains only ROADMAP.md, REQUIREMENTS.md and the todo moved to `.planning/todos/done/`.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- `lagoon.ValidateRequest` and the pl/en `lagoon::validation.*` catalogs exist and reproduce the recorded Polish 422 shape; lagoon.Validate callers are unchanged except the fixed min/between message.
|
||||||
|
- attach exports URL helpers, decodes webp; tide records and replays multipart bodies and masks upload URLs and publication dates.
|
||||||
|
- beachcomber exposes found and weights; the user plugin has user groups with an unchanged user API.
|
||||||
|
- ROADMAP and REQUIREMENTS carry the D-03/D-04/D-06/D-19/D-20 wording.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-p-ytarium-api-collections-and-albums/12-01-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
---
|
||||||
|
phase: 12-p-ytarium-api-collections-and-albums
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["12-01"]
|
||||||
|
files_modified:
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/access.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/fingerprint.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_404.html
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_share_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_context_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/realtime_channels_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go
|
||||||
|
- ../fonoteka.go/config/storage.yaml
|
||||||
|
- ../fonoteka.go/parity/manifest.yaml
|
||||||
|
- ../fonoteka.go/parity/fixtures/routes/
|
||||||
|
- ../fonoteka.go/parity/fixtures/files/
|
||||||
|
- ../fonoteka.go/parity/fonoteka_seed_test.go
|
||||||
|
- ../fonoteka.go/parity/parity_test.go
|
||||||
|
- ../fonoteka.go/parity/README.md
|
||||||
|
- ../fonoteka.go/README.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [API-01]
|
||||||
|
estimate:
|
||||||
|
tokens: 300000
|
||||||
|
raw_tokens: 300000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Per C-01 and RESEARCH Pattern 1, `classes.Resolve` ports ActiveCollectionResolver::resolve: a personal token must carry exactly one collection id >= 1 that is accessible (else the Winter HTML 404 page), a JWT caller gets the stored context if still accessible, else the lowest-id accessible collection with no kind filter (as PHP), else a provisioned `Moja kolekcja`, under `SELECT ... FOR UPDATE` on the users row then the context row."
|
||||||
|
- "Per RESEARCH Pattern 2, every request-facing collection and album query goes through `classes.AccessibleBy(userID, token)`: grouped owner-or-editor membership AND, for a token with collection_ids, `(id IN pin OR (owner_id = user AND kind = 'wishlist'))`."
|
||||||
|
- "Per D-01 and D-10, the collections routes (index, store, show, update, destroy, photos upload/delete, image upload/delete, switch with throttle:10,1), `me/context`, `realtime/channels` and `collection/share` show/update/regenerate (regenerate with throttle:10,1) are mounted once each and reused on the token group exactly where routes.php mirrors them: GET collections, GET/PUT/DELETE collections/{id}, collection photos and image."
|
||||||
|
- "Per D-26, the token group applies no group-level scope; every token route carries exactly one `inv.scope:read` or `inv.scope:write` as in routes.php, so a write-only token reaches PUT collections/{id} and gets 403 on GET collections, and `/genres` and `/me` keep `inv.scope:read`."
|
||||||
|
- "Per D-26, deleting a collection loads its albums and deletes them one by one inside the transaction, so each album gets its Typesense removal and its `deleted.fonoteka.album` broadcast, then every owner and editor who lost access has their context repaired by the collection provisioner."
|
||||||
|
- "Per C-03, collection responses come from the full `SerializeCollection` port (id, name, description, album_count, is_owner, is_active, photos, image, created_at, updated_at, plus owner_name on index only with the owner/local-part rule) and photos from `SerializePhoto` (id, url, thumb_url 200x200 crop)."
|
||||||
|
- "Per D-22, `config/storage.yaml` roots the bucket at `storage/app/uploads/public` with prefix `/storage/app/uploads/public`, so photo URLs equal PHP's and the user avatar route still passes its recorded fixtures."
|
||||||
|
- "Per D-20, `GET realtime/channels` (JWT only) returns `{\"data\":{\"collection\":\"collection:<resolved id>\"}}`, and `me/context` returns exactly its recorded flags (can_use_ai, can_manage_org, has_organisation, ai_org_lock, ai_inherited, is_collection_owner, invitation_acceptance_required false, can_import_discogs, market_currency_default, market_currencies) and never a collection id or channel."
|
||||||
|
- "Per D-25, the site-admin predicate in `me/context` gates reads the user's group codes (`admin`), and AiGate's site-admin branch falls through because no global vision model exists in the Go port (documented)."
|
||||||
|
- "Per D-06, only the owner surface of `collection/share` exists: a token caller or an editor gets 404 `{\"error\":\"Collection not found\"}`; regenerate draws 16 characters from the 62-symbol alphabet with crypto/rand rejection sampling (reject bytes >= 248) under a row lock."
|
||||||
|
- "Per D-21, `POST collections/{id}/switch` for a foreign, missing or wishlist id, and any token call that would switch, answers with the Winter production 404 HTML page (status 404, `text/html; charset=UTF-8`, stylesheet origin from app.url), re-recorded under APP_DEBUG=false."
|
||||||
|
- "Per D-08 and D-11, every route above has recorded cases for its success and each distinct error (404 foreign or missing id, 404 editor on owner-only actions, 422 validation, multipart photo and image uploads with url/thumb_url), each case a self-contained flow from the Go seed hook state, and all are `ported` and pass `TestParityCorpus` with `expectedPortedRoutes` raised by 23."
|
||||||
|
- "Edge (API-01 empty): a user with no accessible collection gets exactly one `Moja kolekcja` on the first resolve, even under two concurrent requests (users row lock), and `GET collections` for that user lists it; an empty pin list on a token is unrestricted only for listing (activeCollectionIdForDisplay null) and is a 404 for resolve."
|
||||||
|
- "Edge (API-01 ordering): `GET collections` orders by name with PHP's stable byte comparison over id-ordered rows (equal names keep ascending id); members and other lists keep their PHP orders."
|
||||||
|
- "Edge (API-01 adjacency): two collections with the same name are distinct rows with distinct ids and both are listed; switching to the already active collection succeeds and rewrites the same context row."
|
||||||
|
- statement: "Edge (API-01 encoding): collection names are stored and returned byte-for-byte as sent (Winter has no TrimStrings or ConvertEmptyStringsToNull), including leading and trailing spaces."
|
||||||
|
verification: backstop
|
||||||
|
artifacts:
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go"
|
||||||
|
provides: "Resolve, SwitchTo, DisplayActiveID, ErrCollectionNotFound"
|
||||||
|
contains: "FOR UPDATE"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/access.go"
|
||||||
|
provides: "AccessibleBy, AlbumsAccessibleBy"
|
||||||
|
contains: "func AccessibleBy("
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go"
|
||||||
|
provides: "ShareState, EnableShare, DisableShare, RenameShare, RegenerateShare, GenerateShareToken"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go"
|
||||||
|
provides: "writeWinterHTTPError, writeValidationFailed"
|
||||||
|
- path: "../fonoteka.go/parity/fonoteka_seed_test.go"
|
||||||
|
provides: "fonoteka seed hook: alice, bob (editor), outsider, collections, tokens"
|
||||||
|
key_links:
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/routes.go"
|
||||||
|
to: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go"
|
||||||
|
via: "one handler value mounted on the JWT and token groups"
|
||||||
|
pattern: "CollectionsIndex"
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go"
|
||||||
|
to: "../fonoteka.go/plugins/golem15/fonoteka/classes/access.go"
|
||||||
|
via: "Scopes(classes.AccessibleBy(user.ID, token))"
|
||||||
|
pattern: "AccessibleBy\\("
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/models/collection.go"
|
||||||
|
to: "golem15_fonoteka_albums"
|
||||||
|
via: "BeforeDelete loads albums and deletes each row"
|
||||||
|
pattern: "BeforeDelete"
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_context_controller.go"
|
||||||
|
to: "../fonoteka.go/plugins/golem15/user/classes/user_groups.go"
|
||||||
|
via: "IsSiteAdmin reads HasGroupCode(admin)"
|
||||||
|
pattern: "HasGroupCode"
|
||||||
|
prohibitions:
|
||||||
|
- requirement_id: API-01
|
||||||
|
category: privacy
|
||||||
|
statement: "me/context MUST NOT return a raw collection id or a channel name; only realtime/channels returns the channel string"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
- requirement_id: API-01
|
||||||
|
category: privacy
|
||||||
|
statement: "GET collections MUST NOT expose another user's full email address; owner_name falls back only to the local part before @"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
- requirement_id: API-01
|
||||||
|
category: safety
|
||||||
|
statement: "Deleting a collection MUST NOT leave albums visible in search or remove the owner's or editors' ability to use the app; every affected user gets a valid active collection afterwards"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Goal
|
||||||
|
|
||||||
|
ROADMAP Phase 12 goal (verbatim, not in user-story form): Collections and Albums endpoints are ported with byte-compatible request/response shapes, including active-context switching, editor invitations, ratings, reservations, cover handling and search. (12-01 Task 4 rewords it per D-03/D-04/D-06.)
|
||||||
|
|
||||||
|
This plan's slice: the Nuxt app can list, create, edit, delete and switch collections, upload collection photos and the switcher image, read its `me/context` flags and Centrifugo channel name, and manage the owner's public share link, all byte-compatible with PHP on both auth groups where PHP mirrors them (API-01; ROADMAP SC-1).
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Port the tenant core (token-aware resolver, provisioning, access scope, gates, fingerprint), the collection serializer, and every collections, me/context, realtime/channels and collection/share route to fonoteka.go, with recordings and replays.
|
||||||
|
|
||||||
|
Purpose: every later handler (household, albums, search) resolves and gates the tenant through this code; me/context and realtime/channels are the first calls the Nuxt app makes. Decisions implemented: C-01, C-02, C-03, D-01, D-06, D-08, D-10, D-11, D-20, D-21, D-22, D-25 (consumer), D-26.
|
||||||
|
Output: classes and controllers listed in files_modified, routes and config, recorded fixtures and manifest flips, a `fonoteka` parity seed hook.
|
||||||
|
|
||||||
|
Repo: fonoteka.go only (summercms.go untouched). No Nuxt or fonoteka-mcp change. Never add co-author tags.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-01-SUMMARY.md
|
||||||
|
@../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||||
|
@../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go
|
||||||
|
@../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go
|
||||||
|
@../fonoteka.go/parity/README.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
- From 12-01: `lagoon.ValidateRequest(ctx, tx, input, []lagoon.RequestRule{{Field, Rules: lagoon.ParseRules("...")}}, tr)`, `lagoon.UploadedFileFromHeader`, `attach.PublicURL`, `(*attach.File).URL()`, tide `Request.Parts`, user plugin `classes.HasGroupCode(ctx, db, userID, "admin")`.
|
||||||
|
- fonoteka today: `classes.AccessibleByMembership(userID)`, `classes.ResolveActiveCollection(ctx, gdb, userID)` (JWT default path only; callers: controllers/genre_controller.go ListGenres and controllers/api/oauth_consent_controller.go), `persistContext` (INSERT ... ON CONFLICT (user_id)), `classes.SaveCollection(ctx, gdb, c, requested)` with `CollectionFillFields = {name, description}`, `classes.FieldErrors`, `classes.SerializeCollection` (4-key stub), `models.Collection{ID, OwnerID, Owner, Name, Description, Kind, PublicToken, PublicEnabled, PublicTokenGeneratedAt, ReservationsAllowed, Editors, DeletedAt, CreatedAt, UpdatedAt}` with `BeforeDelete` doing a bulk album delete, `models.ApiToken{CollectionIDs lagoon.Jsonable[[]uint], Scopes}`, `models.UserCollectionContext`, `models.OrgAiCredential`, `models.UserAiCredential`, `models.UserDiscogsCredential`, `models.OrgDiscogsCredential`, `models.AlbumMarketCurrencies`.
|
||||||
|
- Request context: `bouncer.User(ctx) (*bouncer.Principal, bool)`; the personal token is `cred, _ := bouncer.Credential(ctx); tok, _ := cred.(*models.ApiToken)` (middleware/token_scope.go); `wire.WriteJSON` (no trailing newline), `wire.WriteOpaque500`; responses carry `Cache-Control: no-cache, private` and `Content-Type: application/json`.
|
||||||
|
- surf: `g.Get/Post/Put/Delete(path, h, mw...)`, `g.Where(param, regex)` constrains only the immediately preceding route (Pitfall 2), inline `throttle:N,M`, `body.limit:<bytes>`.
|
||||||
|
- attach: `system_files` rows via attach.File (AttachmentType = model MorphName, Field photos/image, IsPublic true), `(*File).Thumb(ctx, bucket, 200, 200, "crop")`, bucket from `app.Lookup[*blob.Bucket]()`, after-commit blob deletion via `DeleteForOwner`/`DeleteKeys`.
|
||||||
|
- lighthouse: deleted album broadcasts are automatic per row (Album binding in realtime.go); beachcomber removes the document per row after commit.
|
||||||
|
- Parity: `seedHooks` map in parity/parity_test.go, `expectedPortedRoutes = 33`, `newConfiguredTarget`, subtest names from `corpusSubtestName` (spaces and slashes become underscores); seed patterns in parity/genres_seed_test.go (upsertParityAlice, mintTestJWT, mintTestInvToken), parity/realtime_seed_test.go (upsertParityOutsider), parity/user_api_seed_test.go; PHP isolation and recording in parity/php_parity.sh and parity/README.md.
|
||||||
|
- PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{routes.php (lines 74-140 and 450-475), controllers/api/CollectionApiController.php, controllers/api/CollectionShareController.php, controllers/api/MeContextController.php, classes/ActiveCollectionResolver.php, classes/CollectionProvisioner.php, classes/CollectionFingerprint.php, classes/CollectionShareService.php, classes/OrgAccess.php, classes/AiGate.php, classes/DiscogsGate.php, classes/DiscogsConfigResolver.php, models/Collection.php (lines 120-230), traits/SerializesFonoteka.php, config/fonoteka.php}; /media/nvme/dev/golem15/fonoteka/modules/system/models/File.php.
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
(This plan's share.)
|
||||||
|
|
||||||
|
- classes: `Resolve(ctx, gdb, user *usermodels.User, token *models.ApiToken) (*models.Collection, error)`, `SwitchTo(ctx, gdb, user, token, collectionID uint) (*models.Collection, error)`, `DisplayActiveID(ctx, gdb, userID uint, token *models.ApiToken) (*uint, error)`, `ErrCollectionNotFound`, `AccessibleBy(userID uint, token *models.ApiToken) func(*gorm.DB) *gorm.DB`, `AlbumsAccessibleBy(userID uint, token *models.ApiToken) func(*gorm.DB) *gorm.DB`, `ProvisionCollection(ctx, tx, user) (*models.Collection, error)`, `CollectionKey(appKey string, id uint) string`, `ShareState`, `ShareStateFor(c *models.Collection) ShareState`, `EnableShare`, `DisableShare`, `RenameShare`, `RegenerateShare`, `GenerateShareToken() (string, error)`, `IsSiteAdmin`, `CanManageOrg`, `AIAllowed`, `ResolveDiscogsConfig`, `DiscogsAllowed`, `CollectionDTO`, `PhotoDTO`, `SerializeCollection`, `SerializePhoto`.
|
||||||
|
- controllers/api: `CollectionsIndex`, `CollectionsStore`, `CollectionsShow`, `CollectionsUpdate`, `CollectionsDestroy`, `CollectionPhotoUpload`, `CollectionPhotoDelete`, `CollectionImageUpload`, `CollectionImageDelete`, `CollectionSwitch`, `MeContext`, `RealtimeChannels`, `CollectionShareShow`, `CollectionShareUpdate`, `CollectionShareRegenerate`; helpers `decodeInput`, `phpInt`, `laravelBoolean`, `writeWinterHTTPError`, `writeValidationFailed`.
|
||||||
|
- Routes: JWT `GET/POST /_fonoteka/api/v1/collections`, `GET/PUT/DELETE .../collections/{id}`, `POST .../collections/{id}/photos`, `DELETE .../collections/{id}/photos/{fileId}`, `POST/DELETE .../collections/{id}/image`, `POST .../collections/{id}/switch` (throttle:10,1), `GET .../me/context`, `GET .../realtime/channels`, `GET/PUT .../collection/share`, `POST .../collection/share/regenerate` (throttle:10,1); token group twins of GET collections, GET/PUT/DELETE collections/{id}, photos and image.
|
||||||
|
- Config keys (plugin): `golem15.fonoteka.ai_org_lock` (false), `golem15.fonoteka.discogs.token` (""), `golem15.fonoteka.discogs.market_currency` ("EUR"); app storage `storage.uploads.bucket_url` and `public_path_prefix` set to the Winter layout.
|
||||||
|
- Parity: seed hook `fonoteka`; vars `jwt:alice`, `jwt:bob`, `jwt:outsider`, `token:pinned`, `token:read-only`, `token:write-only`, `id:collection`, `id:bob-collection`, `id:foreign-collection`.
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1: The Nuxt collection switcher and an MCP personal token both list the caller's collections through one tenant core</name>
|
||||||
|
<reversibility rating="reversible">Handlers and classes are new; the old ResolveActiveCollection callers move to Resolve with their fixtures kept green.</reversibility>
|
||||||
|
<precondition>Plan 12-01 is executed: `go doc ./modules/lagoon ValidateRequest` exits 0 in summercms.go and `go -C ../fonoteka.go doc ./plugins/golem15/user/classes HasGroupCode` exits 0.</precondition>
|
||||||
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/access.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go, ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go, ../fonoteka.go/config/storage.yaml, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/parity_test.go</files>
|
||||||
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ActiveCollectionResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/CollectionProvisioner.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php (scopeAccessibleByMembership, scopeAccessibleBy, tokenCollectionRestriction), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CollectionApiController.php (index, activeCollectionIdForDisplay), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php (serializeCollection, serializePhoto, relativeMediaUrl), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 74-110 and 450-470), ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go, ../fonoteka.go/config/storage.yaml, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/genres_seed_test.go, ../fonoteka.go/parity/realtime_seed_test.go, ../fonoteka.go/parity/README.md, ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_collections_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/GET__api_v1_fonoteka_collections_personal_token.yaml, ../fonoteka.go/parity/fixtures/seed/bootstrap.yaml</read_first>
|
||||||
|
<action>(1) access.go (RESEARCH Pattern 2): move AccessibleByMembership here unchanged and add `AccessibleBy(userID, token)` (membership AND, when the token has a non-empty CollectionIDs, the grouped `(golem15_fonoteka_collections.id IN pin OR (owner_id = user AND kind = 'wishlist'))`) and `AlbumsAccessibleBy(userID, token)` as an EXISTS over golem15_fonoteka_collections (deleted_at IS NULL) with that same predicate, ANDed with albums.deleted_at IS NULL.
|
||||||
|
|
||||||
|
(2) active_collection.go (C-01, RESEARCH Pattern 1, T-12-12): `Resolve(ctx, gdb, user, token)`: token present: require exactly one id that is a positive integer, load through AccessibleBy, else return ErrCollectionNotFound; no token: one lagoon.Transaction that runs `SELECT id FROM users WHERE id = ? FOR UPDATE`, then the context row FOR UPDATE, then stored-if-accessible (AccessibleBy with a nil token), else `AccessibleBy(user, nil)` ordered by id with NO kind filter (A2: PHP resolve() has none; add a Go test where a lower-id wishlist owned by the user is picked, exactly as PHP), else ProvisionCollection. Persist the fallback with persistContext. `SwitchTo(ctx, gdb, user, token, id)`: a token returns ErrCollectionNotFound; otherwise the same two locks, target must be accessible AND kind = collection, then upsert the context. `DisplayActiveID` ports activeCollectionIdForDisplay (token with exactly one id: that id; else stored context id; else lowest accessible kind=collection id; never provisions, never locks). Keep `ResolveActiveCollection(ctx, gdb, userID)` only as a thin wrapper that loads the user and calls Resolve with a nil token, or delete it after moving both callers; ListGenres must pass the request's personal token so the token-group genres route honours the pin (as PHP GenreApiController does). The pending-invitation guard is added in 12-03 (user split), not here.
|
||||||
|
|
||||||
|
(3) collection_provisioner.go: port CollectionProvisioner::provision (inside the caller's tx: lock users row, reuse the lowest-id accessible kind=collection collection by membership, else create `Moja kolekcja` owned by the user with kind collection, then persist the context); returns the collection.
|
||||||
|
|
||||||
|
(4) serialize.go: replace the collection stub with an ordered `CollectionDTO` struct (PHP key order: id, name, description, album_count, is_owner, is_active, photos, image, created_at, updated_at, and an `owner_name` field that is emitted only by index) and `PhotoDTO{ID, URL, ThumbURL}`. `SerializeCollection(c, userID *uint, activeID *uint, albumCount int64, photos []attach.File, image *attach.File, bucket)` or an equivalent loader-friendly signature; is_owner is null when userID is nil, is_active null when activeID is nil; photos []PhotoDTO never null; times as wire.Time Carbon +00:00; SerializePhoto uses (*attach.File).URL() and Thumb(200, 200, "crop"). Album serializer stays for 12-04.
|
||||||
|
|
||||||
|
(5) storage.yaml, per D-22: `bucket_url: "file://./storage/app/uploads/public"`, `public_path_prefix: "/storage/app/uploads/public"`, with a comment citing Winter cms.php and File::getPublicPath. Run the user avatar parity cases and the user plugin avatar tests to prove avatars still pass.
|
||||||
|
|
||||||
|
(6) request.go: `decodeInput(r) (map[string]any, error)` (JSON via json.Decoder UseNumber; form and multipart with files as lagoon.UploadedFile; an empty body is an empty map, as Laravel's request->all()), `phpInt(raw string, def int) int` (leading numeric prefix like PHP (int): "12abc" is 12, "abc" is 0), `laravelBoolean(v any) bool` (true for 1, "1", true, "true", "on", "yes"), and a small `requestScope(w, r, app)` returning the gorm handle, the user model and the optional *models.ApiToken (opaque 500 when the DB or user is missing).
|
||||||
|
|
||||||
|
(7) collections_controller.go: `CollectionsIndex` ports index: AccessibleBy(user, token) AND kind = collection, album counts in one grouped query (albums not soft-deleted), photos and image loaded per collection from system_files (field photos ordered by sort_order then id; field image), owner rows for owner_name, then a stable sort by name bytes over rows already ordered by id (sort.SliceStable with strings.Compare), DisplayActiveID for is_active, owner_name null for own rows else trimmed owner name else email local part else null. Response `{"data":[...]}`, 200, `Cache-Control: no-cache, private`.
|
||||||
|
|
||||||
|
(8) routes.go: JWT group mounts `GET /collections` with CollectionsIndex; the token group becomes `surf.Use("inv_token", "throttle:fonoteka-api-token")` with per-route scopes per D-26: `/genres` read, `/me` read, `/collections` read. Update routes_group_test.go expectations that assumed the group-level read scope and add one assertion that a write-only token is refused on GET collections with the exact InvScope 403 body.
|
||||||
|
|
||||||
|
(9) Parity: write parity/fonoteka_seed_test.go with seed hook `fonoteka` (register it in seedHooks) creating, in seed order: alice (activated, organisation owner as the PHP bootstrap leaves her), bob, outsider; alice's `Parity Collection` with alice's context on it; bob's own collection; an outsider collection; bob as editor of alice's collection (golem15_fonoteka_collection_editors row, role editor, granted_by alice); JWTs for all three (mintTestJWT); personal tokens for alice: pinned (read, write, ai; collection_ids [alice collection]), read-only and write-only. Store every id and credential in the tide store under the var names listed in Artifacts. Record the PHP side the same way: run the PHP bootstrap seed, create bob and outsider with `php_parity.sh artisan tinker` as the README does for outsider, add the editor row and the three tokens through tinker or the ported routes, and put their values only in the 0600 vars file. Add manifest cases for GET collections (jwt: alice sees own and shared collections with owner_name; token: pinned token sees only the pin with is_active from the pin; write-only token 403) and re-record both route fixtures; flip both routes to `ported` with `seed_hook: fonoteka`; raise expectedPortedRoutes by 2 in this task (the remaining 21 flips come in Tasks 2 and 3).
|
||||||
|
|
||||||
|
(10) Smoke test collections_smoke_test.go `TestCollectionsIndexBothGroups` through the assembled router: JWT alice sees two rows with correct is_owner/owner_name; the pinned token sees one; a token pinned to a foreign collection sees none; `TestResolveProvisionsOnce` runs two concurrent Resolve calls for a user with no collection and finds exactly one `Moja kolekcja` and one context row.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCollectionsIndexBothGroups|TestResolveProvisionsOnce|TestGenresSharedHandler|TestFullRouteTableAuthGroupMutualExclusivity)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run 'TestParityCorpus' -count=1 -v</automated>
|
||||||
|
<fails_when>Any command exits non-zero; the plugin run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestCollectionsIndexBothGroups"; the parity run lacks "--- PASS: TestParityCorpus/coverage" or any TestParityCorpus subtest for the genres, collections, me, avatar or oauth routes reports FAIL.</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c 'FOR UPDATE' ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go` prints at least 2.
|
||||||
|
- `grep -n 'inv.scope:read' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` shows the scope only as a per-route argument (no `surf.Use(... "inv.scope:read")` group declaration remains).
|
||||||
|
- `grep -n 'bucket_url' ../fonoteka.go/config/storage.yaml` shows `file://./storage/app/uploads/public` and `grep -n 'public_path_prefix' ../fonoteka.go/config/storage.yaml` shows `/storage/app/uploads/public`.
|
||||||
|
- `grep -A3 'id: "GET /_fonoteka/api/v1/collections jwt"' ../fonoteka.go/parity/manifest.yaml | grep -c 'status: ported'` prints 1, and the same for `GET /api/v1/fonoteka/collections personal_token`.
|
||||||
|
- `grep -c '"fonoteka":' ../fonoteka.go/parity/parity_test.go` prints 1 (seed hook registered).
|
||||||
|
- `go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets` exits 0.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>A JWT user and a pinned personal token each get PHP's exact collection list from one handler through the token-aware resolver, access scope and serializer, and the token group enforces one scope per route.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: The Nuxt app creates, edits, deletes and switches collections and manages their photos and switcher image</name>
|
||||||
|
<reversibility rating="costly">D-26 replaces the bulk album delete in Collection.BeforeDelete with a per-row delete; admin deletes (cabana) go through the same hook, so it is flagged.</reversibility>
|
||||||
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/fingerprint.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_404.html, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/files/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/parity_test.go</files>
|
||||||
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CollectionApiController.php (store, show, update, destroy, uploadPhoto, deletePhoto, uploadImage, deleteImage, switchTo), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/CollectionFingerprint.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php (beforeDelete, attachMany photos, attachOne image), /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Database/Relations/AttachMany.php and AttachOne.php (remove and delete semantics), ../fonoteka.go/parity/fixtures/routes/DELETE___fonoteka_api_v1_household_members_{id}_jwt.yaml (recorded Winter 404 page bytes), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go (winter page embed precedent), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (UploadAvatar: multipart, attach row, blob write, P7 D-04), ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go, ../fonoteka.go/plugins/golem15/fonoteka/search.go, summercms.go modules/lagoon/attach/file.go (DeleteForOwner, DeleteKeys), summercms.go modules/lagoon/transaction.go (Transaction, AfterCommit), ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_collections_{id}_switch_jwt.yaml</read_first>
|
||||||
|
<action>(1) Store/show/update/destroy (C-02, C-03): validate with lagoon.ValidateRequest using PHP's rules verbatim (store: `name` required|string|max:255, `description` nullable|string; update: name nullable|string|max:255, description nullable|string); 422 body through writeValidationFailed `{"error":"Validation failed","errors":{...}}`. Fill through SaveCollection (CollectionFillFields only; owner_id and kind server-set on create to the caller and `collection`); strings persisted exactly as sent. Store returns 201 `{"data":CollectionDTO}` with is_owner true and is_active null; show and update 200 with is_active null; a foreign or missing id is 404 `{"error":"Collection not found"}` (JSON, not HTML). Destroy: owner-only (an editor gets the same 404), then in one lagoon.Transaction delete the collection and run ProvisionCollection for the owner and every editor; respond 200 `{"message":"Collection deleted"}`.
|
||||||
|
|
||||||
|
(2) Collection.BeforeDelete, per D-26 and the RESEARCH anti-pattern: load the collection's non-deleted albums through the hook's tx and delete each loaded album value so GORM callbacks see a primary key (beachcomber after-commit removal and the lighthouse deleted broadcast fire per album), inside WithSoftDeleteCascade. Add a smoke test with the memory realtime driver and a recording beachcomber engine: deleting a collection with two albums yields two `deleted.fonoteka.album` publications and two index deletions after commit, none on rollback.
|
||||||
|
|
||||||
|
(3) Photos and image (D-11, D-22): POST collections/{id}/photos (accessible by member) and POST collections/{id}/image (owner only) parse multipart, validate `file` with `required|image|mimes:jpg,jpeg,png,gif,webp|max:10240`, write the blob under attach's disk-name and partition scheme, insert the system_files row (attachment_type Collection MorphName, field photos or image, is_public true, sort_order as Winter assigns), replace an existing image on image upload (delete the old row and blobs after commit), and answer 201 `{"data":PhotoDTO}`. DELETE photos/{fileId}: 404 Collection not found, then 404 `{"error":"Photo not found"}` when the file is not one of this collection's photos, else remove per Winter AttachMany remove semantics read from vendor and answer `{"message":"Photo removed"}`. DELETE image: owner-only, delete when present, `{"message":"Image removed"}`. Route constraints: `g.Where("id", "[0-9]+")` and `g.Where("fileId", "[0-9]+")` immediately after each route.
|
||||||
|
|
||||||
|
(4) Switch (D-01, D-21): `POST collections/{id}/switch` with `throttle:10,1`, calls SwitchTo; success 200 `{"data":{"switched":true,"collection_key":CollectionKey(app.key, id)}}`. fingerprint.go ports CollectionFingerprint: hex HMAC-SHA256 of `fonoteka:collection:<id>` keyed by the raw `app.key` config string (including any base64: prefix), first 32 characters. ErrCollectionNotFound becomes writeWinterHTTPError(w, app, 404). http_errors.go: embed winter_404.html as a template whose only variable is the stylesheet href `{app.url}/modules/system/assets/css/styles.css` (trailing slash of app.url trimmed); bytes otherwise identical to the page recorded under APP_DEBUG=false (copy them from the re-recorded fixture, not from the old debug recording); status 404, `Content-Type: text/html; charset=UTF-8`, `Cache-Control: no-cache, private`. Design the helper so 12-03 adds 409 and 410 pages by adding files only.
|
||||||
|
|
||||||
|
(5) Token twins (D-10, D-26): GET collections/{id} read; PUT and DELETE collections/{id}, POST photos, DELETE photos/{fileId}, POST and DELETE image write. Not on the token group: POST collections, switch.
|
||||||
|
|
||||||
|
(6) Recordings (D-08, D-21): under APP_DEBUG=false (php_parity.sh pins it) add and record cases per route as self-contained multi-step flows from the seed state: POST collections 201 and 422 (missing name, 256-character name, numeric name); GET collections/{id} 200 own, 200 shared (bob on alice's collection), 404 foreign, 404 missing; PUT 200 and 422 and 404; DELETE 200 owner, 404 editor, 404 foreign; photos POST 201 (multipart with tide Parts and a small PNG under parity/fixtures/files/), 422 missing file, 422 text file; photos DELETE 200 and 404 photo; image POST 201 owner, 404 editor, 422; image DELETE 200 and 404 editor; switch 200, 404 HTML foreign, 404 HTML wishlist id; each token twin's own case including a read-only token refused on a write twin and a write-only token accepted. Flip all ten JWT and eight token routes of this task to ported (minus the two Task 1 already flipped) and raise expectedPortedRoutes accordingly.
|
||||||
|
|
||||||
|
(7) Smoke tests in collections_smoke_test.go: `TestCollectionDeleteRemovesAlbumsOneByOne` (step 2), `TestCollectionPhotoUpload` (step 3), and `TestCollectionSwitchRefusals` (a foreign id, a wishlist id and a personal-token caller each get the Winter 404 page bytes with the app.url origin; the eleventh switch within a minute gets the throttle response; a successful switch returns the 32-character collection_key equal to a php -r computed HMAC for a fixed test key).</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCollectionsIndexBothGroups|TestCollectionDeleteRemovesAlbumsOneByOne|TestCollectionPhotoUpload|TestCollectionSwitchRefusals)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run 'TestParityCorpus' -count=1 -v</automated>
|
||||||
|
<fails_when>Any command exits non-zero; the plugin run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestCollectionDeleteRemovesAlbumsOneByOne, TestCollectionPhotoUpload and TestCollectionSwitchRefusals; the parity run reports a FAIL for any collections subtest or lacks "--- PASS: TestParityCorpus/coverage".</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c 'Delete(&Album{})' ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go` prints 0 and BeforeDelete iterates over loaded albums.
|
||||||
|
- `grep -c 'throttle:10,1' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints at least 1 on the switch route line.
|
||||||
|
- `grep -c '/modules/system/assets/css/styles.css' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_404.html` prints 1 and the file holds a template action for the origin instead of a literal `127.0.0.1`.
|
||||||
|
- The recorded switch 404 fixture has `Content-Type: "text/html; charset=UTF-8"` and no PHP file path or `line ` debug text (`grep -c 'vendor/' <fixture>` prints 0).
|
||||||
|
- TestCollectionPhotoUpload asserts the system_files row, the blob and the 200x200 crop thumb; a file of 10240 KB plus one byte gets 422 with the pl `max.file` catalog message for `file`; a body above a test config's `http.body_limits.upload_bytes` gets the router's 413; a .txt payload gets the 422 image/mimes messages.
|
||||||
|
- A recorded upload fixture carries `parts:` with a `sha256:` and its file exists under parity/fixtures/files/.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Collections can be created, edited, deleted (with per-album cleanup and context repair), switched with PHP's HTML 404 for refused switches, and given photos and a switcher image whose URLs match PHP, on both auth groups.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: The Nuxt app reads its context flags and channel name, and the owner manages the public share link</name>
|
||||||
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_context_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/realtime_channels_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_share_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/README.md, ../fonoteka.go/README.md</files>
|
||||||
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/MeContextController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/OrgAccess.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AiGate.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/DiscogsGate.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/DiscogsConfigResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/config/fonoteka.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php (marketCurrency, MARKET_CURRENCIES), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (realtime/channels closure, lines 80-100), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CollectionShareController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/CollectionShareService.php, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_me_context_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_realtime_channels_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_collection_share_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/PUT___fonoteka_api_v1_collection_share_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_collection_share_regenerate_jwt.yaml, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/album.go (marketCurrency), ../fonoteka.go/plugins/golem15/user/classes/user_groups.go, ../fonoteka.go/README.md</read_first>
|
||||||
|
<action>(1) gates.go (D-25, RESEARCH Finding 2): `IsSiteAdmin(ctx, db, user)` = user-plugin `HasGroupCode(ctx, db, user.ID, "admin")`; `CanManageOrg` = site admin OR organisation_role in {owner, admin}; `AIAllowed(ctx, db, cfg, user)` ports AiGate: the site-admin branch needs a configured global vision model, which does not exist in the Go port, so it falls through exactly as PHP does when Golem15.Golem is absent (comment this); org lock from `golem15.fonoteka.ai_org_lock` applies to org members only; otherwise a UserAiCredential row or the org's OrgAiCredential row. `ResolveDiscogsConfig` ports DiscogsConfigResolver (site admin: `golem15.fonoteka.discogs.token`; else the user's credential; else the org's) returning the token and source; `DiscogsAllowed` is a non-blank token. These read only existence or the token through the encrypted cast; never log a token.
|
||||||
|
|
||||||
|
(2) config.yaml: add `ai_org_lock: false`, `discogs.token: ""`, `discogs.market_currency: "EUR"` (plus a comment naming SUMMER_GOLEM15__FONOTEKA__... env overrides) beside the oauth block, and document them in ../fonoteka.go/README.md's configuration section.
|
||||||
|
|
||||||
|
(3) me_context_controller.go `MeContext`: resolve the active collection (Resolve with the request token), then emit the ordered struct with exactly the ten keys of the recorded body: can_use_ai, can_manage_org, has_organisation (organisation_id non-null), ai_org_lock, ai_inherited (has organisation AND org AI credential exists AND no user AI credential), is_collection_owner (active owner_id == user), invitation_acceptance_required (always false), can_import_discogs, market_currency_default (models' marketCurrency(): configured value upper-cased if it is in the list, else EUR, as Album::marketCurrency), market_currencies (models.AlbumMarketCurrencies, never null). ErrCollectionNotFound maps to the Winter 404 page.
|
||||||
|
|
||||||
|
(4) realtime_channels_controller.go `RealtimeChannels` (D-20): resolve, then 200 `{"data":{"collection":"collection:<id>"}}`; JWT group only. The raw id in the channel name matches PHP (the collection authorizer re-validates membership on every subscribe, T-12-13).
|
||||||
|
|
||||||
|
(5) share_service.go and collection_share_controller.go (D-06, T-12-08): `GenerateShareToken` (16 symbols from `0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ` via crypto/rand, skipping bytes >= 248, index byte mod 62), unique-index retry loop as PHP; `ShareStateFor(c)` = `{enabled, name, token, path ("/k/" + token for a collection, "/w/" for a wishlist, null when no token), generated_at}` exactly as stateFor; Enable (generate a token when none exists, set enabled, stamp generated_at), Disable, Rename (`name` column), Regenerate (new token and generated_at) each in a lagoon.Transaction that reloads the row FOR UPDATE and saves only the share columns. Controller: owner surface only (token caller or non-owner gets 404 `{"error":"Collection not found"}` JSON); update validates `enabled` sometimes|boolean and `name` sometimes|required|string|min:1|max:255, applies name before enabled as PHP, and returns the envelope `{"data":ShareState}`; regenerate carries `throttle:10,1`. Never log a share token.
|
||||||
|
|
||||||
|
(6) Routes (JWT only): `GET /me/context`, `GET /realtime/channels`, `GET /collection/share`, `PUT /collection/share`, `POST /collection/share/regenerate` with `throttle:10,1`.
|
||||||
|
|
||||||
|
(7) Recordings and flips: me/context for alice (owner), bob (editor, is_collection_owner false), a user whose org has an AI credential and no user credential (ai_inherited true), and a site admin (alice linked to an `admin` group on both sides via tinker and the seed hook); realtime/channels for alice and for a freshly provisioned user; share show 200 and editor 404; share update enable, rename, disable, 422 (`name` empty string, `enabled` "abc") and editor 404; regenerate 200 (token masked or captured into the vars file so no live token sits in git: add a capture rule or manifest capture with category share for `$.data.token`). Flip me/context, realtime/channels and the three share routes to ported (the full 23 for this plan), set expectedPortedRoutes to 56, and update parity/README.md with the Phase 12 recording recipe (bob, outsider, editor row, tokens, admin group, APP_DEBUG=false).
|
||||||
|
|
||||||
|
(8) Smoke tests: `TestMeContextFlags` (site admin via the user group, org lock on and off, inherited AI), `TestRealtimeChannelsName`, `TestShareTokenAlphabet` (10000 tokens: length 16, alphabet only, no byte >= 248 accepted) and `TestShareOwnerOnly` (editor and token get 404).</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestMeContextFlags|TestRealtimeChannelsName|TestShareTokenAlphabet|TestShareOwnerOnly|TestCollectionsIndexBothGroups)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run 'TestParityCorpus' -count=1 -v && go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets</automated>
|
||||||
|
<fails_when>Any command exits non-zero; the plugin run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestMeContextFlags and TestShareOwnerOnly; the parity run reports FAIL for a me/context, realtime/channels or collection/share subtest or lacks "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret.</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows the value 56 (33 before Phase 12 plus 23).
|
||||||
|
- `grep -c 'collection_id' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_context_controller.go` prints 0 (no raw id key).
|
||||||
|
- `grep -c 'HasGroupCode' ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go` prints at least 1.
|
||||||
|
- `grep -c 'REJECT\|248' ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go` prints at least 1.
|
||||||
|
- `grep -c 'ai_org_lock' ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml` prints 1 and `grep -c 'ai_org_lock' ../fonoteka.go/README.md` prints at least 1.
|
||||||
|
- TestRealtimeChannelsName also walks `surf.BuildRouter(...).Routes()` and asserts that `/_fonoteka/api/v1/realtime/channels`, `/_fonoteka/api/v1/me/context` and the three `/_fonoteka/api/v1/collection/share` patterns exist and that no `/api/v1/fonoteka` pattern ends in `realtime/channels`, `me/context`, `collection/share`, `collection/share/regenerate` or `/switch`.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The Nuxt app's first calls (context flags and channel name) and the owner's share-link screen are byte-compatible with PHP, gated by the same admin, org and credential rules, and every collections-area route of Phase 12 is ported.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| JWT or personal-token caller → collection routes | The caller names collection ids in paths; the server must decide tenancy |
|
||||||
|
| Personal token (MCP, agents) → tenant resolution | A token is pinned to one collection and narrower scopes |
|
||||||
|
| Multipart upload → blob storage | Untrusted bytes are written to disk and thumbnailed |
|
||||||
|
| Share token → anonymous viewers (Phase 13) | The token minted here is the bearer secret of public views |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-12-01 | Information Disclosure | collections/{id}, photos/{fileId}, image | high | mitigate | Every lookup runs through AccessibleBy(user, token); file lookups are scoped to the collection's own attachment rows; foreign and missing ids share one 404 body (Tasks 1-2). |
|
||||||
|
| T-12-03 | Elevation of Privilege | personal token pin | high | mitigate | Resolve requires exactly one accessible pinned id; AccessibleBy narrows every query to the pin plus the owner's wishlist; SwitchTo refuses tokens (Tasks 1-2). |
|
||||||
|
| T-12-04 | Elevation of Privilege | owner-only interactive routes on the token group | high | mitigate | switch, share, me/context and realtime/channels are not mounted on the token group; share also refuses a token in-handler (Tasks 2-3; route-table test in 12-05). |
|
||||||
|
| T-12-05 | Elevation of Privilege | editor acting as owner | high | mitigate | Explicit owner_id checks on destroy, image upload/delete and share; editors get 404 (Tasks 2-3). |
|
||||||
|
| T-12-08 | Spoofing | share token | high | mitigate | crypto/rand with rejection sampling over a 62-symbol alphabet, unique retry, FOR UPDATE mutation, never logged (Task 3). |
|
||||||
|
| T-12-29 | Denial of Service | collection photo/image upload | medium | mitigate | body.limit caps, Laravel max:10240 file rule, image and mimes sniff before the blob write (Task 2). |
|
||||||
|
| T-12-30 | Tampering | mass assignment on collections | high | mitigate | SaveCollection fills only name and description; owner_id and kind are server-set (Task 2; fuzz in 12-05). |
|
||||||
|
| T-12-12 | Tampering | concurrent resolve provisioning | medium | mitigate | users row FOR UPDATE before the context row (PHP lock order) and the unique user_id context key; TestResolveProvisionsOnce (Task 1). |
|
||||||
|
| T-12-13 | Elevation of Privilege | realtime/channels raw id | low | accept | Matches PHP; the id is the caller's own resolved collection, never accepted as a tenant selector on any write, and the collection authorizer re-validates membership on every subscribe. |
|
||||||
|
| T-12-19 | Information Disclosure | Winter error pages | low | mitigate | Pages are the APP_DEBUG=false production bytes with only the stylesheet origin templated from app.url; no paths, lines or stack traces (Task 2). |
|
||||||
|
| T-12-SC | Tampering | package installs | low | accept | No new dependency in this plan. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green (including the user plugin and its avatar tests after the storage change).
|
||||||
|
- `go -C ../fonoteka.go test ./parity -run TestParityCorpus -count=1` reports 56 ported and passing.
|
||||||
|
- `check_corpus.go --require-recorded --check-secrets` green.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- 23 collections-area routes are ported and replay their recorded PHP fixtures, including HTML 404 pages and multipart uploads.
|
||||||
|
- The token group enforces one scope per route; collection delete cleans albums one by one and repairs contexts.
|
||||||
|
- me/context, realtime/channels and the owner share surface match PHP bodies.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
---
|
||||||
|
phase: 12-p-ytarium-api-collections-and-albums
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: ["12-02"]
|
||||||
|
files_modified:
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/org_provisioner.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/jobs.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/mail.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/views/mail/collection_invitation.htm
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/views/mail/collection_invitation-en.htm
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/views/mail/layouts/plytarium.htm
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitations_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/members_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_409.html
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_410.html
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go
|
||||||
|
- ../fonoteka.go/parity/manifest.yaml
|
||||||
|
- ../fonoteka.go/parity/fixtures/routes/
|
||||||
|
- ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml
|
||||||
|
- ../fonoteka.go/parity/fonoteka_seed_test.go
|
||||||
|
- ../fonoteka.go/parity/fonoteka_flows_test.go
|
||||||
|
- ../fonoteka.go/parity/parity_test.go
|
||||||
|
- ../fonoteka.go/parity/check_corpus.go
|
||||||
|
- ../fonoteka.go/parity/check_corpus_test.go
|
||||||
|
- ../fonoteka.go/parity/capture-rules.yaml
|
||||||
|
- ../fonoteka.go/parity/php_parity.sh
|
||||||
|
- ../fonoteka.go/parity/README.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [API-01]
|
||||||
|
estimate:
|
||||||
|
tokens: 240000
|
||||||
|
raw_tokens: 240000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Per D-13, POST household/invitations (owner only, throttle:10,1) upserts the pending invitation row for the normalized email with a fresh 64-hex token (sha256 at rest, expires now+7 days, accepted/revoked cleared) and enqueues the invitation mail River job in the same lagoon transaction; it answers 202 `{\"data\":{\"state\":\"pending\"}}`, and a rolled-back write leaves neither a river_job row nor a mail."
|
||||||
|
- "Per D-23, the raw token is carried in the job args only as app-key ciphertext and decrypted only inside the mail worker; it never appears in plain text in river_job.args, in summer_jobs (no Dispatch is used) or in any log line."
|
||||||
|
- "Per D-13, the mail worker sends `golem15.fonoteka::mail.collection_invitation` (or `-en` when the inviter's preferred_locale is en) to the invitee with collectionName and invitationUrl `{app.url}/zaproszenia/<token>` or `{app.url}/en/invitations/<token>`, observed through postcard's memory driver."
|
||||||
|
- "Per D-15, OrgProvisioner runs inside the invite transaction (an org-less inviter becomes owner of `plytarium-org-<id>`), resend rotates the hash and expiry for a pending invitation, cancel stamps revoked_at, accept by a logged-in user with the matching email adds the editor row, moves the invitee's context, joins an org-less invitee to the inviter's organisation as member, stamps accepted_at/by and clears that user's PendingInvitationRegistration rows."
|
||||||
|
- "Per D-14, accept writes the `invitation_accepted` notification row for the inviter with payload {collection_id, email} and emits `notification:new` and `notification:count` on `user:<inviter id>` through lighthouse in the same transaction (published after commit)."
|
||||||
|
- "Per D-15, DELETE household/members/{id} removes an editor (never the owner), repairs the member's context with the collection provisioner and detaches a non-owner member from the organisation only when no other household collection still grants access; GET household/members lists the owner first then editors by users.id with role and removable."
|
||||||
|
- "Per the user's 12-03 split, the pending-invitation guard runs in Resolve and SwitchTo for JWT callers: a valid pending registration row answers 409 with the Winter page; a stale row is deleted and resolution continues."
|
||||||
|
- "Per D-21, every HttpException path (owner check 404, missing or accepted invitation 404 on cancel/resend, member 404, accept 410, guard 409) answers with the Winter production HTML page re-recorded under APP_DEBUG=false (the old debug accept fixture is replaced), and the invitation ValidationException envelopes (missing email, malformed email) are recorded and reproduced."
|
||||||
|
- "Per D-08, the `nuxt-collections` flow (create, switch, me/context, share show/update/regenerate, invite, accept as a second user, members, remove member) is recorded from PHP and replays green against Go, and all seven household/invitation routes are ported with expectedPortedRoutes raised by 7."
|
||||||
|
- "Edge (API-01 adjacency): inviting an email that already has a pending invitation for the collection reuses that row (no second row), rotates its token and expiry and clears revoked_at; the previous raw token then accepts with 410."
|
||||||
|
- "Edge (API-01 encoding): invitation emails are normalized by ASCII-only lowercasing and PHP trim characters (space, tab, LF, CR, NUL, vertical tab) like PHP 8 strtolower(trim()), then checked with a FILTER_VALIDATE_EMAIL-compatible rule; accept compares the stored email with the invitee's normalized email byte for byte."
|
||||||
|
- "Edge (API-01 empty): GET household/invitations with no invitations returns `{\"data\":[]}` and GET household/members always returns at least the owner row."
|
||||||
|
- "Edge (API-01 ordering): invitations list newest id first; members list owner first then editors by users.id ascending."
|
||||||
|
- "Edge (API-01 concurrency): two concurrent accepts of one token produce exactly one editor row and one notification; the second gets 410 (row lock FOR UPDATE on the invitation)."
|
||||||
|
artifacts:
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go"
|
||||||
|
provides: "SendInvitation, ResendInvitation, CancelInvitation, AcceptInvitation, RemoveEditor, NormalizeInvitationEmail, ErrInvitationUnavailable"
|
||||||
|
contains: "sha256"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/jobs.go"
|
||||||
|
provides: "pact.HasJobs, InvitationMailArgs, invitation mail worker"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go"
|
||||||
|
provides: "WriteNotification, NotifyInvitationAccepted"
|
||||||
|
- path: "../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml"
|
||||||
|
provides: "recorded PHP Nuxt collections/household flow"
|
||||||
|
key_links:
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go"
|
||||||
|
to: "summercms.go modules/conga/conga.go"
|
||||||
|
via: "Manager.Enqueue on the invitation write transaction"
|
||||||
|
pattern: "Enqueue\\("
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/jobs.go"
|
||||||
|
to: "summercms.go modules/postcard"
|
||||||
|
via: "Mailer.Send with the locale-picked template"
|
||||||
|
pattern: "collection_invitation"
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go"
|
||||||
|
to: "summercms.go modules/lighthouse/suppress.go"
|
||||||
|
via: "Service.Emit on user:<id> inside the write transaction"
|
||||||
|
pattern: "Emit\\("
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go"
|
||||||
|
to: "golem15_fonoteka_pending_invitation_registrations"
|
||||||
|
via: "guardPendingInvitation before the users lock"
|
||||||
|
pattern: "PendingInvitationRegistration"
|
||||||
|
prohibitions:
|
||||||
|
- requirement_id: API-01
|
||||||
|
category: privacy
|
||||||
|
statement: "The raw invitation token MUST NOT be readable anywhere except the sent mail: not in API responses, river_job.args plaintext, summer_jobs, logs or committed fixtures"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
- requirement_id: API-01
|
||||||
|
category: safety
|
||||||
|
statement: "Accepting an invitation or being removed as an editor MUST NOT delete or empty the user's own collections or albums"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
- requirement_id: API-01
|
||||||
|
category: privacy
|
||||||
|
statement: "An editor or a personal token MUST NOT list or manage the household's invitations or members; the response is the same 404 page as for a missing collection"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Goal
|
||||||
|
|
||||||
|
ROADMAP Phase 12 goal (verbatim, not in user-story form): Collections and Albums endpoints are ported with byte-compatible request/response shapes, including active-context switching, editor invitations, ratings, reservations, cover handling and search. (12-01 Task 4 rewords it per D-03/D-04/D-06.)
|
||||||
|
|
||||||
|
This plan's slice: a collection owner invites a household member by email, the member receives the mail, accepts while logged in and starts editing the shared collection; the owner sees members and invitations, resends or cancels, and removes a member, exactly as the Nuxt app does against PHP (API-01; ROADMAP SC-1 "editor invitation/acceptance").
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Port InvitationService (existing-account path), OrgProvisioner, the NotificationService write path, the pending-invitation guard, the invitation mail job with an encrypted token, the mail templates, and the household/invitations/members/accept routes, with HTML error pages, recorded fixtures and the `nuxt-collections` flow.
|
||||||
|
|
||||||
|
Purpose: household sharing is the multi-user heart of Płytarium; the accept flow is the one D-14 says must match PHP DB state. Decisions implemented: D-01 (throttles), D-08, D-13, D-14, D-15, D-21, D-23, C-02.
|
||||||
|
Output: classes, jobs, mail templates, controllers, routes, recordings, the flow fixture and its Go replay test, a check_corpus secret rule for invitation tokens.
|
||||||
|
|
||||||
|
Repo: fonoteka.go only. Never add co-author tags.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md
|
||||||
|
@../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||||
|
@../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go
|
||||||
|
@../fonoteka.go/parity/README.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
- From 12-02: `classes.Resolve`, `classes.SwitchTo`, `classes.ErrCollectionNotFound`, `classes.ProvisionCollection(ctx, tx, user)`, `classes.AccessibleByMembership`, `writeWinterHTTPError(w, app, status)` (add pages by file), `writeValidationFailed`, `decodeInput`, `requestScope`, seed hook `fonoteka` (alice owner, bob editor, outsider), JWT vars.
|
||||||
|
- From 12-01: `lagoon.ValidateRequest`, `lagoon.ParseRules`.
|
||||||
|
- conga: `conga.From(app) (*conga.Manager, error)`, `(*Manager).Enqueue(ctx, db *gorm.DB, args pact.JobArgs, conga.EnqueueOpts{Queue, Delay, MaxAttempts}) error` (InsertTx on db's *sql.Tx), `conga.Job[T](fn, conga.OnQueue(q), conga.MaxAttempts(n), conga.Timeout(d))`; jobs reach River through `pact.HasJobs` (`Jobs() []pact.Job`) at Boot; `pact.JobArgs{Kind() string}`.
|
||||||
|
- lagoon: `Transaction(ctx, gdb, func(ctx, tx) error)`, `AfterCommit`, `NewEncrypted(plaintext) Encrypted`, `(Encrypted).Value()` (ciphertext string), `(*Encrypted).Scan(src)`, `(Encrypted).Reveal()`; keys published at boot from app.key.
|
||||||
|
- lighthouse: `(*Service).Emit(ctx, db, lighthouse.Broadcast{Channels, Event, Payload})` enqueues one publish job on db's transaction.
|
||||||
|
- postcard: `Mailer.Send(ctx, postcard.Message{Template, To, Vars})`, `MemoryDriver` for tests; plugins ship templates through `pact.HasMailTemplates` (`MailTemplatesFS() fs.FS`, `MailTemplates() []string`, `MailLayouts() map[string]string`), precedent ../fonoteka.go/plugins/golem15/user/plugin.go lines 34-48 and 169-180 and its views/mail tree.
|
||||||
|
- Models: `models.CollectionInvitation{ID, CollectionID, Email, TokenHash, InvitedBy, ExpiresAt, AcceptedAt, AcceptedBy, RevokedAt, CreatedAt, UpdatedAt}` with `Status()`, `models.CollectionEditor` (join_tables.go pivot), `models.PendingInvitationRegistration`, `models.Notification{UserID, Type, Payload lagoon.Jsonable[map[string]any], ReadAt, CreatedAt}`, user plugin `models.Organisation`, `models.User{OrganisationID, OrganisationRole, PreferredLocale, Email, Name}`.
|
||||||
|
- PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{controllers/api/InvitationApiController.php, controllers/api/CollectionMemberController.php, classes/InvitationService.php, classes/OrgProvisioner.php, classes/NotificationService.php (write, notifyInvitationAccepted), classes/ActiveCollectionResolver.php (guardPendingInvitation), models/CollectionInvitation.php (status, isPending), models/Notification.php (TYPE_* constants), views/mail/collection_invitation.htm, views/mail/collection_invitation-en.htm, views/mail/layouts/plytarium.htm, Plugin.php (registerMailTemplates, line 349), routes.php (household and invitations lines)}; /media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/classes/CentrifugoClient.php (publishToUser channel `user:<id>`).
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
(This plan's share.)
|
||||||
|
|
||||||
|
- classes: `SendInvitation(ctx, gdb, actor, collection, email) error`, `ResendInvitation(ctx, gdb, actor, collection, invitationID) error`, `CancelInvitation(ctx, gdb, actor, collection, invitationID) error`, `AcceptInvitation(ctx, gdb, user, rawToken) (*models.Collection, error)`, `RemoveEditor(ctx, gdb, actor, collection, memberID) error`, `NormalizeInvitationEmail(email string) (string, error)`, `ErrInvitationUnavailable`, `ErrInvitationAcceptanceRequired`, `ErrInvalidInvitationEmail`, `ProvisionOrgFor(ctx, tx, user) (uint, error)`, `WriteNotification(ctx, tx, svc, userID, typ string, payload map[string]any) error`, `NotifyInvitationAccepted(ctx, tx, svc, inv) error`, constants `NotificationTypeAlbumAdded`, `NotificationTypeInvitationAccepted`.
|
||||||
|
- Plugin: `Jobs() []pact.Job`, `InvitationMailArgs{InvitationID uint; Token string}` (Token holds ciphertext; Kind `golem15.fonoteka.invitation_mail`), `MailTemplatesFS`, `MailTemplates`, `MailLayouts` (`plytarium`).
|
||||||
|
- controllers/api: `HouseholdInvitationsIndex`, `HouseholdInvitationsStore`, `HouseholdInvitationsCancel`, `HouseholdInvitationsResend`, `HouseholdMembersIndex`, `HouseholdMembersDestroy`, `InvitationAccept`.
|
||||||
|
- Routes (JWT only): `GET/POST /_fonoteka/api/v1/household/invitations` (POST throttle:10,1), `DELETE .../household/invitations/{id}`, `POST .../household/invitations/{id}/resend` (throttle:10,1), `GET .../household/members`, `DELETE .../household/members/{id}`, `POST .../invitations/{token}/accept`.
|
||||||
|
- Parity: `fixtures/nuxt/nuxt-collections.yaml`, `TestFonotekaNuxtFlows/nuxt-collections`, check_corpus rule for raw invitation tokens, var `secret:invite`.
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1: An owner invites a member by email, the mail goes out from a transactional job, and the member accepts into the shared collection</name>
|
||||||
|
<reversibility rating="reversible">New job kind, templates and handlers; the encrypted-args format is private to this job and can change with the next deploy because River drains jobs.</reversibility>
|
||||||
|
<precondition>Plan 12-02 is executed: `go -C ../fonoteka.go doc ./plugins/golem15/fonoteka/classes Resolve` exits 0 and the parity seed hook `fonoteka` exists.</precondition>
|
||||||
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/org_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go, ../fonoteka.go/plugins/golem15/fonoteka/jobs.go, ../fonoteka.go/plugins/golem15/fonoteka/mail.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/collection_invitation.htm, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/collection_invitation-en.htm, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/layouts/plytarium.htm, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitations_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_410.html, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/php_parity.sh</files>
|
||||||
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/InvitationService.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/OrgProvisioner.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (lines 20-100 and 260-284), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/InvitationApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/CollectionInvitation.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Notification.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/collection_invitation.htm, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/collection_invitation-en.htm, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/layouts/plytarium.htm, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php (registerMailTemplates), ../fonoteka.go/plugins/golem15/user/plugin.go (HasMailTemplates wiring), ../fonoteka.go/plugins/golem15/user/views/mail/activate.htm and layouts/user.htm (Go template conversion precedent), ../fonoteka.go/plugins/golem15/user/classes/mail.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Boot, lazy DB, realtime field), ../fonoteka.go/plugins/golem15/fonoteka/schedule.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection_invitation.go, ../fonoteka.go/plugins/golem15/fonoteka/models/notification.go, ../fonoteka.go/plugins/golem15/fonoteka/models/pending_invitation_registration.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/join_tables.go, summercms.go modules/conga/conga.go (Enqueue, Job, EnqueueOpts), summercms.go modules/lagoon/encrypted.go, summercms.go modules/lighthouse/suppress.go (Emit), summercms.go modules/postcard/mailer.go and drivers.go (MemoryDriver), ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_invitations_{token}_accept_jwt.yaml (debug page to replace), ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_household_invitations_jwt.yaml</read_first>
|
||||||
|
<action>(1) org_provisioner.go: port OrgProvisioner::provisionFor inside the caller's tx (users row FOR UPDATE, reuse an existing organisation_id, else firstOrCreate `golem15_user_organisations` by slug `plytarium-org-<id>` with name equal to the slug, set organisation_id and organisation_role owner).
|
||||||
|
|
||||||
|
(2) invitation_service.go (D-13, D-15, D-23): `NormalizeInvitationEmail` (PHP trim set and ASCII-only lowercase, then the FILTER_VALIDATE_EMAIL-compatible check from lagoon's email rule or a local port; failure returns ErrInvalidInvitationEmail carrying PHP's English literal `The email must be a valid email address.`). Owner assertion as assertOwner: a personal token on the request or a non-owner returns ErrCollectionNotFound. `SendInvitation`: in one lagoon.Transaction, ProvisionOrgFor(actor), load the pending (accepted_at IS NULL) row for (collection, email) FOR UPDATE or start a new one, set token_hash = hex sha256 of a fresh token (32 random bytes hex-encoded, 64 chars), invited_by, expires_at now+7 days, clear accepted_at, accepted_by, revoked_at, save, then enqueue `InvitationMailArgs{InvitationID, Token: ciphertext}` through conga Manager.Enqueue on the same tx (queue `mail`, MaxAttempts 3). Ciphertext comes from lagoon.NewEncrypted(raw).Value(); never keep the raw token beyond the function. Do not use Dispatch (summer_jobs) for this job. `AcceptInvitation`: in one tx look up by sha256(token) FOR UPDATE; 410 ErrInvitationUnavailable when missing, not pending (Status), expired, revoked or the stored email differs from the normalized user email; firstOrCreate the editor row (role editor, granted_at now, granted_by invited_by), upsert the user's context to the collection, join an org-less user to the inviter's organisation as member, stamp accepted_at and accepted_by, delete that user's PendingInvitationRegistration rows for this invitation, and call NotifyInvitationAccepted on the same tx. Never delete the invitee's own collection.
|
||||||
|
|
||||||
|
(3) notification_service.go (D-14): `WriteNotification(ctx, tx, svc, userID, typ, payload)` inserts the notification row, then Emit on channel `user:<id>` event `notification:new` with ordered payload {id, type, payload, created_at as Carbon +00:00} and event `notification:count` with {count: unread rows for the user} computed in the same tx; `NotifyInvitationAccepted` writes `invitation_accepted` with {collection_id, email} to invited_by when positive. Constants for all five PHP TYPE_* values.
|
||||||
|
|
||||||
|
(4) jobs.go and mail.go: the plugin implements pact.HasJobs returning the invitation mail job; the worker decrypts Token with lagoon.Encrypted Scan plus Reveal, loads the invitation with collection and inviter, skips (returns nil, logs the invitation id only) when the invitation is no longer pending or sha256(token) no longer equals token_hash (a later resend superseded it), otherwise picks the locale from the inviter's preferred_locale (`en` gives the -en template and `/en/invitations/<token>`, anything else Polish and `/zaproszenia/<token>`), builds invitationUrl from app.url with the trailing slash trimmed, and sends through the postcard mailer to the invitation email. The plugin implements pact.HasMailTemplates with views/mail embedded: port collection_invitation.htm, collection_invitation-en.htm and layouts/plytarium.htm with the same front matter, subject and Markdown body converted the way the user plugin's templates were (Twig variables become the postcard template variables), layout alias `plytarium`. Never log args, the token or the URL.
|
||||||
|
|
||||||
|
(5) Controllers and routes: `HouseholdInvitationsStore` validates `email` with Laravel `required|email` via lagoon.ValidateRequest (its 422 envelope is the ValidationException shape that Task 3 records and pins; in this task return the errors through one helper so Task 3 changes only that helper), resolves the owner collection (Resolve plus owner check, ErrCollectionNotFound is the Winter 404 page), calls SendInvitation and answers 202 `{"data":{"state":"pending"}}`; `InvitationAccept` answers 200 `{"data":{"state":"accepted","collection_name":"..."}}` or the Winter 410 page (add winter_410.html from the re-recorded fixture with the same app.url stylesheet template). Mount on the JWT group only: `POST /household/invitations` with `throttle:10,1` and `POST /invitations/{token}/accept` (no constraint, as routes.php).
|
||||||
|
|
||||||
|
(6) Recordings: re-record the accept fixture under APP_DEBUG=false (410 for an unknown token) and add the 200 accept case and the invite 202 case. The raw token reaches the PHP recorder through the log mailer: extend php_parity.sh so `MAIL_MAILER=log` writes to a parity-owned log file (never under parity/fixtures), read the token from it and put it only in the 0600 vars file as `secret:invite`; on the Go side the seed hook or the test reads it from the postcard memory driver or inserts a known invitation with that token's hash. Flip the two routes to ported.
|
||||||
|
|
||||||
|
(7) Smoke tests household_smoke_test.go: `TestInvitationMailEnqueuedInTx` (commit: one river_job row of kind golem15.fonoteka.invitation_mail whose args do not contain the raw token; worker run sends one memory mail with the right template, recipient and URL; rollback: no row, no mail), `TestInvitationAcceptAddsEditor` (editor row, context moved, notification row, two Emit publications on user:<inviter>, pending registration rows cleared, invitee's own collection untouched).</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestInvitationMailEnqueuedInTx|TestInvitationAcceptAddsEditor)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run 'TestParityCorpus' -count=1 -v</automated>
|
||||||
|
<fails_when>Any command exits non-zero; the plugin run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for both named tests; the parity run reports FAIL for an invitations subtest or lacks "--- PASS: TestParityCorpus/coverage".</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c 'Enqueue(' ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go` prints at least 1 and `grep -c 'Dispatch(' ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go` prints 0.
|
||||||
|
- `grep -c 'NewEncrypted' ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go` prints at least 1.
|
||||||
|
- `grep -c '/zaproszenia/' ../fonoteka.go/plugins/golem15/fonoteka/jobs.go` and `grep -c '/en/invitations/' ../fonoteka.go/plugins/golem15/fonoteka/jobs.go` each print at least 1.
|
||||||
|
- `grep -c 'layout = "plytarium"' ../fonoteka.go/plugins/golem15/fonoteka/views/mail/collection_invitation.htm` prints 1.
|
||||||
|
- The re-recorded accept 410 fixture has `Content-Type: "text/html; charset=UTF-8"` and `grep -c 'line [0-9]' <fixture>` prints 0 (no debug trace).
|
||||||
|
- TestInvitationMailEnqueuedInTx reads river_job.args as text and asserts the raw token string is absent.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The invite-to-accept path works end to end with a mail that leaves only after commit, an encrypted token at rest, PHP's 202/200/410 bodies and the accept notification.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: The owner manages invitations and members, and a member removed from the household keeps a working account</name>
|
||||||
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitations_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/members_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_409.html, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/parity_test.go</files>
|
||||||
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/InvitationService.php (resend, cancel, removeEditor, assertOwner, personalTokenIsActive, notFound), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CollectionMemberController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/InvitationApiController.php (index, cancel, resend, ownerCollection), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ActiveCollectionResolver.php (guardPendingInvitation), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/CollectionProvisioner.php, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_household_invitations_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/DELETE___fonoteka_api_v1_household_invitations_{id}_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_household_invitations_{id}_resend_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_household_members_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/DELETE___fonoteka_api_v1_household_members_{id}_jwt.yaml, ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go, ../fonoteka.go/plugins/golem15/fonoteka/models/pending_invitation_registration.go</read_first>
|
||||||
|
<action>(1) Service: `ResendInvitation` (owner assertion; pending row of this collection by id FOR UPDATE else ErrCollectionNotFound; new token, hash, expires now+7d, revoked_at cleared; enqueue a new mail job on the same tx, exactly as Task 1), `CancelInvitation` (pending row FOR UPDATE else ErrCollectionNotFound; revoked_at now), `RemoveEditor` (owner assertion; in one tx lock the member's users row; missing member or the owner returns ErrCollectionNotFound; delete exactly one editor row else ErrCollectionNotFound; compute hasHouseholdCollection before provisioning (member not org owner, has organisation, and some collection accessible by membership is owned by a user in that organisation); ProvisionCollection(member); detach organisation_id and organisation_role only when the member is not an org owner, has an organisation and hasHouseholdCollection is false). Never delete a collection or album.
|
||||||
|
|
||||||
|
(2) Guard (user split for 12-03): add `guardPendingInvitation` to Resolve and SwitchTo for non-token callers, before the users lock, porting PHP: in its own transaction lock the user's PendingInvitationRegistration row; none means continue; load the invitation row FOR UPDATE; when it is pending, unexpired, unrevoked and its email equals the user's email (trimmed, lowercased) return ErrInvitationAcceptanceRequired, else delete the registration row and continue. Map ErrInvitationAcceptanceRequired to the Winter 409 page in writeWinterHTTPError's caller helper (add winter_409.html from the recording) wherever ErrCollectionNotFound is mapped today (collections, me/context, realtime/channels, share, household, albums later).
|
||||||
|
|
||||||
|
(3) Controllers: `HouseholdInvitationsIndex` (owner collection; rows of the collection ordered by id desc mapped to {id, email, state (models Status: pending, accepted, expired or unavailable as PHP status), expires_at Carbon}; `{"data":[...]}` never null), `HouseholdInvitationsCancel` 200 `{"data":{"state":"unavailable"}}`, `HouseholdInvitationsResend` 200 `{"data":{"state":"pending"}}`, `HouseholdMembersIndex` (owner row first {id, name (string, empty when null), email, role owner, removable false}, then editors ordered by users.id with role editor and removable true), `HouseholdMembersDestroy` 200 `{"data":{"removed":true}}`. Every ErrCollectionNotFound is the Winter 404 page. Routes on the JWT group only: GET /household/invitations, DELETE /household/invitations/{id} (constraint [0-9]+), POST /household/invitations/{id}/resend (constraint, throttle:10,1), GET /household/members, DELETE /household/members/{id} (constraint).
|
||||||
|
|
||||||
|
(4) Recordings (D-08, D-21): index with pending, accepted and revoked rows and the editor 404 page; cancel 200 and 404 page (already accepted id, foreign id); resend 200 and 404 page; members 200 (owner plus bob) and editor 404 page; destroy 200 (bob removed; then bob's me/context in the same flow shows his own collection active), 404 page for the owner's id and a non-member id; the 409 guard case (a pending registration row for a seeded valid invitation, request GET me/context). Flip the five routes to ported (seven with Task 1) and raise expectedPortedRoutes to 63.
|
||||||
|
|
||||||
|
(5) Smoke tests: `TestRemoveEditorRepairsContext` (bob's context moves to his own collection, his own collection and albums still exist, org detach only without another household collection), `TestPendingInvitationGuard` (409 for a valid row, stale row deleted then 200), `TestConcurrentAcceptSingleEditor` (two goroutines accept one token: one 200 and one 410, one editor row, one notification).</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestRemoveEditorRepairsContext|TestPendingInvitationGuard|TestConcurrentAcceptSingleEditor|TestInvitationAcceptAddsEditor)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run 'TestParityCorpus' -count=1 -v</automated>
|
||||||
|
<fails_when>Any command exits non-zero; the plugin run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestRemoveEditorRepairsContext, TestPendingInvitationGuard and TestConcurrentAcceptSingleEditor; the parity run reports FAIL for a household subtest or lacks "--- PASS: TestParityCorpus/coverage".</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows the value 63.
|
||||||
|
- `grep -c 'PendingInvitationRegistration' ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go` prints at least 1.
|
||||||
|
- `grep -n 'household' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` shows the household routes only inside the `/_fonoteka/api/v1` JWT group.
|
||||||
|
- The recorded members 404 and guard 409 fixtures carry `text/html; charset=UTF-8` and the Go replay matches them byte for byte.
|
||||||
|
- The resend and invite throttle lines in routes.go carry `throttle:10,1`.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Owners see and manage invitations and members with PHP's bodies and HTML error pages; removed members land in a working context of their own; a pending registration blocks the app with PHP's 409 until accepted.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: The Nuxt household journey replays end to end, and invitation tokens can never be committed to the corpus</name>
|
||||||
|
<files>../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/check_corpus.go, ../fonoteka.go/parity/check_corpus_test.go, ../fonoteka.go/parity/capture-rules.yaml, ../fonoteka.go/parity/README.md, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitations_controller.go</files>
|
||||||
|
<read_first>/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useFonoteka.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useCollectionSwitcher.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useCollectionSync.ts (request order and headers of the household screens), ../fonoteka.go/parity/nuxt_flow_test.go (flow replay with slices and store injection), ../fonoteka.go/parity/fixtures/nuxt/nuxt-browse.yaml (flow shape), ../fonoteka.go/parity/capture-rules.yaml, ../fonoteka.go/parity/check_corpus.go (scanSecrets), ../fonoteka.go/parity/check_corpus_test.go, ../fonoteka.go/parity/README.md, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/Handler.php (invalidJson for ValidationException)</read_first>
|
||||||
|
<action>(1) ValidationException envelopes (D-21, RESEARCH Finding 3, A4): record POST household/invitations with `{}` (Laravel `required`), with `{"email":"not-an-email"}` (Laravel `email` rule), and with an address that passes Laravel's email rule but fails FILTER_VALIDATE_EMAIL after trim and lowercase (normalizeEmail's own ValidationException with the English literal), all under APP_DEBUG=false with `Accept: application/json`. Make HouseholdInvitationsStore reproduce exactly the recorded envelope and status for each (Laravel's invalidJson shape is `{"message":first message,"errors":{...}}` with message possibly suffixed `(and N more errors)`; follow the bytes). Flip nothing new here beyond adding these cases.
|
||||||
|
|
||||||
|
(2) Flow (D-08): author a request spec for `nuxt-collections` in the order the Nuxt composables issue them (create collection, switch to it, me/context, realtime/channels, share show, share update enable, share regenerate, invite bob, accept as bob, members, remove bob, bob's me/context) with the same headers the app sends; record it from isolated PHP with `summer parity:record --spec` in two runs around the invite step, reading the token from the parity mail log into the vars file as `secret:invite` between them, and merge into parity/fixtures/nuxt/nuxt-collections.yaml with captures for every id and the share token category. parity/fonoteka_flows_test.go `TestFonotekaNuxtFlows` with subtest `nuxt-collections`: boot the Go target with the `fonoteka` seed hook, replay the steps before the accept step, take the raw token from the postcard memory driver (or the delivered job) into the store as `secret:invite`, replay the rest; assert every step matches and that the final DB state has bob without an editor row, an `invitation_accepted` notification for alice, and bob's context on his own collection.
|
||||||
|
|
||||||
|
(3) Secrets: extend check_corpus.go scanSecrets to fail on a 64-hex-character token in any fixture path or body (for example `invitations/<64 hex>/accept`) that is not a `{{...}}` reference, with a check_corpus_test.go case for a planted token and a clean `{{secret:invite}}` reference; add the capture rule for share tokens (`$.data.token` on collection/share responses, category share) if Task 3 of 12-02 did not already. Update parity/README.md with the household recording recipe (log mailer, secret:invite, two-run spec recording, merge).</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows|TestParityCorpus|TestCheckCorpus.*)$' -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</automated>
|
||||||
|
<fails_when>Any command exits non-zero; the run prints "no tests to run" or "--- FAIL", or lacks "--- PASS: TestFonotekaNuxtFlows/nuxt-collections" or "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret or an unrecorded route.</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `test -f ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml` succeeds and `grep -c 'secret:invite' ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml` prints at least 1.
|
||||||
|
- `grep -cE '[0-9a-f]{64}' ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml` prints 0.
|
||||||
|
- check_corpus_test.go has a case where a planted 64-hex token makes `--check-secrets` fail.
|
||||||
|
- The three invitation validation fixtures exist and replay green against Go.
|
||||||
|
- parity/README.md documents MAIL_MAILER=log and `secret:invite`.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The household journey the Nuxt app performs replays against Go with PHP's bodies and DB effects, the validation envelopes match, and the corpus refuses any committed invitation token.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Invitee mailbox → accept route | The raw token in the mail is the only bearer of the invitation |
|
||||||
|
| Owner → invite/resend/cancel/members | Owner-only household management over JWT |
|
||||||
|
| Write transaction → River → mail | The token leaves the DB for the job queue and the mail driver |
|
||||||
|
| Committed fixtures → git | Recorded flows must never carry a usable token |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-12-06 | Spoofing | invitation accept | high | mitigate | Lookup by sha256 FOR UPDATE; pending, unexpired, unrevoked and exact normalized email match required, else 410; resend rotates the hash; concurrent accepts serialize on the row lock (Tasks 1-2). |
|
||||||
|
| T-12-07 | Information Disclosure | raw token in river_job.args, summer_jobs and logs | high | mitigate | Args carry app-key AES-GCM ciphertext (D-23); Enqueue not Dispatch so summer_jobs never sees it; the worker and handlers never log args, token or URL; a test reads river_job.args as text (Task 1). |
|
||||||
|
| T-12-31 | Elevation of Privilege | household routes under a personal token | high | mitigate | JWT group only; assertOwner also refuses a token in-handler (second lock) (Tasks 1-2; route-table test in 12-05). |
|
||||||
|
| T-12-32 | Elevation of Privilege | editor managing members or invitations | high | mitigate | Owner check on the resolved collection before every household action; editors get the 404 page (Task 2). |
|
||||||
|
| T-12-20 | Information Disclosure | committed flow fixtures | high | mitigate | Tokens only as `{{secret:invite}}`; check_corpus --check-secrets fails on any 64-hex token (Task 3). |
|
||||||
|
| T-12-21 | Denial of Service | invite and resend mail flooding | medium | mitigate | throttle:10,1 on store and resend (D-01); one pending row per (collection, email) so repeated invites reuse a row (Tasks 1-2). |
|
||||||
|
| T-12-22 | Tampering | accept joining the inviter's organisation | medium | mitigate | Only an org-less invitee joins, as member, inside the accept transaction; never overwrites an existing organisation (Task 1). |
|
||||||
|
| T-12-SC | Tampering | package installs | low | accept | No new dependency in this plan. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green.
|
||||||
|
- Parity corpus at 63 ported and passing; `TestFonotekaNuxtFlows/nuxt-collections` passes; check_corpus with --check-secrets green.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Seven household/invitation routes ported with PHP bodies, HTML error pages and validation envelopes.
|
||||||
|
- Invitation mail is transactional, locale-correct and its token is never stored or logged in plain text.
|
||||||
|
- The recorded Nuxt household journey replays green, including DB state after accept and removal.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-p-ytarium-api-collections-and-albums/12-03-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
---
|
||||||
|
phase: 12-p-ytarium-api-collections-and-albums
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 4
|
||||||
|
depends_on: ["12-03"]
|
||||||
|
files_modified:
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/style_resolver.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/tracklist_text_parser.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/added_date_parser.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/duplicate_matcher.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/completeness.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_sync.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_stats.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/realtime.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_bulk_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_stats_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_sync_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_search_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/ratings_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/lookups_controller.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go
|
||||||
|
- ../fonoteka.go/parity/manifest.yaml
|
||||||
|
- ../fonoteka.go/parity/fixtures/routes/
|
||||||
|
- ../fonoteka.go/parity/fixtures/files/
|
||||||
|
- ../fonoteka.go/parity/fixtures/nuxt/nuxt-albums.yaml
|
||||||
|
- ../fonoteka.go/parity/fonoteka_seed_test.go
|
||||||
|
- ../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
|
||||||
|
- ../fonoteka.go/README.md
|
||||||
|
- scripts/check-phase10.sh
|
||||||
|
- scripts/check-phase10.1.sh
|
||||||
|
- scripts/check-phase11.sh
|
||||||
|
autonomous: true
|
||||||
|
requirements: [API-02]
|
||||||
|
estimate:
|
||||||
|
tokens: 380000
|
||||||
|
raw_tokens: 380000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Per C-02 and RESEARCH Summary, the album write service ports AlbumWriteService create/apply: AlbumFillFields only, ArtistResolver (numeric ids, Discogs ids and names resolved by name_key firstOrCreate, ordered pivot, displayFor artist_display), styles resolved or created by name/slug, `tracklist_text` parsed by the TracklistTextParser port (max 200 lines), `created_at` through the AddedDateParser port, barcode normalization, completeness sync, and cover import; unknown and server-owned keys never persist."
|
||||||
|
- "Per RESEARCH Pattern 4 and P11 D-08, POST albums and PUT albums/{id} run the whole write under `lighthouse.WithoutBroadcasting[models.Album]` and emit exactly one `created.fonoteka.album` or `updated.fonoteka.album` on the album's kind=collection channel with the full serialized album (genre, styles, artists, photos), and the created and updated broadcast goldens are assertions that pass (the gate scripts no longer expect them to skip)."
|
||||||
|
- "Per D-02, POST albums/bulk validates `albums` required|array|min:1 plus every album rule under `albums.*.`, creates every row in one transaction under suppression, returns 201 `{\"data\":[...],\"duplicates\":[...]}`, and emits exactly one `collection.bulk_updated` `{\"reason\":\"bulk_create\",\"count\":N}` only when at least one album was created in a kind=collection collection."
|
||||||
|
- "Per RESEARCH Pitfall 6, every album created in a kind=collection collection writes an `album_added` notification for each owner and editor except the actor, with notification:new and notification:count emitted in the write transaction (published after commit)."
|
||||||
|
- "Per D-05, `cover_urls` (at most 5, https only, host discogs.com or a subdomain, fetchguard AllowHosts with the configured byte cap and timeout) are imported after the album row commits; each failed URL is recorded in cover_import_failures; a failed cover never fails or rolls back the album."
|
||||||
|
- "Per D-07 and D-11, POST albums/{id}/photos accepts exactly one of a multipart `file` (Laravel image/mimes/max:10240 plus the ImageContentGuard sniff-and-decode, webp included) or a `cover_url` fetched with fetchguard PublicOnly, maps each fetch failure reason to PHP's exact message, carries throttle:20,1 on the JWT route only, and answers 201 `{\"data\":PhotoDTO}`."
|
||||||
|
- "Per D-16, D-17 and D-19, albums/search uses the Typesense path only when the search_use_typesense gate is on, the engine is configured, the sort is not rating, name, artist or price, and no rating filter is set: one SearchPage with PHP's query_by order `name,artist_display,style_names,genre_name,track_titles,notes,label,catalog_number` and weights `10,10,5,5,3,1,3,3`, items re-gated in SQL by AlbumsAccessibleBy plus collection_id plus filters in engine order, and meta.total the re-gated count over at most 1000 engine ids; an engine error logs a warning and falls back to the SQL path, which uses ILIKE with % and _ escaped over the authenticated text fields and artists.name."
|
||||||
|
- "Per D-12, search replays are recorded and replayed with search disabled; Typesense behaviour is covered by Go tests with a fake engine (full leak suite in 12-05)."
|
||||||
|
- "Per D-02, albums/stats, value, missing and sync (sync JWT only with throttle:60,1, PHP cursor `base64(<sync_version ISO>|<id>)`, per_page default 100 and max 200, tombstones in delta mode) return PHP's bodies for the active collection."
|
||||||
|
- "Per D-01 and D-10, the albums, rating, photos, stats, value, search, bulk routes and the lookups (GET artists, GET and POST styles, POST genres) are mounted once and reused on the token group exactly where routes.php mirrors them, each with exactly one inv.scope; the cover-price and Discogs match/apply/recognize/import routes stay pending (D-04)."
|
||||||
|
- "Per D-08, the `nuxt-albums` flow (create with and without cover_urls, update, rate, unrate, photo upload and delete, search, stats, value, missing, sync, bulk, delete) is recorded from PHP and replays green, and expectedPortedRoutes reaches 99."
|
||||||
|
- "Edge (API-02 boundary): year accepts 1889 and 2100 and rejects 1888 and 2101; per_page is clamped to 1..50 for index and search and 1..200 for sync with PHP (int) prefix parsing; cover_urls accepts five URLs and rejects six; rating accepts 1 and 5 and rejects 0 and 6; search meta.total never exceeds 1000 on the Typesense path."
|
||||||
|
- "Edge (API-02 precision): market_price_stored round-trips as a fixed four-decimal string (never float64) with min:0 and max:999999.9999 compared exactly; albums/value totals are exact SQL decimal sums formatted like PHP sprintf('%.2f') with the rounding of tie values pinned against php -r output."
|
||||||
|
- "Edge (API-02 concurrency): two concurrent PUT albums/{id}/rating calls by the same user leave exactly one rating row (unique user and album) holding one of the two values; a bulk create interrupted by a failing row rolls back every row and emits nothing."
|
||||||
|
artifacts:
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go"
|
||||||
|
provides: "CreateAlbum, UpdateAlbum (create/apply ports), SaveAlbum kept for existing callers"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go"
|
||||||
|
provides: "SearchAlbums with the Scout-exact Typesense path and the SQL path"
|
||||||
|
contains: "SearchPage"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go"
|
||||||
|
provides: "CoverImporter with an injectable fetch func"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go"
|
||||||
|
provides: "IsAllowedImage"
|
||||||
|
- path: "../fonoteka.go/parity/fixtures/nuxt/nuxt-albums.yaml"
|
||||||
|
provides: "recorded PHP Nuxt albums flow"
|
||||||
|
key_links:
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go"
|
||||||
|
to: "summercms.go modules/lighthouse/suppress.go"
|
||||||
|
via: "WithoutBroadcasting[models.Album] then one Service.Emit"
|
||||||
|
pattern: "WithoutBroadcasting"
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go"
|
||||||
|
to: "summercms.go modules/beachcomber/searchable.go"
|
||||||
|
via: "beachcomber.SearchPage for ids and found, then SQL recount"
|
||||||
|
pattern: "SearchPage\\("
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go"
|
||||||
|
to: "../fonoteka.go/plugins/golem15/fonoteka/classes/access.go"
|
||||||
|
via: "AlbumsAccessibleBy re-gates every engine id"
|
||||||
|
pattern: "AlbumsAccessibleBy"
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go"
|
||||||
|
to: "summercms.go modules/fetchguard/fetch.go"
|
||||||
|
via: "Fetch with AllowHostsMode discogs.com"
|
||||||
|
pattern: "AllowHosts"
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go"
|
||||||
|
to: "summercms.go modules/fetchguard/fetch.go"
|
||||||
|
via: "Fetch with PublicOnlyMode"
|
||||||
|
pattern: "PublicOnly"
|
||||||
|
prohibitions:
|
||||||
|
- requirement_id: API-02
|
||||||
|
category: privacy
|
||||||
|
statement: "Search MUST NOT return or count an album the caller cannot access, whatever the search index contains"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
- requirement_id: API-02
|
||||||
|
category: safety
|
||||||
|
statement: "A failed or slow cover download MUST NOT lose the album the user just saved"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
- requirement_id: API-02
|
||||||
|
category: privacy
|
||||||
|
statement: "An album-added notification MUST NOT go to the actor or to anyone outside the collection's owner and editors"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Goal
|
||||||
|
|
||||||
|
ROADMAP Phase 12 goal (verbatim, not in user-story form): Collections and Albums endpoints are ported with byte-compatible request/response shapes, including active-context switching, editor invitations, ratings, reservations, cover handling and search. (12-01 Task 4 rewords it per D-03/D-04/D-06.)
|
||||||
|
|
||||||
|
This plan's slice: the Nuxt Albums UI and the MCP server can add, browse, edit, rate, photograph, bulk-add, sync, count, value and search albums and look up artists, styles and genres, with PHP's bodies and Centrifugo events (API-02; ROADMAP SC-2, SC-3 code path, SC-4).
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Port the album write path and its helpers, the full album serializer, all album routes in scope, album search with the Scout-exact recount, the artists/styles/genres lookups, the Nuxt albums flow, and flip the created/updated broadcast goldens.
|
||||||
|
|
||||||
|
Purpose: albums are the bulk of the app; search is the security-critical read path (Pitfall 15). Decisions implemented: C-02, C-03, C-04, D-01, D-02, D-05, D-07, D-08, D-09, D-10, D-11, D-12, D-16, D-17, D-19, D-24 (consumer).
|
||||||
|
Output: classes, controllers, routes, recordings and the nuxt-albums flow, broadcast golden assertions, gate script updates.
|
||||||
|
|
||||||
|
Repos: fonoteka.go, plus summercms.go `scripts/check-phase10.sh`, `scripts/check-phase10.1.sh` and `scripts/check-phase11.sh` (their pending lists for the two goldens). Never add co-author tags.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-03-SUMMARY.md
|
||||||
|
@../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
|
||||||
|
@../fonoteka.go/plugins/golem15/fonoteka/realtime.go
|
||||||
|
@../fonoteka.go/plugins/golem15/fonoteka/search.go
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
- From 12-01: `lagoon.ValidateRequest`, `lagoon.ParseRules`, `lagoon.CustomRule`, `lagoon.UploadedFile`; `beachcomber.SearchPage(ctx, engine, index, Query{Q, QueryBy, QueryByWeights, FilterBy, SortBy, Page, PerPage}) (SearchResult{IDs, Found}, error)`; `attach.PublicURL`, `(*attach.File).URL()`, webp decoding; tide `Request.Parts`.
|
||||||
|
- From 12-02: `classes.Resolve`, `classes.AlbumsAccessibleBy(userID, token)`, `classes.SerializePhoto`, `PhotoDTO`, `writeValidationFailed`, `writeWinterHTTPError`, `decodeInput`, `phpInt`, `laravelBoolean`, `requestScope`, `classes.CollectionKey`, seed hook `fonoteka`.
|
||||||
|
- From 12-03: `classes.WriteNotification`, `classes.NotificationTypeAlbumAdded`, the 409 guard mapping.
|
||||||
|
- fonoteka today: `classes.SaveAlbum(ctx, gdb, album, requested, artistIDs)` (fill, validate, lagoon.Transaction, ResolveArtists numeric-only, tx.Save, syncArtists), `AlbumFillFields`, `classes.SerializeAlbum` (8-key map; realtime.go albumPayload calls it and `albumBroadcast.Album` is `map[string]any`), `models.Album` (AlbumFormats, AlbumConditions, AlbumMarketCurrencies, MediumFamily, BeforeSave market stamping, track_titles), `models.AlbumArtist`, `models.AlbumRating`, `models.Artist`, `models.Style`, `models.Genre`, `models.AlbumSearchQueryBy` (Go order differs from PHP; the search uses PHP's order), settingsGate in search.go, lighthouse Album binding in realtime.go (alias fonoteka.album, albumChannels), controllers.ListGenres and polishOrderColumns/polishCollation `pl-x-icu` in controllers/genre_controller.go.
|
||||||
|
- fetchguard: `Fetch(ctx, rawURL, Policy{Mode: AllowHostsMode or PublicOnlyMode, AllowHosts, MaxBytes, Timeout}, cfg) (*Result, error)` with `*fetchguard.Error{Reason}` reasons invalid_url, scheme, unresolvable, private_ip, network_error, too_large; dotted-suffix host match.
|
||||||
|
- lighthouse: `WithoutBroadcasting[T](ctx, fn func(ctx) error) error` (writes must use the ctx handed to fn), `(*Service).Emit(ctx, db, Broadcast{Channels, Event, Payload})`.
|
||||||
|
- Parity: broadcast goldens `fixtures/broadcasts/{created,updated,deleted,bulk}.yaml`, `TestBroadcastGoldens` (created/updated skip with `pending: Phase 12 ...`), gate scripts' `APP_PENDING_SKIPS`/`GOLDEN_SKIPS` in scripts/check-phase10.sh, check-phase10.1.sh, check-phase11.sh (they refuse an expected skip that passes).
|
||||||
|
- PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{controllers/api/AlbumApiController.php, controllers/api/AlbumSyncController.php, controllers/api/RatingApiController.php, controllers/api/ArtistApiController.php, controllers/api/StyleApiController.php, controllers/api/GenreApiController.php, classes/AlbumWriteService.php, classes/ArtistResolver.php, classes/AlbumCompletenessService.php, classes/AlbumDuplicateMatcher.php, classes/TracklistTextParser.php, classes/AddedDateParser.php, classes/ImageContentGuard.php, classes/ManualCoverUrlFetcher.php, classes/discogs/CoverImporter.php, classes/discogs/DiscogsInputParser.php (normalizeBarcode), classes/AlbumSearchService.php, classes/AlbumSyncService.php, classes/PolishOrder.php, classes/NotificationService.php (notifyAlbumAdded), Plugin.php (registerNotificationListeners), models/Album.php, traits/SerializesFonoteka.php, config/fonoteka.php}; /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Builder.php (lines 533-556), Engines/TypesenseEngine.php.
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
(This plan's share.)
|
||||||
|
|
||||||
|
- classes: `CreateAlbum(ctx, gdb, svc, collectionID uint, input map[string]any, opts AlbumWriteOptions) (*models.Album, error)`, `UpdateAlbum(ctx, gdb, svc, album, input, opts) (*models.Album, error)`, `AlbumWriteOptions{CoverURLs []string}`, `ResolveArtistInputs`, `ArtistDisplayFor`, `ResolveStyleIDs`, `ParseTracklistText`, `ParseAddedDate`, `FindDuplicateAlbum`, `SyncCompletion`, `MissingTags`, `NotifyAlbumAdded`, `IsAllowedImage`, `CoverImporter{Fetch func(ctx, url string) ([]byte, string, error)}`, `ImportCovers`, `FetchManualCover`, `ManualCoverMessage(reason string) string`, `SearchAlbums(ctx, gdb, engineSvc, params AlbumSearchParams) (AlbumPage, error)`, `AlbumSyncDelta`, `AlbumTombstones`, `AlbumStats`, `AlbumValue`, `MissingAlbums`, `AlbumDTO`, `SerializeAlbum`, `SerializeArtist`, `SerializeArtistAggregate`, `SerializeGenre`, `SerializeStyle`.
|
||||||
|
- controllers/api: `AlbumsIndex`, `AlbumsStore`, `AlbumsShow`, `AlbumsUpdate`, `AlbumsDestroy`, `AlbumsBulk`, `AlbumPhotoUpload`, `AlbumPhotoDelete`, `AlbumRatingUpdate`, `AlbumRatingDestroy`, `AlbumsStats`, `AlbumsValue`, `AlbumsMissing`, `AlbumsSync`, `AlbumsSearch`, `ArtistsIndex`, `StylesIndex`, `StylesStore`, `GenresStore`.
|
||||||
|
- Routes: JWT `albums/search`, `albums/stats`, `albums/value`, `albums/sync` (throttle:60,1), `albums/missing`, `albums/bulk`, `albums`, `albums/{id}`, `albums/{id}/photos` (throttle:20,1), `albums/{id}/photos/{fileId}`, `albums/{id}/rating`, `genres` POST, `styles`, `artists`; token twins per routes.php.
|
||||||
|
- Config keys: `golem15.fonoteka.discogs.max_covers` (5), `.cover_max_bytes` (10485760), `.cover_timeout_seconds` (10), `.cover_host_suffix` (".discogs.com"), `golem15.fonoteka.covers.manual_url_max_bytes` (10485760), `.manual_url_timeout_seconds` (10).
|
||||||
|
- Parity: `fixtures/nuxt/nuxt-albums.yaml`, `TestFonotekaNuxtFlows/nuxt-albums`, created/updated goldens asserted.
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1: Adding an album from the Nuxt form or an MCP token stores it like PHP, notifies the household and publishes one created event</name>
|
||||||
|
<reversibility rating="reversible">New write path beside SaveAlbum; SaveAlbum stays callable for existing tests and admin code.</reversibility>
|
||||||
|
<precondition>Plan 12-03 is executed: `go -C ../fonoteka.go doc ./plugins/golem15/fonoteka/classes WriteNotification` exits 0.</precondition>
|
||||||
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/style_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/tracklist_text_parser.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/added_date_parser.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/duplicate_matcher.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/completeness.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/parity_test.go, scripts/check-phase10.sh, scripts/check-phase10.1.sh, scripts/check-phase11.sh</files>
|
||||||
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (store, show, albumRules, duplicateAttrs, serializeDuplicate, context, embedsFor, publishAlbumBroadcast), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumWriteService.php (create, apply, applyFields, applyAddedAt, syncArtists, resolveStyleIds, syncCompletion, maybeImportCovers), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ArtistResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/TracklistTextParser.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AddedDateParser.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumDuplicateMatcher.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumCompletenessService.php (syncCompletion, missingTags), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsInputParser.php (normalizeBarcode), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (notifyAlbumAdded), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php (registerNotificationListeners, resolveNotificationActor), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php (getBroadcastPayload, mediumFamily, beforeSave), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php (serializeAlbum, serializeOwnRating, serializeArtist), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album.go, ../fonoteka.go/plugins/golem15/fonoteka/models/artist.go, ../fonoteka.go/plugins/golem15/fonoteka/models/style.go, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/fixtures/broadcasts/created.yaml, ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_albums_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/POST__api_v1_fonoteka_albums_personal_token.yaml, scripts/check-phase11.sh (GOLDEN_SKIPS, refuse rule), scripts/check-phase10.sh and scripts/check-phase10.1.sh (APP_PENDING_SKIPS)</read_first>
|
||||||
|
<action>(1) Helpers (ports, each a small file): ArtistResolver (resolve: numeric id existing; `discogs:` or Discogs id inputs as PHP; a name trimmed and keyed by Artist name_key, firstOrCreate with slug; returns ordered ids without duplicates; `ArtistDisplayFor(ids)` joining names as PHP displayFor), `ResolveStyleIDs` (numeric existing id, else name or slug match, else create, as SerializesFonoteka resolveStyleIds), `ParseTracklistText` (TracklistTextParser: line split on CRLF/LF/CR, max 200 lines, autoNumber, position/title/artist/duration), `ParseAddedDate` (AddedDateParser FORMATS and MIN_DATE, returns nil for refused formats), `FindDuplicateAlbum(collectionID, attrs)` (AlbumDuplicateMatcher with its POLISH_FOLD map), `SyncCompletion` and `MissingTags` (AlbumCompletenessService BASE_TAGS, cond, price, rating rules), barcode normalization (normalizeBarcode). Keep every PHP constant and string verbatim.
|
||||||
|
|
||||||
|
(2) album_write_service.go: `CreateAlbum` and `UpdateAlbum` porting create/apply inside one lagoon.Transaction: Fill with AlbumFillFields, apply genre_id, styles, artists (pivot sort_order), tracklist or tracklist_text, created_at via ParseAddedDate, completion sync, dismiss_missing handling as applyFields does, collection_id server-set from the resolved active collection, market_price_source preserved as SaveAlbum does today. Strings are stored as sent (no trimming beyond what PHP does). Covers (`cover_urls`) are NOT imported in this task (Task 2 adds them after commit). SaveAlbum stays exported and green for its existing tests.
|
||||||
|
|
||||||
|
(3) Notifications (Pitfall 6): register a GORM after-create callback for models.Album in classes (RegisterHook pattern, Before gorm:commit_or_rollback_transaction as lighthouse/beachcomber do) that, for an album whose collection kind is `collection`, calls `NotifyAlbumAdded(ctx, tx, svc, album, actor)` writing `album_added` {album_id, album_name, collection_id, actor_name} to the owner and every editor except the actor (actor from the frontend principal in ctx, nil for none or a backend admin, per resolveNotificationActor); the wishlist branch is Phase 13. This fires for any album insert, as PHP's eloquent.created listener does.
|
||||||
|
|
||||||
|
(4) serialize.go: `AlbumDTO` ordered as serializeAlbum (id, name, artists ordered by pivot sort_order, artist_display, year, format, condition, medium, genre {id,name} or null, styles [{id,name}], shelf, barcode, discogs_id, edition, label, catalog_number, country, market_price_stored string or null, market_price_currency, market_price_checked_at, market_price_source, tracklist (never null), cover_import_failures (never null), notes, rating (the caller's own rating or null), photos (PhotoDTO list ordered as attachMany), created_at, updated_at); no `reservation` key (Phase 13). `SerializeArtist`, `SerializeArtistAggregate`, `SerializeGenre`, `SerializeStyle`. realtime.go: albumPayload uses the DTO (change `albumBroadcast.Album` to `*AlbumDTO` with omitempty) built from a fresh read with genre, styles, artists and photos.
|
||||||
|
|
||||||
|
(5) albums_controller.go: `AlbumsStore` (Resolve; ValidateRequest with albumRules(true) ported verbatim including the tracklist_text 200-line closure via CustomRule with PHP's literal message, the created_at date rules, cover_urls array|max:5 and cover_urls.* string|url|max:2048, tracklist.*.duration regex; FindDuplicateAlbum before create; CreateAlbum inside `lighthouse.WithoutBroadcasting[models.Album]`; then one `svc.Emit` of `created.fonoteka.album` on albumChannels with the albumPayload shape when the channel list is non-empty; respond 201 `{"data":AlbumDTO,"duplicate":DuplicateDTO or null}`), and `AlbumsShow` (AlbumsAccessibleBy plus collection_id = active id, embeds plus own rating, else 404 `{"error":"Album not found"}`). Routes: JWT and token `POST /albums` (token write) and `GET /albums/{id}` (token read) with `[0-9]+`.
|
||||||
|
|
||||||
|
(6) Goldens (D-09): rework TestBroadcastGoldens `created` to drive POST albums through the assembled handler on the memory/fake Centrifugo driver and assert the recorded created golden (timestamps and actor normalized by tide); remove its pending skip. Clear `TestBroadcastGoldens/created` from APP_PENDING_SKIPS in scripts/check-phase10.sh and scripts/check-phase10.1.sh and from GOLDEN_SKIPS in scripts/check-phase11.sh (keep `updated` until Task 2), updating their comments, and make each script's `--self-test` still pass.
|
||||||
|
|
||||||
|
(7) Recordings: POST albums 201 (minimal create, create with styles and tracklist_text, create that triggers `duplicate`), 422 cases (missing name and artists, year 1888, six cover_urls, bad duration, numeric name), token 201; GET albums/{id} 200 own, 200 shared to bob, 404 foreign, token pinned 404 for an album outside the pin. Flip the four routes to ported and raise expectedPortedRoutes by 4.
|
||||||
|
|
||||||
|
(8) Smoke tests: `TestAlbumStoreSingleCreatedEvent` (one publication, payload album has artists in order, no duplicate events), `TestAlbumAddedNotifiesHousehold` (bob gets the notification when alice creates; alice does not), `TestAlbumWriteHelpersMatchPHP` (table cases for the parsers and the duplicate matcher taken from the PHP sources' behaviour; run php -r to confirm any ambiguous case before pinning it).</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAlbumStoreSingleCreatedEvent|TestAlbumAddedNotifiesHousehold|TestAlbumWriteHelpersMatchPHP)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestBroadcastGoldens|TestParityCorpus)$' -count=1 -v && scripts/check-phase11.sh --self-test</automated>
|
||||||
|
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL" or "DATA RACE", or lacks "--- PASS: TestBroadcastGoldens/created" or "--- PASS: TestParityCorpus/coverage"; "--- SKIP: TestBroadcastGoldens/created" appears; the gate self-test reports a failed detector.</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c 'WithoutBroadcasting\[models.Album\]' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go` prints at least 1.
|
||||||
|
- `grep -n 'GOLDEN_SKIPS=' scripts/check-phase11.sh` no longer lists `TestBroadcastGoldens/created`.
|
||||||
|
- `grep -c 'album_added' ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go` prints at least 1.
|
||||||
|
- The PHP tracklist_text closure message appears verbatim in albums_controller.go (`grep -c 'more than 200 lines' ...` prints at least 1).
|
||||||
|
- `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes -run 'TestSaveAlbum|FuzzSaveAlbum' -count=1` still passes (old callers intact).
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>A created album matches PHP's response, DB rows, household notifications and single Centrifugo event, on both auth groups, and the created golden is an assertion.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Users edit, rate, photograph, bulk-add, delete, sync, count and value their albums with PHP's bodies and events</name>
|
||||||
|
<reversibility rating="reversible">Handlers and helpers are additive; the cover import runs after commit and can be disabled by config max_covers.</reversibility>
|
||||||
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_sync.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_stats.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/completeness.go, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_bulk_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_stats_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_sync_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/ratings_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/files/, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/README.md, scripts/check-phase10.sh, scripts/check-phase10.1.sh, scripts/check-phase11.sh</files>
|
||||||
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (index, update, destroy, bulk, stats, value, missing, uploadPhoto, coverUrlErrorMessage, deletePhoto, paginated), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/RatingApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumSyncController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSyncService.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumCompletenessService.php (applyMissingConstraints, applyListFilters, counters), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ImageContentGuard.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ManualCoverUrlFetcher.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/CoverImporter.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/config/fonoteka.php, summercms.go modules/fetchguard/fetch.go and policy.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go (12-02 upload pattern), ../fonoteka.go/parity/fixtures/broadcasts/updated.yaml, ../fonoteka.go/parity/fixtures/broadcasts/bulk.yaml, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_albums_stats_jwt.yaml, GET___fonoteka_api_v1_albums_value_jwt.yaml, GET___fonoteka_api_v1_albums_missing_jwt.yaml, GET___fonoteka_api_v1_albums_sync_jwt.yaml, POST___fonoteka_api_v1_albums_bulk_jwt.yaml (all under ../fonoteka.go/parity/fixtures/routes/)</read_first>
|
||||||
|
<action>(1) Covers (D-05, D-07, T-12-09): `CoverImporter` ports CoverImporter.import with a `Fetch` field defaulting to fetchguard.Fetch with AllowHostsMode, AllowHosts ["discogs.com"] (PHP suffix `.discogs.com`), MaxBytes and Timeout from the new config keys; https only; at most `discogs.max_covers`; each URL that fails (scheme, host, fetch error, not an image per IsAllowedImage) goes into cover_import_failures in input order; success attaches a photo. Import runs AFTER the album write commits (never hold the write tx across downloads), then saves cover_import_failures, then serializes and emits; this applies to POST albums (extend Task 1's store), PUT albums/{id} and each bulk row. `IsAllowedImage` ports ImageContentGuard (sniff ALLOWED_IMAGE_MIMES, decode config succeeds, webp included per D-24). `FetchManualCover` uses fetchguard PublicOnlyMode with `covers.manual_url_*` limits and adds the app-level checks PHP does (2xx status, image content type, IsAllowedImage), returning a reason; `ManualCoverMessage` maps invalid_url, scheme, unresolvable, private_ip, network_error, http_status, content_type, too_large, invalid_image to PHP's exact English strings.
|
||||||
|
|
||||||
|
(2) Album routes: `AlbumsIndex` (per_page phpInt default 20 clamped 1..50, page min 1, AlbumsAccessibleBy plus active collection, order by name then id, lagoon pagination envelope without links), `AlbumsUpdate` (albumRules(false), UpdateAlbum under WithoutBroadcasting, one `updated.fonoteka.album` Emit, cover_urls after commit, 200 `{"data":AlbumDTO}`), `AlbumsDestroy` (soft delete through the model so the automatic deleted broadcast and index removal fire; PHP response body), ratings (`PUT albums/{id}/rating` validates rating required|integer|min:1|max:5, upserts the caller's row on the unique (user, album) key, returns `{"data":{"rating":n}}`; DELETE returns `{"data":{"rating":null}}`; 404 `{"error":"Album not found"}`), photos (`POST albums/{id}/photos`: exactly one of file or cover_url else 422 `{"error":"Validation failed","errors":{"file":["Provide exactly one of file or cover_url."]}}`; file branch: ValidateRequest file rules, then IsAllowedImage else `The file is not a valid image.`, then attach as 12-02 does; cover_url branch: `cover_url` required|string|url|max:2048 then FetchManualCover, failure 422 under `cover_url`; JWT route `throttle:20,1`, token route none; `DELETE albums/{id}/photos/{fileId}` with `Photo not found`), `AlbumsBulk` (rules prefixed `albums.*.`, one lagoon.Transaction under WithoutBroadcasting creating each row via CreateAlbum with duplicates computed first, rollback on any failure, then cover imports per row after commit, then one `collection.bulk_updated` Emit only when created rows exist and the active kind is collection; 201 `{"data":[...],"duplicates":[...]}`). Stats (`AlbumStats`: totals albums, all_count, vinyl, cd, cassette, box, artists, missing_albums and genres aggregates with album_count ordered as PHP plus an id tiebreak), value (`AlbumValue`: per-currency SUM in SQL as decimal, PHP sprintf('%.2f') formatting confirmed with php -r for tie values, albums_total, albums_valued, albums_without_price), missing (filters page, per_page 1..50, include_condition, include_price, include_rating, dismissed as laravelBoolean; MissingTags per album and the counters), sync (`AlbumSyncDelta` and `AlbumTombstones` ports with the cursor, checkpoint clamped to now, `mode`, `collection_key` (CollectionKey), `total_estimate`, `changed` rows carrying `sync_version`; JWT only with `throttle:60,1`). Token twins exactly as routes.php (search later, stats read, value read, bulk write, index read, update write, destroy write, photos POST and DELETE write, rating PUT and DELETE write); every `{id}` and `{fileId}` gets its Where right after its route.
|
||||||
|
|
||||||
|
(3) Goldens: drive PUT albums/{id} through the handler in TestBroadcastGoldens `updated`, drop its skip and its remaining entries in the three gate scripts (now empty pending lists; self-tests pass); keep `deleted` and `bulk` green through the real handlers.
|
||||||
|
|
||||||
|
(4) Config keys in plugin config.yaml and ../fonoteka.go/README.md: discogs.max_covers, cover_max_bytes, cover_timeout_seconds, cover_host_suffix; covers.manual_url_max_bytes, manual_url_timeout_seconds (PHP defaults).
|
||||||
|
|
||||||
|
(5) Recordings: index (two pages, per_page=abc clamps), update 200 and 422 and 404, destroy 200 and 404, rating PUT 200, 422 (0 and 6) and DELETE 200, photos multipart 201 (png and webp), 422 both-or-neither, 422 non-image, cover_url 422 for `http://` (scheme) and a private literal address (private_ip) without network, photo delete 200 and 404, bulk 201 with two rows, 422 `{"albums":[]}` (one message), cover_urls with a non-https and an off-list host producing cover_import_failures (network-free, Pitfall 10), stats, value, missing (with each include flag), sync full and delta with a cursor; token twins each with their own case. Flip every route of this task and raise expectedPortedRoutes.
|
||||||
|
|
||||||
|
(6) Smoke tests: `TestCoverImportAfterCommit` (injected Fetch: one success, one failure recorded; a fetch panic or timeout leaves the album committed), `TestManualCoverReasons` (every reason maps to PHP's text; private IPv4, IPv6 loopback and a NAT64-embedded private address refused by fetchguard), `TestAlbumPhotoUpload` (row, blob, thumb, webp accepted, polyglot rejected, 10240 KB plus one byte rejected), `TestBulkSingleSummaryEvent` (rollback emits nothing), `TestRatingUpsertConcurrent`, `TestAlbumValueFormatting` (php -r pinned values).</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCoverImportAfterCommit|TestManualCoverReasons|TestAlbumPhotoUpload|TestBulkSingleSummaryEvent|TestRatingUpsertConcurrent|TestAlbumValueFormatting)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestBroadcastGoldens|TestParityCorpus)$' -count=1 -v && scripts/check-phase10.sh --self-test && scripts/check-phase10.1.sh --self-test && scripts/check-phase11.sh --self-test</automated>
|
||||||
|
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestBroadcastGoldens/updated, TestBroadcastGoldens/bulk, TestCoverImportAfterCommit and "--- PASS: TestParityCorpus/coverage"; a gate self-test reports a failed detector.</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c 'PublicOnly' ../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go` and `grep -c 'AllowHosts' ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go` each print at least 1.
|
||||||
|
- `grep -c 'Provide exactly one of file or cover_url.' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go` prints 1.
|
||||||
|
- `grep -n 'throttle:20,1' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` matches only the JWT photos route and `grep -n 'throttle:60,1' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` only the sync route.
|
||||||
|
- `grep -n 'APP_PENDING_SKIPS=' scripts/check-phase10.sh scripts/check-phase10.1.sh` and `grep -n 'GOLDEN_SKIPS=' scripts/check-phase11.sh` show empty lists.
|
||||||
|
- The upload fixtures carry `parts:` with sha256 and their url/thumb_url bodies replay through the tide upload mask.
|
||||||
|
- `grep -c 'cover_max_bytes' ../fonoteka.go/README.md` prints at least 1.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The whole album management surface the Albums UI uses is ported with PHP's bodies, single-event broadcasting, guarded cover fetching and exact upload handling, and all four album broadcast goldens are assertions.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Users search their albums and look up artists, styles and genres, and the Nuxt albums journey replays end to end</name>
|
||||||
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_search_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/lookups_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/nuxt/nuxt-albums.yaml, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/README.md</files>
|
||||||
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSearchService.php (whole file), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (search rules, RATING_FILTERS, SORTS), /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Builder.php (paginate, getTotalCount lines 533-556), /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Engines/TypesenseEngine.php (maxPerPage, performPaginatedSearch), /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Searchable.php (queryScoutModelsByIds), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/ArtistApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/StyleApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/GenreApiController.php (store), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/PolishOrder.php, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useAlbumsQuery.ts, useBulkAlbumTape.ts, useRealtimeSync.ts (request order for the flow), ../fonoteka.go/plugins/golem15/fonoteka/search.go (settingsGate), ../fonoteka.go/plugins/golem15/fonoteka/models/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (aggregate and Polish ordering precedent), ../fonoteka.go/parity/fonoteka_flows_test.go (12-03 flow test), ../fonoteka.go/parity/genres_seed_test.go and ../fonoteka.go/parity/README.md (the temporary genres seed hook note)</read_first>
|
||||||
|
<action>(1) album_search.go `SearchAlbums` (D-16, D-17, D-19): validation as PHP search (rating in RATING_FILTERS, sort in SORTS, dir asc|desc, format in AlbumFormats, medium in mediums, artist nullable|integer|exists:golem15_fonoteka_artists,id, decade nullable|integer|between:1880,2100 plus the multiple-of-ten CustomRule with PHP's literal message, genre and style nullable|integer). Typesense path conditions as resolveSort/searchWithScout: settingsGate on, engine Configured, q non-empty or as PHP requires, sort not in rating/name/artist/price and no rating filter. Query: Q, QueryBy `name, artist_display, style_names, genre_name, track_titles, notes, label, catalog_number` (PHP order, not models.AlbumSearchQueryBy), QueryByWeights 10,10,5,5,3,1,3,3, FilterBy `collection_id:=[<id>]` joined with genre/style/format/medium/artist/decade filters by ` && ` exactly as PHP, SortBy `_text_match:desc,` prefix when q is set then `year` or `created_at` with dir, Page and PerPage. Items: SQL `id IN ids` plus AlbumsAccessibleBy(user, token) plus collection_id = active plus the same filters plus embeds, ordered by engine position. Total (Scout v10.25.0): when len(page ids) is below found, fetch min(found, 1000) ids with SearchPage at per_page found (if under 250) or pages of 250 up to 1000, then COUNT the re-gated rows; else count the re-gated page ids; last_page = max(ceil(total/per_page), 1). Engine error: slog warning without query text, then the SQL path. SQL path: text search ILIKE with `%`, `_` and `\` escaped (ESCAPE '\') over name, notes, artist_display, track_titles, label, catalog_number OR EXISTS artists.name, filters, rating filter joined to the caller's rating, sorts with pl-x-icu collation for name and artist, rating and price sorts as PHP, id tiebreak always, lagoon pagination envelope.
|
||||||
|
|
||||||
|
(2) album_search_controller.go `AlbumsSearch` on JWT and token (read). Replays use search disabled (D-12): the parity config keeps search_use_typesense off.
|
||||||
|
|
||||||
|
(3) lookups_controller.go: `ArtistsIndex` (aggregate with album_count over the active collection's accessible albums, PolishOrder by artists.name with pl-x-icu and id tiebreak, PHP validation if any), `StylesIndex` (orderBy name with collation decision documented and id tiebreak), `StylesStore` and `GenresStore` (PHP validation and bodies, including the existing-name branch). Mount JWT and token twins (index read, store write). The temporary `genres` seed hook stays: it now seeds identities for ported routes; update the parity README sentence that promised to delete it once POST genres is ported, explaining why it stays.
|
||||||
|
|
||||||
|
(4) Flow (D-08): record `nuxt-albums` from PHP with a request spec in the composables' order (create without and with cover_urls using a network-free failing URL, update, rate, unrate, photo upload multipart and delete, search with q and filters, stats, value, missing, sync full then delta, bulk, delete) and add subtest `nuxt-albums` to TestFonotekaNuxtFlows replaying it against Go on the `fonoteka` seed.
|
||||||
|
|
||||||
|
(5) Recordings: search (q match, no match, each filter, each sort, rating filter, per_page clamp, 422 for decade 1995, unknown artist id, bad sort), token search pinned; artists, styles GET (jwt and token), styles POST 201/200-existing/422, genres POST 201/existing/422 (jwt and token). Flip all remaining Phase 12 routes; expectedPortedRoutes becomes 99. Confirm with check_corpus that no Phase 12 route of CONTEXT's domain list is still pending and that the D-04 Discogs routes and every wishlist/public/onboarding route are still pending.
|
||||||
|
|
||||||
|
(6) Smoke tests: `TestAlbumSearchSQLEscaping` (q `50%` and `a_b` match literally), `TestAlbumSearchTypesenseRecount` (fake engine registered through beachcomber.RegisterEngine with scripted ids and found: a stale id shortens the page and is not counted; found 3000 caps the recount at 1000; an engine error falls back to SQL with a warning).</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAlbumSearchSQLEscaping|TestAlbumSearchTypesenseRecount)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows|TestParityCorpus|TestBroadcastGoldens)$' -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</automated>
|
||||||
|
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestFonotekaNuxtFlows/nuxt-albums", "--- PASS: TestFonotekaNuxtFlows/nuxt-collections" and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret or an unrecorded route.</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows the value 99.
|
||||||
|
- `grep -c '10,10,5,5,3,1,3,3' ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go` prints at least 1 (or the weights appear as the equivalent int slice with a comment naming PHP's string).
|
||||||
|
- `grep -c '1000' ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go` prints at least 1 (Scout max_total_results cap).
|
||||||
|
- `grep -c "ESCAPE" ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go` prints at least 1.
|
||||||
|
- The manifest still marks `POST /api/v1/fonoteka/albums/{id}/cover-price/discogs personal_token`, `POST /_fonoteka/api/v1/albums/recognize jwt` and every `wishlist/` route as pending.
|
||||||
|
- `test -f ../fonoteka.go/parity/fixtures/nuxt/nuxt-albums.yaml` succeeds.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Search, lookups and the full albums journey match PHP; every Phase 12 route is ported and the corpus stands at 99 ported routes.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Caller → album ids in paths and bulk bodies | Ids and bodies are untrusted; tenancy is server-resolved |
|
||||||
|
| User-supplied URLs → outbound fetch | cover_urls and cover_url make the server fetch remote content |
|
||||||
|
| Uploaded bytes → blob storage and decoders | Photos are stored and thumbnailed |
|
||||||
|
| Search engine → SQL | Engine ids and found counts are candidates only |
|
||||||
|
| Write transaction → Centrifugo and notifications | Album data and notifications leave for subscribers |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-12-33 | Information Disclosure | albums/{id}, photos/{fileId}, rating | high | mitigate | AlbumsAccessibleBy plus collection_id = active on every lookup; photo lookup scoped to the album's attachments; one 404 body for foreign and missing (Tasks 1-2). |
|
||||||
|
| T-12-02 | Information Disclosure | albums/search items and meta.total | high | mitigate | Every engine id re-gated in SQL; total recounted in SQL over at most 1000 ids (D-19); engine errors fall back to SQL (Task 3; leak suite in 12-05). |
|
||||||
|
| T-12-34 | Elevation of Privilege | token pin on albums, stats, value, search, lookups | high | mitigate | Resolve honours the pin; AlbumsAccessibleBy narrows; sync is JWT only (Tasks 1-3). |
|
||||||
|
| T-12-09 | Tampering / Information Disclosure | cover_urls and cover_url fetches (SSRF) | high | mitigate | fetchguard AllowHosts discogs.com for imports and PublicOnly for manual URLs: dial-time private-address refusal, no redirects, byte cap, timeout; https only; never inside the write tx (Task 2). |
|
||||||
|
| T-12-10 | Tampering / Denial of Service | photo upload | high | mitigate | body limit, max:10240 rule, ImageContentGuard sniff and decode, throttle:20,1 on JWT (Task 2). |
|
||||||
|
| T-12-11 | Tampering | mass assignment on albums and bulk | high | mitigate | AlbumFillFields only; collection_id and market_price_source server-controlled; fuzz in 12-05 (Tasks 1-2). |
|
||||||
|
| T-12-23 | Information Disclosure | album_added notifications and broadcasts | medium | mitigate | Recipients are the collection's owner and editors minus the actor; broadcasts only on the kind=collection channel; published after commit (Task 1). |
|
||||||
|
| T-12-24 | Denial of Service | search recount and bulk size | medium | mitigate | Recount capped at 1000 ids in pages of 250; bulk bounded by body limits and validation; cover imports capped at max_covers per album (Tasks 2-3). |
|
||||||
|
| T-12-SC | Tampering | package installs | low | accept | No new dependency in this plan (golang.org/x/image arrived in 12-01). |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; `go vet ./... && go test ./...` green in summercms.go (gate scripts only changed there).
|
||||||
|
- Parity corpus at 99 ported and passing; TestFonotekaNuxtFlows (both flows) and TestBroadcastGoldens (all four) pass; check_corpus --require-recorded --check-secrets green.
|
||||||
|
- `scripts/check-phase10.sh --self-test`, `scripts/check-phase10.1.sh --self-test`, `scripts/check-phase11.sh --self-test` pass.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- All 36 album and lookup routes in Phase 12 scope are ported on the right groups with PHP bodies.
|
||||||
|
- One broadcast per store/update, one summary per bulk, household notifications on create, covers imported after commit through fetchguard.
|
||||||
|
- Search re-gates items and total; the Nuxt albums journey replays green.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-p-ytarium-api-collections-and-albums/12-04-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
phase: 12-p-ytarium-api-collections-and-albums
|
||||||
|
plan: 05
|
||||||
|
type: execute
|
||||||
|
wave: 5
|
||||||
|
depends_on: ["12-04"]
|
||||||
|
files_modified:
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/search_leak_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/fake_engine_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_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/phase12_security_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/phase12_classes_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_helpers_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/search_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/phase12_controllers_test.go
|
||||||
|
- ../fonoteka.go/plugins/golem15/user/classes/user_groups_test.go
|
||||||
|
- ../fonoteka.go/parity/check_corpus_test.go
|
||||||
|
- modules/lagoon/validate_request_test.go
|
||||||
|
- modules/lagoon/validate_rules_test.go
|
||||||
|
- modules/lagoon/attach/url_test.go
|
||||||
|
- modules/tide/multipart_test.go
|
||||||
|
- modules/tide/normalize_upload_test.go
|
||||||
|
- modules/beachcomber/searchpage_test.go
|
||||||
|
- modules/beachcomber/typesense/searchpage_test.go
|
||||||
|
- scripts/check-phase12.sh
|
||||||
|
- .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
|
||||||
|
- .planning/REQUIREMENTS.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [API-01, API-02]
|
||||||
|
estimate:
|
||||||
|
tokens: 280000
|
||||||
|
raw_tokens: 280000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Per D-18 and ROADMAP SC-3, with a scripted fake engine (no Typesense container) returning poisoned ids through beachcomber, GET albums/search returns none of: (1) a stale document whose album moved to a collection the caller cannot access, (2) a mis-scoped document carrying the caller's collection_id for a foreign album, (3) a soft-deleted album, (4) an album of a collection the caller was removed from as editor, (5) for a personal token pinned to one collection, hits from the user's other collections; on both auth groups."
|
||||||
|
- "Per D-19, the same leak test asserts meta.total and last_page never count a poisoned id (re-gated recount), found above 1000 caps the recount at 1000, and the inflated-found quirk of D-16 point 3 does not exist."
|
||||||
|
- "Per D-10 and D-26, a route-table test over the assembled router asserts that every `/api/v1/fonoteka` route carries exactly one `inv.scope:<read|write|ai>` equal to routes.php for that route, that switch, share, me/context, realtime/channels, household, invitations and sync are absent from the token group, that every `{id}`, `{fileId}` and `{collectionId}` path parameter has a `[0-9]+` constraint, and that the D-01 inline throttles sit on exactly their routes."
|
||||||
|
- "Per C-02 and ROADMAP SC-5, `FuzzWriteEndpoints` enumerates every write route of this phase from the route table (POST, PUT, DELETE, multipart included) and, for random extra keys and every server-owned key (id, owner_id, collection_id, kind, public_token, public_enabled, token_hash, user_id, market_price_source when not requested, created_at, updated_at, deleted_at, organisation_id), asserts no column outside the endpoint's allow-list changes in Postgres; its seed corpus runs in plain `go test`."
|
||||||
|
- "Each T-12-01..T-12-34 threat with disposition mitigate has a named test that fails when its protection is removed; `scripts/check-phase12.sh --removal` applies anchor-exact mutations to the protecting code, requires the named test to fail on an assertion, and restores the file byte for byte."
|
||||||
|
- "Every Go package created or changed in Phase 12 in both repos reaches at least 80% statement coverage under `go test -cover` (framework: lagoon request validation, lagoon/attach, tide, beachcomber, beachcomber/typesense; app: fonoteka classes, controllers/api, the user plugin classes and updates), with the per-package numbers recorded in 12-VALIDATION.md."
|
||||||
|
- "`scripts/check-phase12.sh --all` runs vet and tests in both repos, the parity corpus (99 ported, 0 failing), TestBroadcastGoldens, TestFonotekaNuxtFlows, check_corpus --require-recorded --check-secrets, TestDocsTree, the named tests by exact name (refusing skips and 'no tests to run'), the coverage floors and the evidence files, and `--self-test` proves each detector fails closed."
|
||||||
|
- "12-SECURITY-REVIEW.md maps every T-12 threat to its disposition, file and passing test; 12-VALIDATION.md has real task ids in its Per-Task Verification Map, no pending rows, and `nyquist_compliant: true`."
|
||||||
|
- "Edge (API-01 empty and API-02 boundary): table tests cover empty bodies on every write endpoint, the year, rating, cover_urls, per_page and file-size boundaries one step either side, and empty search pages."
|
||||||
|
- statement: "Edge (API-02 concurrency): fuzz and race runs over the write endpoints under `-race` show no data race in handlers or helpers; delivery order of separate broadcast jobs is not guaranteed, as with PHP's queued jobs."
|
||||||
|
verification: backstop
|
||||||
|
artifacts:
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/search_leak_test.go"
|
||||||
|
provides: "TestSearchLeak with five poisoned cases and total assertions on both groups"
|
||||||
|
contains: "TestSearchLeak"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go"
|
||||||
|
provides: "FuzzWriteEndpoints enumerated from the route table"
|
||||||
|
contains: "FuzzWriteEndpoints"
|
||||||
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go"
|
||||||
|
provides: "TestRouteTablePhase12 scopes, absences, constraints, throttles"
|
||||||
|
- path: "scripts/check-phase12.sh"
|
||||||
|
provides: "fail-closed Phase 12 gate with --self-test, --named, --removal, --coverage, --all"
|
||||||
|
- path: ".planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md"
|
||||||
|
provides: "threat-to-test evidence"
|
||||||
|
key_links:
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/search_leak_test.go"
|
||||||
|
to: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go"
|
||||||
|
via: "fake engine registered with beachcomber.RegisterEngine and selected by search.driver"
|
||||||
|
pattern: "RegisterEngine"
|
||||||
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go"
|
||||||
|
to: "summercms.go modules/surf/router.go"
|
||||||
|
via: "surf.BuildRouter(...).Routes() enumerates write routes"
|
||||||
|
pattern: "Routes\\(\\)"
|
||||||
|
- from: "scripts/check-phase12.sh"
|
||||||
|
to: ".planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md"
|
||||||
|
via: "evidence stage refuses pending or TBD rows"
|
||||||
|
pattern: "12-VALIDATION.md"
|
||||||
|
prohibitions:
|
||||||
|
- requirement_id: API-02
|
||||||
|
category: privacy
|
||||||
|
statement: "The leak test MUST NOT pass by disabling search or by asserting only status codes; it asserts the absence of each poisoned album in data and in meta.total"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
- requirement_id: API-01
|
||||||
|
category: transparency
|
||||||
|
statement: "A threat MUST NOT be marked mitigated in the security review without a named test that was run and seen to fail when the protection is removed"
|
||||||
|
status: resolved
|
||||||
|
verification: test
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Goal
|
||||||
|
|
||||||
|
ROADMAP Phase 12 goal (verbatim, not in user-story form): Collections and Albums endpoints are ported with byte-compatible request/response shapes, including active-context switching, editor invitations, ratings, reservations, cover handling and search. (12-01 Task 4 rewords it per D-03/D-04/D-06.)
|
||||||
|
|
||||||
|
This plan's slice: the project rule's last plan. It proves the phase's security properties (search leak, token scopes, mass assignment, the T-12 threats), brings full unit coverage to the Phase 12 code in both repos, adds the fail-closed Phase 12 gate, and signs off security and validation (API-01, API-02; ROADMAP SC-3, SC-5).
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Write the D-18 leak test with D-19 total assertions, the route-table scope tests, the request-DTO fuzz over every write endpoint, failing-when-broken tests for every T-12 threat, full unit coverage for the phase's packages, `scripts/check-phase12.sh`, 12-SECURITY-REVIEW.md and the validated 12-VALIDATION.md.
|
||||||
|
|
||||||
|
Purpose: CLAUDE.md lean rule 3 (unit tests are always the last plan) and the security-review requirement for a phase touching authorization, public tokens and uploads. Decisions covered: D-10, D-18, D-19, D-26, C-02, and the evidence for every other D-NN through named tests.
|
||||||
|
Output: tests in both repos, the gate script, the security review, the validated validation file, and the API-01/API-02 traceability update.
|
||||||
|
|
||||||
|
Repos: summercms.go (framework tests, gate script, planning docs) and fonoteka.go (app tests). Production code changes only where a test exposes a real bug; each such fix is its own commit naming the threat or decision. Planning docs and code in separate commits. Never add co-author tags.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-01-SUMMARY.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-03-SUMMARY.md
|
||||||
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-04-SUMMARY.md
|
||||||
|
@scripts/check-phase11.sh
|
||||||
|
@.planning/phases/11-jobs-realtime-and-search-infrastructure/11-SECURITY-REVIEW.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
- From 12-01..12-04 (see their SUMMARY files for final names): `lagoon.ValidateRequest`, tide `Parts` and upload masks, `beachcomber.SearchPage`, `beachcomber.RegisterEngine(name, EngineFactory)`, attach `URL`/`PublicURL`; app `classes.Resolve`, `AccessibleBy`, `AlbumsAccessibleBy`, `SearchAlbums`, `CreateAlbum`, `UpdateAlbum`, `SendInvitation`, `AcceptInvitation`, `RemoveEditor`, `GenerateShareToken`, `CollectionKey`, `IsAllowedImage`, `CoverImporter`, `FetchManualCover`; handlers and routes listed in the plans' Artifacts sections; seed hook `fonoteka`; `TestFonotekaNuxtFlows`.
|
||||||
|
- Route table: `rt, _ := surf.BuildRouter(app, plugins); rt.Routes()` returns entries with Method, Pattern, Middleware []string, Raw (see routes_isolation_test.go TestFullRouteTableAuthGroupMutualExclusivity for the boot recipe: bootConfig, bouncer.NewRegistry, party.Activate of golem15.user and golem15.fonoteka, stubInvToken).
|
||||||
|
- PHP route contract for scopes and throttles: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (JWT group lines 74-330, token group 450-520).
|
||||||
|
- Gate precedent: scripts/check-phase11.sh (stages, --self-test detectors, --named exact-name runs that refuse skip and no-tests, --removal anchor-exact mutation with byte-identical restore via cmp, evidence stage refusing pending rows) and 11-SECURITY-REVIEW.md (threat table with test names).
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
(This plan's share.)
|
||||||
|
|
||||||
|
- Tests: `TestSearchLeak` (subtests stale-moved, mis-scoped, soft-deleted, removed-editor, token-pin, total-cap, both groups), `TestRouteTablePhase12`, `FuzzWriteEndpoints` with a committed seed corpus, `TestPhase12Threats` (one subtest per T-12 id), package-level unit tests in both repos.
|
||||||
|
- Gate: `scripts/check-phase12.sh` with `--self-test`, `--go`, `--parity`, `--named`, `--removal`, `--coverage`, `--evidence`, `--all`.
|
||||||
|
- Docs: 12-SECURITY-REVIEW.md, validated 12-VALIDATION.md, REQUIREMENTS.md traceability rows API-01 and API-02 set to Complete.
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1: A poisoned search index cannot leak an album or a count to any caller, proven through the real search route on both auth groups</name>
|
||||||
|
<precondition>Plan 12-04 is executed: `go -C ../fonoteka.go doc ./plugins/golem15/fonoteka/classes SearchAlbums` exits 0 and the parity corpus reports 99 ported routes.</precondition>
|
||||||
|
<files>../fonoteka.go/plugins/golem15/fonoteka/fake_engine_test.go, ../fonoteka.go/plugins/golem15/fonoteka/search_leak_test.go</files>
|
||||||
|
<read_first>../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_search_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/search.go (settingsGate), ../fonoteka.go/plugins/golem15/fonoteka/search_smoke_test.go and album_search_test.go (existing search harness), ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go (assembled router recipe), ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go (bootConfig, bootDB), summercms.go modules/beachcomber/engines.go (RegisterEngine), summercms.go modules/beachcomber/searchable.go, /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Builder.php (lines 533-556), .planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md (D-16, D-18, D-19)</read_first>
|
||||||
|
<action>(1) fake_engine_test.go: a test-only engine registered under a unique driver name with beachcomber.RegisterEngine in the test's init or TestMain (never in production code), implementing Engine and PageSearcher; each test scripts the ids (in order), found, and errors per call, and records every Query received (Q, QueryBy, QueryByWeights, FilterBy, SortBy, Page, PerPage) so tests can assert PHP's query_by order, weights and the collection_id filter.
|
||||||
|
|
||||||
|
(2) search_leak_test.go `TestSearchLeak` through the assembled router with search_use_typesense on, search.driver set to the fake, Postgres seeded with alice, bob, outsider and their collections, and a q that forces the Typesense path. Subtests, each asserting the response `data` ids and `meta.total`/`last_page` exactly: stale-moved (an album of alice moved to outsider's collection still returned by the engine with alice's filter: absent and not counted), mis-scoped (outsider's album id returned for alice's collection_id: absent, not counted), soft-deleted (alice's soft-deleted album id: absent, not counted), removed-editor (bob removed as editor of alice's collection, engine still returns alice's album ids for bob's request on his resolved collection: absent, not counted), token-pin (alice's token pinned to collection A, engine returns ids from alice's collection B: absent, not counted on the token group), short-page (page of 3 with one poisoned id returns 2 rows and total 2 when found equals 3), recount (found 300 over a per_page of 20: the second SearchPage calls use per_page 250 and the total is the re-gated count), cap (found 3000: no call asks beyond 1000 ids and total is at most 1000), engine-error (the fake errors: a warning is logged and the SQL path answers with only accessible rows). Run every case on both `/_fonoteka/api/v1/albums/search` and `/api/v1/fonoteka/albums/search` (read token). Assert the recorded Query has QueryBy in PHP order and weights 10,10,5,5,3,1,3,3.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^TestSearchLeak$' -count=1 -race -v</automated>
|
||||||
|
<fails_when>Non-zero exit; output prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestSearchLeak/token-pin", "--- PASS: TestSearchLeak/cap" and "--- PASS: TestSearchLeak/removed-editor".</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c 't.Run(' ../fonoteka.go/plugins/golem15/fonoteka/search_leak_test.go` prints at least 9.
|
||||||
|
- `grep -c 'meta' ../fonoteka.go/plugins/golem15/fonoteka/search_leak_test.go` prints at least 1 and every subtest compares total, not only data.
|
||||||
|
- `grep -rln 'RegisterEngine' ../fonoteka.go/plugins/golem15/fonoteka --include=*.go` lists only files whose names end in `_test.go` (the fake stays test-only).
|
||||||
|
- Temporarily removing the AlbumsAccessibleBy scope from the re-gate query makes at least the stale-moved, mis-scoped and removed-editor subtests fail (checked by `scripts/check-phase12.sh --removal` in Task 3).
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Success criterion 3 is proven through the real route: no poisoned index entry can surface an album or a count to a JWT user or a pinned token.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Every token route carries PHP's one scope, every write endpoint ignores server-owned keys, and every T-12 protection has a test that breaks without it</name>
|
||||||
|
<files>../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_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/phase12_security_test.go</files>
|
||||||
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (both groups), ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_fuzz_test.go and collection_write_service_fuzz_test.go (P5 service-level fuzz precedent), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go (AlbumFillFields), ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go (CollectionFillFields), the 12-02, 12-03 and 12-04 threat registers in their PLAN.md files, .planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md (Security Domain)</read_first>
|
||||||
|
<behavior>
|
||||||
|
- TestRouteTablePhase12: a token route with zero or two inv.scope entries fails; a scope that differs from routes.php fails; any of switch, collection/share, me/context, realtime/channels, household/*, invitations/*/accept, albums/sync on /api/v1/fonoteka fails; an unconstrained {id} fails; throttle:10,1 exactly on switch, share regenerate, invitation store and resend; throttle:20,1 exactly on the JWT album photo upload; throttle:60,1 exactly on sync.
|
||||||
|
- FuzzWriteEndpoints: for each enumerated write route, a request body made of a valid base plus fuzzed extra keys and values never changes owner_id, collection_id, kind, public_token, public_enabled, token_hash, user_id or timestamps beyond what the endpoint sets itself, and never panics (500 is a failure).
|
||||||
|
- TestPhase12Threats: one subtest per mitigated T-12 id asserting the protection (IDOR 404 parity for foreign and missing ids on every id route, token pin on stats/value/search/artists/genres, editor 404 on owner-only routes, accept 410 for reused, expired, revoked and wrong-email tokens, share token alphabet and uniqueness, SSRF refusals for private and redirecting URLs, polyglot upload refusal, raw invite token absent from river_job.args and captured logs, provisioning race, Winter pages without debug text, notification recipients).
|
||||||
|
</behavior>
|
||||||
|
<action>(1) routes_table_phase12_test.go `TestRouteTablePhase12`: boot the assembled router as routes_isolation_test.go does; hold an explicit table of the 66 Phase 12 routes plus genres and me with method, pattern, group, expected scope (token group) and expected throttle, transcribed from routes.php with the line number in a comment per entry; assert every expectation in the behavior list and that the table matches the router (no missing and no unexpected Phase 12 route).
|
||||||
|
|
||||||
|
(2) write_endpoints_fuzz_test.go `FuzzWriteEndpoints`: enumerate POST/PUT/DELETE routes from rt.Routes() under the two Phase 12 prefixes (exclude routes outside this phase by an explicit allow-list of Phase 12 patterns), give each a valid base body (JSON or multipart with a tiny PNG) and the set of columns it may change, then for fuzz input add keys and values (including every server-owned key name, nested objects, arrays, numbers as strings, very long strings) and assert, by snapshotting the affected rows before and after in Postgres, that only allowed columns changed and that the response is not 500. Commit a seed corpus under testdata/fuzz/FuzzWriteEndpoints/ so plain `go test` runs every route with the server-owned keys. Reset the DB state per iteration with a savepoint or a fresh seed.
|
||||||
|
|
||||||
|
(3) phase12_security_test.go `TestPhase12Threats` with subtests named by threat id (T-12-01 ... T-12-34 for every mitigated threat in plans 12-01 to 12-04) implementing the behavior list. Use the fake Centrifugo/memory drivers, the postcard memory driver, and a slog handler capturing logs. Any genuine bug found is fixed in production code in its own commit naming the threat.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestRouteTablePhase12|FuzzWriteEndpoints|TestPhase12Threats)$' -count=1 -race -v && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^$' -fuzz '^FuzzWriteEndpoints$' -fuzztime 60s</automated>
|
||||||
|
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestRouteTablePhase12", "--- PASS: FuzzWriteEndpoints" and "--- PASS: TestPhase12Threats"; the fuzz run prints "Failing input written to".</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c 'routes.php' ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go` prints at least 1 and the expectation table has an entry per Phase 12 route.
|
||||||
|
- `ls ../fonoteka.go/plugins/golem15/fonoteka/testdata/fuzz/FuzzWriteEndpoints/` lists at least one seed file per enumerated write route.
|
||||||
|
- `grep -c 't.Run("T-12-' ../fonoteka.go/plugins/golem15/fonoteka/phase12_security_test.go` prints at least 15.
|
||||||
|
- The fuzz target fails when CollectionFillFields temporarily gains `owner_id` (checked by `--removal` in Task 3).
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The token surface, the mass-assignment boundary of every write endpoint and every Phase 12 threat are pinned by tests that fail when the protection is removed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Phase 12 code is fully unit tested in both repos and a fail-closed gate, the security review and the validation file sign it off</name>
|
||||||
|
<files>modules/lagoon/validate_request_test.go, modules/lagoon/validate_rules_test.go, modules/lagoon/attach/url_test.go, modules/tide/multipart_test.go, modules/tide/normalize_upload_test.go, modules/beachcomber/searchpage_test.go, modules/beachcomber/typesense/searchpage_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/phase12_classes_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_helpers_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/search_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/phase12_controllers_test.go, ../fonoteka.go/plugins/golem15/user/classes/user_groups_test.go, ../fonoteka.go/parity/check_corpus_test.go, scripts/check-phase12.sh, .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, .planning/REQUIREMENTS.md</files>
|
||||||
|
<read_first>scripts/check-phase11.sh (whole script: stages, detectors, --named, --removal, evidence), .planning/phases/11-jobs-realtime-and-search-infrastructure/11-SECURITY-REVIEW.md, .planning/phases/11-jobs-realtime-and-search-infrastructure/11-VALIDATION.md (validated map format), .planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md, the four Phase 12 SUMMARY files (final test names and files), modules/phrasebook/lang/pl/validation.yaml, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Concerns/ValidatesAttributes.php</read_first>
|
||||||
|
<action>(1) Framework unit coverage (summercms.go, neutral names): validate_rules_test.go table over every supported rule with pass, fail, absent, null-with-nullable, blank-string and type-variant cases, every size message variant, wildcard expansion with nested arrays and missing parents, bail and implicit stop, custom rules, catalog fallback pl to en, the min/max/between fix; attach URL and webp edge cases; tide multipart (empty parts, value-only parts, unicode filenames, missing file, sha mismatch, Body plus Parts) and upload-mask negatives (wrong prefix, partition, size, mode); beachcomber fallback, weights length mismatch, per_page above 250, found decoding errors.
|
||||||
|
|
||||||
|
(2) App unit coverage (fonoteka.go): classes (resolver branches including a pin of zero, two ids, a non-integer and an inaccessible id; provisioner; gates matrix; share service; fingerprint against a php -r computed value; invitation normalization boundaries; notification payloads; album helpers: tracklist parser lines and 201-line refusal, added-date formats and MIN_DATE, duplicate matcher Polish folding, completeness tags, barcode normalization; cover importer and manual fetcher with injected fetchers; search SQL escaping and filter strings; sync cursor encode/decode and malformed cursors; stats, value formatting and missing counters), controllers/api helpers (decodeInput for JSON, form, multipart and empty bodies; phpInt; laravelBoolean; writeWinterHTTPError for 404, 409, 410 with app.url variants; writeValidationFailed), user plugin UserGroupCodes and HasGroupCode, check_corpus's invitation-token rule.
|
||||||
|
|
||||||
|
(3) scripts/check-phase12.sh modelled on check-phase11.sh: stages `--go` (vet and test both repos), `--parity` (TestParityCorpus with 99 ported and 0 failing parsed from the coverage line, TestBroadcastGoldens all four passing, TestFonotekaNuxtFlows both subtests, check_corpus --require-recorded --check-secrets), `--named` (every test named in 12-VALIDATION.md run by exact name with -run '^Name$', refusing skip, fail and "no tests to run"), `--removal` (anchor-exact mutations restored byte for byte with cmp: drop AlbumsAccessibleBy from the search re-gate, drop the token narrowing in AccessibleBy, drop the owner check in share, add owner_id to CollectionFillFields, drop the sha256 lookup in accept, swap PublicOnly for no policy in the manual fetcher, make the invitation args carry the plaintext token; each must make its named test fail on an assertion), `--coverage` (go test -coverprofile per listed package; refuse any package below 80% with its number), `--evidence` (12-SECURITY-REVIEW.md lists every T-12 id with a passing test, 12-VALIDATION.md has no pending or TBD row and nyquist_compliant true), `--all`, and `--self-test` proving each detector fails closed on planted inputs. It never names the application in framework-facing output beyond the paths it must call.
|
||||||
|
|
||||||
|
(4) Planning docs (separate commit): 12-SECURITY-REVIEW.md (reviewer note: self-performed by the executor if no reviewer agent can be spawned, per the 08-10 precedent; table of T-12-01..T-12-34 and T-12-SC with category, severity, disposition, protecting file and the test name that was run and seen failing under --removal); 12-VALIDATION.md: replace the seeded Per-Task Verification Map rows with the final rows (task ids 12-01-T1..12-05-T3, the exact commands each plan ran, file-exists ticks, statuses), tick Wave 0 items, set status validated, nyquist_compliant true, wave_0_complete true; REQUIREMENTS.md traceability rows API-01 and API-02 set to Complete and their checkboxes ticked.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>scripts/check-phase12.sh --self-test && scripts/check-phase12.sh --all</automated>
|
||||||
|
<fails_when>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 the evidence stage reporting a pending row or a T-12 id without a test.</fails_when>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `test -x scripts/check-phase12.sh` succeeds and `scripts/check-phase12.sh --self-test` exits 0.
|
||||||
|
- `grep -c 'nyquist_compliant: true' .planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md` prints 1 and `grep -c '| TBD |' .planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md` prints 0.
|
||||||
|
- Every T-12 id defined in 12-01..12-04 threat registers appears in 12-SECURITY-REVIEW.md (`grep -o 'T-12-[0-9]*' ... | sort -u` matches the union from the four plans).
|
||||||
|
- `grep -n 'API-01 | Phase 12' .planning/REQUIREMENTS.md` and `grep -n 'API-02 | Phase 12' .planning/REQUIREMENTS.md` show Complete.
|
||||||
|
- The coverage stage prints one line per listed package with a value of at least 80%.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Phase 12 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 test, and a validated validation file.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## 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 12-01 to 12-04 (T-12-01 to T-12-24 and T-12-28 to T-12-34): TestSearchLeak covers T-12-02 and T-12-28, TestRouteTablePhase12 covers T-12-03, T-12-04, T-12-31 and T-12-34, FuzzWriteEndpoints covers T-12-11 and T-12-30, and TestPhase12Threats has one subtest per remaining id. The rows below are the threats this plan itself introduces.
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-12-25 | Tampering | gate --removal leaving mutated source | medium | mitigate | Anchor-exact mutation, cmp byte-identical restore, trap on exit, refuse to run on a dirty tree (Task 3). |
|
||||||
|
| T-12-26 | Repudiation | security review claims without evidence | medium | mitigate | Evidence stage refuses a T-12 id without a named, executed, removal-proven test (Task 3). |
|
||||||
|
| T-12-27 | Information Disclosure | fuzz seed corpus | low | mitigate | Seeds use synthetic values only; check_corpus-style secret scan over testdata/fuzz in the gate (Task 3). |
|
||||||
|
| T-12-SC | Tampering | package installs | low | accept | No new dependency in this plan. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `scripts/check-phase12.sh --all` exits 0 and `--self-test` exits 0.
|
||||||
|
- Both repos: `go vet ./... && go test ./... -count=1` green; `go test ./cmd/summer -run TestDocsTree -count=1` green.
|
||||||
|
- 12-VALIDATION.md validated; 12-SECURITY-REVIEW.md complete; API-01 and API-02 Complete in REQUIREMENTS.md.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- ROADMAP SC-3 (search leak including total) and SC-5 (request-DTO fuzz over every write endpoint) are proven by named tests.
|
||||||
|
- Every Phase 12 package in both repos is at or above 80% coverage; the gate fails closed.
|
||||||
|
- Security review and validation sign-off are complete with real task ids.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-p-ytarium-api-collections-and-albums/12-05-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -534,9 +534,11 @@ func CollectionKey(appKey string, id uint) string {
|
|||||||
| A5 | The publication normalizer does not mask the album subtree's `created_at`/`updated_at` | Finding 5 | If it already does, there is less tide work |
|
| A5 | The publication normalizer does not mask the album subtree's `created_at`/`updated_at` | Finding 5 | If it already does, there is less tide work |
|
||||||
| A6 | `isSiteAdmin` can be false for everyone in Phase 12 because the Go user plugin has no `users_groups` | Open Q3 | `can_manage_org`/`can_use_ai`/`can_import_discogs` are wrong for site admins |
|
| A6 | `isSiteAdmin` can be false for everyone in Phase 12 because the Go user plugin has no `users_groups` | Open Q3 | `can_manage_org`/`can_use_ai`/`can_import_discogs` are wrong for site admins |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions (RESOLVED)
|
||||||
|
|
||||||
None of these is decided yet. Each needs the user's confirmation at the plan-count checkpoint.
|
All six were decided by the user at the plan-count checkpoint on 2026-10-02 and recorded in 12-CONTEXT.md:
|
||||||
|
Q1 → D-19 (Scout recount), Q2 → D-20 (port realtime/channels), Q3 → D-25 (option b: add user groups, signed off),
|
||||||
|
Q4 → D-22 (match PHP prefix), Q5 → D-24 (register webp), Q6 → D-23 (encrypt token in job args).
|
||||||
|
|
||||||
1. **D-16 correction (Finding 1).**
|
1. **D-16 correction (Finding 1).**
|
||||||
- What we know: installed Scout v10.25.0 recounts `total` in SQL over min(found, 1000) re-gated ids.
|
- What we know: installed Scout v10.25.0 recounts `total` in SQL over min(found, 1000) re-gated ids.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ created: "2026-10-02"
|
|||||||
| **Config file** | none (`parity/parity_test.go` TestMain starts Postgres) |
|
| **Config file** | none (`parity/parity_test.go` TestMain starts Postgres) |
|
||||||
| **Quick run command** | `cd fonoteka.go && go test ./plugins/golem15/fonoteka/... -short -count=1` |
|
| **Quick run command** | `cd fonoteka.go && go test ./plugins/golem15/fonoteka/... -short -count=1` |
|
||||||
| **Full suite command** | `cd fonoteka.go && go vet ./... && go test ./... -count=1`, plus `cd summercms.go && go vet ./... && go test ./... -count=1` for framework changes |
|
| **Full suite command** | `cd fonoteka.go && go vet ./... && go test ./... -count=1`, plus `cd summercms.go && go vet ./... && go test ./... -count=1` for framework changes |
|
||||||
| **Parity command** | `cd fonoteka.go && go test ./parity -run 'TestParityCorpus|TestBroadcastGoldens|TestNuxtFlow' -count=1` |
|
| **Parity command** | `cd fonoteka.go && go test ./parity -run 'TestParityCorpus|TestBroadcastGoldens|TestFonotekaNuxtFlows' -count=1` |
|
||||||
| **Estimated runtime** | ~180 seconds (full suite, both repos, with containers) |
|
| **Estimated runtime** | ~180 seconds (full suite, both repos, with containers) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -39,15 +39,26 @@ created: "2026-10-02"
|
|||||||
|
|
||||||
## Per-Task Verification Map
|
## Per-Task Verification Map
|
||||||
|
|
||||||
Seeded from RESEARCH.md by requirement; task IDs are filled in once PLAN.md files exist.
|
Task IDs are `<plan>-T<task>`. Framework commands run from `summercms.go`; application commands use `go -C ../fonoteka.go`. Plan 12-05 Task 3 replaces the Status and File Exists columns with run evidence and `scripts/check-phase12.sh --named` runs every named test by exact name.
|
||||||
|
|
||||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
| TBD | 01 | 1 | framework | — | beachcomber `found`/weights, tide multipart + URL mask, Laravel-semantics validator + catalogs, attach URL export | unit | `cd summercms.go && go test ./modules/... -count=1` | ❌ W0 | ⬜ pending |
|
| 12-01-T1 | 12-01 | 1 | API-01, API-02 | T-12-14, T-12-15 | Laravel 9 request validation (implicit stop, wildcards, size-typed messages, pl/en catalogs); lagoon.Validate min/between fix keeps user-api bodies | unit | `go test ./modules/lagoon -run '^(TestValidateRequest.*\|TestValidate.*Message.*)$' -count=1 -v` | ❌ W0 | ⬜ pending |
|
||||||
| TBD | 02 | 2 | API-01 | T-12-01, T-12-03, T-12-04, T-12-05 | Collections, switch, me/context, realtime/channels, share replays; token pin and narrowing | parity + integration | `go test ./parity -run TestParityCorpus/.*collection` | ❌ W0 | ⬜ pending |
|
| 12-01-T2 | 12-01 | 1 | API-01, API-02 | T-12-16, T-12-17 | Winter upload URLs (URL, PublicURL), webp decode, tide multipart parts with sha256, upload URL and publication date masks | unit | `go test ./modules/lagoon/attach ./modules/tide -run '^(TestFileURLWinterLayout\|TestThumbWebP\|TestMultipart.*\|TestNormalizeUploadURL.*\|TestNormalizePublication.*)$' -count=1 -v` | ❌ W0 | ⬜ pending |
|
||||||
| TBD | 03 | 3 | API-01 | T-12-06, T-12-07 | Invite mail enqueued in tx, absent on rollback; token encrypted in job args, never logged | integration + parity flow | `go test ./parity -run TestNuxtFlow/nuxt-collections` | ❌ W0 | ⬜ pending |
|
| 12-01-T3 | 12-01 | 1 | API-01, API-02 | T-12-28, T-12-18 | beachcomber SearchPage found and query_by_weights; user groups additive (user-api payload unchanged) | unit + integration | `go test ./modules/beachcomber/... -run '^(TestSearchPage.*\|TestTypesenseSearchPage.*)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/updates -run '^(TestUserGroups.*)$' -count=1 -v` | ❌ W0 | ⬜ pending |
|
||||||
| TBD | 04 | 4 | API-02 | T-12-02, T-12-09, T-12-10, T-12-11 | Album CRUD, rating, photos, bulk, stats/value/missing/sync, search, lookups; upload guard; SSRF guard | parity + integration | `go test ./parity -run 'TestParityCorpus/.*albums|TestBroadcastGoldens'` | ❌ W0 | ⬜ pending |
|
| 12-01-T4 | 12-01 | 1 | API-01, API-02 | — | ROADMAP/REQUIREMENTS reworded per D-03, D-04, D-06, D-19, D-20 | docs check | `grep -q 'realtime/channels' .planning/ROADMAP.md && grep -q 'realtime/channels' .planning/REQUIREMENTS.md` | ✅ | ⬜ pending |
|
||||||
| TBD | 05 | 5 | API-01, API-02 | T-12-01..T-12-11 | D-18 leak test (5 cases + total), request-DTO fuzz, route-table single-scope test | security + fuzz + unit | `go test ./plugins/golem15/fonoteka/... -run 'TestSearchLeak|TestRouteTable|FuzzWriteEndpoints' -count=1` | ❌ W0 | ⬜ pending |
|
| 12-02-T1 | 12-02 | 2 | API-01 | T-12-01, T-12-03, T-12-12 | Token-aware resolver, AccessibleBy narrowing, one-time provisioning under locks, collections list on both groups, per-route scopes (D-26) | integration + parity | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCollectionsIndexBothGroups\|TestResolveProvisionsOnce)$' -count=1 -race -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-02-T2 | 12-02 | 2 | API-01 | T-12-05, T-12-29, T-12-30, T-12-19 | Collection CRUD, per-album delete (D-26), photos and image uploads, switch with Winter 404 page | integration + parity | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCollectionDeleteRemovesAlbumsOneByOne\|TestCollectionPhotoUpload\|TestCollectionSwitchRefusals)$' -count=1 -race -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-02-T3 | 12-02 | 2 | API-01 | T-12-04, T-12-08, T-12-13 | me/context flags (site admin via groups), realtime/channels, owner-only share with crypto/rand token | integration + parity | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestMeContextFlags\|TestRealtimeChannelsName\|TestShareTokenAlphabet\|TestShareOwnerOnly)$' -count=1 -race -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-03-T1 | 12-03 | 3 | API-01 | T-12-06, T-12-07, T-12-22 | Invite mail job enqueued in tx with encrypted token, absent on rollback; accept adds editor and notification | integration + parity | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestInvitationMailEnqueuedInTx\|TestInvitationAcceptAddsEditor)$' -count=1 -race -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-03-T2 | 12-03 | 3 | API-01 | T-12-31, T-12-32, T-12-21 | Owner-only household management, member removal repairs context, pending-invitation 409 guard, single accept under concurrency | integration + parity | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestRemoveEditorRepairsContext\|TestPendingInvitationGuard\|TestConcurrentAcceptSingleEditor)$' -count=1 -race -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-03-T3 | 12-03 | 3 | API-01 | T-12-20 | nuxt-collections flow replay; ValidationException envelopes; no raw invitation token in the corpus | parity flow | `go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows\|TestParityCorpus\|TestCheckCorpus.*)$' -count=1 -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-04-T1 | 12-04 | 4 | API-02 | T-12-11, T-12-23 | Album create on both groups, single created event, album_added notifications, created golden asserted | integration + parity | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAlbumStoreSingleCreatedEvent\|TestAlbumAddedNotifiesHousehold\|TestAlbumWriteHelpersMatchPHP)$' -count=1 -race -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-04-T2 | 12-04 | 4 | API-02 | T-12-33, T-12-09, T-12-10, T-12-24 | Album CRUD, ratings, uploads with image guard, SSRF-guarded cover fetches after commit, bulk single summary, stats/value/missing/sync | integration + parity | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCoverImportAfterCommit\|TestManualCoverReasons\|TestAlbumPhotoUpload\|TestBulkSingleSummaryEvent\|TestRatingUpsertConcurrent\|TestAlbumValueFormatting)$' -count=1 -race -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-04-T3 | 12-04 | 4 | API-02 | T-12-02, T-12-34 | Search SQL escaping and Scout-exact recount, lookups, nuxt-albums flow, 99 ported routes | integration + parity | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAlbumSearchSQLEscaping\|TestAlbumSearchTypesenseRecount)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows\|TestParityCorpus\|TestBroadcastGoldens)$' -count=1 -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-05-T1 | 12-05 | 5 | API-02 | T-12-02, T-12-28 | D-18 leak test (5 cases) with D-19 total and cap on both groups | security | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^TestSearchLeak$' -count=1 -race -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-05-T2 | 12-05 | 5 | API-01, API-02 | T-12-01..T-12-34 | Route-table one-scope test (D-10, D-26), request-DTO fuzz over every write endpoint (C-02), one subtest per threat | security + fuzz | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestRouteTablePhase12\|FuzzWriteEndpoints\|TestPhase12Threats)$' -count=1 -race -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-05-T3 | 12-05 | 5 | API-01, API-02 | T-12-25, T-12-26, T-12-27 | Full unit coverage (80% floor per package), fail-closed gate with removal mutations, security review and validation sign-off | unit + gate | `scripts/check-phase12.sh --self-test && scripts/check-phase12.sh --all` | ❌ W0 | ⬜ pending |
|
||||||
|
|
||||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
@@ -55,10 +66,10 @@ Seeded from RESEARCH.md by requirement; task IDs are filled in once PLAN.md file
|
|||||||
|
|
||||||
## Wave 0 Requirements
|
## Wave 0 Requirements
|
||||||
|
|
||||||
- [ ] Re-record the HttpException cases under `APP_DEBUG=false` (accept 410, switch 404, household/members 404, invitations 404, token 404) — D-21
|
- [ ] Re-record the HttpException cases under `APP_DEBUG=false` (accept 410, switch 404, household/members 404, invitations 404, guard 409) — D-21 (12-02-T2, 12-03-T1, 12-03-T2)
|
||||||
- [ ] tide request `body_file` (multipart) + `url`/`thumb_url` disk-name normalizer + publication date masking (framework)
|
- [ ] tide request multipart `parts` + `url`/`thumb_url` disk-name normalizer + publication date masking (framework) — 12-01-T2
|
||||||
- [ ] Seed hooks or flows for a second user and an outsider (reuse `id:outsider` from Phase 11)
|
- [ ] Seed hook `fonoteka` with alice, bob (editor), an outsider and personal tokens (reuse the `id:outsider` recipe from Phase 11) — 12-02-T1
|
||||||
- [ ] Fake `beachcomber` engine with scripted ids and `found` for D-18/D-19
|
- [ ] Fake `beachcomber` engine with scripted ids and `found` for D-18/D-19 — smoke in 12-04-T3, full suite in 12-05-T1
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Phase 12 API coverage
|
||||||
|
|
||||||
|
No external API integration: ports the app's own HTTP API; reuses Phase 11's Typesense search and Centrifugo publish; cover imports are HTTPS image downloads, not the Discogs API (Phase 14).
|
||||||
Reference in New Issue
Block a user