diff --git a/.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md b/.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md new file mode 100644 index 0000000..2635c22 --- /dev/null +++ b/.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md @@ -0,0 +1,662 @@ +# Phase 14.2: Local Fonoteka frontend on local SummerCMS backend - Research + +**Researched:** 2026-10-07 +**Domain:** Local dev-stack glue: Go seed command, start script and fail-closed guard, Nuxt dev proxy, Playwright e2e against the Go backend +**Confidence:** HIGH for the code facts (read in this session, binary built and probed). MEDIUM for the Centrifugo side, because the shared `/etc/centrifugo/config.json` cannot be read. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +#### Local data +- **D-01:** Local data comes from `fonoteka migrate` followed by a new seed command. The Phase 20 `import:winter` runner is not pulled forward, and PHP data is not imported in this phase. +- **D-02:** The seed is an application command, `fonoteka dev:seed` (final name at the planner's discretion), in the fonoteka plugin, not a framework seeder hook. It refuses to run unless the app environment is local/dev, and it is idempotent: a second run changes nothing. It creates: + - a verified user; + - an owned collection; + - a few albums; + - a wishlist entry; + - a personal API token (`inv_` prefix) so fonoteka-mcp can be pointed at the local stack. +- **D-03:** Seeded albums get bundled test cover images (small, embedded or generated, no network), attached through the normal attach path into `storage/app/uploads/public`. Their `/storage/app/uploads/public/...` URLs then resolve through the Nuxt `/storage` proxy right away. +- **D-04:** The seeded user's credentials come from `E2E_EMAIL` / `E2E_PASSWORD`, the same variables the Nuxt Playwright suite uses, with a fallback to `demo@example.com` / `changeme`. That way the seed and the Playwright login match without extra wiring. + +#### Stack startup +- **D-05:** A single script, `fonoteka.go/scripts/dev-stack.sh`, uses host services and adds no docker compose. It: + 1. checks that the services are reachable; + 2. runs `summer build`, `migrate` and `dev:seed`; + 3. reindexes search after the seed; + 4. starts `fonoteka serve`; + 5. runs the fail-closed guard (D-09); + 6. starts `pnpm dev` in the sibling `vue-fonoteka-app`. +- **D-06:** Postgres, Typesense and Centrifugo are all required. The script refuses to start, and names the missing service, if any of them is unreachable. Realtime is expected to work locally, which means `NUXT_PUBLIC_CENTRIFUGO_WS_URL` is set and the app origin `http://localhost:3119` is in Centrifugo's allowed origins (documented). +- **D-07:** Go serves on `:8423`, which is distinct from PHP's `:8422` and from the framework default `:8080`. PHP and Go can then run side by side, and a stale origin is obvious. +- **D-08:** Settings and secrets live in a gitignored `fonoteka.go/.env.dev`, with a committed `.env.dev.example` that holds placeholders only. It covers the DSN, JWT secret, app key, Typesense and Centrifugo keys, and the Nuxt-side values. The script sources it and fails, listing each missing key. The script passes `NUXT_DEV_BACKEND_ORIGIN`, `NUXT_INTERNAL_API_ORIGIN` and `NUXT_PUBLIC_CENTRIFUGO_WS_URL` as environment variables on the `pnpm dev` process. It never writes `vue-fonoteka-app/.env`, so the developer's PHP-targeted setup stays intact. + +#### PHP fall-through guard +- **D-09:** The guard has three parts: + 1. **Preflight:** before Nuxt starts, the script probes the Go origin for a Go-only marker and refuses to continue if the marker is missing, if the target is `:8422`, or if a PHP backend answers. + 2. **Pinned SSR origin:** `NUXT_INTERNAL_API_ORIGIN` is always set explicitly to the Go origin, so SSR can never fall back to `https://plytarium.com`. + 3. **Post-start probe:** once Nuxt is up, the script probes through Nuxt's proxy (`http://localhost:3119/...`) and confirms the marker comes back, which proves the proxy reaches Go. +- **D-10:** The marker is a route only the Go binary serves (an existing framework health/readiness route, or the smallest new one) that stays **outside the parity manifest**. No manifest route's response changes, and no new header is added to API responses. +- **D-11:** `vue-fonoteka-app` may receive **small non-breaking changes** (user's decision). The constraint: the app must still run without issues against the PHP backend. Gaps found during the session are fixed in Go first, with a parity case added where a recorded PHP response exists. An app change is acceptable only when it leaves PHP behavior unchanged (for example an env-switched test helper, a new backend-agnostic spec, or docs and `.env.example` notes). Commits in `vue-fonoteka-app` go to its own repo, which is a submodule of the PHP `fonoteka` repo. +- **D-12:** Failures show up through the Go request log, which is redacted by sunscreen and records each request's path and status, and through Nuxt's normal behavior. The script tees both processes into labeled log files, and the run docs say to watch them for 4xx/5xx and 404 responses. No 404 tally or dev-only error UI is added. + +#### Flow proof +- **D-13:** The flows are proven twice: + - **Playwright:** the existing Playwright e2e suite runs against the Go stack. + - **Manual session:** one checklist-driven session, recorded as UAT evidence. It covers login, browse collections and albums, search, edit, rate, cover upload, wishlist add/remove, and realtime working. +- **D-14:** The Playwright seed helper (`tests/e2e/helpers/runTinker.ts`) gets a non-breaking switch. When `E2E_BACKEND=go` is set, it seeds by running the Go binary's `dev:seed` (binary path from `E2E_GO_BIN` or similar) instead of `php artisan tinker`. When the switch is unset, today's PHP behavior is unchanged. +- **D-15:** The Go gate is the core-flow subset of specs: `auth.setup`, `login`, `smoke`, `fonoteka-item` (browse/edit/rate/cover), `public-share`, `realtime`, `i18n`, plus whichever specs cover search and wishlist. The researcher maps each flow to a spec. `fonoteka-ai-assist`, `fonoteka-bulk-add`, `offline`, `registration-invitation` and `responsive` are run and reported but do not block, because they need external keys or are outside the core flows. +- **D-16:** If no existing spec covers a core flow (wishlist add/remove and search are the likely candidates), a new spec is added to `vue-fonoteka-app`. It must be backend-agnostic, so it passes against both PHP and Go. +- **D-17:** The last plan of the phase (unit tests last, project rule) adds Go unit tests for `dev:seed` (environment refusal, idempotency, created shape) and for the marker route. It also adds `summercms.go/scripts/check-phase14.2.sh`, following the existing `check-phase*.sh` pattern, which: + - runs `go vet` and `go test ./...`; + - exercises the guard against a stale `:8422` origin and against an unset internal origin, and expects a refusal each time; + - runs the Go-gate Playwright subset (D-15) against the started stack. + +### Claude's Discretion +- The exact command name and flags of the seed command, the number of seeded albums and their content, and how the test images are produced. +- Which Go-only route serves as the marker, and how a PHP answer is detected in the preflight. +- The script structure: subcommands, how processes are supervised and stopped, log file locations, and whether `summer build` runs on every start. +- The run docs (a section in `fonoteka.go/README.md` or a dedicated `RUNNING-LOCALLY.md`) and the keys in `.env.dev.example`. +- How River jobs run locally (`work_in_serve: true` is the current default and is fine). + +### Deferred Ideas (OUT OF SCOPE) +- Bringing the Phase 20 `import:winter` runner forward to load real local PHP data was considered and not chosen. It stays in Phase 20. +- A 404/5xx tally by path at script shutdown was offered and not chosen. It can be added later if the logs prove too noisy. + +#### Reviewed Todos (not folded) +- `2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md`: a benchmark, not local-run glue (Phase 20 lists it as deferred too). +- `nest-framework-packages-under-modules.md`, `bonfire-duplicate-command-names.md`, `backend-admin-api-tokens.md`: framework work unrelated to the local Nuxt run; matched by keyword only. + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| QA-05 | "Cutover: the parity harness is green on all 154 routes and vue-fonoteka-app and fonoteka-mcp run unchanged against the Go backend" [VERIFIED: .planning/REQUIREMENTS.md:124] | This phase covers the "vue-fonoteka-app runs unchanged against the Go backend" half locally. See the Flow-to-Spec map (§Q1), the Go gaps that block an unchanged run (G1–G4), the guard design (§Pattern 4), and the Validation Architecture. The fonoteka-mcp half is covered only as far as minting a local `inv_` token (D-02); MCP must call Go directly on `:8423` because Nuxt does not proxy `/api/v1/fonoteka/*`. Note: the REQUIREMENTS traceability table already marks QA-05 "Phase 15 / Complete" [VERIFIED: .planning/REQUIREMENTS.md:245]. That is a bookkeeping inconsistency for the planner/verifier to fix; it does not change this phase's scope. | + + +## Project Constraints (from CLAUDE.md) + +- Lean planning: few, large plans per phase. Present the plan count with a one-line scope for each plan and wait for confirmation before writing PLAN.md files. +- Unit tests are always the **last** plan of a phase. Earlier plans may carry smoke tests. +- Go: standard library first. Add a dependency only when the research doc or a phase decision names it. Keep `go vet` and `go test ./...` green at every commit. +- Compiled plugins only. No runtime plugin loading. +- API parity is the acceptance test. Do not "improve" response shapes. (Relevant here: the marker route stays outside the manifest, and no manifest response changes, per D-10.) +- Commits: no co-author tags, one logical change per commit, planning docs and code in separate commits. `vue-fonoteka-app` commits go to its own repo. Shared plugin submodules (`plugins/golem15/user` and others) are committed inside the submodule first, then the pointer is bumped in a separate commit. Never stage submodule files from the parent. +- Core plugins (user, blog, pages, payment) must not get breaking changes. `sm-user-plugin` is shared, so do not add a marker route or change behavior there. +- Documentation: any change to a `summercms.go/modules/*` exported API, config key or CLI command updates that module's `README.md` and the affected `docs/` pages in the same change. Check with `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check`. Framework READMEs never name a consuming application. The `fonoteka.go` README is the application, so naming Płytarium and Nuxt there is fine. +- Do not hand-edit `fonoteka.go/main.go` or `plugins.gen.go`; `summer build` regenerates them. +- Two repos: `summercms.go` is the framework only; `fonoteka.go` is the app. Planning docs stay in `summercms.go/.planning`. + +## Summary + +The phase is mostly glue, but this research turned up four **Go-side gaps** that would make an "unchanged" Nuxt run fail. Each was verified by building the current `fonoteka.go` binary and running it: + +1. **Plugin CLI commands get no database.** `fonoteka fonoteka:reindex` and `fonoteka user:token:create` both exit 1 with `database is not configured` in the real binary. Only the lagoon, conga and surf runtime commands open and publish the pool. D-05 step 3 (reindex) is therefore broken today, and the new `dev:seed` must open the database itself. +2. **The Go binary does not serve `/storage/app/uploads/public/...`.** `route:list` has no such route. The framework ships `attach.StaticHandlerPublic`, but nothing mounts it. Every cover through the Nuxt `/storage` proxy would 404, which breaks D-03. +3. **There is no per-request log.** `surf` has no `slog` call at all, so the "Go request log … records each request's path and status" that D-12 relies on does not exist. +4. **There is no health or marker route.** D-09's post-start probe goes *through the Nuxt proxy*, so the marker must sit under a prefix Nuxt proxies (`/_fonoteka/api`, `/_user/api`, `/_feedback/api`, `/api/realtime`, `/storage`, `/plugins/golem15/feedback`). A framework path such as `/_summer/health` would not be reachable through Nuxt. + +On the Nuxt side, the biggest risk in the CONTEXT turns out to be smaller than expected: **`runTinker`/`seedE2EFixtures` has zero callers**. No spec seeds anything. The specs need only a logged-in user (E2E_EMAIL/E2E_PASSWORD), an active collection and, for browse/i18n/offline, some albums. The real gap is coverage. `fonoteka-item.spec.ts` only *creates* an album (no edit, rate or cover), and **no spec covers search, edit, rate, cover upload or wishlist add/remove**. `realtime.spec.ts` is also stale: its `realtime-status`/`realtime-degraded` test IDs were removed from the app on 2026-06-12, so it fails against *any* backend. `public-share.spec.ts` stubs every `/public/*` call with `page.route`, so it never touches the backend. + +**Primary recommendation:** close G1–G4 in Go first (plan 1). Then build `dev-stack.sh` with `preflight` / `up` / `down` subcommands and a dev-only marker route under `/_fonoteka/api/v1/` (plan 2). Add backend-agnostic, self-seeding specs for album edit/rate/cover, search and wishlist, rewrite `realtime.spec.ts`, and add the `E2E_BACKEND=go` switch (plan 3). Finish with Go unit tests and `check-phase14.2.sh`, which parses the Playwright JSON report and treats any *skip* in a gate spec as a failure (plan 4). + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Login, JWT issue/refresh | API / Backend (Go `golem15.user`) | Browser (JS cookie `auth_token`, Bearer header) | JWT lives in a JS-readable cookie and is sent as `Authorization: Bearer`. The backend sets no cookies, so the proxy needs no cookie rewriting. | +| Same-origin API routing in dev | Frontend Server (Nuxt dev: Vite proxy + Nitro devProxy + routeRules) | — | `NUXT_DEV_BACKEND_ORIGIN` is read once at config load. No CORS is involved. | +| SSR API calls | Frontend Server (Nitro, `internalApiOrigin`) | API | SSR uses the absolute origin, not the proxy. Pinning it is D-09.2. | +| Upload serving `/storage/app/uploads/public/*` | API / Backend (Go, **new mount**) | CDN/nginx in prod (Phase 20) | Nuxt proxies `/storage` to the backend origin. Go must answer it. | +| Realtime token + subscribe proxy | API (Go `/api/realtime/token`, `/api/realtime/subscribe`) | Centrifugo (external service) | The browser connects to Centrifugo directly (not proxied). Centrifugo calls Go's subscribe proxy. | +| Search | API (Go → Typesense when `search_use_typesense` is on) | Database (fallback) | Go falls back to SQL on an engine error, logging a Warn. | +| Seed data | CLI (fonoteka plugin command) | Database + upload bucket | App command (D-02), not a framework hook. | +| Fail-closed guard | Shell script (`dev-stack.sh preflight`) | API marker route | The script decides; the Go route only identifies itself. | + +## Key Findings and Gaps (read first) + +| # | Finding | Evidence | Impact / what the plan must do | +|---|---------|----------|---------------------------------| +| G1 | Plugin commands run without a database. `fonoteka:reindex` prints `fonoteka:reindex: database is not configured` (rc=1); `user:token:create` prints `golem15.user: database is not configured` (rc=1). | [VERIFIED: ran freshly built binary with SUMMER_ENV=local and a bogus DSN; `migrate:status` reached the DB (`lagoon: ping postgres: failed to connect … password authentication failed`) while the two plugin commands failed before connecting]. Cause: `console/reindex.go:55-58` reads `app.Lookup[*gorm.DB]()`, and only `lagoon/commands.go:230-240 withDB`, `conga/commands.go:213-224 withDB` and `surf/serve.go:40-47` open and publish the pool. | `dev:seed` must call `lagoon.OpenFromApp` + `lagoon.Publish` itself. Publish is required (not just Open), because the fonoteka slug and credential hooks register through `lagoon.OnDatabase` (`plugin.go:209-210`). `fonoteka:reindex` needs the same fix (app-level), or D-05 step 3 fails. The `user:token:*` commands in the shared `sm-user-plugin` are broken the same way. This is out of scope here (the seed mints its own token), so log it as a todo. | +| G2 | No `/storage` route in Go. | [VERIFIED: `route:list` of a fresh build; the non-API routes are only `_feedback`, `/plugins/golem15/feedback/assets/js/embed.js`, `/.well-known/oauth-authorization-server`, `/oauth/mcp/*`, `/api/realtime/{token,subscribe}`, `/plytadmin*`]. The framework has `func StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler` [VERIFIED: summercms.go/modules/lagoon/attach/static.go:32], which no package mounts. | Mount `GET {storage.uploads.public_path_prefix}/{path...}` → `attach.StaticHandlerPublic`, resolving bucket and DB per request. Phase 20 D-06 also routes `/storage/app/uploads` to Go, so this is needed beyond local dev. | +| G3 | No request access log. | [VERIFIED: `grep slog surf/*.go` (non-test) returns nothing; no `access_log` anywhere in modules]. The sunscreen README only describes a redacting handler. | D-12's "Go request log" must be added: an opt-in surf middleware (config key, default off) logging method, matched pattern, status and duration. Its exported surface needs README/docs updates. | +| G4 | No health/marker route, and the marker must be Nuxt-proxied. | [VERIFIED: route list above; Nuxt proxy prefixes at nuxt.config.ts:247-266, 364-372, 392-401]. | Add a dev-only route under `/_fonoteka/api/v1/` in the fonoteka plugin (see Pattern 3). | +| G5 | `runTinker`/`seedE2EFixtures` has **no callers**; specs do not seed. | [VERIFIED: `grep -rn "runTinker\|seedE2EFixtures" tests/` matches only `helpers/runTinker.ts` and comments]. | The D-14 switch is cheap. The real seeding need is a user, an active collection, a wishlist and some albums (with covers for offline). New specs should seed themselves through the UI or API. | +| G6 | `realtime.spec.ts` is stale. | [VERIFIED: `realtime.spec.ts:28` `page.getByTestId('realtime-status')`, `:39` `getByTestId('realtime-degraded')`; neither ID exists in `app/` (grep). Git `-S"realtime-status"`: added ff33376 2026-06-10, removed 6fcdb8e 2026-06-12 "compose dashboard"]. | It fails on PHP and Go alike. Rewrite it as a backend-agnostic spec (a D-11 test-only change). | +| G7 | `fonotekaReachable()` probes `E2E_BACKEND_URL` directly, defaulting to `'http://localhost:8422'`. | [VERIFIED: tests/e2e/helpers/fonotekaReachable.ts:3,8]. | If unset or left in `tests/e2e/.env.local`, gate specs **skip** (Go-only stack) or gate on PHP. The check script must export `E2E_BACKEND_URL=http://127.0.0.1:8423` and fail on any skip. | +| G8 | Search silently falls back to SQL when Typesense errors. | [VERIFIED: classes/album_search.go:217-228, `logger.Warn("golem15.fonoteka: album search engine failed, falling back to the database search", …)`]. | A search spec passes even with Typesense broken. The check script should grep the Go log for that Warn and fail on it. | +| G9 | `search_use_typesense` is seeded **false**, and `fonoteka:reindex` refuses while it is off. | [VERIFIED: updates/20_remaining.go:198-200 `SELECT 1, false, NOW(), NOW()`; console/reindex.go:68-70 `!searchEnabled(ctx, gdb)` → `reindexNotConfigured`]. | `dev:seed` must set it true. Flip it last (after the albums exist) so the seed's own writes do not hit Typesense before the index exists, then run reindex. | +| G10 | The Typesense container is stopped. | [VERIFIED: `docker ps -a`: `plytarium-typesense-instance typesense/typesense:26.0 Exited`; port binding `{"8108/tcp":[{"HostIp":"127.0.0.1","HostPort":"8181"}]}`]. | The preflight names it and suggests `docker start plytarium-typesense-instance`. | +| G11 | `fonoteka.go/storage/` is not gitignored. | [VERIFIED: fonoteka.go/.gitignore = `/bin/ /tmp/ *.exe go.work.sum *.sqlite parity/.vars/ parity/tmp/`; `storage/app/uploads` exists, empty]. | Seeded covers would show as untracked files. Add `/storage/app/uploads/public/` (and `.env.dev`) to `.gitignore`. | +| G12 | The shared Centrifugo (v6.5.1, systemd, `/etc/centrifugo/config.json`, mode 600 owned by `http`) cannot be read. Its subscribe proxy most likely targets PHP. | [VERIFIED: process `/usr/local/bin/centrifugo -c /etc/centrifugo/config.json`, listening `*:8787`; `centrifugo version` = v6.5.1]. Proxy target: [ASSUMED]. | See Open Question 1. A Go-issued token with the same HMAC secret connects, but `collection:{id}` subscribes go to whatever proxy endpoint the config names. | +| G13 | `fonoteka.go` submodules have pointer drift (`feedback`, `golem`, `user` each ahead by a "pin release dependencies" commit). | [VERIFIED: `git diff --submodule=log`]. | This was already the state before the phase. Plans must not sweep these pointers into phase commits by accident. | + +## Q1. Flow → Spec map and seeding (D-15/D-16) + +**No spec calls `runTinker` or `seedE2EFixtures`.** Seeding today is manual (the helper is "a DOCUMENTED, opt-in helper — it is NOT auto-run by the suite" [VERIFIED: tests/e2e/helpers/runTinker.ts:15-16]). What `seedE2EFixtures` would send if called [VERIFIED: runTinker.ts:92-106]: +- `\Golem15\User\Models\User::firstOrNew(['email' => E2E_EMAIL])`, then set the password and confirmation and `is_activated = true`, then save. The Go seed covers this. +- `\Golem15\Journal\Models\Post::updateOrCreate(['slug' => 'e2e-seed-post'], …)`. Journal is not part of `fonoteka.go`, and the app does not use `journalApiBase` anywhere (grep `journal` in `app/` returns nothing). Under `E2E_BACKEND=go` this step is **skipped**. + +How each spec gets its data: + +| Spec | Flow(s) it proves | Backend touched? | Data it needs | Gate (D-15) | +|------|-------------------|------------------|---------------|-------------| +| `auth.setup.ts` | login (UI, `/en/login`) → storageState `tests/e2e/.auth/user.json` | yes | user with E2E creds, `must_change_password=false` | gate (runs as `setup` project dependency) | +| `login.spec.ts` | login + "JWT never in markup" | yes | same user | gate | +| `smoke.spec.ts` | landing renders | no | — | gate | +| `fonoteka-item.spec.ts` | **create** album at `/albums/new` (only that, no edit/rate/cover) | yes (`fonotekaReachable` + creds) | active collection; genres (seeded by migrate) | gate | +| `public-share.spec.ts` | `/k/{token}` render, 404/429 panels | **no**: every `/_fonoteka/api/v1/public/{token}*` call is stubbed with `page.route` | none | gate (backend-agnostic, proves nothing about Go's `/public/*`) | +| `realtime.spec.ts` | intended: realtime badge | **stale**: test IDs removed 2026-06-12 | — | gate → **must be rewritten** | +| `i18n.spec.ts` | locale toggle, html lang, keys; one backend case `/kolekcja` export CTA `Eksportuj \d+ pozycj` | one case | albums in the active collection (count) | gate | +| `landing.spec.ts` | marketing landing | no | — | not listed (report) | +| `responsive.spec.ts` | `/albums` search input and filter sheet visible | yes | logged-in user | report-only | +| `offline.spec.ts` | Dexie offline sync, covers offline | yes | albums **with covers** (`blobCoverCount > 0`) | report-only | +| `fonoteka-ai-assist.spec.ts` | AI page shell (recognize stubbed) | yes | user | report-only | +| `fonoteka-bulk-add.spec.ts` | bulk add (Discogs fully stubbed) | yes | user | report-only | +| `registration-invitation.spec.ts` | registration/invite (fully stubbed) | no | — | report-only | + +**Flows with NO covering spec, so new backend-agnostic specs are needed (D-16):** + +| Flow | Existing selectors to use [VERIFIED: grep of `app/`] | Recommended spec | +|------|------------------------------------------------------|------------------| +| browse collections | collection switcher (no test ID found in this pass; the executor greps `useCollectionSwitcher` consumers) | covered in the manual session; optional assertion in `core-flows` | +| edit album | `album-edit`, `album-edit-mobile` (`pages/albums/[id]/index.vue`), `album-name`, `album-save` (`AlbumForm.vue`) | `album-edit-rate-cover.spec.ts` | +| rate | `rating-stars` (`RatingStars.vue`) | same spec | +| cover upload | file input in `AlbumCoverGallery.vue:114` (`type="file" accept="image/*" multiple class="sr-only"`); `PhotoUploader.vue:312,372`; the store posts to `/albums/${album.id}/photos` (`stores/fonoteka.ts:234`) | same spec, `setInputFiles` with an in-memory PNG (like `fonoteka-ai-assist.spec.ts` `PNG_1PX`) | +| search | `albums-search-input` (used by `responsive.spec.ts`) | `album-search.spec.ts`: create a uniquely named album, type its name, `expect.poll` for the tile | +| wishlist add/remove | `/wishlist/new` page, `` `wishlist-item-${album.id}` ``, `wishlist-item-remove` | `wishlist.spec.ts` | + +**Self-seeding rule for new specs:** create the data each spec needs through the UI, or through `page.request` against the **Nuxt origin** (same-origin, proxied, Bearer from the `auth_token` cookie), with a `Date.now()` suffix in names. Such specs pass on PHP and Go alike and do not depend on `dev:seed` content beyond the user. Specs must use `fonotekaReachable()` plus the E2E creds gate like the existing ones, so PHP CI behavior is unchanged. + +**What `dev:seed` must provide for the gate (minimum):** a user matching E2E creds (activated, `must_change_password=false`, `has_self_set_password=true`), an owned `kind=collection` collection persisted as the user's context, at least 3 albums (the i18n count and offline covers), a `kind=wishlist` collection with at least 1 album (the wishlist entry), and `search_use_typesense=true`. + +## Q2. playwright.config.ts + +[VERIFIED: vue-fonoteka-app/playwright.config.ts:10-56] +- Env loading: `dotenv.config({ path: 'tests/e2e/.env.local' })` then `tests/e2e/.env`. dotenv does **not** override variables already in the environment, so the check script's exports win. +- `FRONTEND_URL = process.env.E2E_FRONTEND_URL || 'http://127.0.0.1:3119'`; `use.baseURL = FRONTEND_URL`; port derived from it. +- `use.storageState: 'tests/e2e/.auth/user.json'`; projects `setup` (`testMatch: /.*\.setup\.ts/`, empty storage) and `chromium` (`dependencies: ['setup']`). +- `workers: 1`, `fullyParallel: false`, `retries: 0`, reporters `html` + `list`. +- `webServer: { command: \`pnpm dev --port ${FRONTEND_PORT} --host 127.0.0.1\`, url: FRONTEND_URL, reuseExistingServer: !process.env.CI, timeout: 120_000 }`. + +Implications: +- Running against the stack: start the stack first, then run with `CI` **unset**, so Playwright reuses the running Nuxt. If Playwright launches its own `pnpm dev`, that process inherits Playwright's env, so the `NUXT_*` Go values must be exported in the check script too. +- Set `E2E_FRONTEND_URL=http://localhost:3119`. `i18n.spec.ts` and `landing.spec.ts` add cookies with the hard-coded `url: 'http://localhost:3119'`, so a `127.0.0.1` baseURL would not carry them. The D-06 Centrifugo origin is also `http://localhost:3119`. +- E2E env vars in use: `E2E_EMAIL`, `E2E_PASSWORD`, `E2E_FRONTEND_URL`, `E2E_BACKEND_URL`, `E2E_CENTRIFUGO` (`=== '1'`), `BACKEND_DIR` (runTinker PHP cwd; defaults to `path.resolve(__dirname, '../../../..')`, which is the PHP fonoteka root). New: `E2E_BACKEND`, `E2E_GO_BIN`, optionally `E2E_GO_DIR`. +- Gate run recommendation: `pnpm exec playwright test auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts public-share.spec.ts realtime.spec.ts i18n.spec.ts --project=chromium --reporter=json`, with `PLAYWRIGHT_JSON_OUTPUT_NAME=`. Parse `stats.unexpected == 0`, `stats.skipped == 0` and `stats.expected > 0`. A skip is how a misconfigured backend URL shows up (G7). + +## Q3. nuxt.config.ts: env names, fallback chain, proxy list, port, auth + +[VERIFIED: vue-fonoteka-app/nuxt.config.ts] +- `const BACKEND_ORIGIN = process.env.NUXT_DEV_BACKEND_ORIGIN || 'http://localhost:8000'` (line 30). Read **once at config load**. +- `devServer: { port: Number(process.env.NUXT_PORT) || 3119 }` (line 51). +- `internalApiOrigin: process.env.NUXT_INTERNAL_API_ORIGIN || process.env.NUXT_DEV_BACKEND_ORIGIN || process.env.NUXT_PUBLIC_SITE_URL || 'https://plytarium.com'` (lines 110-113). +- `public.centrifugoWsUrl: ''` (line 155). Overridden at runtime by `NUXT_PUBLIC_CENTRIFUGO_WS_URL`. Empty means realtime silently disabled (`useCentrifugo.ts:59-63`). +- `public.feedbackWidgetKey: process.env.NUXT_PUBLIC_FEEDBACK_WIDGET_KEY || ''` (line 128). A PHP widget key left in `.env` would make the widget call Go's `/_feedback/api/v1/{key}/config` with an unknown key. Have the script export it explicitly (empty unless `.env.dev` sets a Go-side key). +- Proxy prefixes, identical in `routeRules` (247-266), `nitro.devProxy` (364-372) and `vite.server.proxy` (392-401): `/_user/api`, `/_journal/api`, `/_fonoteka/api`, `/_feedback/api`, `/plugins/golem15/feedback`, `/api/realtime`, `/storage`. All use `changeOrigin: true`. +- Auth: JWT in the JS-readable `auth_token` cookie (SameSite=Lax), attached as `Authorization: Bearer` by `app/plugins/api.ts` on both SSR and client. SSR uses `config.internalApiOrigin` as `baseURL` (api.ts:28-30). No backend cookies and no CORS: the browser only talks to `:3119`, and SSR talks to Go server-to-server. +- **Env precedence:** Nuxt loads `vue-fonoteka-app/.env` through c12 `setupDotenv`, which only assigns a key `if (targetEnvironment[key] === void 0 || dotenvVars.has(key))` [VERIFIED: node_modules/.pnpm/c12@3.3.4…/c12/dist/index.mjs:28-31]. Variables exported on the `pnpm dev` process therefore win over `.env`, including deliberately empty strings. This confirms D-08's "pass env vars, never write `.env`" works. + +## Q4. fonoteka.go facts for the seed, script and serve + +- **Command registration:** `func (p *Plugin) Commands() []bonfire.Command { cmds := []bonfire.Command{ console.OAuthClientCommand(p.app), console.PruneNotificationsCommand(p.app), console.ReindexCommand(p.app), } … }` [VERIFIED: plugins/golem15/fonoteka/plugin.go:292-301]. Add `console.DevSeedCommand(p.app)` here, in a new file `console/dev_seed.go`. `bonfire.Command{Name, Description, Flags []bonfire.Flag{Name, Description, Default, Bare}, Args, Run func(ctx, in bonfire.Input, out bonfire.Output) error}` [VERIFIED: summercms.go/modules/bonfire/command.go:15-38]. +- **Environment:** `Environment()` returns the env name. Detection: `envName = lookupEnv(c.environ, "SUMMER_ENV")`; if empty, `envName = "production"` [VERIFIED: summercms.go/modules/compass/config.go:193-199]. The seed refuses unless `app.Config.Environment()` is one of the allowed dev names. Recommend `local` and `dev` (D-02); the script sets `SUMMER_ENV=local`. Compass also merges `/../.env` (`readDotEnv(filepath.Join(filepath.Dir(c.dir), ".env"))`, config.go:187). That means a `fonoteka.go/.env` would be read automatically, but `.env.dev` is **not**, so the script must export it (`set -a; . ./.env.dev; set +a`). +- **Config dir is cwd-relative:** `cfg, err := compass.Load("config")` [VERIFIED: fonoteka.go/main.go:31], and the bucket is `file://./storage/app/uploads/public` [VERIFIED: config/storage.yaml]. Every `bin/fonoteka` invocation (script, Playwright `E2E_GO_BIN`) must run with cwd = `fonoteka.go`. +- **Boot requirements for any command:** `golem15.user: jwt.secret is empty (set SUMMER_GOLEM15__USER__JWT__SECRET)` and `cabana: admin.jwt.secret is empty (set SUMMER_ADMIN__JWT__SECRET)` both fail even `route:list` [VERIFIED: ran binary]. DB-opening commands also need a valid `SUMMER_APP__KEY` (base64 of 32 bytes; `fonoteka key:generate` prints one) [CITED: 11.2-RESEARCH.md citing lagoon/connection.go:77-90]. +- **Creating the data with existing code** (all in package `classes` of the fonoteka plugin unless noted): + - User: `usercontrollers.RegisterUser(ctx, app, tx, fields, usercontrollers.RegisterOptions{Activate: true})` hashes with `golem15.user.password.bcrypt_cost` and validates the model rules [VERIFIED: sm-user-plugin/controllers/registration.go:26-38,57]. Lookup by email first for idempotency. `BootstrapOwner` is unsuitable because it refuses once any user exists. + - Collection + context: `ProvisionCollection(ctx, tx, user)` returns the context collection, else the lowest accessible one, else creates `"Moja kolekcja"` (`DefaultCollectionName`) and persists the context [VERIFIED: classes/collection_provisioner.go:14-75]. + - Wishlist: `ProvisionWishlist(ctx, tx, user)` reuses the own wishlist or creates `"Moja lista życzeń"` (`DefaultWishlistName`, `Kind: kindWishlist` where `const kindWishlist = "wishlist"`) [VERIFIED: classes/wishlist_resolver.go:15-19,48-84]. A wishlist entry is an album whose `collection_id` is the wishlist collection [VERIFIED: parity/fonoteka_seed_test.go:1324-1340 inserts wishlist albums with `collection_id = wishlistID`]. + - Albums: `CreateAlbum(ctx, tx, collectionID, input map[string]any) (*models.Album, error)` [VERIFIED: classes/album_write_service.go:378-400]. Wrap it in `lighthouse.WithoutBroadcasting[models.Album](ctx, fn)` [VERIFIED: summercms.go/modules/lighthouse/suppress.go:19] so the seed queues no realtime broadcast jobs. + - Covers: `StoreAlbumPhoto(ctx, db, bucket, albumID, filename, data) (*attach.File, error)` writes under a Winter disk name and attaches to `photos` [VERIFIED: classes/album_files.go:72-85]. The bucket in a CLI context comes from `attach.OpenBucket(ctx, app.Config)` + `attach.Publish(app, bucket)` (exported; used by `surf/serve.go:110-122` and `lagoon/commands.go:208-228`). Generate the images with stdlib `image/png` (solid or gradient squares, deterministic per album). No network and no committed binaries are needed. + - Rating (optional, so "rate" has a baseline): `models.AlbumRating{AlbumID, UserID, Rating}` on table `golem15_fonoteka_album_ratings` [VERIFIED: models/album_rating.go]. + - API token: `userclasses.APITokenPrefix(app)` reads `golem15.user.api_tokens.prefix` (`inv_` in this app) and `userclasses.MintAPIToken(prefix, userID, name, scopes, expiresAt, collectionIDs)` returns `(secret, *models.APIToken, error)`; allowed scopes are `"read", "write", "ai"` [VERIFIED: sm-user-plugin/classes/api_tokens.go:19-23,48-94]. For idempotency, see Pattern 1. + - Search setting: `models.Settings{SearchUseTypesense bool}` on table `golem15_fonoteka_settings`, singleton row `id = 1` [VERIFIED: models/settings.go; updates/20_remaining.go:198-200]. +- **Lagoon attach path / cover file functions:** `StorePublicFile` → `attach.BlobKey(diskName)` → `bucket.WriteAll`; `AttachAlbumPhoto` sets `Field = "photos"`, `AttachmentType = models.Album{}.MorphName()` and `sort_order = id` [VERIFIED: classes/album_files.go:20-69]. Public URLs come out as `/storage/app/uploads/public//` (prefix from `storage.uploads.public_path_prefix`) [VERIFIED: config/storage.yaml]. +- **Serve address:** `serve` has flag `addr` with `Default: ":8080"`; an empty value falls back to `":8080"` [VERIFIED: summercms.go/modules/surf/serve.go:28-37]. Use `bin/fonoteka serve --addr 127.0.0.1:8423`, bound to loopback only. +- **Reindex:** the command name is exactly `fonoteka:reindex`, with flag `drop-old-items-index` [VERIFIED: console/reindex.go:44-50]. It refuses unless the engine is `typesense`, configured (API key) and `search_use_typesense` is on (reindex.go:68-70), and needs the G1 fix. +- **Other useful commands in the binary** [VERIFIED: `fonoteka --help`]: `migrate`, `migrate:status`, `key:generate`, `websockets:health` (works without a DB; prints `✗ Connection check failed: centrifugo: /info: HTTP 401` and exits 1 on a bad key [VERIFIED: ran]), `user:token:create` (broken by G1). +- **summer build:** `summer` is at `~/go/bin/summer` (built 2026-10-05), which is **not on PATH** in non-interactive shells. `buildCommand` uses `os.Getwd()` as the app dir [VERIFIED: summercms.go/cmd/summer/main.go:56-66] and writes `bin/` (`cmd/summer/runtime.go:105`). Recommend that the script build the CLI from source (`go -C "$FRAMEWORK" build -o "$TMP/summer" ./cmd/summer`) and then run `(cd "$APP" && "$TMP/summer" build)`, so a stale `~/go/bin/summer` is never used. `fonoteka.go/go.mod` has `replace git.golem15.com/golem15/summercms => ../summercms.go`, so framework edits are picked up. + +## Q5. Marker route (D-10) + +Existing Go routes outside `parity/manifest.yaml`: `/plytadmin*` (cabana admin, not Nuxt-proxied), `GET /plugins/golem15/feedback/assets/js/embed.js` (PHP serves the same file statically), `/.well-known/oauth-authorization-server` and `/oauth/mcp/*` (PHP serves these too, and they are in the manifest's `oauth` group) [VERIFIED: route:list + manifest auth groups]. **None is both Go-only and under a Nuxt-proxied prefix.** There is no framework health route. + +**Recommendation:** add a route in the fonoteka plugin (the app's own namespace, so no shared plugin is touched): `GET /_fonoteka/api/v1/_dev/backend`, registered **only when `app.Config.Environment()` is not `production`** (env-gated: zero production surface, invisible to parity). It returns `200 {"backend":"summercms","app":"fonoteka","env":""}` with `Cache-Control: no-store`. Put it in its own `r.GroupRaw` (or a public group) with no auth and a small throttle. It is not added to the manifest or `routes.snapshot`. `TestParityContract` compares the snapshot against **PHP** `routes.php`, not against the Go table, so a Go-only route does not break it [VERIFIED: parity/parity_contract_test.go:13-45]. Phase route-table tests (`routes_table_phase1x_test.go`) assert properties of named routes, so the executor should run them after adding the route. + +**Preflight logic (script):** +1. Refuse if `NUXT_DEV_BACKEND_ORIGIN` or `NUXT_INTERNAL_API_ORIGIN` is unset, if they differ, if the port is `8422`, or if the host is not loopback (`localhost`/`127.0.0.1`/`::1`). This covers any `plytarium.com` value. +2. `curl -sS -D headers -o body "$ORIGIN/_fonoteka/api/v1/_dev/backend"`: refuse unless the status is 200 **and** `jq -e '.backend=="summercms"'` holds. +3. Refuse if any response header matches `^X-Powered-By: PHP` (case-insensitive). The local PHP has `expose_php => On` [VERIFIED: `php -i`, PHP 8.5.10], so `php artisan serve` stamps `X-Powered-By: PHP/8.5.10` on every response. Go never sets it. PHP has no catch-all route in `plugins/golem15/fonoteka/routes.php` [VERIFIED: grep], so PHP answers 404 on the marker path, which step 2 already refuses. +4. Post-start: the same request against `http://localhost:3119/_fonoteka/api/v1/_dev/backend` (the Vite proxy) must return the same JSON. Also probe an unauthenticated `GET http://localhost:3119/_fonoteka/api/v1/albums` and expect 401 from Go. + +## Q6. check-phase script pattern (D-17) + +[VERIFIED: summercms.go/scripts/check-phase14.2.1.sh (410 lines; the most recent and leanest), check-phase14.sh (1265 lines)] +- Header comment describing the gate. `set -euo pipefail`, `unset FORCE_COLOR`. +- Paths from overridable env (`ROOT="${PHASE1421_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"`, sibling repos `$ROOT/../…`). +- `*_REQUIRE=(TestA TestB …)` arrays of named tests that must pass. +- `detect()`: an embedded `python3` heredoc reading `go test -json`, with exit codes 1 fail/build, 2 skip, 3 zero tests or "no tests to run", 4 non-JSON, 5 missing required name, 6 data race. `go_json DIR ARGS…` runs `go test -json` and the detector. +- `run_self_test`: `bash -n`, then `expect_detect` on planted JSON lines for each exit code. It also checks that every `--mode)` case exists. +- One `run_` per mode, `run_all` looping `bash "${BASH_SOURCE[0]}" "--$stage"` with `PASS/FAIL` lines, and a final `echo "Phase 14.2.1 gate passed"`. +- `case "${1:---all}" in --self-test) … esac`. + +Recommended modes for `check-phase14.2.sh`: `--self-test` (also plants a fake Playwright JSON with a skip and expects refusal), `--go` (`go vet` + `go_json` on the `fonoteka.go` plugin packages and the touched `summercms.go` modules, with `REQUIRE_TESTS` naming the new dev-seed, marker, storage-mount and access-log tests), `--docs` (`go test ./cmd/summer -run TestDocsTree` if framework modules changed), `--guard` (Pattern 4 refusal cases), `--e2e` (starts the stack via `dev-stack.sh up --detach` or requires it running, runs the Playwright gate with the JSON reporter, then greps the Go log for `album search engine failed` and for 5xx), and `--all`. The `--e2e` stage fails closed when services are missing (D-06). It is not silently skipped. + +## Q7. Centrifugo local config and realtime keys + +`config/realtime.yaml` keys and env mapping [VERIFIED: fonoteka.go/config/realtime.yaml; fonoteka.go/README.md:75-87]: + +| Config key | Env | Default in repo | +|------------|-----|-----------------| +| `realtime.centrifugo.api_url` | `SUMMER_REALTIME__CENTRIFUGO__API_URL` | `http://127.0.0.1:8001/api` | +| `realtime.centrifugo.api_key` | `SUMMER_REALTIME__CENTRIFUGO__API_KEY` | `""` (nothing published) | +| `realtime.centrifugo.token_secret` | `SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET` | `""` (token route answers 503 `{"error":"WebSocket not configured"}`) | +| `realtime.centrifugo.token_ttl` | `SUMMER_REALTIME__CENTRIFUGO__TOKEN_TTL` | `3600` | +| `realtime.centrifugo.ws_url` | `SUMMER_REALTIME__CENTRIFUGO__WS_URL` | `/ws` | +| `realtime.centrifugo.proxy_secret` | `SUMMER_REALTIME__CENTRIFUGO__PROXY_SECRET` | `""` (every subscribe denied) | +| `realtime.centrifugo.subscribe_path` | — | `/api/realtime/subscribe` | +| `realtime.broadcast_namespace` | `SUMMER_REALTIME__BROADCAST_NAMESPACE` | `""` | + +Centrifugo v6 config keys [CITED: centrifugal.dev/docs/server/configuration; centrifugal.dev/docs/server/proxy]: `client.token.hmac_secret_key` (must equal Go `token_secret`), `client.allowed_origins` (add `http://localhost:3119` **and** `http://127.0.0.1:3119`), `http_api.key` (= Go `api_key`), `http_server.port`, `channel.proxy.subscribe.endpoint` (→ `http://127.0.0.1:8423/api/realtime/subscribe`) with `channel.proxy.subscribe.http.static_headers` `{"X-Centrifugo-Secret": ""}`, and `channel.namespaces[]` entries `collection` and `wishlist` with `subscribe_proxy_enabled: true`. Env override form: `CENTRIFUGO_