Files
summercms/.planning/phases/12-p-ytarium-api-collections-and-albums/12-03-PLAN.md
2026-10-02 08:40:06 +02:00

41 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
12-p-ytarium-api-collections-and-albums 03 execute 3
12-02
../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
true
API-01
tokens raw_tokens tasks confidence
240000 240000 3 low
truths artifacts key_links prohibitions
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).
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go SendInvitation, ResendInvitation, CancelInvitation, AcceptInvitation, RemoveEditor, NormalizeInvitationEmail, ErrInvitationUnavailable sha256
path provides
../fonoteka.go/plugins/golem15/fonoteka/jobs.go pact.HasJobs, InvitationMailArgs, invitation mail worker
path provides
../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go WriteNotification, NotifyInvitationAccepted
path provides
../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml recorded PHP Nuxt collections/household flow
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go summercms.go modules/conga/conga.go Manager.Enqueue on the invitation write transaction Enqueue(
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/jobs.go summercms.go modules/postcard Mailer.Send with the locale-picked template collection_invitation
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go summercms.go modules/lighthouse/suppress.go Service.Emit on user:<id> inside the write transaction Emit(
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go golem15_fonoteka_pending_invitation_registrations guardPendingInvitation before the users lock PendingInvitationRegistration
requirement_id category statement status verification
API-01 privacy 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.

Repo: fonoteka.go only. Never add co-author tags.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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 - 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:`).

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.
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.