Files
summercms/.planning/phases/12-p-ytarium-api-collections-and-albums/12-03-SUMMARY.md

19 KiB

phase, plan, subsystem, tags, requires, provides, affects, actuals, plan_head_before, plan_head_after, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status
phase plan subsystem tags requires provides affects actuals plan_head_before plan_head_after tech-stack key-files key-decisions patterns-established requirements-completed coverage duration completed status
12-p-ytarium-api-collections-and-albums 03 api
fonoteka
household
invitations
members
river
conga
postcard
lighthouse
notifications
parity
tide
phase provides
12-p-ytarium-api-collections-and-albums 12-02: classes.Resolve/SwitchTo/ProvisionCollection/AccessibleByMembership, requestScope/readInput/writeWinterHTTPError, the fonoteka seed hook and fonoteka_reset.php
phase provides
11-jobs-realtime-and-search-infrastructure conga Enqueue on a transaction, lighthouse Emit and memory driver, postcard catalog and memory driver
classes.SendInvitation/ResendInvitation/CancelInvitation/AcceptInvitation/RemoveEditor, NormalizeInvitationEmail, ValidLaravelEmail
classes.ErrInvitationUnavailable (410), ErrInvitationAcceptanceRequired (409), ErrInvalidInvitationEmail
classes.ProvisionOrgFor (OrgProvisioner port)
classes.WriteNotification/NotifyInvitationAccepted, NotificationPayload, NotificationType* constants
classes.InvitationMailArgs (golem15.fonoteka.invitation_mail, queue mail, 3 attempts, token as app-key ciphertext)
plugin pact.HasJobs (invitation mail worker) and pact.HasMailTemplates (collection_invitation, -en, plytarium layout)
pending-invitation guard in Resolve and SwitchTo; writeResolveError/WriteResolveError mapping 404/409 at every resolver caller
7 household/invitation routes ported on the JWT group (63 ported routes)
parity
nuxt-collections flow, TestFonotekaNuxtFlows, 64-hex invitation-token secret rule, share capture rules, php_parity.sh serve-mail/invite-token
12-04
12-05
13
tokens tasks commits
56421 3 3
6c729a708f96fe941c4791049b89a9cad321461f f88c01e787ecb541c91aee0638c2c21b36d62e9a
added patterns
A secret that must leave the request (invitation token) rides a River job enqueued with conga Enqueue on the write transaction, encrypted with lagoon.Encrypted; the worker decrypts and re-checks the sha256 before sending
Notification writes insert the row with PHP json_encode text and Emit notification:new then notification:count on user:<id> on the same transaction
Every PHP HttpException and every non-HTTP exception PHP answers in production is an embedded Winter page (winter_<status>.html); validation exceptions outside Laravel's JSON path are the 500 page
Parity cases needing a raw token seed it on both sides (secret:invite in the private store, sha256 in the row); flows that mint one in PHP read it from the log mailer between two recording runs
Route-qualified seed extras: fonotekaCaseExtras keys may be "<route id>#<case id>"
created modified
../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go
../fonoteka.go/plugins/golem15/fonoteka/classes/org_provisioner.go
../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go
../fonoteka.go/plugins/golem15/fonoteka/classes/laravel_email.go
../fonoteka.go/plugins/golem15/fonoteka/jobs.go
../fonoteka.go/plugins/golem15/fonoteka/mail.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/winter_409.html
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_410.html
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_500.html
../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go
../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml
../fonoteka.go/parity/fonoteka_flows_test.go
../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/{me_context,realtime_channels,collection_share,collections,token_api,oauth_consent}_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/parity_test.go
../fonoteka.go/parity/parity_contract_test.go
../fonoteka.go/parity/fonoteka_seed_test.go
../fonoteka.go/parity/fonoteka_reset.php
../fonoteka.go/parity/synthetic_test.go
../fonoteka.go/parity/php_parity.sh
../fonoteka.go/parity/check_corpus.go
../fonoteka.go/parity/check_corpus_test.go
../fonoteka.go/parity/capture-rules.yaml
../fonoteka.go/parity/README.md
Recorded PHP answers every invitation ValidationException (store required|email and normalizeEmail) with Winter's 500 'Blad strony' page, not a 422 JSON envelope: the Winter error handler renders any non-HTTP exception; the store reproduces that page
The store's Laravel email rule is an egulias RFCValidation port (ValidLaravelEmail), distinct from lagoon's FILTER_VALIDATE_EMAIL email rule, so user@localhost passes the rule and fails normalizeEmail as in PHP
Winter 409 and 410 are the generic error page with that status and a trailing newline (the 404 CMS page has none)
InvitationMailArgs lives in classes (the service enqueues it); jobs.go aliases it and owns the worker; the worker skips a superseded or no longer pending invitation
Route-level accept/resend cases seed invitations with a known token on both sides; only the Nuxt flow reads a PHP-minted token from the log mailer
The nuxt-collections replay runs on its own database because the journey switches alice's context and would leak into later shared-database tests
All resolver callers (me/context, realtime/channels, share, switch, genres, token store, OAuth consent) map errors through writeResolveError, so the 409 guard is a Winter page everywhere
classes.JobQueue (Enqueue signature of *conga.Manager) keeps services testable without a worker
Smoke tests read river_job rows (args, broadcast events) instead of starting a worker, and call the job function directly with a memory mailer
API-01
id description requirement verification human_judgment
D1 Invite writes the pending invitation (fresh 64-hex token, sha256 at rest, 7-day expiry, reuse of the pending row) and enqueues the mail job in the same transaction with the token only as ciphertext; rollback leaves no row and no job; the worker sends the locale-picked template and link API-01
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go#TestInvitationMailEnqueuedInTx pass
kind ref status
e2e go -C ../fonoteka.go test ./parity -run TestParityCorpus (POST household/invitations: case, repeat, editor, org-less-owner, 4 validation cases) pass
false
id description requirement verification human_judgment
D2 Accept adds the editor, moves the context, joins an org-less invitee to the inviter's organisation, stamps the invitation, clears pending registrations, writes the invitation_accepted notification with two user:<inviter> publications; concurrent accepts give one 200 and one 410 API-01
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go#TestInvitationAcceptAddsEditor pass
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go#TestConcurrentAcceptSingleEditor pass
kind ref status
e2e go -C ../fonoteka.go test ./parity -run TestParityCorpus (POST invitations/{token}/accept: missing, accepted, wrong-user, expired, revoked) pass
false
id description requirement verification human_judgment
D3 Owner invitation and member management (index, cancel, resend, members, remove) with Winter 404 pages for editors and misses; removal repairs the member's context and detaches the organisation only without another household collection API-01
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go#TestRemoveEditorRepairsContext pass
kind ref status
e2e go -C ../fonoteka.go test ./parity -run TestParityCorpus (18 recorded household cases) pass
false
id description requirement verification human_judgment
D4 Pending-invitation guard: a valid registration answers the Winter 409 page on resolve and switch; a stale one is deleted and the request continues API-01
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go#TestPendingInvitationGuard pass
kind ref status
e2e go -C ../fonoteka.go test ./parity -run TestParityCorpus (me/context pending-invitation 409, stale-registration 200) pass
false
id description requirement verification human_judgment
D5 The Nuxt household journey replays against Go with PHP's bodies and DB effects; no raw invitation token can be committed to the corpus API-01
kind ref status
e2e go -C ../fonoteka.go test ./parity -run TestFonotekaNuxtFlows/nuxt-collections pass
kind ref status
unit ../fonoteka.go/parity/check_corpus_test.go#TestCheckCorpusInvitationToken pass
kind ref status
other go -C ../fonoteka.go run ./parity/check_corpus.go --require-recorded --check-secrets pass
false
id description verification human_judgment rationale
D6 Invitation mail rendering (Polish and English templates, plytarium layout) looks right in a mail client
true Tests assert recipient, subject and link; the HTML layout's visual fidelity to the PHP mail (and the footer without the year) needs a human look
36min 2026-10-02 complete

Phase 12 Plan 03: Household invitations and members Summary

Owner invitations by email with a transactional, encrypted-token River mail job, acceptance into the shared collection with the inviter's notification, member and invitation management, and the 409 pending-invitation guard: 7 routes ported byte-compatibly from 35 fresh PHP recordings plus the recorded Nuxt household journey.

Performance

  • Duration: 36 min
  • Started: 2026-10-02T10:40:14Z
  • Completed: 2026-10-02T11:16:30Z
  • Tasks: 3 (tracer verified end to end before expansion)
  • Files modified: 72 in fonoteka.go (code, tests, 37 fixtures, docs)

Accomplishments

  • SendInvitation checks ownership (a personal token is refused in-handler too), normalizes the email like PHP 8 strtolower(trim()) plus FILTER_VALIDATE_EMAIL, then in one transaction provisions the inviter's organisation, upserts the single pending invitation of (collection, email) with a fresh 64-hex token (sha256 at rest, 7 days) and enqueues golem15.fonoteka.invitation_mail with conga Enqueue (never Dispatch). The token sits in river_job.args only as lagoon.Encrypted ciphertext.
  • The mail worker (jobs.go) decrypts, skips an invitation that is gone, no longer pending or superseded by a resend, and sends collection_invitation or -en (inviter's preferred_locale) with {app.url}/zaproszenia/<token> or /en/invitations/<token>.
  • AcceptInvitation locks the invitation by hash, answers 410 for missing, accepted, revoked, expired or other-email invitations, adds the editor (granted by the inviter), moves the context, joins an org-less invitee to the inviter's organisation as member, stamps acceptance, clears pending registrations and writes invitation_accepted with notification:new and notification:count on user:<inviter>, all on one transaction.
  • Resend rotates token and expiry and enqueues a new mail; cancel revokes; RemoveEditor deletes one editor row, repairs the member's context through the collection provisioner and detaches a non-owner only when no other household collection remains. No collection or album is ever deleted.
  • Resolve and SwitchTo run the pending-invitation guard for JWT callers (409 page for a valid registration, stale row deleted). Every resolver caller now maps 404/409 through one helper.
  • 35 PHP cases recorded under APP_DEBUG=false (the old debug accept fixture replaced); TestParityCorpus reports 63 ported routes passing; nuxt-collections (15 steps) replays green; check_corpus.go --require-recorded --check-secrets is green and now refuses any 64-hex value.

Task Commits

All commits are in fonoteka.go (summercms.go code is untouched):

  1. Task 1: invite and accept (tracer) - bf0a9dd (feat)
  2. Task 2: invitations and members management, pending-invitation guard - 63774b6 (feat)
  3. Task 3: Nuxt household journey, validation recordings, secret rule - f88c01e (test)

Ledger: fonoteka.go 6c729a7..f88c01e, 3 commits (git rev-list --count).

Files Created/Modified

  • classes/invitation_service.go, org_provisioner.go, notification_service.go, laravel_email.go: the household services, OrgProvisioner, notification write path, Laravel email rule
  • classes/active_collection.go: the pending-invitation guard
  • jobs.go, mail.go, views/mail/*: invitation mail job and templates
  • controllers/api/invitations_controller.go, members_controller.go, winter_409/410/500.html: handlers and Winter pages; resolver callers switched to writeResolveError
  • routes.go: seven JWT-only routes with {id} constraints and throttle:10,1 on store and resend
  • parity/*: seed extras on both sides, manifest flips, 35 route fixtures, the flow, its replay test, the 64-hex secret rule, share capture rules, serve-mail/invite-token, README recipe

Decisions Made

See key-decisions in the frontmatter.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] Invitation validation is a 500 page, not a 422 envelope

  • Found during: Task 1 (probing PHP before recording)
  • Issue: The plan expected Laravel's {"message","errors"} envelope. Recorded PHP answers {}, not-an-email, user@localhost and a padded email with Winter's 500 "Błąd strony" page: the Winter error handler renders any non-HTTP exception.
  • Fix: writeInvitationValidationFailed writes the 500 page; four recorded cases pin it.
  • Commits: bf0a9dd, f88c01e

2. [Rule 1 - Bug] Laravel's email rule is not FILTER_VALIDATE_EMAIL

  • Issue: lagoon's email rule ports FILTER_VALIDATE_EMAIL; Laravel's rule is egulias RFCValidation (accepts user@localhost, quoted and UTF-8 local parts, refuses surrounding spaces).
  • Fix: classes.ValidLaravelEmail, written against a PHP truth table of 45 addresses.
  • Commit: bf0a9dd

3. [Rule 2 - Missing critical] Test encryption key for the parity target

  • Issue: The parity Go target never loads app.key, so encrypting the job token failed.
  • Fix: The parity TestMain publishes a test-only column key, as serve does from app.key.
  • Commit: bf0a9dd

4. [Rule 1 - Bug] Resolver errors beyond 404 were opaque 500s

  • Issue: The token store and OAuth consent mapped every resolver error to the opaque 500, and the new 409 needed a page everywhere.
  • Fix: One writeResolveError (404, 409, else opaque 500) at all resolver callers, WriteResolveError for the genres controller.
  • Commit: 63774b6

5. [Rule 3 - Blocking] Flow replay leaked into later shared-database tests

  • Issue: The journey switches alice to a new collection; TestOAuthFlows (later in file order) then saw "Domowa półka" and three manual tokens.
  • Fix: nuxt-collections replays on its own database (isolatedFlowDB).
  • Commit: f88c01e

6. [Rule 3 - Blocking] Seed extras keyed by route

  • Issue: Case ids such as case, accepted and revoked repeat across routes with different seed states.
  • Fix: fonotekaCaseExtra(routeID, caseID) checks "<route id>#<case id>" first.
  • Commit: bf0a9dd

7. [Plan adjustment] Final context of the removed member

  • The plan expected bob's context on his own collection after removal. In the seed bob still edits alice's Parity Collection, which has the lower id, so the provisioner moves him there, as PHP's recording shows. The flow test asserts that.

8. [Plan adjustment] Mail layout footer

  • Go templates have no date helper and postcard layouts receive only Content, Subject, css and brandCss, so the plytarium footer reads "© Płytarium." without the year.

9. [Recording approach] Seeded tokens for route cases

  • Route-level accept, resend and guard cases seed the invitation with a known token on both sides (secret:invite in the private store). The log mailer path (serve-mail, invite-token) is used for the Nuxt flow, where PHP mints the token itself.

Total deviations: 6 auto-fixed (3 bugs, 1 missing critical, 2 blocking) plus 3 plan adjustments. Impact on plan: All seven routes and the flow replay byte-compatibly; every deviation follows the recorded PHP contract or keeps the shared corpus honest. No scope creep.

Issues Encountered

  • Commits were made on master in fonoteka.go, as in 12-01 and 12-02 (git.branching_strategy: none), although the executor's default-branch guard reports master as protected.
  • The isolated PHP ran under php -S (not artisan serve) so that the log mailer's stderr reaches $PARITY_ROOT/mail.log; it was stopped afterwards. Nothing was written into the PHP checkout. The private vars and mail log stay in /tmp/summercms-parity/p1203 (mode 0600).

Known Stubs

None.

User Setup Required

None. Production needs app.key set (already required for encrypted columns) and the job worker running (queue.work_in_serve or queue:work) for invitation mail; the mail queue is picked up automatically from the registered job.

Next Phase Readiness

  • 12-04 can reuse WriteNotification for notifyAlbumAdded (ordered NotificationPayload, PHP json_encode text) and writeResolveError for album handlers.
  • 12-05 has householdHarness, invitationJobs/decryptJobToken, testInvitationMailer and ValidLaravelEmail's truth table to extend for coverage.
  • Phase 13 consumes PendingInvitationRegistration on register and adds the notifications list on top of the rows written here.

Phase: 12-p-ytarium-api-collections-and-albums Completed: 2026-10-02

Self-Check: PASSED

All 17 created files listed in key-files exist on disk. Commits bf0a9dd, 63774b6 and f88c01e exist in fonoteka.go and 6f37a37 in summercms.go. Plan-level verification passed: go vet ./... and go test ./... -count=1 in fonoteka.go (root, parity, the fonoteka plugin and sm-user-plugin), TestParityCorpus with 63 ported and passing, TestFonotekaNuxtFlows/nuxt-collections passing, and check_corpus.go --require-recorded --check-secrets exiting 0.