From 1f4e1e01c40a71621db1d9b6bfaa1a23aa266990 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 16:55:29 +0200 Subject: [PATCH] docs(12-05): sign off the Phase 12 security review, validation and API-01/API-02 - 12-SECURITY-REVIEW.md maps T-12-01..T-12-34 and T-12-SC to a named test and 25 removal checks, all seen failing with the protection removed - 12-VALIDATION.md validated with the final per-task map, nyquist_compliant - REQUIREMENTS.md: API-01 and API-02 complete --- .planning/REQUIREMENTS.md | 8 +- .../12-SECURITY-REVIEW.md | 101 ++++++++++++++++++ .../12-VALIDATION.md | 73 +++++++------ 3 files changed, 143 insertions(+), 39 deletions(-) create mode 100644 .planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 4ab4b38..b63b192 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -73,8 +73,8 @@ Requirements for v1 (the Płytarium port). Each maps to roadmap phases. "User" b ### Płytarium API (API) -- [ ] **API-01**: Collections: CRUD, active-context switch (me/context flags plus the opaque channel name from realtime/channels), editor invitations and acceptance, owner-only share link show/update/regenerate -- [ ] **API-02**: Albums: CRUD, ratings, photo upload and manual cover URL, Discogs cover import (cover_urls), artists/genres/styles lookups, search that treats Typesense as a pre-filter with both the items and the total re-gated in SQL +- [x] **API-01**: Collections: CRUD, active-context switch (me/context flags plus the opaque channel name from realtime/channels), editor invitations and acceptance, owner-only share link show/update/regenerate +- [x] **API-02**: Albums: CRUD, ratings, photo upload and manual cover URL, Discogs cover import (cover_urls), artists/genres/styles lookups, search that treats Typesense as a pre-filter with both the items and the total re-gated in SQL - [ ] **API-03**: Wishlist: items, subscriptions, public-wishlist/{token} views, album reservations (reserve/reveal), purchase and digest triggers - [ ] **API-04**: Notifications: list, mark read, prune; realtime token endpoint owned by the websockets plugin - [ ] **API-05**: CSV import as a multi-step session (store, show/poll, mapping patch, per-row edit, commit, cancel) and CSV export on both authenticated groups @@ -212,8 +212,8 @@ Which phases cover which requirements. Updated during roadmap creation. | AUTH-06 | Phase 8 | Complete | | AUTH-07 | Phase 8 | Complete | | AUTH-08 | Phase 9 | Complete | -| API-01 | Phase 12 | Pending | -| API-02 | Phase 12 | Pending | +| API-01 | Phase 12 | Complete | +| API-02 | Phase 12 | Complete | | API-03 | Phase 13 | Pending | | API-04 | Phase 13 | Pending | | API-05 | Phase 13 | Pending | diff --git a/.planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md b/.planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md new file mode 100644 index 0000000..0c36e58 --- /dev/null +++ b/.planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md @@ -0,0 +1,101 @@ +--- +phase: "12" +reviewed: "2026-10-02" +reviewer: "gsd-executor, plan 12-05 (self-performed code-and-test review of plans 12-01 to 12-05; no reviewer agent was spawned, per the 08-10 precedent)" +threats_open: 0 +gate: "scripts/check-phase12.sh --all" +removal_harness: "scripts/check-phase12.sh --removal" +--- + +# Phase 12 Security Review + +This is a code-and-test review of every threat in the registers of Plans 12-01 to 12-05 (T-12-01 to T-12-34 and T-12-SC). Severity and disposition are copied from the originating plan; T-12-SC is declared by every plan and is listed once with the strictest entry (12-01: high, mitigate, the `golang.org/x/image` bump), the later plans adding no dependency. The executor performed the review itself, as plan 08-10 did, because no separate reviewer agent was spawned. + +A high threat counts as mitigated only when its named test or gate stage fails with the protection removed. `scripts/check-phase12.sh --removal` does this for every high mitigated threat: it refuses a file with uncommitted changes, applies an anchor-exact mutation that removes the protection, runs the named test, requires it to fail on an assertion (a build failure does not count), and restores the file byte for byte, checked with `cmp`. The results are under "Removal checks". The one accepted threat keeps its rationale from its originating plan. + +The review found four defects in Phase 12 code and two in the 12-01 framework port, all fixed in plan 12-05 with a failing-when-broken test (see "Fixes made during the review"). The most serious: a 100-byte PNG declaring a 30000x30000 canvas, uploaded by any household member, made every later listing of the collection and the album answer 500 (T-12-16). + +Commands run from `summercms.go`; `../fonoteka.go` tests run inside that repository. Gate stages are modes of `scripts/check-phase12.sh`. + +| Threat | Category | Component | Severity | Disposition | Production mitigation | Test or gate stage | Observed result | Residual risk | +|--------|----------|-----------|----------|-------------|-----------------------|--------------------|-----------------|---------------| +| T-12-01 | Information Disclosure | collections/{id}, photos/{fileId}, image | high | mitigate | `controllers/api/collections_controller.go` `findAccessibleCollection` scopes every lookup with `classes.AccessibleBy(user, token)`; photo and image lookups are scoped to the collection's own `system_files` rows; foreign and missing ids share one 404 body | `TestPhase12Threats/T-12-01` (GET/PUT/DELETE, photo, image and a foreign photo id: identical 404s, the foreign photo stays attached), `TestCollectionsIndexBothGroups`, parity 404 fixtures; stage `--named` | pass; removal check RC-01 fails TestPhase12Threats/T-12-01 | None known | +| T-12-02 | Information Disclosure | albums/search items and meta.total | high | mitigate | `classes/album_search.go` re-gates every engine id with `ScopedAlbums` (`AlbumsAccessibleBy` plus the active collection) for the page and for the total; engine errors fall back to SQL | `TestSearchLeak` (stale-moved, mis-scoped, soft-deleted, removed-editor and its resolve race, token-pin, on both groups, page ids and meta.total compared exactly) | pass; removal checks RC-02 and RC-04 fail TestSearchLeak | A stale index document shortens a page, as Scout does | +| T-12-03 | Elevation of Privilege | personal token pin | high | mitigate | `classes/access.go` `AccessibleBy` narrows to the pin (plus the owner's wishlist) as a separate grouped AND; `Resolve` needs exactly one accessible pinned id; `SwitchTo` refuses tokens | `TestPhase12Threats/T-12-03` (token collections list holds only the pin; the owner's other collection is a 404 like a missing one; SwitchTo refuses), `TestResolvePinnedToken`, `TestCollectionsIndexBothGroups` | pass; removal check RC-05 fails TestPhase12Threats/T-12-03 | None known | +| T-12-04 | Elevation of Privilege | owner-only interactive routes on the token group | high | mitigate | `routes.go` mounts switch, collection/share, me/context, realtime/channels, sync and missing on the JWT group only; the share handler and `SwitchTo` also refuse a token | `TestRouteTablePhase12` (no missing or unexpected route, every JWT-only path absent from the token group), `TestPhase12Threats/T-12-04` (each path unmounted for a token; the share token unchanged) | pass; removal check RC-07 fails TestRouteTablePhase12 | None known | +| T-12-05 | Elevation of Privilege | editor acting as owner | high | mitigate | Explicit `owner_id == user` checks in collection destroy, image upload and delete, and the share surface; editors get the 404 | `TestPhase12Threats/T-12-05` (editor delete, image upload/delete and share show/update/regenerate refused; the row, its image and share token unchanged), `TestShareOwnerOnly` | pass; removal check RC-10 fails TestPhase12Threats/T-12-05 | None known | +| T-12-06 | Spoofing | invitation accept | high | mitigate | `classes/invitation_service.go` `AcceptInvitation` looks the invitation up by sha256 `FOR UPDATE`, requires it pending, unexpired, unrevoked and addressed to the caller's normalized e-mail, else 410; resend rotates the hash | `TestPhase12Threats/T-12-06` (another user's, unknown, reused, expired, revoked and rotated tokens all 410), `TestInvitationAcceptAddsEditor`, `TestConcurrentAcceptSingleEditor` | pass; removal checks RC-12 (e-mail check) and RC-13 (sha256 lookup) fail TestPhase12Threats/T-12-06 | A leaked invitation link is usable by its addressee until accepted or expired, as in PHP | +| T-12-07 | Information Disclosure | raw token in river_job.args, summer_jobs and logs | high | mitigate | `enqueueInvitationMail` stores the token in the job args only as `lagoon.Encrypted` ciphertext under the app key, through `Enqueue` (never `summer_jobs`); nothing logs args, token or link | `TestPhase12Threats/T-12-07` (no 64-hex value in `river_job.args`, the decrypted token matches the stored hash and appears neither in args nor in the captured logs nor in `summer_jobs`), `TestInvitationMailEnqueuedInTx` | pass; removal check RC-14 fails TestPhase12Threats/T-12-07 | Whoever holds the app key can decrypt queued tokens | +| T-12-08 | Spoofing | share token | high | mitigate | `classes/share_service.go` draws 16 symbols of a 62-symbol alphabet from `crypto/rand` with rejection at 248, retries on a unique clash, mutates under `FOR UPDATE`, never logs | `TestPhase12Threats/T-12-08` (300 unique tokens of the alphabet, rejection sampling over scripted bytes, an exhausted source fails), `TestShareTokenAlphabet` | pass; removal check RC-15 fails TestPhase12Threats/T-12-08 | About 95 bits per token | +| T-12-09 | Tampering / Information Disclosure | cover_urls and cover_url fetches (SSRF) | high | mitigate | Manual cover URLs go through fetchguard `PublicOnly` (https only, dial-time private-address refusal, no redirects, byte cap, timeout); cover imports through fetchguard `AllowHosts` (`.discogs.com`), after the write commits | `TestPhase12Threats/T-12-09` (http, ftp, loopback, RFC 1918, link-local metadata, IPv6 loopback and ULA refused with PHP's reasons; importer refuses non-Discogs, non-https and private hosts and attaches nothing), `TestManualCoverReasons`, `TestCoverImportAfterCommit`, fetchguard's own tests | pass; removal check RC-16 (fetch without fetchguard) fails TestPhase12Threats/T-12-09 | DNS rebinding is refused at dial time by fetchguard | +| T-12-10 | Tampering / Denial of Service | photo upload | high | mitigate | Router body cap, the `max:10240` file rule, `classes.IsAllowedImage` (sniff plus `DecodeConfig`), `throttle:20,1` on the JWT photo route | `TestPhase12Threats/T-12-10` (a PNG-signature polyglot and an HTML file refused before any row is written, an over-cap body 413, the 21st upload in a minute 429), `TestAlbumPhotoUpload` | pass; removal check RC-17 fails TestPhase12Threats/T-12-10 | The token group's photo route carries only the token bucket, as in PHP | +| T-12-11 | Tampering | mass assignment on albums and bulk | high | mitigate | `AlbumFillFields` plus a per-field setter (`setAlbumField` has no server-owned column) and a server-set `collection_id`; `market_price_source` is only ever cleared | `FuzzWriteEndpoints` (all 41 Phase 12 write routes from the route table; every server-owned key with a hostile value plus fuzzed keys; Postgres snapshots allow only each route's own columns; 82 seeds in plain `go test`, 60 s fuzzing clean) | pass; removal check RC-18 (collection_id taken from the input) fails FuzzWriteEndpoints | None known | +| T-12-12 | Tampering | concurrent resolve provisioning | medium | mitigate | `users` row `FOR UPDATE` before the context row, the unique `user_id` context key | `TestPhase12Threats/T-12-12` (six concurrent first resolves provision one collection), `TestResolveProvisionsOnce` | pass | None known | +| 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. | none (accepted) | accepted | The channel name reveals the caller's own collection id | +| T-12-14 | Tampering | lagoon `exists:` and regex rules | high | mitigate | `modules/lagoon/validate_rules.go` `ParseRules` checks table and column names against `identName` at registration (panics on a bad one); the value is bound, compared as text; regexes come only from code and must compile in RE2 | `TestValidateRequestParseRules`, `TestValidateRequestExistsRule` (injection-shaped values are plain misses, an unsafe inferred column and a missing table are errors), `TestPhase12Threats/T-12-14` | pass; removal check RC-20 fails TestValidateRequestParseRules | None known | +| T-12-15 | Denial of Service | wildcard expansion and size rules | medium | mitigate | Expansion is bounded by the decoded body, which surf caps; size counts are linear; RE2 has no backtracking | `TestPhase12Threats/T-12-15` (20000 wildcard rows validate in about a second; an over-cap JSON body is 413), `TestValidateRulesMatchLaravel` | pass | None known | +| T-12-16 | Denial of Service | webp/png/jpeg decode in Thumb and DecodeConfig | medium | mitigate | `attach.File.Thumb` reads the size from the header and never decodes more than 4096x4096 pixels; since 12-05 an unusable original gets WinterCMS's broken-image picture instead of an error (fixed: the error made every later listing 500) | `TestThumbBrokenSourceServesPlaceholder`, `TestPhase12Threats/T-12-16` (a 30000x30000 header-only PNG uploads at once and the listings still answer 200), `TestThumbModesAndFormats` | pass | None known | +| T-12-17 | Information Disclosure | tide multipart part files | medium | mitigate | Part bytes are committed test images pinned by sha256; substitution never touches file bytes; `check_corpus --check-secrets` scans every YAML | `TestPhase12Threats/T-12-17` (each part file is an image or the one short text fixture, no 64-hex value), `TestMultipartTamperedPartFileFailsLoad`, `TestMultipartPartEdges` (symlink out of the fixture directory refused) | pass | None known | +| T-12-18 | Elevation of Privilege | user groups table | medium | mitigate | No route writes `users_groups`; `User.Groups` is `json:"-"`; the site-admin predicate reads group codes server-side | `TestPhase12Threats/T-12-18` (no write route mentions groups; a user with groups marshals and answers me/context without them), `TestUserGroupCodesAndHasGroupCode`, `TestMeContextFlags` | pass | None known | +| T-12-19 | Information Disclosure | Winter error pages | low | mitigate | The embedded pages are PHP's `APP_DEBUG=false` bytes with only the stylesheet origin templated from `app.url` | `TestPhase12Threats/T-12-19` (no stack trace, path, line number or exception class in any page or a live 404), `TestWinterErrorWriters` | pass | None known | +| T-12-20 | Information Disclosure | committed flow fixtures | high | mitigate | Raw invitation tokens appear only as `{{secret:invite}}`; `parity/check_corpus.go --check-secrets` fails on any 64-hex value except a multipart sha256 pin | `TestCheckCorpusInvitationToken`, `TestPhase12Threats/T-12-20` (every fixture scanned), `check-phase12.sh --parity` (runs `check_corpus.go --require-recorded --check-secrets`) | pass; removal check RC-21 fails TestCheckCorpusInvitationToken | None known | +| T-12-21 | Denial of Service | invite and resend mail flooding | medium | mitigate | `throttle:10,1` on store and resend; one pending row per (collection, e-mail) | `TestPhase12Threats/T-12-21` (a repeated e-mail reuses its row; the store is throttled within ten requests), `TestRouteTablePhase12` (inline throttles on exactly their routes) | pass | None known | +| T-12-22 | Tampering | accept joining the inviter's organisation | medium | mitigate | Only an org-less invitee joins, as member, inside the accept transaction | `TestPhase12Threats/T-12-22` (an invitee's own organisation stays; an org-less invitee joins as member) | pass | None known | +| 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, after commit | `TestPhase12Threats/T-12-23` (an editor's album notifies only the owner), `TestAlbumAddedNotifiesHousehold`, `TestBroadcastGoldens` | pass | None known | +| 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 the body cap and validation; cover imports capped at `max_covers` | `TestSearchLeak/cap`, `TestPhase12Threats/T-12-24` (six cover_urls 422, an over-cap bulk 413, the importer stops at max_covers) | pass | None known | +| T-12-25 | Tampering | gate --removal leaving mutated source | medium | mitigate | `check-phase12.sh --removal` refuses a file with uncommitted changes, mutates by exact anchor, restores in a `finally` and compares with `cmp` | `check-phase12.sh --self-test` (removal harness on a scratch module: a guarded mutation fails, a surviving one is reported, a non-unique anchor and a dirty file are refused, the file is restored) | pass; both repositories clean after the 25 removal runs | An interrupted run (SIGKILL) could leave a mutation; `git status` shows it | +| T-12-26 | Repudiation | security review claims without evidence | medium | mitigate | `check-phase12.sh --evidence` refuses a threat without one review row, a high mitigated threat without a removal row, a mitigated threat naming no test, and a validation row that is not green or names a test the gate does not run | `check-phase12.sh --evidence`, `check-phase12.sh --self-test` (five plants refused) | pass | The review text itself is written by the executor | +| T-12-27 | Information Disclosure | fuzz seed corpus | low | mitigate | Seeds hold synthetic values only; the gate scans `testdata/fuzz` for 64-hex values, `inv_` tokens, JWTs and bearer headers | `check-phase12.sh --parity` (corpus scan), `check-phase12.sh --self-test` (four planted secrets and an empty corpus refused) | pass; removal check RC-25 fails `--self-test` | None known | +| T-12-28 | Information Disclosure | beachcomber SearchPage found count | high | mitigate | `found` is used only as the size of the SQL recount (D-19); the total exposed is the re-gated count of at most 1000 engine ids | `TestSearchLeak` (short-page, recount, cap: no poisoned id counted, the recount pages of 250, never beyond 1000), `TestSearchPageUsesPageSearcher` | pass; removal check RC-03 fails TestSearchLeak | None known | +| T-12-29 | Denial of Service | collection photo/image upload | medium | mitigate | Body cap, `max:10240`, image and mimes sniff before the blob write | `TestPhase12Threats/T-12-29` (a non-image refused on photos and image with no row written), `TestCollectionPhotoUpload` | pass | None known | +| T-12-30 | Tampering | mass assignment on collections | high | mitigate | `CollectionFillFields` holds only name and description; `owner_id`, `kind` and the share columns are server-set | `FuzzWriteEndpoints` (collection create, update, share and delete routes with every server-owned key hostile) | pass; removal check RC-19 (owner_id fillable) fails FuzzWriteEndpoints | None known | +| T-12-31 | Elevation of Privilege | household routes under a personal token | high | mitigate | Household and invitation routes are on the JWT group only; `assertInvitationOwner` and the handlers' `ownerCollection` also refuse a token | `TestRouteTablePhase12`, `TestPhase12Threats/T-12-31` (every household path unmounted for a token; SendInvitation and RemoveEditor refuse a token) | pass; removal checks RC-08 (route) fails TestRouteTablePhase12 and RC-09 (service lock) fails TestPhase12Threats/T-12-31 | None known | +| T-12-32 | Elevation of Privilege | editor managing members or invitations | high | mitigate | `ownerCollection` checks the resolved collection's owner before every household action; editors get the 404 page | `TestPhase12Threats/T-12-32` (every household action of an editor is the 404 page and changes nothing), `TestHouseholdInvitationsIndexRoute` | pass; removal check RC-11 fails TestPhase12Threats/T-12-32 | None known | +| T-12-33 | Information Disclosure | albums/{id}, photos/{fileId}, rating | high | mitigate | `FindAccessibleAlbum` is `ScopedAlbums` plus the id; photo lookups match the album's own attachments; one 404 body for foreign, out-of-context and missing ids | `TestPhase12Threats/T-12-33` (every id route on both groups, a foreign and an out-of-context album, another album's photo id) | pass; removal checks RC-22 (photo scoping) and RC-23 (album scoping) fail TestPhase12Threats/T-12-33 | None known | +| T-12-34 | Elevation of Privilege | token pin on albums, stats, value, search, lookups | high | mitigate | `Resolve` answers a token with its pin, never the owner's stored context; `AlbumsAccessibleBy` narrows; sync and missing are JWT only | `TestPhase12Threats/T-12-34` (a pinned token sees only its collection on albums, stats, value, search and artists while the owner's context is elsewhere), `TestSearchLeak/token-pin`, `TestRouteTablePhase12` | pass; removal check RC-06 fails TestPhase12Threats/T-12-34 | None known | +| T-12-SC | Tampering | Go module installs (golang.org/x/image v0.46.0) | high | mitigate | `golang.org/x/image` pinned at v0.46.0 with go.sum checksums (D-24); no other dependency was added in Phase 12 | `check-phase12.sh --go` (module pin over `go list -m all` of both repositories), `--self-test` (plants) | pass; removal check RC-24 (pin check disabled) fails `--self-test` | None known | + +## Removal checks + +Each row is one anchor-exact mutation from `scripts/check-phase12.sh --removal`. The anchor occurs exactly once in the file; the test must fail on an assertion (not a build failure); the file is restored and compared with `cmp` against the copy saved before the mutation. All twenty-five were run on 2026-10-02 and all failed as required; `git status` was clean in both repositories afterwards. RC-12 and RC-22 first failed to build (an unused variable and an unused import) and were rewritten to compile before being run again. + +| Check | Threat | File | Anchor removed or changed | Replacement | Test run | Observed | +|-------|--------|------|---------------------------|-------------|----------|----------| +| RC-01 | T-12-01 | `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go` | `Scopes(classes.AccessibleBy(userID, token)).` in `findAccessibleCollection` | removed | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-01$'` | fails: TestPhase12Threats/T-12-01; restored, cmp ok | +| RC-02 | T-12-02 | `../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go` | `ScopedAlbums(...)` on the page re-gate | an unscoped album query | `go test ./plugins/golem15/fonoteka -run '^TestSearchLeak$'` | fails: stale-moved, mis-scoped, token-pin, short-page, recount, removed-editor (both groups); restored, cmp ok | +| RC-03 | T-12-28 | `../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go` | `ScopedAlbums(...)` on the total recount | an unscoped album query | `go test ./plugins/golem15/fonoteka -run '^TestSearchLeak$'` | fails: every total assertion, including cap; restored, cmp ok | +| RC-04 | T-12-02 | `../fonoteka.go/plugins/golem15/fonoteka/classes/album_queries.go` | `Scopes(AlbumsAccessibleBy(userID, token)).` in `ScopedAlbums` | removed | `go test ./plugins/golem15/fonoteka -run '^TestSearchLeak$'` | fails: token-pin, removed-editor/race; restored, cmp ok | +| RC-05 | T-12-03 | `../fonoteka.go/plugins/golem15/fonoteka/classes/access.go` | `if pin := tokenCollectionPin(token); len(pin) > 0 {` | `... false && len(pin) > 0 {` | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-03$'` | fails: TestPhase12Threats/T-12-03; restored, cmp ok | +| RC-06 | T-12-34 | `../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go` | `if token != nil {` before `resolvePinnedTokenCollection` | `if false {` | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-34$'` | fails: TestPhase12Threats/T-12-34; restored, cmp ok | +| RC-07 | T-12-04 | `../fonoteka.go/plugins/golem15/fonoteka/routes.go` | the token group's `g.Get("/me", ...)` | plus a token `GET /collection/share` | `go test ./plugins/golem15/fonoteka -run '^TestRouteTablePhase12$'` | fails: router-matches-table, jwt-only-routes-absent-from-token-group; restored, cmp ok | +| RC-08 | T-12-31 | `../fonoteka.go/plugins/golem15/fonoteka/routes.go` | the token group's `g.Get("/me", ...)` | plus a token `GET /household/members` | `go test ./plugins/golem15/fonoteka -run '^TestRouteTablePhase12$'` | fails: router-matches-table, jwt-only-routes-absent-from-token-group; restored, cmp ok | +| RC-09 | T-12-31 | `../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go` | `if token != nil \|\| actor == nil \|\| ...` in `assertInvitationOwner` | without `token != nil` | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-31$'` | fails: TestPhase12Threats/T-12-31; restored, cmp ok | +| RC-10 | T-12-05 | `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go` | `if c == nil \|\| c.OwnerID != user.ID {` in collection destroy | `if c == nil {` | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-05$'` | fails: TestPhase12Threats/T-12-05; restored, cmp ok | +| RC-11 | T-12-32 | `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitations_controller.go` | `if c.OwnerID != user.ID {` in `ownerCollection` | `if false {` | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-32$'` | fails: TestPhase12Threats/T-12-32; restored, cmp ok | +| RC-12 | T-12-06 | `../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go` | `if inv.Email != email {` in `AcceptInvitation` | `if false && inv.Email != email {` | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-06$'` | fails: TestPhase12Threats/T-12-06; restored, cmp ok | +| RC-13 | T-12-06 | `../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go` | `invitationTokenHash(rawToken)` in the accept lookup | `rawToken` | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-06$'` | fails: TestPhase12Threats/T-12-06; restored, cmp ok | +| RC-14 | T-12-07 | `../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go` | `InvitationMailArgs{InvitationID: invitationID, Token: s}` (ciphertext) | `Token: raw` | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-07$'` | fails: TestPhase12Threats/T-12-07; restored, cmp ok | +| RC-15 | T-12-08 | `../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go` | `shareRejectAt = 248` | `= 255` | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-08$'` | fails: TestPhase12Threats/T-12-08; restored, cmp ok | +| RC-16 | T-12-09 | `../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go` | `return fetchguard.Fetch(ctx, rawURL, policy, nil)` | a fetch that skips fetchguard | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-09$'` | fails: TestPhase12Threats/T-12-09; restored, cmp ok | +| RC-17 | T-12-10 | `../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go` | the body of `IsAllowedImage` | `return true` first | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-10$'` | fails: TestPhase12Threats/T-12-10; restored, cmp ok | +| RC-18 | T-12-11 | `../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go` | `a.CollectionID = collectionID` in `CreateAlbum` | `collection_id` taken from the input | `go test ./plugins/golem15/fonoteka -run '^FuzzWriteEndpoints$'` | fails: FuzzWriteEndpoints (every album-create seed); restored, cmp ok | +| RC-19 | T-12-30 | `../fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go` | `CollectionFillFields = []string{"name", "description"}` | plus `"owner_id"` | `go test ./plugins/golem15/fonoteka -run '^FuzzWriteEndpoints$'` | fails: FuzzWriteEndpoints (the collection update seeds on both groups); restored, cmp ok | +| RC-20 | T-12-14 | `modules/lagoon/validate_rules.go` | `if !identName.MatchString(table) {` in `exists` parsing | `if false {` | `go test ./modules/lagoon -run '^TestValidateRequestParseRules$'` | fails: TestValidateRequestParseRules; restored, cmp ok | +| RC-21 | T-12-20 | `../fonoteka.go/parity/check_corpus.go` | `if hex64Re.MatchString(line) {` | `if false && ...` | `go test ./parity -run '^TestCheckCorpusInvitationToken$'` | fails: planted_path, planted_body, planted_bare; restored, cmp ok | +| RC-22 | T-12-33 | `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go` | the photo lookup's attachment type, id and field conditions | the file id alone | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-33$'` | fails: TestPhase12Threats/T-12-33; restored, cmp ok | +| RC-23 | T-12-33 | `../fonoteka.go/plugins/golem15/fonoteka/classes/album_queries.go` | `ScopedAlbums(...)` in `FindAccessibleAlbum` | an unscoped album query | `go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-33$'` | fails: TestPhase12Threats/T-12-33; restored, cmp ok | +| RC-24 | T-12-SC | `scripts/check-phase12.sh` (mutated copy) | the `golang.org/x/image` lookup in `module_pin` | a fixed audited line | `bash --self-test` | fails: "refuse: self-test module_pin accepted golang.org/x/image v0.45.0"; original untouched | +| RC-25 | T-12-27 | `scripts/check-phase12.sh` (mutated copy) | `sys.exit(1)` after a corpus secret is found | `pass` | `bash --self-test` | fails: "refuse: self-test corpus_scan accepted a planted secret"; original untouched | + +Not every protection is removable on its own. Dropping `AlbumsAccessibleBy` from `ScopedAlbums` (RC-04) leaves the stale-moved, mis-scoped and soft-deleted cases passing, because the active-collection filter and GORM's own `deleted_at` filter still exclude them; only the token pin and the resolve/removal race depend on the access scope, and those cases fail. Making `collection_id` fillable in `AlbumFillFields` alone does not survive to the database either (`setAlbumField` has no setter for it and `CreateAlbum` sets the column), so RC-18 removes the server-set assignment instead. + +## Fixes made during the review + +| Defect | Threat | Fix | Failing-when-broken test | Commit | +|--------|--------|-----|--------------------------|--------| +| A stored photo whose original is missing, undecodable or declares more than 4096x4096 pixels made `attach.File.Thumb` return an error, so every listing that shows it (GET collections, the collection, the album) answered 500 from then on: a 100-byte PNG from any household member broke the household's listings | T-12-16 | `Thumb` follows WinterCMS's `File::makeThumb` catch branch: log the reason, store WinterCMS's broken-image picture (`attach.BrokenImagePNG`) as the thumbnail and return its URL | `TestThumbBrokenSourceServesPlaceholder` (RED: three errors), `TestPhase12Threats/T-12-16` | summercms.go `1307060` | +| `in` compared array elements loosely and `not_in` failed an array when any element was listed or when the array rule was absent; Laravel uses `array_diff` (exact strings) and `validateNotIn` is `!validateIn` | T-12-14 | `validateIn`/`validateNotIn` follow Laravel | `TestValidateRulesMatchLaravel` (162 cases recorded from WinterCMS's validator; RED: three divergences) | summercms.go `36983bc` | +| JSON floats were cast to strings with up to 17 digits; PHP's `(string)` uses 14 (`%.14G`) | T-12-14 | `phpFloatString` formats like zend_gcvt | `TestPHPFloatStringMatchesPHPCast` (RED: nine values) | summercms.go `3ac1d64` | +| `(int)` of request values read digits only and overflowed (`"1e2"` was 1, `"9999999999999999999"` was PHP_INT_MIN, out-of-range floats undefined); `is_numeric("1e400")` was false; float strings used Go's `%G` | — (API-02 parity) | `phpIntCast` follows `zval_get_long` (numeric prefix, saturating) and `zend_dval_to_lval` (modular), `phpNumericValue` accepts overflowing exponents, `phpStringOf` uses `%.14G` | `TestPHPValueCasts` (RED: nineteen values) | fonoteka.go `64f5496` | +| The Laravel e-mail rule's domain part refused literals with spaces, allowed 63-byte labels after the first (egulias counts the dot), had no 253-byte cap and refused comments at the start of the domain | — (API-01 parity, not observable: every invitation e-mail failure answers the same 500 page) | `laravelDomainOK` follows egulias | `TestValidLaravelEmailRFC` (RED: three addresses) | fonoteka.go `817867d` | +| `albums/sync?per_page=1e2` paged one album where PHP pages 100 (`phpInt` read digits only); `$request->boolean()` did not trim and refused `1.0` | — (API-02 parity) | `phpInt` uses `classes.PHPIntCast`; `laravelBoolean` follows `FILTER_VALIDATE_BOOLEAN` | `TestRequestCasts` (RED: six values) | fonoteka.go `2869747` | diff --git a/.planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md b/.planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md index d3ac952..9fb34c2 100644 --- a/.planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md +++ b/.planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md @@ -3,10 +3,12 @@ phase: "12" slug: "p-ytarium-api-collections-and-albums" # status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6) # audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117) -status: draft -nyquist_compliant: false -wave_0_complete: false +status: validated +nyquist_compliant: true +wave_0_complete: true created: "2026-10-02" +validated: "2026-10-02" +gate: "scripts/check-phase12.sh --all" --- # Phase 12 — Validation Strategy @@ -24,7 +26,8 @@ created: "2026-10-02" | **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 | | **Parity command** | `cd fonoteka.go && go test ./parity -run 'TestParityCorpus|TestBroadcastGoldens|TestFonotekaNuxtFlows' -count=1` | -| **Estimated runtime** | ~180 seconds (full suite, both repos, with containers) | +| **Phase gate** | `scripts/check-phase12.sh --self-test && scripts/check-phase12.sh --all` (summercms.go); `--removal` runs the RC mutations of 12-SECURITY-REVIEW.md | +| **Estimated runtime** | ~180 seconds (full suite, both repos, with containers); the gate's `--all` about 12 minutes | --- @@ -32,44 +35,44 @@ created: "2026-10-02" - **After every task commit:** quick run command plus `go vet` in the touched repo - **After every plan wave:** full suite in both repos plus the parity command -- **Before `/gsd-verify-work`:** full suite green in both repos; parity corpus with the new routes flipped to `ported` and passing; `check_corpus.go --require-recorded --check-secrets` green; `go test ./cmd/summer -run TestDocsTree` green +- **Before `/gsd-verify-work`:** `scripts/check-phase12.sh --all` (vet and tests in both repos, the parity corpus with 99 ported routes, the broadcast goldens, both Nuxt flows, `check_corpus.go --require-recorded --check-secrets`, every named test by exact name, the coverage floors and this file) - **Max feedback latency:** 60 seconds (quick run) --- ## Per-Task Verification Map -Task IDs are `-T`. 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 IDs are `-T`. Every row's command was run on 2026-10-02 and passed; `scripts/check-phase12.sh --named` runs all of these tests by exact name and refuses a skip, a missing pass or "no tests to run" (the fonoteka plugin tests under `-race`). Framework commands run from `summercms.go`; application commands use `go -C ../fonoteka.go`. | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| -| 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 | -| 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 | -| 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 | -| 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 | -| 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 | +| 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 '^(TestValidateRequestEmptyArrayStopsAtRequired\|TestValidateRequestWildcardNamesIndexedAttribute\|TestValidateRequestWildcardWithoutParentAddsNothing\|TestValidateRequestStringLengthCountsCharacters\|TestValidateRequestBetweenIntegerBoundary\|TestValidateRequestNumericPrecision\|TestValidateRequestPolishFallsBackToEnglish\|TestValidateRequestPresenceSemantics\|TestValidateRequestBailAndOrder\|TestValidateRequestCustomRuleMessageVerbatim\|TestValidateRequestParseRules\|TestValidateRequestUploadedFile\|TestValidateRequestEmailURLBoolean\|TestValidateRequestExistsNeedsDatabase\|TestValidateRequestErrorKeysDeclarationOrder\|TestValidateNumericRangeMessagePicksFailedBound)$' -count=1 -v` | ✅ `modules/lagoon/validate_request_test.go`, `validate_test.go` | ✅ green | +| 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\|TestMultipartRecordReplaySendsIdenticalBytes\|TestMultipartTamperedPartFileFailsLoad\|TestMultipartRejectsInvalidParts\|TestMultipartKeepsNonMultipartContentType\|TestNormalizeUploadURLMasksRandomParts\|TestNormalizeUploadURLReportsWrongShape\|TestNormalizePublicationAlbumDates)$' -count=1 -v` | ✅ `modules/lagoon/attach/url_test.go`, `modules/tide/multipart_test.go` | ✅ green | +| 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 '^(TestSearchPageUsesPageSearcher\|TestSearchPageFallsBackToSearchIDs\|TestSearchPageNullEngine\|TestTypesenseSearchPageFoundAndWeights\|TestTypesenseSearchPageRejectsBadQueries)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/updates -run '^(TestUserGroupsMigration\|TestUserGroupsCodesAndRelation)$' -count=1 -v` | ✅ `modules/beachcomber/searchpage_test.go`, `modules/beachcomber/typesense/searchpage_test.go`, `plugins/golem15/user/updates/user_groups_test.go` | ✅ green | +| 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` | ✅ | ✅ green | +| 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\|TestResolvePinnedToken\|TestResolveFallbackHasNoKindFilter\|TestTokenGroupOneScopePerRoute)$' -count=1 -race -v` | ✅ `plugins/golem15/fonoteka/collections_smoke_test.go`, `routes_group_test.go` | ✅ green | +| 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` | ✅ `plugins/golem15/fonoteka/collections_smoke_test.go` | ✅ green | +| 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` | ✅ `plugins/golem15/fonoteka/collections_smoke_test.go` | ✅ green | +| 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` | ✅ `plugins/golem15/fonoteka/household_smoke_test.go` | ✅ green | +| 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, the 409 guard for an unaccepted registration invitation, single accept under concurrency | integration + parity | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestRemoveEditorRepairsContext\|TestPendingInvitationGuard\|TestConcurrentAcceptSingleEditor)$' -count=1 -race -v` | ✅ `plugins/golem15/fonoteka/household_smoke_test.go` | ✅ green | +| 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\|TestCheckCorpusInvitationToken)$' -count=1 -v` | ✅ `parity/fonoteka_flows_test.go`, `parity/check_corpus_test.go` | ✅ green | +| 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` | ✅ `plugins/golem15/fonoteka/albums_smoke_test.go` | ✅ green | +| 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` | ✅ `plugins/golem15/fonoteka/albums_smoke_test.go` | ✅ green | +| 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` | ✅ `plugins/golem15/fonoteka/albums_smoke_test.go`, `parity/broadcast_goldens_test.go` | ✅ green | +| 12-05-T1 | 12-05 | 5 | API-02 | T-12-02, T-12-28 | D-18 leak test (stale-moved, mis-scoped, soft-deleted, removed-editor with the resolve race, token-pin) with the D-19 total, short page, recount and 1000-id cap on both groups | security | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^TestSearchLeak$' -count=1 -race -v` | ✅ `plugins/golem15/fonoteka/search_leak_test.go`, `fake_engine_test.go` | ✅ green | +| 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 all 41 write endpoints (C-02) with a seed corpus per route, one subtest per threat | security + fuzz | `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` | ✅ `plugins/golem15/fonoteka/routes_table_phase12_test.go`, `write_endpoints_fuzz_test.go`, `testdata/fuzz/FuzzWriteEndpoints/` (41 seeds), `phase12_security_test.go` | ✅ green | +| 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), PHP truth tables for the validator and request casts, fail-closed gate with removal mutations, security review and validation sign-off | unit + gate | `go test ./modules/lagoon ./modules/lagoon/attach ./modules/tide -run '^(TestValidateRulesMatchLaravel\|TestPHPFloatStringMatchesPHPCast\|TestValidateRequestGoTypedValues\|TestValidateRequestMimesSniffing\|TestValidateRequestUploadedFileFromHeader\|TestValidateRequestRuleBuilders\|TestValidateRequestCustomLinePlaceholders\|TestValidateRequestExistsRule\|TestThumbBrokenSourceServesPlaceholder\|TestThumbModesAndFormats\|TestBucketAndURLEdges\|TestMultipartPartEdges\|TestNormalizeMaskEdges\|TestCoverageReportHelpers)$' -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/... ./plugins/golem15/user/classes -run '^(TestPHPValueCasts\|TestValidLaravelEmailRFC\|TestParseTracklistTextMatchesPHP\|TestParseAddedDateMatchesPHP\|TestMatchNormalizersMatchPHP\|TestFormatValueTotalMatchesPHP\|TestSyncCursorAndCarbonTime\|TestSearchHelpers\|TestDecodeInput\|TestRequestCasts\|TestWinterErrorWriters\|TestAlbumSyncRoute\|TestAlbumsMissingRoute\|TestStylesRoutes\|TestHouseholdInvitationsIndexRoute\|TestHandlersFailClosedOnDatabaseErrors\|TestHandlerRequestPaths\|TestUserGroupCodesAndHasGroupCode\|TestUserClassHelpers)$' -count=1 && scripts/check-phase12.sh --self-test && scripts/check-phase12.sh --all` | ✅ `scripts/check-phase12.sh`, the test files named in 12-05-SUMMARY.md, `12-SECURITY-REVIEW.md` | ✅ green | -*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* +*Status: ⬜ to do · ✅ green · ❌ red · ⚠️ flaky* --- ## Wave 0 Requirements -- [ ] 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 multipart `parts` + `url`/`thumb_url` disk-name normalizer + publication date masking (framework) — 12-01-T2 -- [ ] 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 — smoke in 12-04-T3, full suite in 12-05-T1 +- [x] 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) +- [x] tide request multipart `parts` + `url`/`thumb_url` disk-name normalizer + publication date masking (framework) — 12-01-T2 +- [x] Seed hook `fonoteka` with alice, bob (editor), an outsider and personal tokens (reuse the `id:outsider` recipe from Phase 11) — 12-02-T1 +- [x] Fake `beachcomber` engine with scripted ids and `found` for D-18/D-19 — smoke in 12-04-T3 (`scriptedEngine`), full suite in 12-05-T1 (`fake_engine_test.go`) --- @@ -77,17 +80,17 @@ Task IDs are `-T`. Framework commands run from `summercms.go`; appli | Behavior | Requirement | Why Manual | Test Instructions | |----------|-------------|------------|-------------------| -| Recording new PHP fixtures against the isolated PHP instance | API-01, API-02 | Needs the running PHP reference instance and capture tooling | Follow the Phase 2 `tide` capture rules (private 0600 vars, no live tokens in git), then run `check_corpus.go --require-recorded --check-secrets` | +| Recording new PHP fixtures against the isolated PHP instance | API-01, API-02 | Needs the running PHP reference instance and capture tooling | Follow the Phase 2 `tide` capture rules (private 0600 vars, no live tokens in git), then run `check_corpus.go --require-recorded --check-secrets` (done for 12-02..12-04; the gate's `--parity` stage re-runs the check) | --- ## Validation Sign-Off -- [ ] All tasks have `` verify or Wave 0 dependencies -- [ ] Sampling continuity: no 3 consecutive tasks without automated verify -- [ ] Wave 0 covers all MISSING references -- [ ] No watch-mode flags -- [ ] Feedback latency < 60s -- [ ] `nyquist_compliant: true` set in frontmatter +- [x] All tasks have `` verify or Wave 0 dependencies +- [x] Sampling continuity: no 3 consecutive tasks without automated verify +- [x] Wave 0 covers all MISSING references +- [x] No watch-mode flags +- [x] Feedback latency < 60s (the `-short` package runs; the testcontainers suites take minutes and run per wave and in the gate) +- [x] The frontmatter sets nyquist_compliant to true -**Approval:** pending +**Approval:** validated 2026-10-02 by plan 12-05 (`scripts/check-phase12.sh --all` prints "phase12 all passed"; `--removal` reports every RC row failing as required). The manual-only row above is collected at /gsd-verify-work.