Six sequential plans: framework gaps, notifications/credentials/onboarding, wishlist, CSV, public views, unit tests and gate. Research open questions marked resolved per the plan-count checkpoint.
Per D-06, `GET /_fonoteka/api/v1/notifications` returns the caller's newest 50 notifications by id descending as `{"data":[{"id","type","payload","read_at","created_at"}]}` with the stored payload bytes in their stored key order, Carbon `+00:00` times or null, and `{"data":[]}` when there are none.
`GET notifications/unread-count` answers `{"data":{"count":N}}` over the caller's unread rows; `POST notifications/read-all` and `POST notifications/{id}/read` answer `{"data":{"state":"read"}}`; `{id}/read` of another user's or a missing id answers the Winter production 404 HTML page (C-02), and read-all never touches another user's rows.
Per D-02, `GET/POST ai-credential`, `GET/POST/DELETE org-ai-credential` and `GET/POST discogs-credential` answer PHP's bodies: `{"configured":false}` or the configured shape with provider, model and base_url only; store answers `{"configured":true,"provider":P}`; org show/destroy without an organisation answer 404 `{"error":"No organisation"}`; org store provisions an organisation first and a non-manager gets 403 `{"error":"Forbidden"}`; org destroy answers `{"configured":false}`.
Per RESEARCH Finding 7, every credential store `$request->validate()` or `ValidationException::withMessages` failure (bad provider, bad base_url, missing api_key with no stored key, malformed Discogs token) answers Winter's generic 500 HTML page with status 500, as production PHP does.
A credential store writes only the validated keys present in the request (absent is not null); api_key and the Discogs token are stored only as `lagoon.Encrypted` ciphertext; a missing api_key reuses the stored key (the org store reuses the caller's own UserAiCredential key, as PHP does); no response body or log line contains a key or token.
`POST discogs-credential` trims the token, applies `/^[A-Za-z0-9_-]{10,255}$/`, refuses `shared=true` from a non-manager with 403 and the `discogs.shared_forbidden` text, writes the user and org credential in one transaction, and answers `{"configured","source","shared"}` from the Discogs resolver (admin → env DISCOGS_TOKEN, then user, then org).
Per D-02, `classes.ResolveAIConfig` walks PHP AiConfigResolver's tiers: site admin → the backend global vision model (through `classes.AdminVisionModel`, which reports none configured until Phase 14 ports the global model, exactly as PHP behaves with no global model), org-lock on and user in an org → the org credential only, a per-user credential, an org credential, else `classes.ErrNoAICredential` (`No AI credential configured.`).
Per D-09 (signed off 2026-10-02, costly) sm-user-plugin's `RegisterEvent` gains `Payload map[string]any` holding a copy of the register input without `password` and `password_confirmation` (user-confirmed at the checkpoint); existing listeners compile unchanged and `/register`'s status codes and bodies replay byte-identically.
Per D-11, sm-user-plugin exports `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` (additive; `/register` calls the same functions, no logic duplicated) so `onboarding/bootstrap` hashes, fires and mints exactly as `/register` does.
Per D-10, fonoteka's RegisterEvent listener hashes `Payload["invitation_token"]` with sha256; a pending, unexpired, unrevoked invitation whose `LOWER(email)` equals the user's trimmed lowercased email upserts `PendingInvitationRegistration` on `user_id` (ON CONFLICT DO UPDATE invitation_id); any other case provisions the user's collection.
Per D-11, `POST onboarding/bootstrap` answers 409 `{"error":"Onboarding already completed"}` when any non-deleted user exists (before validation); a validation failure answers the Winter 500 page; otherwise under `pg_advisory_xact_lock(hashtext('fonoteka:onboarding:bootstrap'))` and an in-transaction recount it creates the organisation (slug from org_name), registers the activated user as its owner, commits, fires RegisterEvent and answers `{"token","user"}`.
`GET onboarding/status` answers `{"needs_onboarding":B}` with B true exactly when no non-deleted user exists; `GET invitations/{token}` answers `{"data":{"state":"pending","collection_name":N}}` for a pending invitation and `{"data":{"state":"unavailable"}}` otherwise; both run under the inline `throttle:10,1` and no auth (routes.php 351-364).
The 14 routes of this plan are ported with re-recorded fixtures from the fonoteka reset, `expectedPortedRoutes` is 113, the two credential `/test` routes stay pending (D-02), and `TestFonotekaNuxtFlows/onboarding` (status, bootstrap, replay 409, invite, register with invitation_token, 409 guard, accept) replays green.
Edge (API-04 boundary): with 52 rows for the caller, `GET notifications` returns exactly the 50 highest ids; marking an already-read notification read again answers 200.
Edge (API-04 empty): a user with no notifications gets `{"data":[]}` and `{"data":{"count":0}}`.
Edge (API-06 encoding): a Discogs token with surrounding spaces is stored trimmed; 9 characters or a character outside `[A-Za-z0-9_-]` gives the 500 page; exactly 10 and exactly 255 characters are accepted.
Edge (API-07 concurrency): two concurrent bootstraps on an empty database create exactly one user, one organisation and one owner; the other answers 409.
statement
verification
Edge (API-06 adjacency): an org-less caller of `POST org-ai-credential` becomes owner of the provisioned `plytarium-org-<id>` and is therefore allowed, as PHP OrgProvisioner does.
api_key and token stored through lagoon.NewEncrypted, never serialized
NewEncrypted
requirement_id
category
statement
status
verification
API-07
privacy
RegisterEvent.Payload MUST NOT carry the password or password_confirmation, in plaintext or hashed form, to any listener
resolved
test
requirement_id
category
statement
status
verification
API-07
safety
The sm-user-plugin change MUST NOT alter any existing exported signature, route, status code or response body of the core user plugin; it is additive only (D-09, D-11, core plugin contract)
resolved
test
requirement_id
category
statement
status
verification
API-06
privacy
No credential response, log line, job argument or committed fixture may contain an API key or Discogs token
resolved
test
requirement_id
category
statement
status
verification
API-06
transparency
The ai-credential/test and discogs-credential/test routes MUST NOT be mounted (no 501 or fake-success shells); they stay pending for Phase 14 (D-02, C-07)
resolved
test
Phase Goal
ROADMAP Phase 13 goal (verbatim, not in user-story form): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets.
This plan's slice: a signed-in user reads and clears their bell notifications and manages their own and their organisation's AI and Discogs keys; a fresh install creates its first owner; an invited person sees what they were invited to, registers with the invitation token and is held at the acceptance step, exactly as the Nuxt app experiences PHP (API-04, API-06, API-07).
Port the notifications routes, the credentials CRUD with the AI resolver, onboarding status and bootstrap, invitation inspection, the additive sm-user-plugin exports (D-09, D-11) and the fonoteka register listener (D-10), with re-recorded fixtures and the `onboarding` flow.
Purpose: these are the small surfaces the SPA calls on every screen (bell), on its settings pages (keys) and on first run (onboarding, invite links). Decisions implemented: D-02, D-06, D-09, D-10, D-11, D-12 (onboarding flow), D-15, C-01, C-02, C-03, C-04, C-07.
Output: classes, handlers, routes, user-plugin exports, recordings, the onboarding flow, ported count 113.
Repos: fonoteka.go and the sm-user-plugin submodule at ../fonoteka.go/plugins/golem15/user. The user-plugin change is committed inside the submodule first (path-scoped git -C ../fonoteka.go/plugins/golem15/user add <paths>), not pushed (pushing goes through ssu when the user asks), then the pointer bump is committed in fonoteka.go. Never add co-author tags.
Routes (JWT group): GET /_fonoteka/api/v1/notifications, GET .../notifications/unread-count, POST .../notifications/read-all, POST .../notifications/{id}/read ([0-9]+), GET|POST .../ai-credential, GET|POST|DELETE .../org-ai-credential, GET|POST .../discogs-credential. Onboarding group (throttle:10,1): GET .../onboarding/status, POST .../onboarding/bootstrap. Public invitation (throttle:10,1): GET .../invitations/{token}.
Parity: seed states notifications, credentials, empty, invite-for-register (Go seed and fonoteka_reset.php), fixtures/nuxt/onboarding.yaml, TestFonotekaNuxtFlows/onboarding.
The admin tier of D-02's AI order reads the backend global vision model, which lives in the unported Golem15.Golem plugin. Go exposes it through classes.AdminVisionModel, which answers "none configured" until Phase 14 (INTG-02, reworded in 13-01 Task 4). With no global model PHP behaves identically, so no Phase 13 response differs; this is a dependency boundary, not a reduced decision.
PHP fires golem15.user.register inside the bootstrap transaction. Go fires it after the bootstrap commit, the same point /register fires it, because the event carries no transaction handle (D-09 adds only Payload). A listener failure is logged and ignored as in /register; the active-collection resolver still provisions lazily. Response bodies and the final DB state match.
Task 1: A signed-in user sees their bell list exactly as PHP returns it
`php -v` exits 0 and `../fonoteka.go/parity/php_parity.sh reset` can rebuild the isolated PHP instance (needed for the re-recording).
../fonoteka.go/plugins/golem15/fonoteka/classes/notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/notifications_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/notifications_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/NotificationApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 100-106), ../fonoteka.go/plugins/golem15/fonoteka/models/notification.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitations_controller.go (handler shape), ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/parity/manifest.yaml (the four notifications entries), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_notifications_jwt.yaml, ../fonoteka.go/parity/fonoteka_seed_test.go (seedFonotekaCase extras), ../fonoteka.go/parity/fonoteka_reset.php (PARITY_CASE extras), ../fonoteka.go/parity/parity_test.go (expectedPortedRoutes), ../fonoteka.go/parity/README.md (Phase 12 collections recording recipe)
Per D-06, C-03 and D-12.
(1) classes/notifications.go ListNotifications(ctx, db, userID uint, limit int) ([]models.Notification, error): user_id = ?, ORDER BY id DESC, LIMIT 50. The payload is read so its stored JSON bytes are emitted unchanged (raw message, not a re-marshalled map) to keep PHP's key order.
(2) controllers/api/notifications_controller.go NotificationsIndex(app): JWT caller via requestScope; body {"data":[{"id","type","payload","read_at","created_at"}]} in that key order, times through the existing Carbon +00:00 formatter, null read_at as null, [] (never null) for no rows; DB errors are writeOpaque500. Route g.Get("/notifications", ...) in the JWT group only.
(3) Parity seed state notifications in both fonoteka_reset.php and seedFonotekaCase: 52 rows for alice with fixed ids, types and created_at (two read, payload keys in PHP's order such as album_id, album_name, collection_id, actor_name), one row for bob. Re-record routes/GET___fonoteka_api_v1_notifications_jwt.yaml from the fonoteka reset with seed_hook: fonoteka and cases case (alice, 50 newest) and empty (outsider), following the Phase 12 recipe in parity/README.md. Set the route status: ported, fix every case status from the new fixture, raise expectedPortedRoutes to 100.
(4) Smoke test notifications_smoke_test.go TestNotificationsRoutes (extended in Task 2): through the assembled handler, alice gets 50 rows newest first with payload bytes equal to the stored JSON, outsider gets {"data":[]}.
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestNotificationsRoutes)$' -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: TestNotificationsRoutes"; the parity run reports a FAIL subtest for the notifications route or lacks "--- PASS: TestParityCorpus/coverage".</fails_when>
<acceptance_criteria>
- grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 100.
- grep -A4 'id: "GET /_fonoteka/api/v1/notifications jwt"' ../fonoteka.go/parity/manifest.yaml | grep -c 'status: ported' prints 1.
- grep -c '"/notifications"' ../fonoteka.go/plugins/golem15/fonoteka/routes.go prints 1 and the line sits inside the /_fonoteka/api/v1 JWT group.
- The re-recorded fixture has no {{id:token}}, {{id:genre}} or {{id:wishlist-album}} placeholder (RESEARCH Finding 5 collisions are gone).
</acceptance_criteria>
The bell list is served by Go with PHP's bytes, recorded from the fonoteka reset and replayed in the corpus.
Task 2: A user clears notifications and manages personal and organisation AI and Discogs keys without any secret leaving the server
The isolated PHP parity instance can be reset (`php -v` exits 0).
../fonoteka.go/plugins/golem15/fonoteka/classes/notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/notifications_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/notifications_smoke_test.go, ../fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/NotificationApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AiCredentialController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OrgAiCredentialController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/DiscogsCredentialController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AiConfigResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AiGate.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/UserAiConfig.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/OrgAiConfig.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/DiscogsConfigResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/OrgAccess.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/pl/lang.php (discogs.*), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/en/lang.php (discogs.*), ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service_fuzz_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/org_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go, ../fonoteka.go/plugins/golem15/fonoteka/models/org_ai_credential.go, ../fonoteka.go/plugins/golem15/fonoteka/models/user_discogs_credential.go, ../fonoteka.go/plugins/golem15/fonoteka/models/org_discogs_credential.go, summercms.go modules/lagoon/encrypted.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go, ../fonoteka.go/parity/fixtures/routes/ (the nine credential and three notification fixtures)
(1) Notifications (D-06, C-02): `UnreadNotificationCount`, `MarkAllNotificationsRead` (only the caller's rows with read_at null; set read_at and updated_at now), `MarkNotificationRead` (update scoped by user_id and id; zero rows → `ErrNotificationNotFound`); handlers `NotificationsUnreadCount` `{"data":{"count":N}}`, `NotificationsReadAll` and `NotificationsMarkRead` `{"data":{"state":"read"}}`, ErrNotificationNotFound → `writeWinterHTTPError(w, app, 404)`. Routes `GET /notifications/unread-count`, `POST /notifications/read-all`, `POST /notifications/{id}/read` with `g.Where("id","[0-9]+")`, JWT group only. The two literal routes are registered before `{id}` as in routes.php.
(2) Credentials (D-02, Finding 7): handlers in credentials_controller.go porting each PHP controller's order of checks and exact bodies. Validation goes through lagoon.ValidateRequest with PHP's rules (provider required|in:claude,openai, api_key nullable|string, model nullable|string|max:255, base_url nullable|url; Discogs token required|string then the trimmed regex ^[A-Za-z0-9_\-]{10,255}$, shared sometimes|boolean); any failure, and the missing-key case, answer writeWinterHTTPError(w, app, 500). Writes go through classes SaveUserAICredential, SaveOrgAICredential, DeleteOrgAICredential, SaveDiscogsCredential in credential_write_service.go: upsert on user_id or organisation_id, fill only present validated keys through CredentialFillFields, store api_key or token with lagoon.NewEncrypted, never read a key back except to reuse it (the org store reuses the caller's UserAiCredential key, as PHP does). Org store order: ProvisionOrgFor, then CanManageOrg → 403 {"error":"Forbidden"}, then validation. Discogs shared=true from a non-manager → 403 {"error": <lang golem15.fonoteka::lang.discogs.shared_forbidden>} (port the pl and en strings to lang.yaml); the user and org rows write in one lagoon.Transaction; DiscogsStatus builds {"configured","source","shared"} from DiscogsAllowed, ResolveDiscogsConfig's source and the org credential's existence. Routes in the JWT group: GET|POST /ai-credential, GET|POST|DELETE /org-ai-credential, GET|POST /discogs-credential; do not mount the two /test routes.
(3) AI resolver (D-02): ai_config_resolver.go AIConfig{Adapter, APIKey, BaseURL, Model string}, ResolveAIConfig(ctx, db, cfg, user) (*AIConfig, error) porting AiConfigResolver's tiers with UserAiConfig/OrgAiConfig defaults (read AiDefaults.php), ErrNoAICredential with PHP's message, and AdminVisionModel as a package-level func(ctx) (*AIConfig, error) defaulting to "none configured" (nil, nil) with a doc comment naming Phase 14 INTG-02. AIAllowed's site-admin branch allows when AdminVisionModel returns a config, otherwise falls through as today. No route calls ResolveAIConfig in Phase 13.
(4) Recordings (D-12): seed state credentials (alice user AI and Discogs credentials, the org's AI and Discogs credentials; bob a plain org member) in both seeds; re-record every case per route from the reset: show configured and not, store (user, org owner, org member 403, org-less caller), destroy (200, no-org 404, member 403), discogs store (user, shared by owner, shared by member 403), at least one 500 page per store route (bad provider, malformed token, missing key), notifications unread-count, read-all, read (own 200, foreign 404 page, missing 404 page). Flip the ten routes to ported; expectedPortedRoutes 110.
(5) Smoke tests: extend TestNotificationsRoutes (count, read-all scope, read own and foreign); credentials_smoke_test.go TestCredentialsCRUD (every body above), TestCredentialSecretsNeverSerialized (stored columns are ciphertext; no response body or captured slog line contains the plaintext key or token), TestResolveAIConfigPrecedence (admin with and without a vision model, org-lock on with a per-user key, per-user, org fallback, nothing → ErrNoAICredential), TestDiscogsSharedMirror (owner shared mirrors to the org row in one transaction; member shared 403 writes nothing).
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestNotificationsRoutes|TestCredentialsCRUD|TestCredentialSecretsNeverSerialized|TestResolveAIConfigPrecedence|TestDiscogsSharedMirror)$' -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 a "--- PASS" line for each of the five named tests; the parity run reports FAIL for a notifications or credential subtest or lacks "--- PASS: TestParityCorpus/coverage".</fails_when>
<acceptance_criteria>
- grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 110.
- grep -cE '"/(ai|discogs)-credential/test"' ../fonoteka.go/plugins/golem15/fonoteka/routes.go prints 0 and both /test manifest entries still read status: pending.
- grep -c 'NewEncrypted' ../fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service.go prints at least 1.
- grep -c 'shared_forbidden' ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml prints at least 1.
- Each credential store route has at least one recorded case with status: 500 whose fixture Content-Type is text/html; charset=UTF-8.
- grep -c 'func ResolveAIConfig(' ../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go prints 1.
</acceptance_criteria>
Notifications can be counted and cleared, and personal and organisation keys can be saved, mirrored and removed with PHP's bodies, error pages and resolution order, with every secret encrypted and never echoed.
Task 3: A fresh install creates its first owner, and an invited person registers with the link's token and is held until they accept
D-09: once other plugins read RegisterEvent.Payload, removing or reshaping it breaks them in every project using the user plugin. Signed off by the user on 2026-10-02, including the password-key stripping; recorded without a new checkpoint.
The isolated PHP parity instance can be reset and its log mailer is available (`php_parity.sh serve-mail`, Phase 12 household recipe).
../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/register_test.go, ../fonoteka.go/plugins/golem15/user/README.md, ../fonoteka.go/plugins/golem15/user, ../fonoteka.go/plugins/golem15/fonoteka/classes/onboarding.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/onboarding_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitation_inspect_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go, ../fonoteka.go/plugins/golem15/fonoteka/onboarding_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/nuxt/onboarding.yaml, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/README.md
../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Register lines 602-703, apiArray, mintFor, requestURL, sessionDeps, registrationSettings), ../fonoteka.go/plugins/golem15/user/register_test.go, ../fonoteka.go/plugins/golem15/user/README.md, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OnboardingController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php (lines 175-205), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/InvitationService.php (inspect, invitationByToken), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/InvitationApiController.php (inspect), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 351-364), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php (register: where golem15.user.register is fired and with what payload), ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Boot listener), ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go (guardPendingInvitation), ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go (invitationTokenHash), ../fonoteka.go/plugins/golem15/fonoteka/models/slug.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go (phase12Universe), ../fonoteka.go/parity/fonoteka_flows_test.go (isolatedFlowDB, replayNuxtCollections), ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml (secret:invite two-run recipe), ../fonoteka.go/parity/README.md (Phase 12 household recording)
(1) sm-user-plugin, per D-09 and D-11 (additive only, core plugin contract): add `Payload map[string]any` to RegisterEvent with a doc comment (a copy of the register input; `password` and `password_confirmation` are never included). In controllers/registration.go export `RegisterOptions{Activate bool; IP string}`, `RegisterUser(ctx, app, db *gorm.DB, fields map[string]any, opts RegisterOptions) (*models.User, map[string][]string, error)` (the existing validate, hash with golem15.user.password.bcrypt_cost, fill, create steps of Register, unchanged; validation errors returned, not written), `FireRegisterEvent(ctx, app, user, fields)` (builds Payload from a copy of fields minus the two password keys and fires; returns the listener error), `IssueToken(app, user, r *http.Request) (string, error)` (mintFor with the secret and requestURL issuer exactly as Register uses) and `APIArray(ctx, app, user)` (apiArray). Register calls these in its existing order and still ignores the fire error; every status and body stays the same. register_test.go `TestRegisterEventPayload`: a listener sees email and an extra `invitation_token` key, never either password key; a listener that does not read Payload still compiles (the test registers one). README: API reference entries for the five exports and Payload, Extension events wording. Commit inside the submodule (path-scoped), then bump the pointer in fonoteka.go in its own commit.
(2) fonoteka listener, per D-10: classes/onboarding.go HandleRegisterEvent(ctx, db, e *userclasses.RegisterEvent) error: a string invitation_token in Payload hashed with invitationTokenHash matches an invitation with accepted_at and revoked_at null, expires_at in the future and LOWER(email) equal to the user's trimmed lowercased email → INSERT ... ON CONFLICT (user_id) DO UPDATE SET invitation_id into pending_invitation_registrations; otherwise ProvisionCollection for the user. plugin.go Boot registers it with app.Events.Listen[*userclasses.RegisterEvent]("golem15.fonoteka", ...) using the lazily published *gorm.DB.
(3) Onboarding, per D-11: OnboardingNeeded(ctx, db) counts users with deleted_at null; BootstrapOwner(ctx, app, db, input, r): a count before anything → ErrOnboardingCompleted (409 {"error":"Onboarding already completed"}); lagoon.ValidateRequest with org_name required|string|max:255, email required|email, password required|min:8, plus an existence check for the email (unique:users) → any failure the Winter 500 page; then one lagoon.Transaction taking SELECT pg_advisory_xact_lock(hashtext('fonoteka:onboarding:bootstrap')), recounting (non-zero → ErrOnboardingCompleted), creating the organisation (name, slug from models.Slug of org_name), calling RegisterUser with password_confirmation equal to password and Activate true (validation errors → 500 page), setting organisation_id and organisation_role owner; after commit FireRegisterEvent with the input (error logged, not returned), IssueToken and APIArray → 200 {"token","user"}. Handlers OnboardingStatus{"needs_onboarding":B} and OnboardingBootstrap; fill the routes.go onboarding group (/onboarding/status, /onboarding/bootstrap).
(4) Inspection: invitation_service.go InspectInvitation(ctx, db, rawToken) (state, collectionName string, err error) porting InvitationService::inspect (pending only when the invitation is pending by its Status rules); handler InvitationInspect answers {"data":{"state":"unavailable"}} or {"data":{"state":"pending","collection_name":N}}; route GET /{token} in the public_invitation group (prefix /_fonoteka/api/v1/invitations, throttle:10,1, no constraint).
(5) routes_table_phase12_test.go: narrow phase12Universe's /invitations/ entry so only /invitations/{token}/accept counts as Phase 12 (the public inspection route belongs to Phase 13); every Phase 12 assertion stays.
(6) Recordings and flow (D-12, D-15): seed states empty (no users) and invite-for-register (a pending invitation from alice to a new address) in both seeds. Re-record route cases: status on empty and seeded, bootstrap 200 (empty), 409 (seeded), 500 page (empty with a short password), inspect pending and unavailable; keep at most 10 inline-throttled anonymous cases per route and reset PHP before each anonymous recording. Record fixtures/nuxt/onboarding.yaml with the two-run secret:invite recipe: status, bootstrap, status, bootstrap 409, owner invites a new address, inspect, register that address with invitation_token, me/context 409 page (guard), accept, me/context 200. Add TestFonotekaNuxtFlows/onboarding replaying it on an isolated database and asserting one owner, one organisation and the cleared pending registration. Flip the three routes; expectedPortedRoutes 113.
(7) Smoke tests onboarding_smoke_test.go: TestBootstrapConcurrent (two goroutines on an empty DB: one 200, one 409, one user, one org), TestRegisterInvitationListener (valid token → pending registration and no collection; expired, revoked, foreign-email and missing token → collection provisioned), TestInspectInvitation (pending, accepted, expired, revoked, unknown).
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestRegisterEventPayload)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestBootstrapConcurrent|TestRegisterInvitationListener|TestInspectInvitation|TestRouteTablePhase12)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus|TestFonotekaNuxtFlows|TestUserAPINuxtFlows)$' -count=1 -v
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE"; output lacks "--- PASS: TestRegisterEventPayload", "--- PASS: TestBootstrapConcurrent", "--- PASS: TestRegisterInvitationListener", "--- PASS: TestFonotekaNuxtFlows/onboarding", "--- PASS: TestUserAPINuxtFlows" or "--- PASS: TestParityCorpus/coverage".</fails_when>
<acceptance_criteria>
- grep -c 'Payload map\[string\]any' ../fonoteka.go/plugins/golem15/user/classes/events.go prints 1.
- grep -rnE '^func (RegisterUser|FireRegisterEvent|IssueToken|APIArray)\(' ../fonoteka.go/plugins/golem15/user --include=*.go | wc -l prints 4.
- grep -cE 'RegisterUser|FireRegisterEvent|IssueToken|APIArray|Payload' ../fonoteka.go/plugins/golem15/user/README.md prints at least 5.
- git -C ../fonoteka.go/plugins/golem15/user log -1 --name-only lists no file outside classes/events.go, controllers/, register_test.go and README.md.
- grep -c 'pg_advisory_xact_lock' ../fonoteka.go/plugins/golem15/fonoteka/classes/onboarding.go prints 1.
- grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 113 and test -f ../fonoteka.go/parity/fixtures/nuxt/onboarding.yaml succeeds with grep -cE '[0-9a-f]{64}' ../fonoteka.go/parity/fixtures/nuxt/onboarding.yaml printing 0.
</acceptance_criteria>
A new site gets exactly one first owner, invitation links show what they invite to, and registering with an invitation token holds the newcomer at acceptance, all through the unchanged-contract user plugin.
<threat_model>
Trust Boundaries
Boundary
Description
Anonymous client → onboarding and inspection routes
Unauthenticated input creates the first owner or probes invitation tokens
JWT client → notifications and credentials
A user reads and writes only their own rows; org writes need owner/admin
Credential at rest → DB and logs
API keys and Discogs tokens must exist only as ciphertext
User plugin event → fonoteka listener
Register input crosses a plugin boundary
STRIDE Threat Register
Threat ID
Category
Component
Severity
Disposition
Mitigation Plan
T-13-08
Information Disclosure
credential responses, logs, marshal
high
mitigate
lagoon.Encrypted with redacting marshal, json:"-" models, show returns provider/model/base_url only; TestCredentialSecretsNeverSerialized scans bodies and captured logs (Task 2).
T-13-09
Elevation of Privilege
org credential store/destroy, Discogs shared
high
mitigate
CanManageOrg after ProvisionOrgFor → 403 Forbidden or shared_forbidden, writing nothing; TestCredentialsCRUD and TestDiscogsSharedMirror member cases (Task 2).
T-13-10
Tampering
credential owner FK mass assignment
high
mitigate
Writes fill only CredentialFillFields; user_id/organisation_id come from the session; the existing credential fuzz plus the 13-06 request fuzz (Task 2).
T-13-11
Tampering
base_url as SSRF target
medium
accept
Phase 13 only stores base_url (`nullable
T-13-18
Elevation of Privilege
onboarding bootstrap
high
mitigate
409 before validation when any user exists; advisory lock plus in-transaction recount; users.email unique backstop; TestBootstrapConcurrent (Task 3).
T-13-19
Spoofing
register listener invitation match
high
mitigate
sha256 match, not accepted, not revoked, expires_at in the future, LOWER(trim(email)) equality; TestRegisterInvitationListener expired/revoked/foreign cases (Task 3).
T-13-20
Information Disclosure
RegisterEvent.Payload
high
mitigate
Payload copies input minus password and password_confirmation; TestRegisterEventPayload (Task 3).
T-13-21
Information Disclosure / Tampering
notifications read and mark-read
medium
mitigate
Every query scoped by user_id; zero updated rows → Winter 404 page; TestNotificationsRoutes foreign-id case (Task 2).
T-13-27
Information Disclosure
invitation inspection
medium
mitigate
Only a sha256-matched pending invitation reveals its collection name; 64-hex tokens; throttle:10,1 shared anonymous budget; TestInspectInvitation (Task 3).
T-13-SC
Tampering
package installs
low
accept
No new dependency in this plan.
</threat_model>
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus at 113 ported and passing; `TestFonotekaNuxtFlows/onboarding` and `TestUserAPINuxtFlows` pass; check_corpus `--require-recorded --check-secrets` green.
- The sm-user-plugin commit touches only the additive files; its pointer bump is its own fonoteka.go commit.
<success_criteria>
Four notification routes, seven credential routes, two onboarding routes and invitation inspection ported with PHP bodies and error pages.
The D-02 resolution order exists for both AI and Discogs; secrets never leave the server.
The register hook works through an additive, documented user-plugin change.
</success_criteria>
Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-02-SUMMARY.md` when done.