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).
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
resolved
test
requirement_id
category
statement
status
verification
API-01
safety
Accepting an invitation or being removed as an editor MUST NOT delete or empty the user's own collections or albums
resolved
test
requirement_id
category
statement
status
verification
API-01
privacy
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
resolved
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").
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.
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.
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
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.
Plan 12-02 is executed: `go -C ../fonoteka.go doc ./plugins/golem15/fonoteka/classes Resolve` exits 0 and the parity seed hook `fonoteka` exists.
../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
/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
(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-` 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:, pending registration rows cleared, invitee's own collection untouched).
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
<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>
<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>
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.
Task 2: The owner manages invitations and members, and a member removed from the household keeps a working account
../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
/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
(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).
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
<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>
<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>
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.
Task 3: The Nuxt household journey replays end to end, and invitation tokens can never be committed to the corpus
../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
/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)
(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).
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
<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>
<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>
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.
<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>
- `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.
<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>
Create `.planning/phases/12-p-ytarium-api-collections-and-albums/12-03-SUMMARY.md` when done.