docs(14.2): create phase plan
Four plans: Go glue (dev seed, plugin-command DB, marker, framework /storage mount, access log), dev-stack.sh and guard, Nuxt specs and manual UAT, unit tests and check-phase14.2.sh. Adds D-23 (Centrifugo collection/wishlist namespaces) and resolves research open questions.
This commit is contained in:
@@ -829,10 +829,21 @@ Plans:
|
||||
3. Failures against Go are visible (no silent fall-through to PHP); the local stack does not require the production origin.
|
||||
4. The new glue (env, proxy, CORS, run docs) has tests or a fail-closed check script, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 4 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 14.2 to break down)
|
||||
|
||||
**Wave 1**
|
||||
- [ ] 14.2-01-PLAN.md — Go glue: `fonoteka:dev-seed`, plugin commands open their own DB (G1), dev-only marker route, framework `/storage` mount and opt-in access log with docs
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 14.2-02-PLAN.md — `scripts/dev-stack.sh` (start, fail-closed guard, logs, e2e verdict), `.env.dev.example`, `.gitignore`, run docs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 14.2-03-PLAN.md — Nuxt `E2E_BACKEND=go` switch, realtime rewrite, edit/rate/cover, search and wishlist specs on Go and PHP, Centrifugo origin step, manual UAT session
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 14.2-04-PLAN.md — Unit tests (seed, commands, marker, uploads mount, access log, switch contract) and `scripts/check-phase14.2.sh`
|
||||
|
||||
### Phase 14.2.1: Translate plugin (INSERTED)
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
---
|
||||
gsd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: 15
|
||||
current_phase_name: Journal plugin
|
||||
current_phase: "14.2"
|
||||
current_phase_name: local-fonoteka-frontend-on-local-summercms-backend
|
||||
status: executing
|
||||
stopped_at: Phase 14.2 context gathered
|
||||
last_updated: "2026-10-07T17:58:22.477Z"
|
||||
last_updated: "2026-10-07T19:26:02.841Z"
|
||||
last_activity: 2026-10-07
|
||||
last_activity_desc: Completed quick task 261006-s0v (remove ML copy-from locale UI)
|
||||
state_head: 286603db8d28d932b209a8016ff604f289f73106
|
||||
state_head: 05349d4b6d9402a8dbee72e5aa9700fa641a6372
|
||||
progress:
|
||||
total_phases: 26
|
||||
total_phases: 30
|
||||
completed_phases: 13
|
||||
total_plans: 135
|
||||
total_plans: 139
|
||||
completed_plans: 135
|
||||
milestone_name: milestone
|
||||
---
|
||||
@@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 15 (Journal plugin) — EXECUTING
|
||||
Phase: 14.2 (local-fonoteka-frontend-on-local-summercms-backend) — READY TO EXECUTE
|
||||
Plan: 4 of 4
|
||||
Status: Ready for phase verification
|
||||
Last activity: 2026-10-07 — Completed quick task 261007-qgx: record heading from translatable (mltext) fields
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/configuration.md
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/database.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
|
||||
- .planning/todos/pending/user-plugin-commands-open-database.md
|
||||
autonomous: true
|
||||
requirements: [QA-05]
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 3
|
||||
confidence: low # uncalibrated (estimate-calibration factor 1, sample_count 0); plan count locked at 4 by D-22, do not split; see "Execution guidance (context budget)"
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-01, D-02 and D-22, `fonoteka fonoteka:dev-seed` (the D-02 seed; the plugin-namespaced name is the planner's naming choice D-02 allows) runs after `fonoteka migrate` in a fresh process with no pre-published database and creates an activated user, an owned collection persisted as the user's context, three albums, one wishlist entry, one rating on the first album, a personal API token with the configured `inv_` prefix, and turns `search_use_typesense` on."
|
||||
- "Per D-02 (edge: idempotency), a second `fonoteka:dev-seed` run with the same inputs adds no row to `users`, `golem15_fonoteka_collections`, `golem15_fonoteka_albums`, `system_files`, `golem15_fonoteka_album_ratings` or the API token table, and prints `unchanged` for every entity."
|
||||
- "Per D-02 (edge: boundary), the seed runs only when the app environment is exactly `local` or `dev`; `production` (what an unset SUMMER_ENV resolves to), `development`, `testing`, `staging` and an empty value are refused before any database connection is opened."
|
||||
- "Per D-03, every seeded album carries one generated PNG cover attached through `classes.StoreAlbumPhoto` under the `storage/app/uploads/public` bucket, and the seed prints each cover's public `/storage/app/uploads/public/...` URL on both created and unchanged runs."
|
||||
- "Per D-04 (edge: empty), the seeded credentials are `E2E_EMAIL` / `E2E_PASSWORD` and fall back to `demo@example.com` / `changeme` when either is unset or empty; the password is never printed."
|
||||
- "Per D-19, the framework key `storage.uploads.serve` (default false) mounts `attach.StaticHandlerPublic` at `storage.uploads.public_path_prefix` plus `/{path...}` from the surf router with `X-Content-Type-Options: nosniff`; a seeded cover URL answers 200 `image/png` from the assembled router, and with the key off the route does not exist. The surf README, docs/services/storage.md and docs/setup/configuration.md describe the key in the same change."
|
||||
- "Per D-09 and D-10, `GET /_fonoteka/api/v1/_dev/backend` answers 200 `{\"backend\":\"summercms\",\"app\":\"fonoteka\",\"env\":\"local\"}` with `Cache-Control: no-store` only when the environment is `local` or `dev`, is absent otherwise, is not listed in `parity/manifest.yaml`, and `TestParityCorpus` still prints `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`."
|
||||
- "Per D-05 and D-22 (research G1), `fonoteka:reindex`, `fonoteka:oauth-client` and `fonoteka:prune-notifications` use the published database when one exists and otherwise open and publish one from `database.dsn`, so none of them stops with `database is not configured` in the real binary."
|
||||
- "Per D-12, the framework key `http.access_log: true` logs one record per request (message `http request`) through the app's published `*slog.Logger` or `slog.Default` with `method`, `pattern`, `status` and `duration_ms`, adding `path` without its query string only for unmatched requests; with the key off nothing is logged."
|
||||
- "(edge: ordering) The seed writes the user, collections, albums, covers, wishlist entry, rating and token first and flips `search_use_typesense` last, so no seed write reaches Typesense before `fonoteka:reindex` builds the index."
|
||||
- "(edge: concurrency) Every seed row is written inside one `lagoon.Transaction` wrapped in `lighthouse.WithoutBroadcasting`; a failed or interrupted run commits no partial rows and queues no broadcast job, and a rerun completes the shape."
|
||||
- statement: "(edge: concurrency) A cover blob written before an aborted seed transaction can remain in the bucket without a system_files row; the rerun attaches a fresh blob and the orphan is never served because StaticHandlerPublic requires a public system_files row."
|
||||
verification: backstop
|
||||
- statement: "(edge: precision) The only numeric value this plan introduces is `duration_ms`, the elapsed request time truncated to whole milliseconds; seeded ratings use the existing 1-5 integer scale."
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "modules/surf/uploads.go"
|
||||
provides: "storage.uploads.serve opt-in mount of attach.StaticHandlerPublic with nosniff (D-19)"
|
||||
contains: "storage.uploads.serve"
|
||||
- path: "modules/surf/accesslog.go"
|
||||
provides: "http.access_log opt-in request log (D-12)"
|
||||
contains: "http.access_log"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go"
|
||||
provides: "DevSeedCommand (fonoteka:dev-seed) and DevEnvironment (D-02)"
|
||||
contains: "fonoteka:dev-seed"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/console/database.go"
|
||||
provides: "commandDatabase and commandBucket: published handle, else open and publish (G1)"
|
||||
contains: "lagoon.Publish"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go"
|
||||
provides: "env-gated marker route GET /_fonoteka/api/v1/_dev/backend (D-09, D-10)"
|
||||
contains: "_dev"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go"
|
||||
provides: "TestDevStackSmoke: fresh-process seed, cover served, rerun unchanged, marker"
|
||||
contains: "TestDevStackSmoke"
|
||||
key_links:
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/uploads.go"
|
||||
via: "BuildRouter calls mountUploads after the plugin route loop and before cabana.Activate"
|
||||
pattern: "mountUploads"
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/accesslog.go"
|
||||
via: "compile wraps the final handler; wrap records each route pattern into the request slot"
|
||||
pattern: "accessLog"
|
||||
- from: "../fonoteka.go/plugins/golem15/fonoteka/plugin.go"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go"
|
||||
via: "Commands() appends console.DevSeedCommand(p.app)"
|
||||
pattern: "DevSeedCommand"
|
||||
- from: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go"
|
||||
via: "covers stored through StoreAlbumPhoto inside the seed transaction"
|
||||
pattern: "StoreAlbumPhoto"
|
||||
- from: "../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/console/database.go"
|
||||
via: "reindex, oauth-client and prune-notifications resolve the database through commandDatabase"
|
||||
pattern: "commandDatabase"
|
||||
- from: "../fonoteka.go/plugins/golem15/fonoteka/routes.go"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go"
|
||||
via: "Routes calls p.devMarkerRoutes(r), which registers nothing outside local/dev"
|
||||
pattern: "devMarkerRoutes"
|
||||
prohibitions:
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "fonoteka:dev-seed MUST NOT open a database connection or write any row unless the app environment is exactly local or dev; production is the default and is always refused"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: transparency
|
||||
statement: "No route listed in parity/manifest.yaml may change its response, and no header may be added to API responses; the marker route and the uploads mount stay outside the manifest"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "The shared sm-user-plugin submodule MUST NOT be modified by this plan; its user:token commands' database gap is recorded as a todo instead (core plugin rule)"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** developer, **I want to** run the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), log in and use the real Nuxt flows, **so that** the port is proven in daily use before production cutover.
|
||||
|
||||
This plan's slice: the Go-side glue every later plan stands on. After it, a fresh `fonoteka` process can seed itself, serve the seeded covers under the WinterCMS upload URLs, name itself through a dev-only marker, rebuild search without a pre-published database, and log every request.
|
||||
|
||||
<objective>
|
||||
Close research gaps G1-G4 in Go (D-22 plan 01 scope): the `fonoteka:dev-seed` command (D-01..D-04), the plugin-command database fix including `fonoteka:reindex` (G1), the env-gated marker route (D-09, D-10), the framework `/storage` uploads mount (D-19) and the opt-in access log (D-12), with the surf README and docs pages updated in the same changes.
|
||||
|
||||
Purpose: plan 02's start script runs `migrate`, `fonoteka:dev-seed`, `fonoteka:reindex` and `serve` against these commands and probes the marker; plan 03's specs load covers through `/storage`.
|
||||
Output: two framework commits in summercms.go (uploads mount + docs, access log + docs), app commits in fonoteka.go, and one planning-doc commit (the user-plugin todo). Smoke tests only; the full unit matrix is plan 04.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-VALIDATION.md
|
||||
@modules/surf/router.go
|
||||
@modules/surf/serve.go
|
||||
@modules/lagoon/attach/static.go
|
||||
@../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
@../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
|
||||
|
||||
<interfaces>
|
||||
- `lagoon.OpenFromApp(ctx, app) (*sql.DB, *gorm.DB, error)` reads `database.dsn` and loads `app.key`; `lagoon.Publish(app, sqlDB, gdb) error` publishes both and runs the `lagoon.OnDatabase` callbacks (the fonoteka slug and credential hooks register there, plugin.go:209). Opening without Publish leaves seeded slugs empty (research anti-pattern).
|
||||
- `attach.OpenBucket(ctx, cfg) (*blob.Bucket, error)` and `attach.Publish(app, bucket) error` (used by surf/serve.go publishUploads and lagoon/commands.go purgeBucket). `attach.StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler` 404s missing or non-public rows and invalid keys; it sets no nosniff header.
|
||||
- `surf.BuildRouter` registers plugin routes in a loop with `r.BindPlugin(p.ID())`, then `cabana.Activate`; `r.app` holds the app; `compile()` returns `r.stripLocalePrefix(pathScopedCORS(r.corsCfg, mux))`; `wrap(rt route)` builds each route's chain inside `recoverJSON`/`recoverBare`.
|
||||
- `bonfire.Command{Name, Description, Flags []bonfire.Flag, Run func(ctx, in bonfire.Input, out bonfire.Output) error}`; console tests use `testInput` (console/oauth_client_test.go) and `bonfire.NewOutput(nil, &buf, &buf)`.
|
||||
- Seed building blocks (fonoteka plugin `classes` unless noted): `usercontrollers.RegisterUser(ctx, app, tx, fields, usercontrollers.RegisterOptions{Activate: true})` (sm-user-plugin controllers/registration.go:38); user model fields `MustChangePassword`, `HasSelfSetPassword`, `IsActivated`; `ProvisionCollection(ctx, tx, user)`; `ProvisionWishlist(ctx, tx, user)`; `CreateAlbum(ctx, tx, collectionID, input map[string]any)`; `StoreAlbumPhoto(ctx, db, bucket, albumID, filename, data) (*attach.File, error)`; `models.AlbumRating{AlbumID, UserID, Rating}`; `models.Settings{SearchUseTypesense}` singleton id 1; `userclasses.APITokenPrefix(app)`, `userclasses.MintAPIToken(prefix, userID, name, scopes, expiresAt, collectionIDs) (secret, *models.APIToken, error)`, `userclasses.HashAPIToken(secret)` (sm-user-plugin classes/api_tokens.go); `lighthouse.WithoutBroadcasting[T](ctx, fn)` (summercms.go modules/lighthouse/suppress.go:19).
|
||||
- Environment: `app.Config.Environment()` reads SUMMER_ENV, default `production` (compass/config.go:193-199). The plugin root test harness `bootConfig` uses `SUMMER_ENV=development`, so the marker stays absent from every existing route-table test.
|
||||
- fonoteka.go submodules `plugins/golem15/{user,golem,feedback}` already show pointer drift (research G13). Stage explicit paths only; never `git add -A` in fonoteka.go.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- CLI command `fonoteka:dev-seed` (no flags) in the fonoteka plugin; exported `console.DevSeedCommand(app *backpack.App) bonfire.Command` and `console.DevEnvironment(app *backpack.App) (string, bool)`.
|
||||
- Environment variables read by the seed: `E2E_EMAIL`, `E2E_PASSWORD` (fallback `demo@example.com` / `changeme`), `DEV_SEED_API_TOKEN` (optional, must start with the configured prefix). Seeded token name `dev-seed-mcp`; seeded album names `Kind of Blue`, `Blue Train`, `Mingus Ah Um`; wishlist album `A Love Supreme`.
|
||||
- Seed output lines `<entity>: created|updated|unchanged` (cover lines carry the public URL; the token line shows the secret only on creation).
|
||||
- Unexported console helpers `commandDatabase(ctx, app) (*gorm.DB, func(), error)` and `commandBucket(ctx, app) (*blob.Bucket, func(), error)`.
|
||||
- Route `GET /_fonoteka/api/v1/_dev/backend` (local/dev only, `throttle:60,1`, not in the manifest); unexported `(*Plugin).devMarkerRoutes(r pact.Router)`.
|
||||
- Framework config keys `storage.uploads.serve` (env `SUMMER_STORAGE__UPLOADS__SERVE`, default false) and `http.access_log` (env `SUMMER_HTTP__ACCESS_LOG`, default false); framework route `GET {storage.uploads.public_path_prefix}/{path...}` bound to plugin id `summercms.surf`; unexported surf `mountUploads` and `accessLog`.
|
||||
- Log record `http request` with attributes `method`, `pattern`, `status`, `duration_ms` and, for unmatched requests only, `path`.
|
||||
- Tests `TestDevStackSmoke` (fonoteka plugin root package) and `TestAccessLogSmoke` (surf).
|
||||
- Planning todo `.planning/todos/pending/user-plugin-commands-open-database.md`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Assumption-delta and API-coverage detectors did not fire for this phase (no core identity change, no external API integration).
|
||||
- `StoreAlbumPhoto` attaches album photos with `is_public = true`; the tracer test proves it by getting a 200 through `StaticHandlerPublic`. If it does not, the seed must not flip the flag by hand; stop and report.
|
||||
- `CreateAlbum` needs at least `name`; the executor reads `album_write_service.go` for any other required input and passes only fields the PHP create form accepts.
|
||||
- The executor must not read any `.env*` secret file; the Bash secret-read guard blocks it. Tests build their config in code.
|
||||
|
||||
## Execution guidance (context budget)
|
||||
|
||||
This plan touches 18 files with an uncalibrated 260k-token estimate. D-22 fixes the phase at 4 plans, so it is not split. Keep it inside one executor's budget this way:
|
||||
|
||||
- **Commit after each task, never across tasks.** Task 1 makes two commits (summercms.go: mount plus docs; fonoteka.go: helper, seed, registration, smoke test). Task 2 makes one fonoteka.go commit and one summercms.go planning-doc commit (the todo). Task 3 makes one summercms.go commit. Each commit happens right after that task's verify passes.
|
||||
- **Mid-plan SUMMARY checkpoint after Task 2.** Once Task 2's commits land, write `14.2-01-SUMMARY.md` with a status of in progress, listing Tasks 1-2 done with commit hashes, the seed output lines from `TestDevStackSmoke`, the parity summary line and any deviations, plus the Task 3 entry point. If the context is past about half at that point, stop there. A fresh executor resumes at Task 3 from that SUMMARY. The final SUMMARY replaces the in-progress status when Task 3 is done.
|
||||
- **Task 3 is self-contained and may run in a fresh context.** It changes only `modules/surf/` and two docs pages, and needs nothing from Tasks 1-2 beyond the committed `router.go`, `README.md` and `docs/setup/configuration.md`. A fresh executor reads Task 3's `read_first` list plus `modules/surf/uploads.go` (to keep the `BuildRouter` and `compile` edits consistent) and does not reopen any fonoteka.go file.
|
||||
- **Keep long test output out of the context.** Send the full output of `TestParityCorpus` (Task 2, up to 30 minutes) and the `-v` test runs to a file in the scratch directory. Read back only the exit status, the `--- PASS`/`--- FAIL`/`--- SKIP` lines and the parity summary line that each task's failure conditions check.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1 (tracer): a seeded cover is served end to end — fresh-process fonoteka:dev-seed, framework /storage mount, 200 image/png</name>
|
||||
<reversibility rating="costly">`storage.uploads.serve` becomes a framework config key other applications and Phase 20 will set; renaming it later needs a deprecation in every app config.</reversibility>
|
||||
<files>modules/surf/uploads.go, modules/surf/router.go, modules/surf/README.md, docs/services/storage.md, docs/setup/configuration.md, ../fonoteka.go/plugins/golem15/fonoteka/console/database.go, ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go</files>
|
||||
<read_first>modules/surf/router.go (BuildRouter 460-568, compile 343-381, wrap 393-447), modules/surf/serve.go (publishUploads), modules/lagoon/attach/static.go, modules/lagoon/attach/bucket.go (OpenBucket default prefix, Publish), modules/lagoon/connection.go (OpenFromApp, Publish), modules/lagoon/commands.go (withDB, purgeBucket), modules/lighthouse/suppress.go, modules/surf/README.md, docs/services/storage.md, docs/setup/configuration.md, docs/database/attachments.md, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Commands 292-301, OnDatabase 209), ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go (testInput), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go (CreateAlbum 378), ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go (bootPG, bootDB, bootConfig), ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go (bouncer registry publish, party.Activate, stubInvToken, BuildRouter), ../fonoteka.go/config/storage.yaml, .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (G1, G2, Q4, Pattern 1, Pattern 2)</read_first>
|
||||
<action>Two commits, framework first (summercms.go: mount plus its docs), then app (fonoteka.go: helper, thin seed, registration, smoke test).
|
||||
|
||||
(1) summercms.go, per D-19. New `modules/surf/uploads.go` with unexported `mountUploads(r *Router, app *backpack.App) error`, called from `BuildRouter` right after the plugin route loop and before `cabana.Activate`. When `app` and `app.Config` are non-nil and `app.Config.Bool("storage.uploads.serve")` is true: bind plugin id `summercms.surf`, take `storage.uploads.public_path_prefix` trimmed of a trailing slash (fall back to the same default `attach.OpenBucket` records; read bucket.go for it), open a raw group at that prefix with no middleware and register `GET /{path...}`. The handler resolves `*blob.Bucket` and `*gorm.DB` with `app.Lookup` on every request (boot and route:list build the router before serve publishes either), answers 404 when either is missing, sets `X-Content-Type-Options: nosniff`, then delegates to `attach.StaticHandlerPublic(bucket, prefix, gdb)`. With the key false or absent, register nothing. A plugin route under the same prefix must keep failing boot through the existing ServeMux conflict error. Add no exported identifier. Docs in the same commit: surf README (Features bullet and a Configuration row for `storage.uploads.serve`, default `false`), docs/services/storage.md (rewrite the paragraph that says the framework mounts no file route: the opt-in key, the `is_public` gate, nosniff, and the existing advice to prefer a separate origin), docs/setup/configuration.md (a table row). Framework docs never name the consuming application.
|
||||
|
||||
(2) fonoteka.go `console/database.go`, per G1: `commandDatabase(ctx, app) (*gorm.DB, func(), error)` returns the published `*gorm.DB` with a no-op closer when one exists, else calls `lagoon.OpenFromApp` then `lagoon.Publish` and returns a closer for the `*sql.DB`. `commandBucket(ctx, app) (*blob.Bucket, func(), error)` does the same with `attach.OpenBucket` and `attach.Publish`.
|
||||
|
||||
(3) `console/dev_seed.go`, per D-01, D-02, D-03, D-04. Exported `DevEnvironment(app *backpack.App) (string, bool)`: the trimmed, lower-cased `app.Config.Environment()` and whether it is exactly `local` or `dev`. Exported `DevSeedCommand(app *backpack.App) bonfire.Command` named `fonoteka:dev-seed` (D-02 leaves the name to the planner; the plugin namespace follows docs/console/writing-commands.md and the bonfire duplicate-name todo). Run order: refuse first with the error `fonoteka:dev-seed: refused in environment "<env>" (set SUMMER_ENV=local)` before anything is opened; then `commandDatabase` and `commandBucket`; then, in one `lagoon.Transaction` inside `lighthouse.WithoutBroadcasting[models.Album]`, the thin path: the user looked up by email (`E2E_EMAIL` / `E2E_PASSWORD` via `os.Getenv`, falling back to `demo@example.com` / `changeme` when unset or empty) and created through `usercontrollers.RegisterUser` with `RegisterOptions{Activate: true}`, then `MustChangePassword` false and `HasSelfSetPassword` true; `ProvisionCollection`; the album `Kind of Blue` (year 1959) through `CreateAlbum` unless a live album with that name exists in the collection; a 64x64 PNG cover generated with `image/png` (solid colour derived from the album's position, no network, no committed binary) stored through `StoreAlbumPhoto` with the transaction handle only when the album has no photos. Print one line per entity, `<entity>: created` or `<entity>: unchanged`; the cover line appends the cover's public URL (`attach.File.URL` or `attach.PublicURL`). Never print the password.
|
||||
|
||||
(4) `plugin.go`: append `console.DevSeedCommand(p.app)` after `console.ReindexCommand(p.app)` in `Commands()` and update its doc comment. Do not touch `main.go` or `plugins.gen.go`.
|
||||
|
||||
(5) `dev_stack_smoke_test.go` (package fonoteka), `TestDevStackSmoke`: call `bootDB(t)` to migrate the shared container, then build a new compass config (`compass.Open` with `SUMMER_ENV=local` plus the JWT secrets bootConfig uses) setting `database.dsn` to `bootPG.ConnectionString(ctx, "sslmode=disable")`, a valid base64 32-byte `app.key`, `storage.uploads.bucket_url` to `file://` plus `t.TempDir()`, `storage.uploads.public_path_prefix` `/storage/app/uploads/public`, `storage.uploads.serve` true and the body limits. Activate `golem15.user`, `golem15.golem`, `golem15.fonoteka` WITHOUT publishing a database, run `console.DevSeedCommand(app).Run` with a minimal `bonfire.Input` value, assert the output has a cover URL, then `surf.Assemble(app, plugins)` and GET that URL through `httptest`: 200, `Content-Type: image/png`, `X-Content-Type-Options: nosniff`. A missing container is a failure, not a skip.
|
||||
|
||||
Stage explicit paths only (never the drifted submodule pointers).</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/surf/ && go test ./modules/surf/ -count=1 && go test ./cmd/summer -count=1 -run '^TestDocsTree$' && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go vet ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -v -run '^TestDevStackSmoke$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP" or "no tests to run"; output lacks "--- PASS: TestDevStackSmoke"; docs:build --check prints any problem line.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'storage.uploads.serve' modules/surf/uploads.go` prints at least 1, and `grep -c 'StaticHandlerPublic' modules/surf/uploads.go` prints at least 1, and `grep -c 'nosniff' modules/surf/uploads.go` prints at least 1.
|
||||
- `grep -l 'storage.uploads.serve' modules/surf/README.md docs/services/storage.md docs/setup/configuration.md | wc -l` prints 3.
|
||||
- `grep -c 'mountUploads(' modules/surf/router.go` prints at least 1.
|
||||
- `grep -c '"fonoteka:dev-seed"' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints 1, and `grep -c 'demo@example.com' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints at least 1.
|
||||
- `grep -c 'console.DevSeedCommand(p.app)' ../fonoteka.go/plugins/golem15/fonoteka/plugin.go` prints 1.
|
||||
- `grep -c 'lagoon.Publish' ../fonoteka.go/plugins/golem15/fonoteka/console/database.go` prints at least 1, and `grep -c 'attach.Publish' ../fonoteka.go/plugins/golem15/fonoteka/console/database.go` prints at least 1.
|
||||
- `git -C ../fonoteka.go show --name-only --format= HEAD -- main.go plugins.gen.go plugins/golem15/user plugins/golem15/golem plugins/golem15/feedback` prints nothing after the fonoteka.go commit (no generated file and no drifted submodule pointer was committed).
|
||||
</acceptance_criteria>
|
||||
<done>A fresh process with no published database seeds a user, a collection and one album with a cover, and the assembled Go router serves that cover under `/storage/app/uploads/public/...` with nosniff; the framework key is documented.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: full D-02 seed shape, idempotent rerun, the env-gated Go marker, and plugin commands that open their own database</name>
|
||||
<files>../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go, ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go, ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go, .planning/todos/pending/user-plugin-commands-open-database.md</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go (Task 1), ../fonoteka.go/plugins/golem15/fonoteka/console/database.go, ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go, ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go (WishlistAlbumsStore 184, createAlbumIn), ../fonoteka.go/plugins/golem15/fonoteka/models/album_rating.go, ../fonoteka.go/plugins/golem15/fonoteka/models/settings.go, ../fonoteka.go/plugins/golem15/fonoteka/updates/20_remaining.go (settings seed 198-200), ../fonoteka.go/plugins/golem15/user/classes/api_tokens.go, ../fonoteka.go/plugins/golem15/user/console/api_tokens.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go (public groups 343-372, realtime mount), ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/parity_contract_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go, .planning/todos/pending/bonfire-duplicate-command-names.md (todo frontmatter shape), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Pattern 1, Pattern 3, Q5, G9)</read_first>
|
||||
<action>(1) Seed shape per D-02, D-03 and research Pattern 1, all in the Task 1 transaction. Owned-collection albums keyed by (collection_id, name) among live rows: `Kind of Blue` (1959), `Blue Train` (1957), `Mingus Ah Um` (1959), each with its own generated cover (distinct colours) added only when the album has no photos. `ProvisionWishlist`, then the wishlist entry `A Love Supreme` (1965) created with `CreateAlbum` on the wishlist collection id, the same path `WishlistAlbumsStore` takes through `createAlbumIn`, keyed by (wishlist collection_id, name). One `models.AlbumRating` with rating 4 for `Kind of Blue`, keyed by (album_id, user_id). API token: when `DEV_SEED_API_TOKEN` is set it must start with `userclasses.APITokenPrefix(app)` (refuse otherwise) and its row is upserted by `HashAPIToken(secret)` under the name `dev-seed-mcp`; when unset, mint once with `MintAPIToken(prefix, userID, "dev-seed-mcp", []string{"read", "write", "ai"}, nil, nil)`, persist it, and print `api token: created <secret> (shown once)`; later runs find the row by name and user and print `api token: unchanged (secret not shown)`. Rerun with a different `E2E_PASSWORD`: when the bcrypt check fails, set the new password and print `user: updated`; the same inputs print `user: unchanged`. Last step (G9): `UPDATE golem15_fonoteka_settings SET search_use_typesense = true WHERE id = 1 AND NOT search_use_typesense`, printing `search: created` when a row changed and `search: unchanged` otherwise.
|
||||
|
||||
(2) G1 per D-05 and D-22: in `reindex.go`, `oauth_client.go` and `prune_notifications.go` replace the published-only lookup and its error with `commandDatabase(ctx, app)` plus a deferred closer. Keep every other line of behaviour, output and message as it is, so the existing console tests pass unchanged on the published-database path. The shared sm-user-plugin is not touched.
|
||||
|
||||
(3) Marker per D-09 and D-10 (research Pattern 3, discretion on the route): new `dev_marker.go` with `func (p *Plugin) devMarkerRoutes(r pact.Router)` that registers nothing unless `console.DevEnvironment(p.app)` reports local or dev. When allowed: group `/_fonoteka/api/v1/_dev` with `surf.Use("throttle:60,1")` and `GET /backend` answering 200 with `Content-Type: application/json`, `Cache-Control: no-store` and the body `{"backend":"summercms","app":"fonoteka","env":"<env>"}` encoded with encoding/json. No auth middleware, no other header. `routes.go` calls it before the realtime mount. Do not add it to `parity/manifest.yaml` or `parity/routes.snapshot`.
|
||||
|
||||
(4) Todo (planning doc, its own commit in summercms.go): `.planning/todos/pending/user-plugin-commands-open-database.md` with the frontmatter shape of the other todos (title, date 2026-10-07, priority low, area sm-user-plugin): `user:token:*` and `user:require-password-change` read only a published database and stop in the real binary; the fix is the same published-else-open pattern, additive and non-breaking, deferred under the core-plugin rule.
|
||||
|
||||
(5) Extend `TestDevStackSmoke`: a second run with the same env prints `unchanged` for every entity, row counts in users, collections, albums, system_files, album ratings and the token table stay equal, `search_use_typesense` is true; GET `/_fonoteka/api/v1/_dev/backend` on the assembled router answers 200 with exactly the three keys and `Cache-Control: no-store`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -v -run '^(TestDevStackSmoke|TestFullRouteTableAuthGroupMutualExclusivity|TestRouteTablePhase14|TestHooksRegisterWhenDatabasePublishedAfterBoot)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/console -count=1 -v -run '^(TestReindexCommand|TestPruneNotifications|TestOAuthClientCommandListPrintsClientIDAndURIsNeverTheSecret)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP" or "no tests to run"; output lacks "--- PASS: TestDevStackSmoke" or "--- PASS: TestReindexCommand".</fails_when>
|
||||
<automated>go -C ../fonoteka.go test ./parity -count=1 -run 'TestParityCorpus' -timeout 30m</automated>
|
||||
<fails_when>Non-zero exit; the summary line is not `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'dev-seed-mcp' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints at least 1, and `grep -c 'DEV_SEED_API_TOKEN' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints at least 1.
|
||||
- `grep -c 'ProvisionWishlist' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints at least 1, and `grep -c 'search_use_typesense' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go` prints at least 1.
|
||||
- `grep -l 'commandDatabase(' ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go | wc -l` prints 3.
|
||||
- `grep -c 'devMarkerRoutes(r)' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints 1, and `grep -c 'no-store' ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go` prints at least 1.
|
||||
- `grep -c '_dev/backend' ../fonoteka.go/parity/manifest.yaml` prints 0.
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user status --porcelain` prints nothing (the shared user plugin is unchanged).
|
||||
- `test -f .planning/todos/pending/user-plugin-commands-open-database.md` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The seed creates the full D-02 shape once and reports `unchanged` on a rerun, the marker answers only in local/dev and stays out of the manifest with the corpus at 175/175, and the three fonoteka plugin commands run without a pre-published database.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: opt-in request log — each Go request's route pattern and status reach the redacted log</name>
|
||||
<reversibility rating="costly">`http.access_log` is a new framework config key and log record shape other applications will grep; renaming it later touches every app config and log query.</reversibility>
|
||||
<files>modules/surf/accesslog.go, modules/surf/accesslog_test.go, modules/surf/router.go, modules/surf/README.md, docs/services/logging.md, docs/setup/configuration.md</files>
|
||||
<read_first>modules/surf/router.go (compile, wrap, stripLocalePrefix 722-731, bufferedResponse 668-707), modules/surf/router_test.go (router construction in tests), modules/sunscreen/README.md, docs/services/logging.md, modules/lighthouse/lighthouse.go (the `app.Lookup[*slog.Logger]()` fallback pattern near line 197), modules/surf/README.md, docs/setup/configuration.md, .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (G3, Open Question 3, Pitfall 8)</read_first>
|
||||
<action>Per D-12 (research G3, OQ3, Pitfall 8). New `modules/surf/accesslog.go`. In `compile()`, when `r.app` and its config are non-nil and `http.access_log` is true, wrap the final handler (outside `stripLocalePrefix` and CORS) with the unexported `accessLog` middleware; when false, add no wrapper. The middleware puts a request-scoped entry into the context, serves through a status-recording `http.ResponseWriter` (first `WriteHeader` wins, default 200; keep `Flush` and `Unwrap` working for `http.ResponseController`), then logs one record. In `wrap(rt)`, add an outermost per-route step that stores `rt.method + " " + rt.path` into that entry when one is present; read the pattern from the entry, not from `r.Pattern`, because `stripLocalePrefix` can hand ServeMux a copied request whose `Pattern` the outer middleware never sees. Logger: `app.Lookup[*slog.Logger]()` when published, else `slog.Default()` (sunscreen-wrapped in generated mains). Record: level Info, message `http request`, attributes `method`, `pattern`, `status`, `duration_ms` (whole milliseconds, int64); only when the pattern is empty (ServeMux 404 or 405, CORS preflight answered before routing) add `path` set to `r.URL.Path`. Never log the query string, headers, the remote address or a matched route's raw path (share and invitation tokens travel in paths). Add no exported identifier.
|
||||
|
||||
`accesslog_test.go` with `TestAccessLogSmoke`: a router with `http.access_log` true, a test plugin route `GET /blog/posts/{id}` and a published `*slog.Logger` writing JSON into a buffer. A request to `/blog/posts/7?token=secret` yields one record with pattern `GET /blog/posts/{id}`, status 200 and no `secret`, no `/blog/posts/7`; a request to an unknown path yields status 404 and `path` without its query string; with the key false the buffer stays empty. Use the plugin names `blog` / `acme` in fixtures.
|
||||
|
||||
Docs in the same commit: surf README (Features bullet and a Configuration row `http.access_log`, default `false`), docs/services/logging.md (new section "Request log": opt-in key and env name, record fields, what is never logged, sunscreen still applies), docs/setup/configuration.md (row). Framework docs never name the consuming application.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/surf/ && go test ./modules/surf/ -count=1 -v -run '^TestAccessLogSmoke$' && go test ./modules/surf/ -count=1 && go test ./cmd/summer -count=1 -run '^TestDocsTree$' && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP" or "no tests to run"; output lacks "--- PASS: TestAccessLogSmoke"; docs:build --check prints any problem line.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'http.access_log' modules/surf/accesslog.go` prints at least 1, and `grep -c 'duration_ms' modules/surf/accesslog.go` prints at least 1.
|
||||
- `grep -c 'RawQuery' modules/surf/accesslog.go` prints 0.
|
||||
- `grep -l 'http.access_log' modules/surf/README.md docs/services/logging.md docs/setup/configuration.md | wc -l` prints 3.
|
||||
- `grep -ci 'fonoteka\|plytarium\|płytarium' modules/surf/README.md docs/services/logging.md docs/services/storage.md` prints 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>With `http.access_log` on, every request produces one redacted record naming the matched route pattern and status; unmatched requests carry their path without the query; with the key off nothing changes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Operator shell → `fonoteka:dev-seed` | Environment variables decide which database is written and which credentials are seeded |
|
||||
| Browser (via Nuxt proxy) → `/storage/app/uploads/public/...` | Untrusted path segments reach the blob store on the app origin |
|
||||
| Any request → access log | Paths can carry share and invitation tokens; logs leave the process |
|
||||
| Network → marker route | Unauthenticated callers learn the backend identity |
|
||||
| Go router → parity contract | Local-run glue must not alter any manifest route |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.2-01 | Tampering | `fonoteka:dev-seed` against a production database | high | mitigate | `DevEnvironment` allow-list is exactly `local`/`dev`; the refusal runs before `commandDatabase`; an unset SUMMER_ENV resolves to `production` and is refused (TestDevStackSmoke here, TestDevSeedRefusesProduction in plan 04) |
|
||||
| T-14.2-02 | Elevation of Privilege | `mountUploads` serving uploads same-origin | high | mitigate | Opt-in key default false; `attach.StaticHandlerPublic` 404s non-public and missing rows; `X-Content-Type-Options: nosniff` on every response; the seed writes PNG only |
|
||||
| T-14.2-03 | Information Disclosure | `accessLog` record | high | mitigate | Matched routes log the pattern, never the raw path; unmatched log `r.URL.Path` without the query; no headers, no remote address; sunscreen handler stays in front; default off |
|
||||
| T-14.2-04 | Information Disclosure | marker route `GET /_fonoteka/api/v1/_dev/backend` | medium | mitigate | Registered only when `DevEnvironment` reports local or dev; absent in production; `throttle:60,1`; body carries no version or secret |
|
||||
| T-14.2-05 | Information Disclosure | seeded API token secret | medium | mitigate | Stored as `HashAPIToken` only; the secret is printed once at creation, never on reruns; `DEV_SEED_API_TOKEN` comes from the environment only |
|
||||
| T-14.2-06 | Tampering | path segments of `/storage/...` | medium | mitigate | `parsePublicBlobPath` inside `StaticHandlerPublic` rejects `..`, empty segments and partition mismatches before `NewReader` (canon path traversal, existing T-05-13 control) |
|
||||
| T-14.2-07 | Tampering | parity contract (D-10) | high | mitigate | Marker and mount stay out of `parity/manifest.yaml` and `routes.snapshot`; `TestParityCorpus` must stay 175/175/0 |
|
||||
| T-14.2-SC | Tampering | Go module and npm installs | high | mitigate | No new dependency: stdlib `image/png`, `log/slog`, existing framework modules only; package legitimacy audit not applicable (research) |
|
||||
|
||||
ASVS L1; every high threat is mitigated with a test-backed control.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- summercms.go: `go vet ./...` and `go test ./...` green (framework commits), `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` clean. Config keys in docs are reviewed by hand (not checked automatically).
|
||||
- fonoteka.go: `go -C ../fonoteka.go vet ./...` and `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/... -count=1` green; `TestParityCorpus` 175/175/0.
|
||||
- `git -C ../fonoteka.go status --porcelain` shows only the pre-existing submodule pointer drift (` M plugins/golem15/feedback`, `golem`, `user`), nothing staged from it.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `fonoteka:dev-seed` creates the full D-02 shape in a fresh process, refuses outside local/dev, and is idempotent.
|
||||
- `storage.uploads.serve` serves a seeded cover with nosniff; `http.access_log` logs pattern and status; both documented in the same change.
|
||||
- The marker answers only in local/dev and the parity corpus is unchanged.
|
||||
- `fonoteka:reindex`, `fonoteka:oauth-client` and `fonoteka:prune-notifications` no longer need a pre-published database.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,334 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["14.2-01"]
|
||||
files_modified:
|
||||
- ../fonoteka.go/scripts/dev-stack.sh
|
||||
- ../fonoteka.go/.env.dev.example
|
||||
- ../fonoteka.go/.gitignore
|
||||
- ../fonoteka.go/config/storage.yaml
|
||||
- ../fonoteka.go/config/http.yaml
|
||||
- ../fonoteka.go/README.md
|
||||
autonomous: false
|
||||
requirements: [QA-05]
|
||||
user_setup:
|
||||
- service: postgres
|
||||
why: "A dedicated local database for the Go backend, never the PHP app's database (D-01, D-06)"
|
||||
env_vars:
|
||||
- name: SUMMER_DATABASE__DSN
|
||||
source: "Create a database such as fonoteka_go_dev with the Polish ICU collation from fonoteka.go/README.md § Database setup; the host must be loopback (127.0.0.1 or localhost)"
|
||||
- service: typesense
|
||||
why: "Album search, required by the start script (D-06)"
|
||||
env_vars:
|
||||
- name: SUMMER_SEARCH__TYPESENSE__API_KEY
|
||||
source: "API key of the plytarium-typesense-instance container (the value the PHP app uses as TYPESENSE_API_KEY)"
|
||||
dashboard_config:
|
||||
- task: "Start the container: docker start plytarium-typesense-instance"
|
||||
location: "host shell"
|
||||
- service: centrifugo
|
||||
why: "Realtime through the shared host Centrifugo v6 on :8787, no dedicated instance (D-18)"
|
||||
env_vars:
|
||||
- name: SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET
|
||||
source: "/etc/centrifugo/config.json key client.token.hmac_secret_key (root-readable; the value the PHP app uses as CENTRIFUGO_SECRET)"
|
||||
- name: SUMMER_REALTIME__CENTRIFUGO__API_KEY
|
||||
source: "/etc/centrifugo/config.json key http_api.key (the value the PHP app uses as CENTRIFUGO_API_KEY)"
|
||||
- service: fonoteka-go
|
||||
why: "Boot secrets and paths for the local Go binary (D-08)"
|
||||
env_vars:
|
||||
- name: SUMMER_APP__KEY
|
||||
source: "Output of `fonoteka key:generate` (base64 of 32 bytes)"
|
||||
- name: SUMMER_GOLEM15__USER__JWT__SECRET
|
||||
source: "Any random string of 32+ bytes, e.g. `openssl rand -base64 48`"
|
||||
- name: SUMMER_ADMIN__JWT__SECRET
|
||||
source: "Any random string of 32+ bytes, e.g. `openssl rand -base64 48`"
|
||||
- name: DEV_STACK_NUXT_DIR
|
||||
source: "Absolute path of the vue-fonoteka-app checkout (/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app on this machine)"
|
||||
estimate:
|
||||
tokens: 180000
|
||||
raw_tokens: 180000
|
||||
tasks: 4
|
||||
confidence: low # uncalibrated (factor 1, sample_count 0); 1 of 4 tasks is the env-file checkpoint; plan count locked at 4 by D-22
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-05 and D-22, `scripts/dev-stack.sh up` runs, in this order: env check, service checks, a `summer` CLI built from `../summercms.go` source, `summer build`, `fonoteka migrate`, `fonoteka fonoteka:dev-seed`, `fonoteka fonoteka:reindex`, `fonoteka serve`, the guard, `pnpm dev` in `DEV_STACK_NUXT_DIR`, and the post-start probe; no docker compose and no dedicated Centrifugo is started (D-18)."
|
||||
- "Per D-06, the script refuses to start and names the service when Postgres (`pg_isready`), Typesense (`/health`) or Centrifugo (`POST <api_url>/info` with the API key) is unreachable or rejects the key; a Centrifugo that rejects the origin `http://localhost:3119` produces a `warn:` line naming the operator step (D-18)."
|
||||
- "Per D-23, `check` (and `up`) probes each realtime namespace in `DEV_STACK_CENTRIFUGO_NAMESPACES` (default `user collection wishlist`) read-only through the Centrifugo HTTP API, `POST <api_url>/history` with `{\"channel\":\"<ns>:dev_stack_probe\",\"limit\":0}`, and prints exactly one `warn: centrifugo namespace <ns> missing` line for every namespace answered with error code 102 (unknown channel); any other answer counts as present, a configured namespace (`user`) is never reported, warnings never refuse, and the script never reads `/etc/centrifugo/config.json` or calls `publish`. The subscribe permission (`allow_subscribe_for_client`) is not observable through the server API; the README states this limitation and plan 03's realtime spec proves the `collection:` subscribe."
|
||||
- "Per D-07, Go serves on `127.0.0.1:8423` (`DEV_STACK_GO_ADDR`), loopback only; an address on port 8422 or a non-loopback host is refused."
|
||||
- "Per D-08 (edge: empty), the script loads the git-ignored env file (`DEV_STACK_ENV_FILE`, default `fonoteka.go/.env.dev`), refuses when it is missing or not git-ignored, and prints one `missing: <KEY>` line for every required key that is unset or empty before refusing; `.env.dev.example` holds placeholders only."
|
||||
- "Per D-08, `pnpm dev` receives `NUXT_DEV_BACKEND_ORIGIN`, `NUXT_INTERNAL_API_ORIGIN`, `NUXT_PUBLIC_CENTRIFUGO_WS_URL` and an empty-by-default `NUXT_PUBLIC_FEEDBACK_WIDGET_KEY` as process environment, and the script writes no file inside `DEV_STACK_NUXT_DIR`."
|
||||
- "Per D-09 (edge: boundary), `dev-stack.sh guard` refuses an origin on port 8422 by the port rule before any network call, refuses a host other than localhost, 127.0.0.1 or ::1 (so any plytarium.com value), and accepts any other loopback port only when its marker answers `\"backend\":\"summercms\"` without an `X-Powered-By: PHP` header."
|
||||
- "Per D-09 (edge: adjacency), `guard` refuses when `NUXT_INTERNAL_API_ORIGIN` is unset or empty, when it differs from `NUXT_DEV_BACKEND_ORIGIN` after trailing-slash normalisation, or when either differs from `http://$DEV_STACK_GO_ADDR`; identical values pass the origin rules."
|
||||
- "Per D-09 and D-03, the post-start probe gets the marker JSON through `http://localhost:3119/_fonoteka/api/v1/_dev/backend`, a 401 from `http://localhost:3119/_fonoteka/api/v1/albums`, and 200 `image/png` for the first seeded cover URL through the Nuxt `/storage` proxy; any miss refuses and stops both processes."
|
||||
- "Per D-12, Go and Nuxt output is prefixed `[go] ` / `[nuxt] ` and tee'd into `tmp/dev-stack/go.log` and `tmp/dev-stack/nuxt.log`; the run docs tell the developer to watch them for 4xx/5xx and 404 responses."
|
||||
- "(edge: idempotency) `up --detach` on a stack this script already started (pid file alive, marker answering) prints `already up` and exits 0; `:8423` or `:3119` held by any other process is refused; `down` with nothing recorded is a no-op that exits 0."
|
||||
- "(edge: concurrency) `up` in the foreground stops both process groups and removes the pid file on Ctrl-C, TERM or any failure (EXIT trap); a second concurrent `up` is refused by the port checks."
|
||||
- "(edge: ordering) `fonoteka:reindex` runs only after `fonoteka:dev-seed` has turned `search_use_typesense` on, and `pnpm dev` starts only after the guard passed."
|
||||
- "`dev-stack.sh e2e-verdict <report.json>` refuses a Playwright JSON report unless `stats.expected > 0`, `stats.unexpected == 0`, `stats.skipped == 0` and `stats.flaky == 0` (research G7: a skip is how a misconfigured backend shows up)."
|
||||
artifacts:
|
||||
- path: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
provides: "up [--detach] | down | status | check | guard | exec | e2e [--report-only] | e2e-verdict"
|
||||
contains: "_dev/backend"
|
||||
- path: "../fonoteka.go/.env.dev.example"
|
||||
provides: "placeholder template of every key the script requires (D-08)"
|
||||
contains: "NUXT_INTERNAL_API_ORIGIN"
|
||||
- path: "../fonoteka.go/.gitignore"
|
||||
provides: "ignores the dev env file and the seeded uploads"
|
||||
contains: "/storage/app/uploads/public/"
|
||||
- path: "../fonoteka.go/README.md"
|
||||
provides: "run docs: prerequisites, Centrifugo v6 operator step (origins and the D-23 namespaces), subcommands, logs, MCP pointing"
|
||||
contains: "dev-stack.sh"
|
||||
key_links:
|
||||
- from: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go"
|
||||
via: "guard and post-start probe GET /_fonoteka/api/v1/_dev/backend directly and through the Nuxt proxy"
|
||||
pattern: "_dev/backend"
|
||||
- from: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go"
|
||||
via: "up runs bin/fonoteka fonoteka:dev-seed and reads the first cover URL from its output"
|
||||
pattern: "fonoteka:dev-seed"
|
||||
- from: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
to: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/nuxt.config.ts"
|
||||
via: "pnpm dev with NUXT_DEV_BACKEND_ORIGIN and NUXT_INTERNAL_API_ORIGIN pinned to the Go origin"
|
||||
pattern: "NUXT_INTERNAL_API_ORIGIN"
|
||||
prohibitions:
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "dev-stack.sh MUST NOT write, copy, move or edit any file inside the vue-fonoteka-app checkout; the developer's PHP-targeted Nuxt setup stays intact"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: transparency
|
||||
statement: "The local stack MUST NOT fall through to PHP or the production origin: Nuxt is never started unless the guard proved the configured origin is the local Go binary"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "dev-stack.sh MUST NOT read or edit /etc/centrifugo/config.json, restart the shared Centrifugo or start a second instance; adding the Nuxt origin and the `collection` / `wishlist` namespaces stays a documented operator step (D-18, D-23), and the namespace probe uses only the read-only `history` API method"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** developer, **I want to** run the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), log in and use the real Nuxt flows, **so that** the port is proven in daily use before production cutover.
|
||||
|
||||
This plan's slice: one command starts the whole stack. After it, `scripts/dev-stack.sh up` brings Go and Nuxt up together on host services, refuses to run when anything would reach PHP or production, and the README explains the local run end to end.
|
||||
|
||||
<objective>
|
||||
Deliver D-22 plan 02: `fonoteka.go/scripts/dev-stack.sh` (D-05, D-06, D-07, D-08, D-09, D-12, D-18, D-23), `.env.dev.example`, the `.gitignore` entries, the app config defaults for the two new framework keys, and the run docs.
|
||||
|
||||
Purpose: plan 03 runs Playwright through `dev-stack.sh e2e`, plan 04's gate exercises `dev-stack.sh guard` refusals and the e2e verdict.
|
||||
Output: fonoteka.go commits only (script, template and ignores, config defaults, README). No framework change.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-SUMMARY.md
|
||||
@../fonoteka.go/README.md
|
||||
|
||||
<interfaces>
|
||||
- From plan 01: `bin/fonoteka fonoteka:dev-seed` prints `<entity>: created|updated|unchanged` lines; cover lines end with the public URL `/storage/app/uploads/public/<partition>/<disk_name>`. Marker: `GET /_fonoteka/api/v1/_dev/backend` → 200 `{"backend":"summercms","app":"fonoteka","env":"local"}` in local/dev only. Framework keys: `SUMMER_STORAGE__UPLOADS__SERVE`, `SUMMER_HTTP__ACCESS_LOG` (both default false). `fonoteka:reindex`, `migrate` and `serve --addr` open their own database.
|
||||
- Config is cwd-relative (`compass.Load("config")` in main.go; bucket `file://./storage/app/uploads/public`): every `bin/fonoteka` call runs with cwd = fonoteka.go. Compass reads `fonoteka.go/.env` automatically but not `.env.dev`, so the script exports it (`set -a`; source; `set +a`).
|
||||
- `summer` is not on PATH for scripts and `~/go/bin/summer` may be stale: build it with `go -C <framework> build -o tmp/dev-stack/summer ./cmd/summer`, then run `summer build` with cwd = fonoteka.go (writes `bin/fonoteka`).
|
||||
- Nuxt (nuxt.config.ts): `NUXT_DEV_BACKEND_ORIGIN` read once at config load (default `http://localhost:8000`); `internalApiOrigin` falls back to `NUXT_DEV_BACKEND_ORIGIN`, then `NUXT_PUBLIC_SITE_URL`, then `https://plytarium.com`; dev port `NUXT_PORT || 3119` and silently moves to another port when 3119 is taken; proxied prefixes `/_user/api`, `/_journal/api`, `/_fonoteka/api`, `/_feedback/api`, `/plugins/golem15/feedback`, `/api/realtime`, `/storage`. Env exported on the `pnpm dev` process wins over `vue-fonoteka-app/.env` (c12 only fills unset keys).
|
||||
- Playwright (playwright.config.ts): loads `tests/e2e/.env.local` then `tests/e2e/.env` without overriding exported variables; `baseURL = E2E_FRONTEND_URL || http://127.0.0.1:3119`; `webServer.reuseExistingServer = !process.env.CI`; JSON reporter output via `PLAYWRIGHT_JSON_OUTPUT_NAME`. `fonotekaReachable()` probes `E2E_BACKEND_URL` (default `http://localhost:8422`).
|
||||
- Centrifugo v6 (D-18): shared systemd instance on :8787, HTTP API `POST /api/info` with header `X-API-Key`, WebSocket `/connection/websocket`; a WebSocket upgrade with a disallowed `Origin` is rejected (403).
|
||||
- Centrifugo v6 server API errors (D-23) [CITED: centrifugal.dev/docs/server/server_api, /docs/server/channels]: HTTP 200 with a body `{"error":{"code":N,"message":"..."}}`. `POST /api/history` with `{"channel":"<ns>:dev_stack_probe","limit":0}` answers code 102 `unknown channel` when namespace `<ns>` is not defined, code 108 `not available` when it exists without history, and a `result` when history is on; it is read-only and publishes nothing. Namespaces live in the `channel.namespaces` array of the v6 config; today the shared instance defines `user` but not `collection` or `wishlist` (D-23).
|
||||
- Local PHP stamps `X-Powered-By: PHP/8.5.10` on every response and has no route at the marker path; Go never sets that header.
|
||||
- The Bash secret-read guard blocks any shell command that names the dev env file (anything matching `.env.<suffix>` other than `.env.example`-style templates). Go through `dev-stack.sh` subcommands; edit `.gitignore` with the Edit tool; never print, cat or grep the env file.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- `fonoteka.go/scripts/dev-stack.sh` subcommands: `up [--detach]`, `down`, `status`, `check`, `guard`, `exec -- <command...>`, `e2e [--report-only] <spec...>`, `e2e-verdict <report.json>`.
|
||||
- Script environment knobs: `DEV_STACK_ENV_FILE` (default `<app>/.env.dev`), `DEV_STACK_GO_ADDR` (default `127.0.0.1:8423`), `DEV_STACK_NUXT_DIR` (required), `DEV_STACK_FRAMEWORK_DIR` (default `<app>/../summercms.go`), `DEV_STACK_CENTRIFUGO_NAMESPACES` (default `user collection wishlist`, D-23).
|
||||
- Runtime files under `fonoteka.go/tmp/dev-stack/`: `go.log`, `nuxt.log`, `pids`, `summer`, `e2e-<timestamp>.json`.
|
||||
- Variables exported by `exec`/`e2e`: `E2E_BACKEND=go`, `E2E_GO_BIN=<app>/bin/fonoteka`, `E2E_GO_DIR=<app>`, `E2E_BACKEND_URL=http://$DEV_STACK_GO_ADDR`, `E2E_FRONTEND_URL=http://localhost:3119`, `E2E_CENTRIFUGO=1`, `E2E_EMAIL`/`E2E_PASSWORD` (env file values, else `demo@example.com` / `changeme`), the four `NUXT_*` values, with `CI` unset.
|
||||
- `fonoteka.go/.env.dev.example` (placeholders), `.gitignore` lines `/.env.dev` and `/storage/app/uploads/public/`.
|
||||
- App config defaults `storage.uploads.serve: false` (config/storage.yaml) and `access_log: false` (config/http.yaml).
|
||||
- README section "Running locally with the Nuxt app".
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `pg_isready`, `curl`, `jq`, `python3`, `ss` and `setsid` are on PATH (verified during planning).
|
||||
- The developer fills the git-ignored env file from this plan's `user_setup` list at the Task 1 checkpoint (`checkpoint:human-action`, `gate="blocking-human"`), so the plan is not autonomous. The executor never reads, prints or greps that file or `/etc/centrifugo/config.json`. Completeness is checked by Task 2's `check_keys`, which prints key names only.
|
||||
- Adding `http://localhost:3119` to Centrifugo's allowed origins and the `collection` / `wishlist` namespaces (D-23) is a plan 03 human-action checkpoint; until then `check` prints the origin `warn:` line and one `warn: centrifugo namespace <ns> missing` line per missing namespace, and realtime stays off, which does not block this plan.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 1: developer creates the git-ignored dev env file, the Go dev database and starts Typesense</name>
|
||||
<read_first>This plan's `user_setup` frontmatter (every key and where its value comes from), ../fonoteka.go/README.md (§ Database setup), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md (D-06, D-08, D-18)</read_first>
|
||||
<action>Per D-06, D-08 and D-18, the developer creates `fonoteka.go/.env.dev` holding every `user_setup` variable of this plan plus the fixed local values listed below, creates the Go dev database and starts Typesense. The secret values come from the root-readable Centrifugo config and the developer's own setup. The executor must never read, print, cat or grep `/etc/centrifugo/config.json` or the dev env file; the Bash secret-read guard blocks that too.</action>
|
||||
<instructions>
|
||||
Claude has done nothing yet in this plan. The first automated task (Task 2) starts the real stack and needs secrets only a person can read: the HMAC secret and API key of the shared Centrifugo (root-owned config), the Typesense API key and a database password.
|
||||
The one human step, in a shell:
|
||||
1. Create a separate Postgres database for Go (for example `fonoteka_go_dev`) with the Polish ICU collation from fonoteka.go/README.md § Database setup, on 127.0.0.1 or localhost. Never use the PHP app's database.
|
||||
2. Run `docker start plytarium-typesense-instance` (port 8181).
|
||||
3. Create `fonoteka.go/.env.dev`. Task 2 adds it to `.gitignore` before anything is staged, and every commit stages explicit paths only. Set these keys:
|
||||
- Secrets, with sources as in `user_setup`: SUMMER_DATABASE__DSN, SUMMER_APP__KEY, SUMMER_GOLEM15__USER__JWT__SECRET, SUMMER_ADMIN__JWT__SECRET, SUMMER_SEARCH__TYPESENSE__API_KEY, SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET (= `client.token.hmac_secret_key`), SUMMER_REALTIME__CENTRIFUGO__API_KEY (= `http_api.key`).
|
||||
- Fixed local values: SUMMER_ENV=local, SUMMER_APP__URL=http://localhost:3119, SUMMER_STORAGE__UPLOADS__SERVE=true, SUMMER_HTTP__ACCESS_LOG=true, DEV_STACK_GO_ADDR=127.0.0.1:8423, SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api, DEV_STACK_NUXT_DIR=/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app, NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423, NUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:8423, NUXT_PUBLIC_CENTRIFUGO_WS_URL=ws://localhost:8787/connection/websocket.
|
||||
- Optional: E2E_EMAIL, E2E_PASSWORD (else `demo@example.com` / `changeme`), DEV_SEED_API_TOKEN, NUXT_PUBLIC_FEEDBACK_WIDGET_KEY.
|
||||
Task 2 commits `.env.dev.example` with the same keys as placeholders for later setups. Adding `http://localhost:3119` to Centrifugo's allowed origins is not part of this step; it is the plan 03 Task 2 checkpoint.
|
||||
</instructions>
|
||||
<verification>Without opening the env file: `docker ps --filter name=plytarium-typesense-instance --filter status=running --format '{{.Names}}'` prints the container name and `curl -fsS http://localhost:8181/health` succeeds. The env file's presence, git-ignore status and completeness are checked by Task 2's `load_env` and `check_keys`, which print `missing: <KEY>` names only, never values.</verification>
|
||||
<resume-signal>Type "env ready" once the env file, the database and Typesense are in place, or describe the problem.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2 (tracer): `dev-stack.sh up --detach` brings Go and Nuxt up and the Go marker and a seeded cover answer through Nuxt's proxy</name>
|
||||
<precondition>Task 1 is resolved ("env ready"): the developer has created fonoteka.go's git-ignored dev env file with every `user_setup` variable of this plan set, the Postgres dev database exists, and `plytarium-typesense-instance` is running.</precondition>
|
||||
<files>../fonoteka.go/scripts/dev-stack.sh, ../fonoteka.go/.env.dev.example, ../fonoteka.go/.gitignore, ../fonoteka.go/config/storage.yaml, ../fonoteka.go/config/http.yaml</files>
|
||||
<read_first>../fonoteka.go/.gitignore, ../fonoteka.go/config/storage.yaml, ../fonoteka.go/config/http.yaml, ../fonoteka.go/config/realtime.yaml, ../fonoteka.go/config/search.yaml, ../fonoteka.go/README.md (Database setup, Configuration), ../fonoteka.go/main.go (compass.Load), cmd/summer/main.go (buildCommand cwd), cmd/summer/runtime.go (bin output), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/nuxt.config.ts (lines 30, 44-52, 110-113, 128, 155, 247-266, 364-372, 392-401), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/.env.example (key names only), scripts/check-phase14.2.1.sh (header and structure style), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q3, Q4, Q5, Q7, Pattern 4, Pattern 5, Pitfalls 5-6)</read_first>
|
||||
<action>Write `scripts/dev-stack.sh` (bash, `set -euo pipefail`, `unset FORCE_COLOR`, header comment describing every subcommand). APP is the script's parent directory; FRAMEWORK is `${DEV_STACK_FRAMEWORK_DIR:-$APP/../summercms.go}`; RUN is `$APP/tmp/dev-stack` (already git-ignored through `/tmp/`). This task implements the happy path of `up [--detach]` and `down`, with these functions, in this order (D-05):
|
||||
|
||||
1. `load_env` (D-08): refuse when `${DEV_STACK_ENV_FILE:-$APP/.env.dev}` is missing; when the file is inside APP refuse unless `git -C "$APP" check-ignore -q` accepts it; export it with `set -a` / `set +a`; force `SUMMER_ENV=local` when unset and refuse any value other than `local` or `dev`; default `DEV_STACK_GO_ADDR` to `127.0.0.1:8423` and refuse a non-loopback host or port 8422 (D-07).
|
||||
2. `check_keys` (D-08): REQUIRED_KEYS = SUMMER_DATABASE__DSN, SUMMER_APP__KEY, SUMMER_GOLEM15__USER__JWT__SECRET, SUMMER_ADMIN__JWT__SECRET, SUMMER_SEARCH__TYPESENSE__API_KEY, SUMMER_REALTIME__CENTRIFUGO__API_URL, SUMMER_REALTIME__CENTRIFUGO__API_KEY, SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET, DEV_STACK_NUXT_DIR, NUXT_DEV_BACKEND_ORIGIN, NUXT_INTERNAL_API_ORIGIN, NUXT_PUBLIC_CENTRIFUGO_WS_URL. Print `missing: <KEY>` for each unset or empty one, then `refuse: <n> required keys missing in <file>` and exit 1. Never echo a value.
|
||||
3. `check_services` (D-06, D-18, D-23): Postgres with `pg_isready -d "$SUMMER_DATABASE__DSN"` and a refusal when the DSN host is not loopback; Typesense `GET http://${SUMMER_SEARCH__TYPESENSE__HOST:-localhost}:${SUMMER_SEARCH__TYPESENSE__PORT:-8181}/health` with a hint `docker start plytarium-typesense-instance`; Centrifugo `POST $SUMMER_REALTIME__CENTRIFUGO__API_URL/info` with the `X-API-Key` header passed on curl's stdin (`-H @-`) so the key never appears in the process list, distinguishing unreachable from key rejected. Each refusal is one `refuse: <service> ...` line. Then an origin probe: a WebSocket upgrade request to the Centrifugo host's `/connection/websocket` with `Origin: http://localhost:3119` and a 2-second limit; a 403 prints `warn: centrifugo does not allow origin http://localhost:3119 — add it to client.allowed_origins in /etc/centrifugo/config.json and restart the service (README)`; never edit that file or restart the service. Then the namespace probe (D-23): for each `<ns>` in `${DEV_STACK_CENTRIFUGO_NAMESPACES:-user collection wishlist}`, `POST $SUMMER_REALTIME__CENTRIFUGO__API_URL/history` with body `{"channel":"<ns>:dev_stack_probe","limit":0}` and the `X-API-Key` header again on curl's stdin; when jq finds `.error.code == 102` print `warn: centrifugo namespace <ns> missing — add {"name": "<ns>", "allow_subscribe_for_client": true} to channel.namespaces in /etc/centrifugo/config.json and restart the service (README)`; treat every other answer (a `result`, code 108 not available, any other code) as present. Use only the `history` method (read-only); never `publish`, `broadcast` or any other mutating API method, and never open the Centrifugo config file. Warnings never refuse: `check` exits 0 when it printed only `warn:` lines.
|
||||
4. `build`: `go -C "$FRAMEWORK" build -o "$RUN/summer" ./cmd/summer`, then `"$RUN/summer" build` with cwd APP. Never use a `summer` from PATH.
|
||||
5. `seed` (D-01, D-05): with cwd APP run `bin/fonoteka migrate`, `bin/fonoteka fonoteka:dev-seed` (keep its output in `$RUN/seed.out` and on the terminal), `bin/fonoteka fonoteka:reindex`, in that order; any non-zero exit refuses.
|
||||
6. `start_go`: refuse when `DEV_STACK_GO_ADDR` is already listening (`ss -ltn`); `setsid bin/fonoteka serve --addr "$DEV_STACK_GO_ADDR"` in the background with stdout and stderr prefixed `[go] ` and appended to `$RUN/go.log` (D-12); record the process group in `$RUN/pids`; wait up to 60 s for the marker on `http://$DEV_STACK_GO_ADDR/_fonoteka/api/v1/_dev/backend`.
|
||||
7. `guard` (D-09 part 1 and 2): the origin rules and marker check of the truths above, run against the env values; refusal lines start with `refuse:`.
|
||||
8. `start_nuxt` (D-08): refuse when `:3119` is already listening (Pitfall 6); from `DEV_STACK_NUXT_DIR` start `setsid env NUXT_DEV_BACKEND_ORIGIN=... NUXT_INTERNAL_API_ORIGIN=... NUXT_PUBLIC_CENTRIFUGO_WS_URL=... NUXT_PUBLIC_FEEDBACK_WIDGET_KEY="${NUXT_PUBLIC_FEEDBACK_WIDGET_KEY:-}" NUXT_PORT=3119 pnpm dev --port 3119`, output prefixed `[nuxt] ` into `$RUN/nuxt.log`; record its process group. Never create, write, copy or edit a file in the Nuxt checkout.
|
||||
9. `probe` (D-09 part 3, D-03): poll up to 180 s for the marker JSON through `http://localhost:3119/_fonoteka/api/v1/_dev/backend`; then require 401 from `http://localhost:3119/_fonoteka/api/v1/albums` and 200 `image/png` for the first `/storage/app/uploads/public/` URL found in `$RUN/seed.out`, fetched through `http://localhost:3119`.
|
||||
10. `up` without `--detach` waits on both process groups with an EXIT/INT/TERM trap that kills both groups and removes `$RUN/pids`; `up --detach` returns after the probe. Any failed step also stops whatever this run started. `down` sends TERM to every recorded group, KILL after 10 s, removes `$RUN/pids`, and exits 0 when nothing is recorded.
|
||||
|
||||
`.env.dev.example` (D-08): every REQUIRED_KEY plus SUMMER_ENV=local, SUMMER_APP__URL=http://localhost:3119, SUMMER_STORAGE__UPLOADS__SERVE=true, SUMMER_HTTP__ACCESS_LOG=true, DEV_STACK_GO_ADDR=127.0.0.1:8423, NUXT_DEV_BACKEND_ORIGIN and NUXT_INTERNAL_API_ORIGIN both `http://127.0.0.1:8423`, SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api, NUXT_PUBLIC_CENTRIFUGO_WS_URL=ws://localhost:8787/connection/websocket, and commented optional E2E_EMAIL, E2E_PASSWORD, DEV_SEED_API_TOKEN, NUXT_PUBLIC_FEEDBACK_WIDGET_KEY. Secrets get empty values or `<placeholder>` text with a comment naming their source (the `user_setup` sources). No real value.
|
||||
|
||||
`.gitignore`: add `/.env.dev` and `/storage/app/uploads/public/` (research G11), using the Edit tool. `config/storage.yaml`: add `serve: false` under `uploads` with a comment (set true locally through SUMMER_STORAGE__UPLOADS__SERVE and after cutover). `config/http.yaml`: add `access_log: false` with a comment (SUMMER_HTTP__ACCESS_LOG).
|
||||
|
||||
Commit in fonoteka.go with explicit paths only.</action>
|
||||
<verify>
|
||||
<automated>bash -n ../fonoteka.go/scripts/dev-stack.sh && bash ../fonoteka.go/scripts/dev-stack.sh up --detach && curl -fsS http://localhost:3119/_fonoteka/api/v1/_dev/backend | jq -e '.backend == "summercms"' && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3119/_fonoteka/api/v1/albums)" = 401; rc=$?; bash ../fonoteka.go/scripts/dev-stack.sh down; exit $rc</automated>
|
||||
<fails_when>Non-zero exit; any line starting with "refuse:" or "missing:"; jq prints false or curl fails; the albums probe is not 401.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n ../fonoteka.go/scripts/dev-stack.sh` exits 0.
|
||||
- `grep -c 'fonoteka:dev-seed' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1, and `grep -c 'fonoteka:reindex' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1.
|
||||
- `grep -c 'NUXT_INTERNAL_API_ORIGIN' ../fonoteka.go/.env.dev.example` prints at least 1, and `grep -c 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423' ../fonoteka.go/.env.dev.example` prints 1.
|
||||
- `grep -nE '(>|>>|tee|cp|mv|install|sed -i)[^#]*DEV_STACK_NUXT_DIR' ../fonoteka.go/scripts/dev-stack.sh` prints nothing.
|
||||
- `grep -nE 'systemctl|/etc/centrifugo/config.json *>' ../fonoteka.go/scripts/dev-stack.sh` prints nothing.
|
||||
- `grep -c 'DEV_STACK_CENTRIFUGO_NAMESPACES' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1, and `grep -c 'dev_stack_probe' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1 (D-23 namespace probe).
|
||||
- `grep -nE '^[^#]*/(publish|broadcast)\b' ../fonoteka.go/scripts/dev-stack.sh` prints nothing (the probe uses the read-only `history` method only).
|
||||
- `git -C ../fonoteka.go check-ignore -q storage/app/uploads/public/probe.png` exits 0.
|
||||
- The `.gitignore` file read with the Read tool contains the line `/.env.dev` (Bash commands naming that file are blocked by the secret-read guard).
|
||||
- `grep -c 'serve: false' ../fonoteka.go/config/storage.yaml` prints 1, and `grep -c 'access_log: false' ../fonoteka.go/config/http.yaml` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>One command takes a configured machine from nothing to Go on 127.0.0.1:8423 and Nuxt on :3119, with the marker, a 401 and a seeded cover all answered through Nuxt's proxy, and `down` stops both.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: guard refusals and the lifecycle, exec and e2e subcommands fail closed</name>
|
||||
<files>../fonoteka.go/scripts/dev-stack.sh</files>
|
||||
<read_first>../fonoteka.go/scripts/dev-stack.sh (Task 2), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/playwright.config.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaReachable.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts, .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q2, Q5 preflight logic, Pattern 4, G7)</read_first>
|
||||
<action>Make each piece callable on its own and fail closed (D-09, D-17 groundwork):
|
||||
|
||||
- `check`: `load_env`, `check_keys`, `check_services` only, including the origin probe and the D-23 namespace probe; it exits 1 on any `refuse:` line and 0 when it printed only `warn:` lines, so plan 03 can assert on the warnings.
|
||||
- `guard`: load the env file only (no key list, no services) so plan 04 can feed planted files through `DEV_STACK_ENV_FILE`. Rules in order, each a `refuse:` line and exit 1: internal origin unset or empty; the two origins differ after trailing-slash normalisation; host not localhost, 127.0.0.1 or ::1; port 8422 (checked before any network call); origin different from `http://$DEV_STACK_GO_ADDR`; marker GET not 200, body without `.backend == "summercms"` (jq), or any response header matching `x-powered-by: php` case-insensitively. `up` calls the same function.
|
||||
- `status`: print each recorded process group as running or gone, and the marker result on the Go origin and through :3119; exit 0 only when both are up.
|
||||
- `up` idempotency: when `$RUN/pids` lists live groups and the marker answers through :3119, print `already up` and exit 0; when either port is held by a process not in `$RUN/pids`, refuse naming the port.
|
||||
- `exec -- <command...>`: `load_env`, then export E2E_BACKEND=go, E2E_GO_BIN=$APP/bin/fonoteka, E2E_GO_DIR=$APP, E2E_BACKEND_URL=http://$DEV_STACK_GO_ADDR, E2E_FRONTEND_URL=http://localhost:3119, E2E_CENTRIFUGO=1, E2E_EMAIL and E2E_PASSWORD (env-file values, else `demo@example.com` / `changeme`, matching the D-04 seed fallback), the four NUXT_* values, unset CI, and exec the command.
|
||||
- `e2e [--report-only] <spec...>`: through `exec`, run `pnpm --dir "$DEV_STACK_NUXT_DIR" exec playwright test <spec...> --project=chromium --reporter=json` with `PLAYWRIGHT_JSON_OUTPUT_NAME=$RUN/e2e-<timestamp>.json`; print the report path and `expected/unexpected/skipped/flaky` counts; unless `--report-only`, finish with `e2e-verdict` on that report (D-15 report-only specs are printed, never blocking).
|
||||
- `e2e-verdict <file>`: refuse unless the file parses and `stats.expected > 0`, `stats.unexpected == 0`, `stats.skipped == 0`, `stats.flaky == 0` (jq); print the counts either way.
|
||||
- A `help` usage text listing every subcommand; an unknown subcommand prints it and exits 2.
|
||||
- Env requirements per subcommand: `e2e-verdict`, `down` and `help` read no env file; `guard` reads the env file but not the key list; `check`, `up`, `status`, `exec` and `e2e` load the full env. This keeps the refusal and verdict paths testable on planted inputs (plan 04 `--guard` and `--self-test`).</action>
|
||||
<verify>
|
||||
<automated>S=../fonoteka.go/scripts/dev-stack.sh; T=$(mktemp -d); bash -n "$S" || exit 1; for o in http://127.0.0.1:8422 https://plytarium.com; do printf 'NUXT_DEV_BACKEND_ORIGIN=%s\nNUXT_INTERNAL_API_ORIGIN=%s\n' "$o" "$o" > "$T/g.env"; DEV_STACK_ENV_FILE="$T/g.env" bash "$S" guard > "$T/out" 2>&1 && exit 1; grep -q '^refuse:' "$T/out" || exit 1; done; printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423\n' > "$T/g.env"; DEV_STACK_ENV_FILE="$T/g.env" bash "$S" guard > "$T/out" 2>&1 && exit 1; grep -q '^refuse:' "$T/out" || exit 1; printf '{"stats":{"expected":3,"unexpected":0,"skipped":1,"flaky":0}}' > "$T/r.json"; bash "$S" e2e-verdict "$T/r.json" && exit 1; printf '{"stats":{"expected":3,"unexpected":0,"skipped":0,"flaky":0}}' > "$T/r.json"; bash "$S" e2e-verdict "$T/r.json"</automated>
|
||||
<fails_when>Non-zero exit: a planted :8422, plytarium.com or unset-internal-origin case was accepted or printed no "refuse:" line, the skipped report was accepted, or the clean report was refused.</fails_when>
|
||||
<automated>S=../fonoteka.go/scripts/dev-stack.sh; O=$(mktemp); DEV_STACK_CENTRIFUGO_NAMESPACES='user dev_stack_absent_ns' bash "$S" check > "$O" 2>&1; rc=$?; grep -E '^(warn|refuse|missing):' "$O"; test "$rc" -eq 0 && grep -q '^warn: centrifugo namespace dev_stack_absent_ns missing' "$O" && ! grep -q '^warn: centrifugo namespace user missing' "$O"</automated>
|
||||
<fails_when>Non-zero exit: `check` exited non-zero or printed a "refuse:" / "missing:" line, the undefined namespace `dev_stack_absent_ns` produced no `warn: centrifugo namespace dev_stack_absent_ns missing` line (positive control, D-23), or the configured `user` namespace was reported missing (negative control).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^ *(up|down|status|check|guard|exec|e2e|e2e-verdict|help)\)' ../fonoteka.go/scripts/dev-stack.sh` prints at least 9.
|
||||
- `grep -ci 'x-powered-by' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1.
|
||||
- `grep -c 'PLAYWRIGHT_JSON_OUTPUT_NAME' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1, and `grep -c 'already up' ../fonoteka.go/scripts/dev-stack.sh` prints at least 1.
|
||||
- `bash ../fonoteka.go/scripts/dev-stack.sh nosuchcommand; test $? -eq 2` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The guard refuses every stale, remote, mismatched or PHP origin before Nuxt could start, the stack lifecycle is idempotent, any Playwright run through the script fails on a skip, and `check` names every missing Centrifugo namespace (D-23) without refusing.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: run docs for the local stack in the fonoteka.go README</name>
|
||||
<files>../fonoteka.go/README.md</files>
|
||||
<read_first>../fonoteka.go/README.md, ../fonoteka.go/scripts/dev-stack.sh (help text), ../fonoteka.go/.env.dev.example, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/.env.example (Centrifugo comments, lines 50-90), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q7, Q8 proxy table, G8, G10)</read_first>
|
||||
<action>Add a README section `## Running locally with the Nuxt app` (the app README may name Płytarium and Nuxt). Cover, per D-05..D-09, D-12, D-18, D-23:
|
||||
- Prerequisites: a separate Postgres database with the Polish ICU collation (link to § Database setup), never the PHP database; `docker start plytarium-typesense-instance` (port 8181); the shared Centrifugo v6 on :8787 and the operator step (D-18, D-23), done once by a person with root: in `/etc/centrifugo/config.json` add `http://localhost:3119` and `http://127.0.0.1:3119` to the `client.allowed_origins` array, and add two entries to the `channel.namespaces` array (the one that already holds `user`), shown in a `json` fenced block in the README exactly as `{"name": "collection", "allow_subscribe_for_client": true}` and `{"name": "wishlist", "allow_subscribe_for_client": true}`, with no `subscribe_proxy_enabled` and no proxy, so PHP apps on the shared instance are unaffected (dev-grade; production namespace authorization is Phase 20's concern); validate with `sudo centrifugo checkconfig --config=/etc/centrifugo/config.json`, then `sudo systemctl restart centrifugo`. Explain why: the app's realtime code uses `collection:{id}` and `wishlist:{id}` channels (D-23), and without the namespaces Centrifugo answers `102: unknown channel` on either backend.
|
||||
- What `dev-stack.sh check` reports for Centrifugo: `warn: centrifugo does not allow origin ...` and `warn: centrifugo namespace <ns> missing ...` (one line per missing namespace among `user`, `collection`, `wishlist`, overridable with `DEV_STACK_CENTRIFUGO_NAMESPACES`); warnings do not stop the stack, realtime just stays off. Limitation: the namespace probe uses the read-only server API `history` call and detects a missing namespace only; whether `allow_subscribe_for_client` is set is not observable that way and is proven by the Playwright realtime spec (`collection:` subscribe answered without an error).
|
||||
- Configuration: copy `.env.dev.example` to the git-ignored `.env.dev` and fill each key (table: key, purpose, where the value comes from); `SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api` overrides the repository default port 8001.
|
||||
- Ports: Nuxt 3119, Go 8423 (loopback), PHP 8422 (may run side by side), Typesense 8181, Centrifugo 8787.
|
||||
- Subcommands of `scripts/dev-stack.sh`: `up`, `up --detach`, `down`, `status`, `check`, `guard`, `exec`, `e2e`, `e2e-verdict`, one line each, plus the order `up` runs in and what the guard refuses.
|
||||
- Logs (D-12): `tmp/dev-stack/go.log` and `tmp/dev-stack/nuxt.log`; watch them for 4xx/5xx and 404 responses; a `album search engine failed` warning means search fell back to SQL (Typesense down or unindexed).
|
||||
- The script never writes `vue-fonoteka-app/.env`; the PHP-targeted setup stays as it is. `/_journal/api` is proxied by Nuxt but answers 404 on Go (the app does not use it).
|
||||
- MCP: `fonoteka-mcp` talks to Go directly (Nuxt does not proxy `/api/v1/fonoteka/*`): `FONOTEKA_API_URL=http://localhost:8423`, `FONOTEKA_API_TOKEN=<inv_ secret printed once by fonoteka:dev-seed, or DEV_SEED_API_TOKEN>`.
|
||||
- `fonoteka:dev-seed` refuses outside SUMMER_ENV local/dev and is safe to rerun.</action>
|
||||
<verify>
|
||||
<automated>R=../fonoteka.go/README.md; for c in 'up' 'up --detach' 'down' 'status' 'check' 'guard' 'exec' 'e2e' 'e2e-verdict'; do grep -qF "dev-stack.sh $c" "$R" || { echo "missing: $c"; exit 1; }; done; grep -qF 'client.allowed_origins' "$R" && grep -qF 'http://localhost:3119' "$R" && grep -qF 'channel.namespaces' "$R" && grep -qF '{"name": "collection", "allow_subscribe_for_client": true}' "$R" && grep -qF '{"name": "wishlist", "allow_subscribe_for_client": true}' "$R" && grep -qF 'centrifugo namespace' "$R" && grep -qF '8423' "$R" && grep -qF 'tmp/dev-stack/go.log' "$R" && grep -qF 'FONOTEKA_API_URL' "$R" && grep -qF 'fonoteka:dev-seed' "$R"</automated>
|
||||
<fails_when>Non-zero exit, a "missing:" line naming an undocumented subcommand, or the README lacks the D-23 namespace snippet (`channel.namespaces` with the exact `collection` and `wishlist` entries) or the `centrifugo namespace` warning description.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^## Running locally with the Nuxt app' ../fonoteka.go/README.md` prints 1.
|
||||
- `grep -c 'checkconfig' ../fonoteka.go/README.md` prints at least 1 (the operator validates the shared config before the D-23 restart).
|
||||
- `grep -c 'docker start plytarium-typesense-instance' ../fonoteka.go/README.md` prints at least 1.
|
||||
- `grep -c 'systemctl restart centrifugo' ../fonoteka.go/README.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A developer who never saw this phase can configure, start, watch, test and stop the local stack from the README alone.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Developer env file → script | Secrets and origins enter the process environment |
|
||||
| Browser and SSR (Nuxt) → backend origin | Requests must reach the local Go binary, never PHP or production |
|
||||
| Script → shared host services | Postgres, Typesense and the shared Centrifugo serve other apps |
|
||||
| Local network → Go dev server | The dev server must not be reachable from the LAN |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.2-08 | Spoofing | Nuxt backend origin (fall-through to PHP or plytarium.com) | high | mitigate | `guard` refuses :8422, non-loopback hosts, unset or mismatched internal origin, a missing marker and any `X-Powered-By: PHP`; `NUXT_INTERNAL_API_ORIGIN` always exported; post-start probe through :3119 |
|
||||
| T-14.2-09 | Information Disclosure | dev env file and Centrifugo API key | high | mitigate | `.gitignore` entry plus a refusal when the env file is not git-ignored; template holds placeholders only; values never echoed; the API key goes to curl through stdin, not argv |
|
||||
| T-14.2-10 | Spoofing | Go dev server exposure | medium | mitigate | `serve --addr 127.0.0.1:8423`; non-loopback `DEV_STACK_GO_ADDR` refused |
|
||||
| T-14.2-11 | Tampering | developer's `vue-fonoteka-app/.env` and PHP setup | high | mitigate | env passed on the `pnpm dev` process only; no write operation targets `DEV_STACK_NUXT_DIR` (acceptance grep) |
|
||||
| T-14.2-12 | Tampering | seeding a non-local database | high | mitigate | script forces SUMMER_ENV local/dev and refuses a non-loopback DSN host; the seed itself refuses outside local/dev (T-14.2-01) |
|
||||
| T-14.2-13 | Information Disclosure | `tmp/dev-stack/*.log` | low | accept | `tmp/` is git-ignored; Go output passes sunscreen and the access log records patterns only; the seed prints the minted token once by design for local MCP use |
|
||||
| T-14.2-21 | Tampering | shared Centrifugo (D-23 namespace probe) | medium | mitigate | The probe calls only the read-only `history` API method on `<ns>:dev_stack_probe` with the API key on curl's stdin; no `publish`/`broadcast` path in the script (acceptance grep); the config file is never read or edited and the restart stays an operator step |
|
||||
| T-14.2-SC | Tampering | package installs | high | mitigate | No install: host tools and existing pnpm dependencies only |
|
||||
|
||||
ASVS L1; every high threat is mitigated.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash -n ../fonoteka.go/scripts/dev-stack.sh`; the Task 3 refusal loop; `bash ../fonoteka.go/scripts/dev-stack.sh up --detach` then `status` exits 0, then `down`.
|
||||
- `go -C ../fonoteka.go vet ./...` and `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/... -count=1` stay green (config defaults only).
|
||||
- `git -C ../fonoteka.go status --porcelain` shows no new untracked file outside the committed set (seeded covers and `tmp/` are ignored).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `scripts/dev-stack.sh up` starts Go and Nuxt together on host services, in the D-05 order, with logs.
|
||||
- Every D-09 refusal case is refused before Nuxt starts; the post-start probe proves the proxy reaches Go.
|
||||
- The README documents the run, the Centrifugo operator step (origins plus the D-23 `collection` / `wishlist` namespaces) and MCP pointing.
|
||||
- `dev-stack.sh check` names every missing Centrifugo origin or namespace as a `warn:` line (D-23).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,296 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["14.2-02"]
|
||||
files_modified:
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md
|
||||
autonomous: false
|
||||
requirements: [QA-05]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 4
|
||||
confidence: low # uncalibrated (factor 1, sample_count 0); 2 of 4 tasks are checkpoints; plan count locked at 4 by D-22
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-14, with `E2E_BACKEND=go` the Playwright seed helper `seedE2EFixtures()` runs `$E2E_GO_BIN fonoteka:dev-seed` with cwd `E2E_GO_DIR` (default the binary's parent directory's parent) and throws on a missing binary or a non-zero exit; `runTinker()` throws in Go mode; with `E2E_BACKEND` unset every existing line of the PHP tinker path is unchanged."
|
||||
- "Per D-14, `auth.setup.ts` calls `seedE2EFixtures()` before logging in only when `E2E_BACKEND === 'go'`; with the switch unset its behaviour against PHP is unchanged."
|
||||
- "Per D-13 and D-15, through `dev-stack.sh e2e` the Go gate subset `auth.setup.ts`, `login.spec.ts`, `smoke.spec.ts`, `fonoteka-item.spec.ts`, `public-share.spec.ts`, `realtime.spec.ts`, `i18n.spec.ts`, `album-edit-rate-cover.spec.ts`, `album-search.spec.ts` and `wishlist.spec.ts` passes against the Go stack with `unexpected 0`, `skipped 0`, `flaky 0` and `expected > 0`."
|
||||
- "Per D-16, `album-edit-rate-cover.spec.ts` proves edit, rate and cover upload, `album-search.spec.ts` proves search and `wishlist.spec.ts` proves wishlist add and remove; each seeds its own uniquely named data through the UI or `page.request` on the Nuxt origin and depends on the seed only for the logged-in user."
|
||||
- "Per D-03 and D-19, after a cover upload the album page shows an `img` whose `src` contains `/storage/app/uploads/public/` and whose `naturalWidth` is greater than 0, loaded through the Nuxt `/storage` proxy."
|
||||
- "Per D-21 and D-23, `realtime.spec.ts` no longer uses the removed `realtime-status` / `realtime-degraded` test IDs; with `E2E_CENTRIFUGO=1` it observes the Centrifugo WebSocket: a connect reply, a `collection:` subscription answered without an error (in particular no `102 unknown channel`, which the D-23 namespace step removes), and a publication push on that channel within 15 s of an album created through the API."
|
||||
- "Per D-11, D-16 and D-21, the new and rewritten specs also pass against the PHP backend on :8422 with the app's own PHP-targeted `.env`, or, when PHP cannot be started on the machine, the reason and a manual PHP check are recorded in 14.2-UAT.md and never reported as passed."
|
||||
- "Per D-11 and D-20, this phase changes no file under `vue-fonoteka-app/app/`, adds no UI, and commits only to the vue-fonoteka-app repository itself."
|
||||
- "Per D-18 and D-23, the operator has added `http://localhost:3119` and `http://127.0.0.1:3119` to `client.allowed_origins` and the entries `{\"name\": \"collection\", \"allow_subscribe_for_client\": true}` and `{\"name\": \"wishlist\", \"allow_subscribe_for_client\": true}` (no proxy, no other option) to `channel.namespaces` of the shared Centrifugo, restarted it, and `dev-stack.sh check` prints neither the origin warning nor any `warn: centrifugo namespace` line; Task 3's realtime spec and Task 4's realtime item run only after this step."
|
||||
- "Per D-13, one checklist-driven manual session against the Go stack is recorded in `14.2-UAT.md`: login, browse collections and albums, search, edit, rate, cover upload, wishlist add and remove, realtime working, and no 5xx in `tmp/dev-stack/go.log`."
|
||||
artifacts:
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts"
|
||||
provides: "E2E_BACKEND=go switch (D-14)"
|
||||
contains: "E2E_GO_BIN"
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts"
|
||||
provides: "backend-agnostic realtime spec (D-21)"
|
||||
contains: "connection/websocket"
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts"
|
||||
provides: "edit, rate, cover upload (D-16)"
|
||||
contains: "rating-stars"
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts"
|
||||
provides: "search (D-16)"
|
||||
contains: "albums-search-input"
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts"
|
||||
provides: "wishlist add and remove (D-16)"
|
||||
contains: "wishlist-item-remove"
|
||||
- path: ".planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md"
|
||||
provides: "D-13 manual session evidence"
|
||||
contains: "realtime"
|
||||
key_links:
|
||||
- from: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go"
|
||||
via: "execFileSync(E2E_GO_BIN, ['fonoteka:dev-seed'], { cwd: E2E_GO_DIR })"
|
||||
pattern: "fonoteka:dev-seed"
|
||||
- from: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts"
|
||||
to: "modules/surf/uploads.go"
|
||||
via: "the uploaded cover's /storage/app/uploads/public URL loads through the Nuxt proxy from the framework mount"
|
||||
pattern: "/storage/app/uploads/public/"
|
||||
- from: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
to: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts"
|
||||
via: "dev-stack.sh e2e exports E2E_BACKEND=go, E2E_GO_BIN and E2E_GO_DIR before Playwright runs the setup project"
|
||||
pattern: "E2E_BACKEND"
|
||||
prohibitions:
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "vue-fonoteka-app MUST keep working against the PHP backend: no app/ source changes, and every new or rewritten spec is run against PHP as well as Go"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: safety
|
||||
statement: "Specs MUST NOT change or delete data they did not create; every created album and wishlist entry carries a Date.now() suffix and is removed by the spec, so the developer's PHP database keeps its real data"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: transparency
|
||||
statement: "With E2E_BACKEND unset, the PHP tinker seeding path MUST behave exactly as before; the Go switch only adds a branch"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** developer, **I want to** run the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), log in and use the real Nuxt flows, **so that** the port is proven in daily use before production cutover.
|
||||
|
||||
This plan's slice: the proof. After it, Playwright exercises every core flow against Go through the real Nuxt app, the same specs pass on PHP, and one human session is on record.
|
||||
|
||||
<objective>
|
||||
Deliver D-22 plan 03 in vue-fonoteka-app: the `E2E_BACKEND=go` seed switch (D-14), the rewritten realtime spec (D-21), new backend-agnostic specs for edit/rate/cover, search and wishlist (D-16), all verified against Go and PHP (D-11), then the operator's Centrifugo step, origins plus the `collection` / `wishlist` namespaces (D-18, D-23), and the checklist-driven manual session (D-13) recorded as `14.2-UAT.md`.
|
||||
|
||||
Purpose: QA-05's "vue-fonoteka-app runs unchanged against the Go backend", proven by the app's own test suite and by a person.
|
||||
Output: vue-fonoteka-app commits (its own repository, a submodule of the PHP fonoteka repo; never committed from the superproject), and `14.2-UAT.md` (planning doc, separate commit in summercms.go).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
- From plan 02: `bash ../fonoteka.go/scripts/dev-stack.sh up --detach | down | status | check | e2e <spec...> | e2e-verdict <file>`. `e2e` exports `E2E_BACKEND=go`, `E2E_GO_BIN`, `E2E_GO_DIR`, `E2E_BACKEND_URL=http://127.0.0.1:8423`, `E2E_FRONTEND_URL=http://localhost:3119`, `E2E_CENTRIFUGO=1`, `E2E_EMAIL` / `E2E_PASSWORD` and the `NUXT_*` Go values, unsets `CI`, runs Playwright with the JSON reporter and fails on any skip.
|
||||
- From plan 01: `fonoteka:dev-seed` (idempotent, D-04 creds fallback `demo@example.com` / `changeme`); uploads served at `/storage/app/uploads/public/...` through the framework mount.
|
||||
- Existing helpers: `fonotekaReachable()` (tests/e2e/helpers/fonotekaReachable.ts) probes `${E2E_BACKEND_URL}/_fonoteka/api/v1/albums` and accepts 200 or 401; gate pattern in `fonoteka-item.spec.ts`: `test.skip(!(await fonotekaReachable()), ...)` plus the E2E creds skip.
|
||||
- Auth: JWT in the JS-readable `auth_token` cookie; API calls carry `Authorization: Bearer <token>`. Specs read the cookie from `page.context().cookies()` and call `page.request` on the Nuxt origin (`/_fonoteka/api/v1/...`, proxied, same-origin). Store endpoints (app/stores/fonoteka.ts): `POST /albums/{id}/photos` (multipart), `PUT /albums/{id}/rating` `{ rating }`, `DELETE /albums/{id}`, `DELETE /wishlist/albums/{id}`; read the store's album create call for the create body.
|
||||
- Selectors (verified in app/): `album-edit` (pages/albums/[id]/index.vue), `album-name` (AlbumDraftFields.vue), `album-save` (AlbumForm.vue), `rating-stars` (RatingStars.vue), `albums-search-input` (pages/albums/index.vue), `` wishlist-item-${album.id} `` and `wishlist-item-remove` (WishlistGrid.vue), page `/wishlist/new`; the cover file input in `AlbumCoverGallery.vue` (`type="file" accept="image/*"`); `PNG_1PX` buffer pattern in `fonoteka-ai-assist.spec.ts`.
|
||||
- Realtime client (app/composables/useCentrifugo.ts, useRealtimeSync.ts): `centrifuge` JSON protocol over `NUXT_PUBLIC_CENTRIFUGO_WS_URL`; after connect the layout resolves `GET /realtime/channels` → `{ data: { collection } }` and subscribes to that `collection:` channel. The acting user's own echo is delivered over the socket but does not refresh the UI, so the spec asserts on WebSocket frames, not on a UI refresh. Until the D-23 operator step, the shared Centrifugo has no `collection` namespace, so that subscribe reply carries `error` code 102 (`unknown channel`) on Go and on PHP alike.
|
||||
- From plan 02 (D-23): `dev-stack.sh check` prints `warn: centrifugo does not allow origin http://localhost:3119 ...` and one `warn: centrifugo namespace <ns> missing ...` line per namespace among `user`, `collection`, `wishlist` that the shared Centrifugo does not define, probed read-only through the HTTP API `history` call; it exits 0 when it printed only warnings. The README documents the namespace snippet.
|
||||
- The Bash secret-read guard blocks commands naming `.env` files other than templates; Playwright and Nuxt read them by themselves.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- `tests/e2e/helpers/runTinker.ts`: Go branch in `backendAvailable()`, `runTinker()` and `seedE2EFixtures()`, driven by `E2E_BACKEND`, `E2E_GO_BIN`, `E2E_GO_DIR` (read at call time).
|
||||
- `tests/e2e/auth.setup.ts`: Go-only seed call before login.
|
||||
- `tests/e2e/.env.example`: documented `E2E_BACKEND`, `E2E_GO_BIN`, `E2E_GO_DIR`.
|
||||
- New specs `album-edit-rate-cover.spec.ts`, `album-search.spec.ts`, `wishlist.spec.ts`; rewritten `realtime.spec.ts` (tests `realtime baseline: albums page boots` and `realtime live: collection publication reaches the socket`).
|
||||
- `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The developer's `tests/e2e/.env.local` holds E2E credentials of a PHP user; Go runs use the exported values from `dev-stack.sh`, which win over dotenv.
|
||||
- The album create body is the one `app/stores/fonoteka.ts` sends; the executor reads it rather than guessing.
|
||||
- Assumption A6 of the research (rewriting `realtime.spec.ts` is acceptable) is settled by D-21.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1 (tracer): `E2E_BACKEND=go` seed switch, and the existing core specs pass on the Go stack with zero skips</name>
|
||||
<files>/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example</files>
|
||||
<read_first>/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaReachable.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/playwright.config.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/session-recovery.test.mjs (contract style), ../fonoteka.go/scripts/dev-stack.sh (exec and e2e exports), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q1, Q2, Pattern 6, G5, G7)</read_first>
|
||||
<action>Per D-14 (additions only; PHP lines stay byte-identical, no reformatting).
|
||||
|
||||
`runTinker.ts`: a small `goMode()` reading `process.env.E2E_BACKEND === 'go'` at call time (not at module load, so the plan 04 contract test can toggle it), and `goDir()` returning `E2E_GO_DIR` or `path.resolve(path.dirname(E2E_GO_BIN), '..')`. In Go mode: `backendAvailable()` returns true only when `E2E_GO_BIN` is set and executable (`fs.accessSync` with `X_OK`); `runTinker()` throws an Error saying PHP tinker is unavailable with `E2E_BACKEND=go` and naming `seedE2EFixtures()`; `seedE2EFixtures()`, placed before the E2E_EMAIL check because the Go seed has its own D-04 fallback, throws when `E2E_GO_BIN` is missing and otherwise runs `execFileSync(E2E_GO_BIN, ['fonoteka:dev-seed'], { cwd: goDir(), env: { ...process.env }, stdio: 'inherit' })`, letting a non-zero exit throw (fail closed, unlike the PHP warn-and-return path). Extend the header comment with the Go mode.
|
||||
|
||||
`auth.setup.ts`: at the start of the setup body, `if (process.env.E2E_BACKEND === 'go') seedE2EFixtures()` (import from `./helpers/runTinker`); nothing else changes.
|
||||
|
||||
`tests/e2e/.env.example`: a commented block documenting `E2E_BACKEND=go`, `E2E_GO_BIN=/path/to/fonoteka.go/bin/fonoteka`, `E2E_GO_DIR=/path/to/fonoteka.go`, and that `fonoteka.go/scripts/dev-stack.sh e2e` sets all three; placeholders only.
|
||||
|
||||
Commit inside the vue-fonoteka-app repository with explicit paths. Do not commit or bump the pointer in the PHP fonoteka superproject.</action>
|
||||
<verify>
|
||||
<automated>bash ../fonoteka.go/scripts/dev-stack.sh up --detach && bash ../fonoteka.go/scripts/dev-stack.sh e2e auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts i18n.spec.ts public-share.spec.ts</automated>
|
||||
<fails_when>Non-zero exit; a "refuse:" line; the printed counts show skipped greater than 0, unexpected greater than 0, flaky greater than 0 or expected 0.</fails_when>
|
||||
<automated>pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app test:contracts</automated>
|
||||
<fails_when>Non-zero exit, or the node --test summary line "# fail" shows a number other than 0.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "E2E_BACKEND" /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts` prints at least 1, and `grep -c "'fonoteka:dev-seed'" /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts` prints 1.
|
||||
- `git -C /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app diff HEAD~1 -- tests/e2e/helpers/runTinker.ts | grep -c '^-[^-]'` prints 0 (additions only; no PHP line removed or reformatted).
|
||||
- `grep -c "E2E_BACKEND === 'go'" /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts` prints 1.
|
||||
- `grep -c 'E2E_GO_BIN' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example` prints at least 1.
|
||||
- `git -C /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app diff --name-only HEAD~1 -- app` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The Playwright setup seeds the Go stack through `fonoteka:dev-seed`, and login, smoke, album create, i18n and public share pass against Go with no skipped test.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking">
|
||||
<name>Task 2: operator adds the Nuxt origin and the `collection` / `wishlist` namespaces to the shared Centrifugo</name>
|
||||
<read_first>../fonoteka.go/README.md (§ Running locally with the Nuxt app, Centrifugo step), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md (D-18, D-23)</read_first>
|
||||
<action>Per D-18 and D-23, the operator edits `/etc/centrifugo/config.json` (Centrifugo v6 key names) in one sitting, keeping every existing entry:
|
||||
1. `client.allowed_origins` array: append `"http://localhost:3119"` and `"http://127.0.0.1:3119"`.
|
||||
2. `channel.namespaces` array (the one that already holds `presence`, `gxv`, `golemxv`, `user`, `family`, `drzewo`, `chat`, `friends`, `video`, `wave`): append the two objects `{"name": "collection", "allow_subscribe_for_client": true}` and `{"name": "wishlist", "allow_subscribe_for_client": true}`. Give them no `subscribe_proxy_enabled`, no proxy and no other option (dev-grade, D-23); production namespace authorization stays Phase 20's concern.
|
||||
3. Validate with `sudo centrifugo checkconfig --config=/etc/centrifugo/config.json`, then run `sudo systemctl restart centrifugo`.
|
||||
The executor never reads, prints or edits that file and never restarts the service; it only runs `dev-stack.sh check` before and after.</action>
|
||||
<instructions>
|
||||
Claude has already: built and started the Go stack, confirmed Centrifugo is reachable with the API key from the dev env file, and run `bash ../fonoteka.go/scripts/dev-stack.sh check`, which printed `warn: centrifugo does not allow origin http://localhost:3119` plus `warn: centrifugo namespace collection missing` and `warn: centrifugo namespace wishlist missing` (the `history` probe answered `102 unknown channel` for both).
|
||||
The one human step: the shared Centrifugo config is root-owned (mode 600, owner http) and serves the PHP apps too, so per D-18 the script never edits it. Adding two origins only widens the allow list, and the two new namespaces carry no proxy and touch no existing namespace, so PHP realtime keeps working (D-23). The exact snippet is in the README section "Running locally with the Nuxt app". `checkconfig` must pass before the restart: a broken JSON edit would take realtime down for every app on the instance.
|
||||
</instructions>
|
||||
<verification>`bash ../fonoteka.go/scripts/dev-stack.sh check` exits 0 and prints no `warn: centrifugo does not allow origin` line and no `warn: centrifugo namespace` line.</verification>
|
||||
<acceptance_criteria>
|
||||
- `bash ../fonoteka.go/scripts/dev-stack.sh check 2>&1 | grep -c 'does not allow origin'` prints 0.
|
||||
- `bash ../fonoteka.go/scripts/dev-stack.sh check 2>&1 | grep -c '^warn: centrifugo namespace'` prints 0 (D-23: `user`, `collection` and `wishlist` all defined).
|
||||
- `bash ../fonoteka.go/scripts/dev-stack.sh check > /dev/null 2>&1; test $? -eq 0` exits 0.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Type "centrifugo updated" once the origins and both namespaces are in, `checkconfig` passed and Centrifugo restarted, or describe the problem.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: backend-agnostic specs for edit, rate, cover, search and wishlist, plus the realtime rewrite, green on Go and on PHP</name>
|
||||
<precondition>Task 2 is done (D-18, D-23): `dev-stack.sh check` prints no Centrifugo origin warning and no `warn: centrifugo namespace` line.</precondition>
|
||||
<files>/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts, .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md (PHP-unavailable branch only)</files>
|
||||
<read_first>/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-item.spec.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-ai-assist.spec.ts (PNG_1PX), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/responsive.spec.ts (albums-search-input), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/fonoteka.ts (album create, photos, rating, wishlist calls), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/pages/albums/[id]/index.vue, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/components/fonoteka/AlbumCoverGallery.vue, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/components/fonoteka/RatingStars.vue, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/components/fonoteka/WishlistGrid.vue, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/pages/wishlist/new.vue, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useCentrifugo.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useRealtimeSync.ts, .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q1 flow-to-spec map, self-seeding rule, G6, G8)</read_first>
|
||||
<action>Per D-11, D-16 and D-21. Shared rules for all four files: gate exactly like `fonoteka-item.spec.ts` (`test.skip` when `fonotekaReachable()` is false or E2E creds are missing, so PHP CI keeps its behaviour); create data through the UI or `page.request` on the Nuxt origin with the Bearer token from the `auth_token` cookie and a `Date.now()` suffix in every name; remove everything the spec created in a `finally` block or `afterEach`; confirm each `data-testid` with a grep of `app/` before using it; wait with `expect.poll`, `waitForResponse` or locator assertions, never fixed sleeps; never log the token.
|
||||
|
||||
`album-edit-rate-cover.spec.ts`: create an album through the API, open `/albums/{id}`; edit: `album-edit`, fill `album-name` with a new unique name, `album-save`, expect the new name on the page; rate: click a star in `rating-stars`, wait for the 2xx `PUT .../albums/{id}/rating`, reload, expect the rating shown; cover: `setInputFiles` on the gallery's file input with an in-memory PNG (the `PNG_1PX` pattern), wait for the 2xx `POST .../albums/{id}/photos`, then expect an `img` whose `src` contains `/storage/app/uploads/public/` with `naturalWidth > 0` (D-03 through the proxy).
|
||||
|
||||
`album-search.spec.ts`: create a uniquely named album through the API, open `/albums`, type the unique part into `albums-search-input`, `expect.poll` until a tile with that name is visible (15 s max), then clear and expect the list back.
|
||||
|
||||
`wishlist.spec.ts`: open `/wishlist/new`, add an entry with a unique name through the form, expect `` wishlist-item-<id> `` visible, click its `wishlist-item-remove`, confirm if the UI asks, expect it gone.
|
||||
|
||||
`realtime.spec.ts` (D-21, full rewrite, no old test IDs): test `realtime baseline: albums page boots` (always runs when gated in): `/albums` answers 200 and no `pageerror` fires. Test `realtime live: collection publication reaches the socket` (`test.skip` unless `E2E_CENTRIFUGO === '1'`): register `page.on('websocket')` for URLs containing `/connection/websocket` before navigating; open `/albums`; parse every received frame as one or more newline-separated JSON messages; expect a reply carrying `connect`; expect a sent `subscribe` whose `channel` starts with `collection:` and its reply without `error` (when the reply carries one, fail with a message naming the error code and, for 102 `unknown channel`, the D-23 namespace step in the fonoteka.go README); then create an album through the API and expect a received message whose `push.channel` starts with `collection:` and carries `pub` within 15 s; delete the album. These are Centrifugo protocol frames, the same for both backends.
|
||||
|
||||
PHP cross-check (D-11): after the Go run, stop the Go stack. Start PHP from `/media/nvme/dev/golem15/fonoteka` with `php artisan serve --host=127.0.0.1 --port=8422` in the background, wait up to 30 s, run the second verify command, then stop PHP. That command first probes PHP: `GET http://127.0.0.1:8422/_fonoteka/api/v1/albums`, the endpoint `fonotekaReachable()` uses, must answer 200 or 401 with an `X-Powered-By: PHP` header. It then takes one of two branches.
|
||||
- **Branch A, PHP answers.** Playwright runs the five specs against PHP. Nuxt starts from the app's own PHP-targeted `.env`. `e2e-verdict` must accept the report, and the command prints `PHP-PASSED`.
|
||||
- **Branch B, PHP cannot be started on this machine.** Before running the verify, add a section to `14.2-UAT.md` headed exactly `## PHP cross-check (Task 3): deferred`. It holds the startup error, the probe result and the manual PHP check: the same five specs, which the developer runs at Task 4 item 11. The verify then only confirms that the section exists and prints `PHP-DEFERRED`.
|
||||
|
||||
Branch B is never reported as a PHP pass, and nothing is skipped silently. The task record and the plan SUMMARY state "PHP cross-check deferred to Task 4 item 11", and the plan is complete only when item 11 is marked pass.
|
||||
|
||||
Commit in the vue-fonoteka-app repository; no file under `app/` changes (D-20).</action>
|
||||
<verify>
|
||||
<automated>bash ../fonoteka.go/scripts/dev-stack.sh up --detach && bash ../fonoteka.go/scripts/dev-stack.sh e2e auth.setup.ts realtime.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts</automated>
|
||||
<fails_when>Non-zero exit; a "refuse:" line; the printed counts show skipped greater than 0, unexpected greater than 0, flaky greater than 0 or expected 0.</fails_when>
|
||||
<automated>bash ../fonoteka.go/scripts/dev-stack.sh down && U=.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md && R="${TMPDIR:-/tmp}/php-e2e-14.2.json" && H="$(curl -s -m 5 -o /dev/null -D - http://127.0.0.1:8422/_fonoteka/api/v1/albums || true)" && if printf '%s\n' "$H" | grep -qE '^HTTP/[0-9.]+ (200|401)' && printf '%s\n' "$H" | grep -qi '^x-powered-by: *php'; then rm -f "$R"; env -u CI -u E2E_BACKEND E2E_FRONTEND_URL=http://localhost:3119 E2E_BACKEND_URL=http://localhost:8422 E2E_CENTRIFUGO=1 PLAYWRIGHT_JSON_OUTPUT_NAME="$R" pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app exec playwright test auth.setup.ts realtime.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts --project=chromium --reporter=json > /dev/null; bash ../fonoteka.go/scripts/dev-stack.sh e2e-verdict "$R" && echo PHP-PASSED; else grep -qF '## PHP cross-check (Task 3): deferred' "$U" && echo 'PHP-DEFERRED: PHP not startable here; manual PHP run due at Task 4 item 11'; fi</automated>
|
||||
<fails_when>Branch A (the :8422 probe answered 200 or 401 with `X-Powered-By: PHP`): fails on a non-zero exit, i.e. `e2e-verdict` refused the PHP report because it is missing or shows skipped greater than 0, unexpected greater than 0, flaky greater than 0 or expected 0, or when the output lacks `PHP-PASSED`. Branch B (the probe failed, PHP not startable): fails on a non-zero exit, i.e. 14.2-UAT.md lacks the `## PHP cross-check (Task 3): deferred` section. A zero exit printing `PHP-DEFERRED` is not a pass. The task is recorded as "PHP cross-check deferred to Task 4 item 11", and the plan is not complete until item 11 passes.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'realtime-status\|realtime-degraded' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts` prints 0.
|
||||
- `grep -c 'connection/websocket' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts` prints at least 1.
|
||||
- `grep -l 'fonotekaReachable' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts | wc -l` prints 4.
|
||||
- `grep -l 'Date.now()' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts | wc -l` prints 3.
|
||||
- `grep -c '/storage/app/uploads/public/' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts` prints at least 1.
|
||||
- `grep -rc 'waitForTimeout' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts` prints 0 for each file.
|
||||
- `git -C /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app log -2 --name-only --format= -- app` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>Edit, rate, cover upload, search, wishlist add/remove and realtime each have a self-seeding spec that passes on Go, and the stale realtime spec is gone. On PHP the specs either pass (`PHP-PASSED`) or, when PHP cannot be started here, sit in 14.2-UAT.md as deferred to Task 4 item 11 (`PHP-DEFERRED`), never reported as passed.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 4: checklist-driven manual session against the Go stack, recorded as 14.2-UAT.md</name>
|
||||
<read_first>.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md (D-13), ../fonoteka.go/README.md (§ Running locally with the Nuxt app), .planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-VERIFICATION.md (evidence style)</read_first>
|
||||
<what-built>The local stack: `bash ../fonoteka.go/scripts/dev-stack.sh up --detach` is running (Claude starts it before this checkpoint) with Nuxt on http://localhost:3119 and Go on 127.0.0.1:8423; logs in `fonoteka.go/tmp/dev-stack/go.log` and `nuxt.log`. Log in with the E2E credentials from the dev env file, or `demo@example.com` / `changeme`.</what-built>
|
||||
<how-to-verify>
|
||||
In a normal browser at http://localhost:3119, tick each item (D-13):
|
||||
1. Login succeeds and lands outside /login.
|
||||
2. Browse collections: open the collection switcher and the collection page; the seeded "Moja kolekcja" lists Kind of Blue, Blue Train and Mingus Ah Um, each with its cover image visible.
|
||||
3. Browse albums: open an album detail page.
|
||||
4. Search: type "Mingus" in the albums search; the album appears; `grep -c 'album search engine failed' ../fonoteka.go/tmp/dev-stack/go.log` prints 0.
|
||||
5. Edit: change an album's name and save; the new name shows after reload.
|
||||
6. Rate: set a star rating; it persists after reload.
|
||||
7. Cover upload: add a photo to an album; it shows on the page.
|
||||
8. Wishlist: add an entry on /wishlist/new, then remove it.
|
||||
9. Realtime: with DevTools → Network → WS open on the Centrifugo socket, the reply to the `collection:` subscribe carries no `error` (no `102 unknown channel`, D-23); edit an album in a second tab; a `push` frame for the `collection:` channel arrives in the first tab.
|
||||
10. Failures are visible: `grep -cE 'status=5[0-9]{2}' ../fonoteka.go/tmp/dev-stack/go.log` prints 0, and `curl -sI http://localhost:3119/_fonoteka/api/v1/_dev/backend` shows no `X-Powered-By: PHP` header.
|
||||
11. PHP cross-check (D-11). If Task 3 printed `PHP-PASSED`, mark this item `n/a (automated in Task 3: PHP-PASSED)`. If Task 3 printed `PHP-DEFERRED`, 14.2-UAT.md has a `## PHP cross-check (Task 3): deferred` section. Start PHP on :8422 yourself, run the five specs listed there against it and report the counts. The item passes only on a run with no skipped, unexpected or flaky test. A failed run, or one not done, keeps the plan open.
|
||||
Claude then writes `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md` with each item as pass or fail plus your notes, the date, the Go and Nuxt commits tested, and commits it as a planning doc. Any fail becomes a Go fix first (D-11), with a parity case where a recorded PHP response exists.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md` exists and lists all eleven items with a pass or fail each. Item 11 may instead read `n/a`, but only when Task 3 printed `PHP-PASSED`.
|
||||
- When 14.2-UAT.md contains `## PHP cross-check (Task 3): deferred`, item 11 is marked pass and records the developer's PHP run counts. Otherwise the plan stays open (D-11).
|
||||
- `grep -ci 'realtime' .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Type "approved" with any notes, or list the failing items.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Playwright process → Go binary | The seed helper executes a binary path taken from the environment |
|
||||
| Specs → developer databases (Go and PHP) | Specs write and delete real rows |
|
||||
| Spec code → logs and reports | Bearer tokens are handled in test code |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.2-14 | Tampering | `runTinker.ts` PHP path | high | mitigate | Additions only, Go branch behind `E2E_BACKEND === 'go'` read at call time; diff gate on removed lines; plan 04 contract test pins the PHP argv and snippets |
|
||||
| T-14.2-15 | Information Disclosure | E2E credentials and the `auth_token` Bearer in specs | medium | mitigate | Credentials from env only; the token is read from the cookie jar and only set as a request header; never logged or attached to reports |
|
||||
| T-14.2-16 | Tampering | developer data in the PHP and Go databases | medium | mitigate | Every created row has a `Date.now()` name and is deleted by the spec; specs never edit or delete pre-existing rows |
|
||||
| T-14.2-SC | Tampering | npm installs | high | mitigate | No install: `@playwright/test` and node built-ins already in the app |
|
||||
|
||||
ASVS L1; the high threat is mitigated with a test-backed control.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Go gate subset (D-15) green through `dev-stack.sh e2e` with zero skips; the PHP run of the new and rewritten specs green through `e2e-verdict` (`PHP-PASSED`), or, when PHP cannot be started, the `## PHP cross-check (Task 3): deferred` section in 14.2-UAT.md with Task 4 item 11 marked pass by the developer's PHP run.
|
||||
- `pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app test:contracts` green.
|
||||
- `git -C /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app status --porcelain -- app` prints nothing.
|
||||
- 14.2-UAT.md committed with every D-13 item.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every core flow named in D-13 has an automated spec that passes against Go.
|
||||
- The same specs pass against PHP (D-11), and no app source changed.
|
||||
- The operator step for Centrifugo (origins and the `collection` / `wishlist` namespaces, D-18 and D-23) is done, `check` prints no Centrifugo warning, and the manual session is on record.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-03-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,263 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["14.2-01", "14.2-02", "14.2-03"]
|
||||
files_modified:
|
||||
- scripts/check-phase14.2.sh
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go
|
||||
- modules/surf/uploads_test.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs
|
||||
autonomous: true
|
||||
requirements: [QA-05]
|
||||
estimate:
|
||||
tokens: 240000
|
||||
raw_tokens: 240000
|
||||
tasks: 3
|
||||
confidence: low # uncalibrated (factor 1, sample_count 0); last plan of the phase per the project rule and D-17
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-17 and D-22 (the last plan, project rule), `bash scripts/check-phase14.2.sh --all` runs `go vet ./...` and `go test ./...` in summercms.go and in the fonoteka.go workspace, the named phase tests through a skip-intolerant detector, the docs checks, the parity corpus, the Nuxt contracts, the guard refusal cases and the Go-gate Playwright subset, and its last line is `Phase 14.2 gate passed`."
|
||||
- "Per D-02 and D-17, `TestDevSeedRefusesProduction` proves an unset SUMMER_ENV, `production`, `development`, `testing` and `staging` are refused before any connection attempt, while `local` and `dev` pass the gate; `TestDevSeedIdempotent` proves a second run adds no row and prints `unchanged` for every entity; `TestDevSeedShape` proves the user, context collection, three albums with one stored cover blob each, wishlist entry, rating, `inv_` token row and `search_use_typesense = true`."
|
||||
- "Per D-04 (edge: empty), `TestDevSeedShape` proves the `demo@example.com` / `changeme` fallback when E2E_EMAIL and E2E_PASSWORD are empty, and a `DEV_SEED_API_TOKEN` without the configured prefix is refused."
|
||||
- "Per D-05 and research G1, `TestDevSeedOpensDatabase`, `TestReindexOpensDatabase` and `TestCommandsOpenDatabase` run the seed, `fonoteka:reindex`, `fonoteka:oauth-client` and `fonoteka:prune-notifications` on an app with no published database and prove each publishes its own handle (seeded slugs non-empty, so the OnDatabase hooks ran) and never prints `database is not configured`."
|
||||
- "Per D-09, D-10 and D-17, `TestDevMarkerRoute` proves the marker answers 200 with exactly `backend`, `app`, `env` and `Cache-Control: no-store` for `local` and `dev`, carries no auth middleware, is absent from the route table and 404 for `production`, `development` and `testing`, and does not appear in `parity/manifest.yaml`."
|
||||
- "Per D-19, `TestUploadsMount` proves the key off registers no route; the key on serves a public blob with its content type and `X-Content-Type-Options: nosniff`, answers HEAD, and 404s an `is_public = false` row, a missing row, a traversal path and an unpublished bucket or database; a plugin route under the prefix fails BuildRouter."
|
||||
- "Per D-12, `TestAccessLog` proves the key off logs nothing; the key on logs the matched pattern and status for 200, 404-from-handler and a recovered panic (500), the raw path without query only for an unmatched request, the pattern behind a locale-prefixed path, an integer `duration_ms`, never a query string, header or Authorization value, and keeps `http.ResponseController` flushing working."
|
||||
- "Per D-09 and D-17 (edge: boundary, adjacency), `--guard` feeds planted env files to `dev-stack.sh guard` and requires a `refuse:` line and a non-zero exit for an origin on :8422, an unset internal origin, mismatched origins, `https://plytarium.com`, a fake server that answers with `X-Powered-By: PHP/8.5.10`, and a fake marker reporting another backend."
|
||||
- "Per D-13, D-15 and research G7, `--e2e` runs the gate specs through `dev-stack.sh e2e` and fails on any skipped, unexpected or flaky test or zero expected tests; the D-15 report-only specs (`fonoteka-ai-assist`, `fonoteka-bulk-add`, `offline`, `registration-invitation`, `responsive`, `landing`) are run and their counts printed without blocking."
|
||||
- "Per research G8 and D-12, `--e2e` fails when `tmp/dev-stack/go.log` contains `album search engine failed` or a 5xx status record after the gate run."
|
||||
- "Per D-06, `--e2e` fails closed when Postgres, Typesense or Centrifugo is unreachable (the `dev-stack.sh` refusal propagates) and never turns a missing service or a missing Docker daemon into a skip."
|
||||
- "Per D-14, `tests/contracts/e2e-backend-switch.test.mjs` proves that with `E2E_BACKEND=go` `seedE2EFixtures()` executes `E2E_GO_BIN` with exactly `['fonoteka:dev-seed']` in `E2E_GO_DIR`, throws on a non-zero exit and on a missing binary, `runTinker()` throws, and with the switch unset the PHP tinker argv and seed snippets are unchanged."
|
||||
- "Per D-10, `--parity` requires the TestParityCorpus summary `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase14.2.sh"
|
||||
provides: "fail-closed phase gate: --self-test --go --docs --parity --contracts --guard --e2e --all"
|
||||
contains: "Phase 14.2 gate passed"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go"
|
||||
provides: "TestDevSeedRefusesProduction, TestDevSeedIdempotent, TestDevSeedShape"
|
||||
contains: "TestDevSeedIdempotent"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go"
|
||||
provides: "TestDevSeedOpensDatabase, TestReindexOpensDatabase, TestCommandsOpenDatabase"
|
||||
contains: "TestReindexOpensDatabase"
|
||||
- path: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go"
|
||||
provides: "TestDevMarkerRoute"
|
||||
contains: "TestDevMarkerRoute"
|
||||
- path: "modules/surf/uploads_test.go"
|
||||
provides: "TestUploadsMount"
|
||||
contains: "TestUploadsMount"
|
||||
- path: "modules/surf/accesslog_test.go"
|
||||
provides: "TestAccessLog (next to TestAccessLogSmoke from plan 01)"
|
||||
contains: "TestAccessLog"
|
||||
- path: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs"
|
||||
provides: "D-14 switch contract"
|
||||
contains: "fonoteka:dev-seed"
|
||||
key_links:
|
||||
- from: "scripts/check-phase14.2.sh"
|
||||
to: "../fonoteka.go/scripts/dev-stack.sh"
|
||||
via: "--guard calls guard with planted DEV_STACK_ENV_FILE; --e2e calls up --detach, guard, e2e and down; --self-test calls e2e-verdict on planted reports"
|
||||
pattern: "dev-stack.sh"
|
||||
- from: "scripts/check-phase14.2.sh"
|
||||
to: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go"
|
||||
via: "APP_REQUIRE names every phase test the detector must see pass"
|
||||
pattern: "TestDevSeedRefusesProduction"
|
||||
- from: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs"
|
||||
to: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts"
|
||||
via: "imports seedE2EFixtures, runTinker and backendAvailable through Node type stripping"
|
||||
pattern: "seedE2EFixtures"
|
||||
prohibitions:
|
||||
- requirement_id: QA-05
|
||||
category: transparency
|
||||
statement: "The phase gate MUST NOT pass when any gate spec or named Go test is skipped, missing or matched zero tests; a skip is a failure"
|
||||
status: resolved
|
||||
verification: test
|
||||
- requirement_id: QA-05
|
||||
category: transparency
|
||||
statement: "The phase gate MUST NOT downgrade an unreachable service, a missing PHP-free marker or an unavailable Docker daemon to a pass or a skip; it fails closed"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** developer, **I want to** run the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), log in and use the real Nuxt flows, **so that** the port is proven in daily use before production cutover.
|
||||
|
||||
This plan's slice: the guarantee. After it, one command proves the local glue still works and fails closed on any regression, and every Go piece the phase added has unit coverage.
|
||||
|
||||
<objective>
|
||||
Deliver D-17 (and D-22 plan 04, the last plan per the project rule): the Go unit tests for `fonoteka:dev-seed` (refusal, idempotency, shape, own database), the reindex and command database fix, the marker route, the uploads mount and the access log; the Nuxt contract test for the `E2E_BACKEND=go` switch; and `summercms.go/scripts/check-phase14.2.sh` following the `check-phase*.sh` pattern.
|
||||
|
||||
Purpose: Success Criterion 4 of the phase (the new glue has tests and a fail-closed check script) and the evidence `/gsd-verify-work 14.2` consumes.
|
||||
Output: test files in fonoteka.go, summercms.go and vue-fonoteka-app (each in its own repository commit) and the gate script in summercms.go.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-CONTEXT.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-VALIDATION.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-SUMMARY.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-SUMMARY.md
|
||||
@.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-03-SUMMARY.md
|
||||
@scripts/check-phase14.2.1.sh
|
||||
|
||||
<interfaces>
|
||||
- Plan 01: `console.DevSeedCommand(app)`, `console.DevEnvironment(app)`, command name `fonoteka:dev-seed`, output lines `<entity>: created|updated|unchanged`, token name `dev-seed-mcp`, env `E2E_EMAIL`, `E2E_PASSWORD`, `DEV_SEED_API_TOKEN`; unexported `commandDatabase`/`commandBucket`; marker `GET /_fonoteka/api/v1/_dev/backend` via `(*Plugin).devMarkerRoutes`; surf `mountUploads` (key `storage.uploads.serve`) and `accessLog` (key `http.access_log`, record `http request` with `method`, `pattern`, `status`, `duration_ms`, `path` on unmatched); smoke tests `TestDevStackSmoke`, `TestAccessLogSmoke`.
|
||||
- Plan 02: `dev-stack.sh guard` reads `DEV_STACK_ENV_FILE` only; `e2e-verdict <file>` reads no env; `e2e [--report-only] <spec...>`; `up --detach` is idempotent and `status` exits 0 only when both processes answer; logs in `fonoteka.go/tmp/dev-stack/go.log`.
|
||||
- Plan 03: `runTinker.ts` reads `E2E_BACKEND`, `E2E_GO_BIN`, `E2E_GO_DIR` at call time; gate specs `auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts public-share.spec.ts realtime.spec.ts i18n.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts`.
|
||||
- Test harnesses: console package `oauthConsoleDB(t)` (shared testcontainer, migrated user and fonoteka schemas, hooks registered), `testInput`, `stripANSI`, `reindexApp` (fake engine); plugin root package `bootPG`, `bootDB(t)`, `bootConfig(t)` (SUMMER_ENV=development), `stubInvToken`; attach `attachGorm` pattern (lagoon/attach/lifecycle_test.go:268) for a Postgres with `system_files`; Nuxt contracts import `.ts` through Node type stripping (tests/contracts/session-recovery.test.mjs).
|
||||
- `check-phase14.2.1.sh`: `detect()` python3 heredoc (exit 1 fail/build, 2 skip, 3 zero or "no tests to run", 4 non-JSON, 5 missing named test, 6 data race), `go_json DIR ARGS`, `expect_detect`, `run_self_test` mode-presence loop, `run_all` re-invoking each stage with PASS/FAIL lines, `case "${1:---all}"` dispatch.
|
||||
- The Bash secret-read guard blocks commands naming `.env` files other than templates: planted guard inputs are named `guard-*.conf`, never `.env*`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- `summercms.go/scripts/check-phase14.2.sh` modes `--self-test`, `--go`, `--docs`, `--parity`, `--contracts`, `--guard`, `--e2e`, `--all`; overridable paths `PHASE142_ROOT`, `PHASE142_APP` (default `$ROOT/../fonoteka.go`), `PHASE142_NUXT` (default `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app`); arrays `APP_REQUIRE`, `CONSOLE_REQUIRE`, `FRAMEWORK_REQUIRE`, `GATE_SPECS`, `REPORT_SPECS`; final line `Phase 14.2 gate passed`.
|
||||
- Go tests `TestDevSeedRefusesProduction`, `TestDevSeedIdempotent`, `TestDevSeedShape` (console), `TestDevSeedOpensDatabase`, `TestReindexOpensDatabase`, `TestCommandsOpenDatabase`, `TestDevMarkerRoute` (plugin root), `TestUploadsMount`, `TestAccessLog` (surf).
|
||||
- Nuxt contract test `tests/contracts/e2e-backend-switch.test.mjs`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Docker is available for testcontainers; a missing daemon fails the gate (no `-short` evidence).
|
||||
- The full `go test ./...` runs are exit-code checks (other packages may skip by design); the skip-intolerant detector covers the phase's own named tests.
|
||||
- `--e2e` and `--guard`'s positive case need the developer's configured env file and running host services, like plan 02 and 03.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1 (tracer): `check-phase14.2.sh --go` proves the seed's production refusal end to end (script, go test -json, detector)</name>
|
||||
<files>scripts/check-phase14.2.sh, ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go</files>
|
||||
<read_first>scripts/check-phase14.2.1.sh (whole file: header, detect, go_json, expect_detect, run_self_test, run_all, dispatch), scripts/check-phase14.sh (go -C usage for sibling repos), ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go (testInput, TestMain, oauthConsoleDB), ../fonoteka.go/plugins/golem15/fonoteka/console/phase14_commands_test.go (reindexApp config via compass.Open, stripANSI), modules/compass/config.go (Options, Environment)</read_first>
|
||||
<action>Per D-17. New `scripts/check-phase14.2.sh` modelled on check-phase14.2.1.sh: header comment describing the gate and every mode, `set -euo pipefail`, `unset FORCE_COLOR`, `ROOT="${PHASE142_ROOT:-...}"`, `APP="${PHASE142_APP:-$ROOT/../fonoteka.go}"`, `NUXT="${PHASE142_NUXT:-/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app}"`. Copy `detect()`, `go_json`, `expect_detect` semantics unchanged. `run_self_test`: `bash -n` on this script, the planted detector cases for exit codes 0-6, and the mode-presence loop, ending with the line `phase14.2 self-test passed`. `run_go` for now: `go -C "$APP" vet ./plugins/golem15/fonoteka/...` and `go_json "$APP" ./plugins/golem15/fonoteka/console -count=1 -run '^TestDevSeedRefusesProduction$'` with `REQUIRE_TESTS` from a `CONSOLE_REQUIRE=(TestDevSeedRefusesProduction)` array. Dispatch `--self-test`, `--go`, `--all` (self-test then go, PASS/FAIL lines, final `Phase 14.2 gate passed`). Later modes are added in Task 3.
|
||||
|
||||
`console/dev_seed_test.go`, `TestDevSeedRefusesProduction` (no database needed): table over environment values unset, `production`, `development`, `testing`, `staging`, `local`, `dev`. Build each config with `compass.Open` (temp dir with `app.yaml`, `Environ` carrying `SUMMER_ENV` only when set) and `database.dsn` pointing at `postgres://seed:seed@127.0.0.1:1/none?sslmode=disable&connect_timeout=1`, no published database. For the refused values, `DevSeedCommand(app).Run` returns an error containing `refused in environment` and the app has no published `*gorm.DB` afterwards (nothing was opened). For `local` and `dev`, the error does not contain `refused in environment` (the gate passed and the connect failed). Also assert `DevEnvironment` returns the same verdicts.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase14.2.sh --self-test && bash scripts/check-phase14.2.sh --go</automated>
|
||||
<fails_when>Non-zero exit; any line starting with "refuse:"; output lacks "phase14.2 self-test passed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n scripts/check-phase14.2.sh` exits 0.
|
||||
- `grep -c 'TestDevSeedRefusesProduction' scripts/check-phase14.2.sh` prints at least 1.
|
||||
- `grep -c '^func TestDevSeedRefusesProduction' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go` prints 1.
|
||||
- `grep -c 'staging' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>The gate script runs a named Go test through the skip-intolerant detector and the seed's production refusal is proven without a database.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: unit tests for the seed shape and idempotency, the own-database commands, the marker, the uploads mount and the access log</name>
|
||||
<files>../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go, modules/surf/uploads_test.go, modules/surf/accesslog_test.go</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go, ../fonoteka.go/plugins/golem15/fonoteka/console/database.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go, ../fonoteka.go/plugins/golem15/fonoteka/console/phase14_commands_test.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go, ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go (BuildRouter table walk), ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/plugins/golem15/user/classes/api_tokens.go, modules/surf/uploads.go, modules/surf/accesslog.go, modules/surf/accesslog_test.go (TestAccessLogSmoke), modules/surf/router_test.go, modules/lagoon/attach/static_test.go, modules/lagoon/attach/lifecycle_test.go (attachGorm 268)</read_first>
|
||||
<behavior>
|
||||
- TestDevSeedShape: E2E_EMAIL/E2E_PASSWORD set → that user exists activated, MustChangePassword false, HasSelfSetPassword true, bcrypt matches; both empty → `demo@example.com` with `changeme`; context collection of kind collection with Kind of Blue, Blue Train, Mingus Ah Um, each with one `photos` attachment whose blob exists in the mem:// bucket; wishlist collection with A Love Supreme; one rating 4 on Kind of Blue; one token row named dev-seed-mcp whose stored hash matches the printed `inv_` secret; settings row 1 has search_use_typesense true; DEV_SEED_API_TOKEN without the prefix → refusal and no row written; DEV_SEED_API_TOKEN with the prefix → row hash equals HashAPIToken(value).
|
||||
- TestDevSeedIdempotent: second run prints only `unchanged` lines (token line without a secret) and every table count is equal; a third run with a different E2E_PASSWORD prints `user: updated` and changes nothing else.
|
||||
- TestDevSeedOpensDatabase / TestReindexOpensDatabase / TestCommandsOpenDatabase: app with only `database.dsn` (bootPG) and no published handle → each command publishes `*gorm.DB`, never prints `database is not configured`; seeded album slugs are non-empty; reindex with no Typesense key prints the not-configured line.
|
||||
- TestDevMarkerRoute: local and dev → 200, exact three keys, env matches, `Cache-Control: no-store`, no `jwt.auth` in the route's middleware; production, development, testing → absent from BuildRouter's table and 404 from Assemble; `parity/manifest.yaml` contains no `_dev/`.
|
||||
- TestUploadsMount: key off → no route and 404; key on → public blob 200 with its content type and nosniff, HEAD 200 with empty body; is_public false, missing row, `..` traversal and encoded traversal → 404; bucket or DB unpublished → 404; a custom `public_path_prefix` is honoured; a plugin route under the prefix makes BuildRouter return an error.
|
||||
- TestAccessLog: key off → no record; key on → pattern and status for 200, a handler 404 and a recovered panic 500; unmatched request → status 404 and `path` without query; a locale-prefixed request still records the route pattern; `duration_ms` is an integer ≥ 0; no record contains the query value, an `Authorization` header value or a header name; `http.NewResponseController(w).Flush()` inside a handler succeeds.
|
||||
</behavior>
|
||||
<action>Per D-02, D-04, D-09, D-10, D-12, D-17, D-19 and research G1. Write the tests in `<behavior>` first; they must pass against the plan 01 code. If one fails because the code is wrong, fix the code in the same commit with the test and say so in the summary; do not weaken an assertion.
|
||||
|
||||
Placement: `TestDevSeedShape` and `TestDevSeedIdempotent` in `console/dev_seed_test.go` on `oauthConsoleDB(t)` with a published mem:// bucket, `golem15.user.api_tokens.prefix` set to `inv_`, SUMMER_ENV local, and `t.Setenv` for the E2E and token variables; count rows per table before and after. The own-database tests in the plugin root `dev_stack_test.go`, using `bootDB(t)` to migrate and a fresh app with `database.dsn` from `bootPG`, plugins activated, no handle published (same set-up as `TestDevStackSmoke`). `TestDevMarkerRoute` in `dev_marker_test.go`, building configs with each SUMMER_ENV and reading the manifest through a path relative to the plugin directory. `TestUploadsMount` in `modules/surf/uploads_test.go` with a testcontainers Postgres holding `system_files` (the `attachGorm` pattern) and `memblob`; `TestAccessLog` added to `modules/surf/accesslog_test.go` next to `TestAccessLogSmoke`, using `blog` / `acme` fixture names. Missing Docker is a test failure, never a skip.
|
||||
|
||||
Commits: fonoteka.go tests, then summercms.go tests, each with explicit paths.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/console -count=1 -v -run '^(TestDevSeedRefusesProduction|TestDevSeedShape|TestDevSeedIdempotent|TestReindexCommand)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -v -run '^(TestDevSeedOpensDatabase|TestReindexOpensDatabase|TestCommandsOpenDatabase|TestDevMarkerRoute|TestDevStackSmoke)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP" or "no tests to run"; output lacks "--- PASS: TestDevSeedIdempotent", "--- PASS: TestDevSeedShape" or "--- PASS: TestDevMarkerRoute".</fails_when>
|
||||
<automated>go vet ./modules/surf/ && go test ./modules/surf/ -count=1 -v -run '^(TestUploadsMount|TestAccessLog|TestAccessLogSmoke)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP" or "no tests to run"; output lacks "--- PASS: TestUploadsMount" or "--- PASS: TestAccessLog".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^func Test' ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go` prints at least 3.
|
||||
- `grep -cE '^func Test(DevSeedOpensDatabase|ReindexOpensDatabase|CommandsOpenDatabase)\(' ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go` prints 3.
|
||||
- `grep -c 'manifest.yaml' ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go` prints at least 1.
|
||||
- `grep -c 'nosniff' modules/surf/uploads_test.go` prints at least 1, and `grep -c '^func TestAccessLog(' modules/surf/accesslog_test.go` prints 1.
|
||||
- `grep -c 't.Skip' modules/surf/uploads_test.go ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go` prints 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>Every Go behaviour the phase added fails a named test when it regresses: seed refusal, shape, idempotency and own database, command database fix, marker gating, uploads mount and access log.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Nuxt switch contract test and the remaining gate stages (docs, parity, contracts, guard, e2e, all)</name>
|
||||
<files>scripts/check-phase14.2.sh, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs</files>
|
||||
<read_first>scripts/check-phase14.2.sh (Task 1), scripts/check-phase14.2.1.sh (run_docs, run_all), ../fonoteka.go/scripts/dev-stack.sh (guard, e2e, e2e-verdict, status, up, down), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/session-recovery.test.mjs, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/package.json (test:contracts), .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-RESEARCH.md (Q2 gate run, Q6, Pattern 4, G7, G8)</read_first>
|
||||
<action>Contract test (D-14), `tests/contracts/e2e-backend-switch.test.mjs` with `node:test`: import `seedE2EFixtures`, `runTinker` and `backendAvailable` from `../e2e/helpers/runTinker.ts` (type stripping). Go mode cases, toggling `process.env` per test and restoring it: a temp executable shell script as `E2E_GO_BIN` that writes its argv and cwd to a temp file → exactly `fonoteka:dev-seed` and cwd equal to `E2E_GO_DIR`; a script exiting 3 → `seedE2EFixtures()` throws; `E2E_GO_BIN` unset → `backendAvailable()` false and `seedE2EFixtures()` throws; `runTinker('x')` throws. Source cases on the file text: the PHP argv `['artisan', 'tinker', '--execute', phpScript]`, `firstOrNew` and `e2e-seed-post` are present; `auth.setup.ts` calls `seedE2EFixtures()` only inside an `E2E_BACKEND === 'go'` condition. Commit in vue-fonoteka-app.
|
||||
|
||||
Gate stages in `scripts/check-phase14.2.sh` (D-17):
|
||||
- `--go`: `go vet ./...` and `go test ./... -count=1 -timeout 60m` in ROOT (exit code), the same with `go -C "$APP"` (workspace), then `go_json` with `CONSOLE_REQUIRE=(TestDevSeedRefusesProduction TestDevSeedShape TestDevSeedIdempotent TestReindexCommand)` on `./plugins/golem15/fonoteka/console`, `APP_REQUIRE=(TestDevStackSmoke TestDevSeedOpensDatabase TestReindexOpensDatabase TestCommandsOpenDatabase TestDevMarkerRoute)` on `./plugins/golem15/fonoteka`, `FRAMEWORK_REQUIRE=(TestUploadsMount TestAccessLog TestAccessLogSmoke)` on `./modules/surf`, each run with a `-run` regex built from its array.
|
||||
- `--docs`: `go test ./cmd/summer -count=1 -run '^TestDocsTree$'` and `go run ./cmd/summer docs:build --check` in ROOT.
|
||||
- `--parity`: `go -C "$APP" test ./parity -count=1 -run TestParityCorpus -timeout 30m -v`, refusing unless the output has `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`.
|
||||
- `--contracts`: `pnpm --dir "$NUXT" test:contracts`, refusing unless it exits 0 and reports `# fail 0`.
|
||||
- `--guard`: in a temp dir, planted files `guard-*.conf` fed through `DEV_STACK_ENV_FILE` to `bash "$APP/scripts/dev-stack.sh" guard`; cases: both origins `http://127.0.0.1:8422`; internal origin unset; origins differing; both `https://plytarium.com`; a python3 `http.server` on a free loopback port answering 404 with `X-Powered-By: PHP/8.5.10` (origins and `DEV_STACK_GO_ADDR` set to it); a python3 server answering 200 `{"backend":"winter"}`. Each case must exit non-zero and print a `refuse:` line, else the stage refuses naming the case. Fake servers are killed by a trap.
|
||||
- `--e2e` (D-06, D-13, D-15): note whether `dev-stack.sh status` is already green; run `dev-stack.sh up --detach` (its refusals propagate; a missing service fails the stage), `dev-stack.sh guard` with the real env file, then `dev-stack.sh e2e` with `GATE_SPECS=(auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts public-share.spec.ts realtime.spec.ts i18n.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts)`; then `dev-stack.sh e2e --report-only` with `REPORT_SPECS=(fonoteka-ai-assist.spec.ts fonoteka-bulk-add.spec.ts offline.spec.ts registration-invitation.spec.ts responsive.spec.ts landing.spec.ts)`, printing counts without failing; then refuse when `$APP/tmp/dev-stack/go.log` contains `album search engine failed` or a line matching `status=5[0-9][0-9]`; run `dev-stack.sh down` only when this stage started the stack.
|
||||
- `--self-test` also: planted Playwright reports (skipped 1, unexpected 1, flaky 1, expected 0, malformed JSON) must each be refused by `dev-stack.sh e2e-verdict`, and a clean report accepted; `bash -n` on dev-stack.sh; the mode-presence loop covers every mode below.
|
||||
- `--all`: self-test, go, docs, parity, contracts, guard, e2e in that order with PASS/FAIL lines; final line `Phase 14.2 gate passed`.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app test:contracts</automated>
|
||||
<fails_when>Non-zero exit, or the node --test summary line "# fail" shows a number other than 0.</fails_when>
|
||||
<automated>bash scripts/check-phase14.2.sh --all 2>&1 | tee "${TMPDIR:-/tmp}/check-phase14.2.log"; test "${PIPESTATUS[0]}" -eq 0 && test "$(tail -n 1 "${TMPDIR:-/tmp}/check-phase14.2.log")" = "Phase 14.2 gate passed"</automated>
|
||||
<fails_when>Non-zero exit; any "FAIL" stage line or "refuse:" line; the last line is not "Phase 14.2 gate passed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `for m in --self-test --go --docs --parity --contracts --guard --e2e --all; do grep -qF -- " $m)" scripts/check-phase14.2.sh || echo "missing $m"; done` prints nothing.
|
||||
- `grep -c 'plytarium.com' scripts/check-phase14.2.sh` prints at least 1, and `grep -c 'X-Powered-By: PHP/8.5.10' scripts/check-phase14.2.sh` prints at least 1.
|
||||
- `grep -c 'album search engine failed' scripts/check-phase14.2.sh` prints at least 1.
|
||||
- `grep -c 'recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0' scripts/check-phase14.2.sh` prints 1.
|
||||
- `grep -c 'fonoteka:dev-seed' /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs` prints at least 1.
|
||||
- `grep -cE 'guard-[a-z0-9-]*\.conf' scripts/check-phase14.2.sh` prints at least 1 (planted guard inputs use `.conf` names the secret-read guard does not block).
|
||||
</acceptance_criteria>
|
||||
<done>`check-phase14.2.sh --all` is the single fail-closed proof of the phase: Go tests, docs, parity, Nuxt contracts, guard refusals and the Go-gate Playwright run, ending with "Phase 14.2 gate passed".</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Test and gate output → phase verdict | Skips, zero matches and missing names must not read as passes |
|
||||
| Gate → guard | The guard's refusals are the phase's protection against PHP or production fall-through |
|
||||
| Gate → logs | Silent degradations (SQL search fallback, 5xx) only show in the Go log |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-14.2-17 | Tampering | Playwright gate verdict | high | mitigate | `e2e-verdict` requires expected > 0 and zero unexpected, skipped and flaky; `--self-test` plants each bad report and requires refusal |
|
||||
| T-14.2-18 | Tampering | Go test verdict | high | mitigate | `detect()` exits non-zero on fail, build failure, skip, zero tests, "no tests to run", non-JSON, missing named test and data race; planted cases in `--self-test` |
|
||||
| T-14.2-19 | Spoofing | guard regression | high | mitigate | `--guard` plants :8422, unset internal origin, mismatched origins, plytarium.com, fake PHP and a wrong marker; every case must refuse |
|
||||
| T-14.2-20 | Repudiation | hidden degradations during the gate run | medium | mitigate | `--e2e` refuses on `album search engine failed` and 5xx records in `go.log` (research G8, D-12) |
|
||||
| T-14.2-SC | Tampering | package installs | high | mitigate | No install: Go stdlib, existing test modules (testcontainers, memblob) and node built-ins |
|
||||
|
||||
ASVS L1; every high threat is mitigated with a self-tested control.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash scripts/check-phase14.2.sh --all` ends with `Phase 14.2 gate passed` (services up, Docker available).
|
||||
- `go vet ./...` and `go test ./...` green in summercms.go and fonoteka.go; `pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app test:contracts` green.
|
||||
- Update `14.2-VALIDATION.md` per-task map statuses only through `/gsd-validate-phase` (not in this plan).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every validation-map behaviour in 14.2-VALIDATION.md has a named, passing automated test or gate stage.
|
||||
- The gate script fails closed on every planted failure in `--self-test` and `--guard`.
|
||||
- Unit tests are the last plan of the phase (project rule, D-17).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-04-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -66,6 +66,8 @@ Repos: `fonoteka.go` (seed command, start script, dev env template, any Go fixes
|
||||
- **D-21:** `vue-fonoteka-app/tests/e2e/realtime.spec.ts` (stale since 2026-06-12) is rewritten as a backend-agnostic spec and verified against PHP as well as Go, per D-11.
|
||||
- **D-22:** Plan count confirmed at 4: (01) Go glue — `dev:seed`, plugin-command DB fix (G1, incl. `fonoteka:reindex`), dev-only marker route, framework `/storage` mount, opt-in access log (G3), READMEs/docs; (02) `dev-stack.sh`, guard, `.env.dev.example`, `.gitignore`, run docs; (03) Nuxt `E2E_BACKEND=go` switch, realtime rewrite, new edit/rate/cover, search, wishlist specs, then the manual UAT session; (04, last) unit tests and `summercms.go/scripts/check-phase14.2.sh`.
|
||||
|
||||
- **D-23:** The shared Centrifugo config has a `user` namespace but **no `collection` or `wishlist` namespace** and no subscribe proxy; the app subscribes to `user:{id}`, `collection:{id}` and `wishlist:{id}` (`useCentrifugo.ts`, `useRealtimeSync.ts`), so `collection:`/`wishlist:` subscribes fail with "unknown channel" on either backend today. The D-18 operator step therefore also adds `collection` and `wishlist` namespaces (`allow_subscribe_for_client: true`, dev-grade; no proxy pointed at either backend, so PHP apps on the shared instance are unaffected) alongside the `http://localhost:3119` origin, then restarts the service. `dev-stack.sh check` reports a missing origin or namespace as a named warning. Production namespace/authorization config stays Phase 20's concern.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**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.
|
||||
**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. *Superseded by D-18: the operator inspected that config at the planning checkpoint (HMAC tokens, HTTP API key, `allow_subscribe_for_client: true`, no subscribe or connect proxy); every Open Question below is resolved.*
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
@@ -111,7 +111,7 @@ On the Nuxt side, the biggest risk in the CONTEXT turns out to be smaller than e
|
||||
| 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. |
|
||||
| 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. *Superseded by D-18: the shared Centrifugo has no subscribe proxy; clients subscribe directly (`allow_subscribe_for_client: true`), so locally only `/api/realtime/token` and Go's HTTP API publishes are involved.* |
|
||||
| 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. |
|
||||
@@ -131,7 +131,7 @@ On the Nuxt side, the biggest risk in the CONTEXT turns out to be smaller than e
|
||||
| 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. |
|
||||
| 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. *Superseded by D-18: the config was inspected; no subscribe or connect proxy exists (the proxy-target assumption A1 was wrong). Go shares the HMAC secret and API key through `.env.dev`; the only gap is `http://localhost:3119` missing from `client.allowed_origins`, a documented operator step.* |
|
||||
| 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)
|
||||
@@ -263,6 +263,8 @@ Recommended modes for `check-phase14.2.sh`: `--self-test` (also plants a fake Pl
|
||||
|
||||
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": "<proxy_secret>"}`, and `channel.namespaces[]` entries `collection` and `wishlist` with `subscribe_proxy_enabled: true`. Env override form: `CENTRIFUGO_<OPTION>` with nested levels joined by `_` (e.g. `CENTRIFUGO_CLIENT_ALLOWED_ORIGINS`). The Go subscribe proxy answers HTTP 200 for both allow and deny, and denies unless the namespace has an authorizer [VERIFIED: lighthouse/README.md:49-53]. Go registers authorizers for `collection` and `wishlist` only [VERIFIED: fonoteka/realtime.go]. The Nuxt client's auto-subscription to `user:{id}` (`useCentrifugo.ts:90`) is therefore denied on Go, **exactly as on PHP** (PHP `ProxyController` denies "Unknown channel namespace" the same way). This is parity, not a gap. "Realtime working" in the manual session means a `collection:{id}` update reaches a second browser session.
|
||||
|
||||
*Superseded by D-18 for the local stack:* the shared instance configures no subscribe proxy, so the `channel.proxy.subscribe.*`, `X-Centrifugo-Secret` and `subscribe_proxy_enabled` settings above are not set and Go's `proxy_secret` is not needed. The deny-by-namespace behaviour described above applies only where a subscribe proxy is configured. Locally only `client.token.hmac_secret_key` (= `SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET`), `http_api.key` (= `SUMMER_REALTIME__CENTRIFUGO__API_KEY`, with `SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api`) and `client.allowed_origins` matter.
|
||||
|
||||
Nuxt side: `NUXT_PUBLIC_CENTRIFUGO_WS_URL=ws://localhost:<port>/connection/websocket` (the current shared instance serves WS and API on `:8787` [VERIFIED: `ss -ltnp` and `curl /connection/websocket` → 400 with CORS headers]). Set `E2E_CENTRIFUGO=1` for the live realtime spec.
|
||||
|
||||
## Q8. Nuxt proxy prefixes vs Go routes
|
||||
@@ -337,6 +339,8 @@ Not applicable: this phase installs no external packages (Go stdlib and existing
|
||||
Go album writes ──River job──▶ Centrifugo HTTP API ──▶ browser (collection:{id})
|
||||
```
|
||||
|
||||
*Superseded by D-18:* the subscribe-proxy edge (`X-Centrifugo-Secret` → `:8423/api/realtime/subscribe`) does not exist locally. The browser connects to the shared `:8787` instance with a Go-issued HMAC token and subscribes directly; Go only issues tokens and publishes through the HTTP API.
|
||||
|
||||
### Recommended file layout
|
||||
|
||||
```
|
||||
@@ -461,6 +465,7 @@ Optionally call `seedE2EFixtures()` from `auth.setup.ts` only `if (process.env.E
|
||||
### Pitfall 4: Centrifugo origin and proxy mismatch
|
||||
**What goes wrong:** the WS handshake is rejected silently (the origin is not allowed, for example `127.0.0.1:3119` vs `localhost:3119`), or `collection:{id}` subscribes are denied because the shared Centrifugo proxies to PHP or has a different `X-Centrifugo-Secret`.
|
||||
**How to avoid:** allow both origins. Make sure the HMAC secret, API key and proxy secret match `.env.dev`. Point the subscribe proxy at `:8423` (Open Question 1).
|
||||
*Superseded by D-18:* there is no subscribe proxy and no proxy secret on the shared instance. The remaining risks are the origin (add both `http://localhost:3119` and `http://127.0.0.1:3119` to `client.allowed_origins`, operator step) and a mismatched HMAC secret or API key in `.env.dev` (the script's `check` reports a rejected key).
|
||||
|
||||
### Pitfall 5: cwd-relative config and bucket
|
||||
**What goes wrong:** `bin/fonoteka` run from another directory finds no `config/` or writes covers under the wrong directory.
|
||||
@@ -526,24 +531,29 @@ err := lighthouse.WithoutBroadcasting[models.Album](ctx, func(ctx context.Contex
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | The shared Centrifugo at `/etc/centrifugo/config.json` has its subscribe proxy pointed at PHP (`:8422`), with secrets that may differ from what Go will use | G12, Q7, OQ1 | Realtime subscribes are denied on Go. Medium impact: realtime is a manual-session item and a gate spec. |
|
||||
| A1 | The shared Centrifugo at `/etc/centrifugo/config.json` has its subscribe proxy pointed at PHP (`:8422`), with secrets that may differ from what Go will use | G12, Q7, OQ1 | Realtime subscribes are denied on Go. Medium impact: realtime is a manual-session item and a gate spec. **WRONG, superseded by D-18:** the inspected config has no subscribe or connect proxy; the secrets are shared with Go through `.env.dev`. |
|
||||
| A2 | `php artisan serve` on the PHP checkout answers the marker path with a 404 (any body) | Q5 | Low: the X-Powered-By check and the `.backend=="summercms"` check each refuse independently |
|
||||
| A3 | `localhost:3119` in the browser and Playwright reaches the Nuxt dev listener on both IPv4 and IPv6 | Q2 | Low: probes fail visibly |
|
||||
| A4 | Setting `app.url` to `http://localhost:3119` locally is right (frontend links such as activation and CSV export URLs) | .env.dev keys | Low: affects emails/links only. The OAuth issuer for local MCP OAuth would be wrong, but MCP OAuth is out of scope locally (personal token only). |
|
||||
| A5 | 3–5 seeded albums plus 1 wishlist album are enough for the i18n count, offline covers and the manual session | Pattern 1 | Low |
|
||||
| A6 | Rewriting `realtime.spec.ts` is acceptable under D-11 ("new backend-agnostic spec" / test helper) | Q1 | Low. If not, add `realtime-live.spec.ts` and mark the stale one report-only. Confirm with the user. |
|
||||
| A6 | Rewriting `realtime.spec.ts` is acceptable under D-11 ("new backend-agnostic spec" / test helper) | Q1 | Low. If not, add `realtime-live.spec.ts` and mark the stale one report-only. Confirm with the user. **Settled by D-21:** rewrite it as a backend-agnostic spec verified on PHP and Go. |
|
||||
|
||||
## Open Questions
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **Which Centrifugo does the local Go stack use?**
|
||||
- Known: one shared v6.5.1 instance on `:8787`, config unreadable by this agent. Its subscribe proxy endpoint is static, so it can serve PHP or Go, not both.
|
||||
- Recommendation: `dev-stack.sh` starts a **dedicated** Centrifugo for Go (`/usr/local/bin/centrifugo -c tmp/dev-stack/centrifugo.json`, port e.g. `8788`). The config is generated from `.env.dev` values with the subscribe proxy at `:8423`, so PHP's realtime stays intact side by side (the spirit of D-07). This deviates slightly from D-05's "host services only checked, not started", so confirm with the user. Alternative: document editing the shared config (PHP realtime then breaks while Go is the target).
|
||||
2. **Where does the `/storage` mount live: framework or app?**
|
||||
- Recommendation: framework, opt-in. `surf` (or `attach`) mounts `StaticHandlerPublic` at `storage.uploads.public_path_prefix` when a new key such as `storage.uploads.serve: true` is set. Phase 20 (D-06 routes `/storage/app/uploads` to Go) and other apps reuse it. It needs README/docs updates. App-level in the fonoteka plugin is the smaller change if the user prefers.
|
||||
3. **Access-log placement and key name.**
|
||||
- Recommendation: `surf` middleware enabled by `http.access_log: true` (env `SUMMER_HTTP__ACCESS_LOG=true`), default off (no behavior change for other apps), logging `method`, `pattern`, `status`, `duration_ms` (+ raw path without query only on 404) through `slog.Default()` (sunscreen).
|
||||
4. **Fix `user:token:*` (shared sm-user-plugin) for G1 now?**
|
||||
- Recommendation: no. Record a todo. The seed mints its own token; changing a shared plugin is outside this phase's need.
|
||||
All four questions were answered at the planning checkpoint on 2026-10-07 (CONTEXT.md D-18..D-22). None is open.
|
||||
|
||||
1. **Which Centrifugo does the local Go stack use?** RESOLVED by **D-18**.
|
||||
- Resolution: the shared host Centrifugo (v6, systemd, `:8787`, `/etc/centrifugo/config.json`) is reused, the same topology as production. The operator inspected its config: HMAC connection tokens (`client.token.hmac_secret_key`), HTTP API key (`http_api.key`), top-level channels with `allow_subscribe_for_client: true`, and **no subscribe or connect proxy**. Assumption A1 was wrong, so PHP and Go can share the instance. Go needs only the same HMAC secret and API key in `.env.dev` (`SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET`, `SUMMER_REALTIME__CENTRIFUGO__API_KEY`, `SUMMER_REALTIME__CENTRIFUGO__API_URL=http://127.0.0.1:8787/api`). No dedicated Centrifugo is started. Adding `http://localhost:3119` to `client.allowed_origins` and restarting the service is a documented operator step that the script never performs; its preflight checks reachability and warns about a missing origin.
|
||||
- Planned in: plan 02 (`check_services` reachability, key and origin probe; README operator step; the env-file checkpoint), plan 03 Task 2 (operator checkpoint) and Task 3 (`realtime.spec.ts`).
|
||||
- *Superseded by D-18:* the original research recommendation, a dedicated Centrifugo started by `dev-stack.sh` on another port with a subscribe proxy at `:8423`, was rejected. It rested on A1.
|
||||
2. **Where does the `/storage` mount live: framework or app?** RESOLVED by **D-19**.
|
||||
- Resolution: framework opt-in in `summercms.go` surf behind the config key `storage.uploads.serve` (default false), mounting `attach.StaticHandlerPublic` at `storage.uploads.public_path_prefix` with `X-Content-Type-Options: nosniff`; Phase 20 reuses it; module README and docs updated in the same change.
|
||||
- Planned in: plan 01 Task 1 (mount and docs), plan 02 Task 2 (`config/storage.yaml` default, `.env.dev.example` sets it true locally), plan 04 Task 2 (`TestUploadsMount`).
|
||||
3. **Access-log placement and key name.** RESOLVED in **plan 01 Task 3** (within D-12 and D-22, which name the opt-in access log; the key name was left to the planner).
|
||||
- Resolution: unexported `surf` middleware enabled by `http.access_log: true` (env `SUMMER_HTTP__ACCESS_LOG`), default off, logging `method`, `pattern`, `status`, `duration_ms`, plus `path` without its query string only for unmatched requests, through the published `*slog.Logger` or `slog.Default()` (sunscreen).
|
||||
- Planned in: plan 01 Task 3, plan 02 Task 2 (`config/http.yaml` default), plan 04 Task 2 (`TestAccessLog`).
|
||||
4. **Fix `user:token:*` (shared sm-user-plugin) for G1 now?** RESOLVED: no.
|
||||
- Resolution: the shared plugin is not changed (core-plugin rule); the seed mints its own token. The gap is recorded as the todo `.planning/todos/pending/user-plugin-commands-open-database.md` (plan 01 Task 2). The related question about rewriting the stale `realtime.spec.ts` (A6) is settled by D-21.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
@@ -551,7 +561,7 @@ err := lighthouse.WithoutBroadcasting[models.Album](ctx, func(ctx context.Contex
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Postgres (host) | Go DB | ✓ `127.0.0.1:5432 - accepting connections` | (auth required; Go DB/DSN not inspected) | Docker PG containers on 55432/55433/55415 exist |
|
||||
| Typesense | search, reindex | ✗ container `plytarium-typesense-instance` (typesense:26.0) **Exited**, binds 127.0.0.1:8181 | 26.0 | `docker start plytarium-typesense-instance` (no fallback per D-06) |
|
||||
| Centrifugo | realtime | ✓ running (systemd, `*:8787`) | v6.5.1 | dedicated instance from the same binary (OQ1) |
|
||||
| Centrifugo | realtime | ✓ running (systemd, `*:8787`) | v6.5.1 | none: the shared instance is reused (*superseded by D-18*; the dedicated-instance fallback was rejected) |
|
||||
| Go | build/test | ✓ | go1.27.0 | — |
|
||||
| Docker | testcontainers in Go tests | ✓ | — | — |
|
||||
| Node / pnpm | Nuxt, Playwright | ✓ | v22.23.2 (`.nvmrc` 22.22.0) / pnpm 11.3.0 | — |
|
||||
@@ -628,7 +638,7 @@ err := lighthouse.WithoutBroadcasting[models.Album](ctx, func(ctx context.Contex
|
||||
| Secrets in logs (tokens in paths, Bearer headers) | Information disclosure | sunscreen + pattern-based access logging; never log headers |
|
||||
| Committing secrets (`.env.dev`, minted token) | Information disclosure | `.gitignore`; the token secret printed once to the terminal only; never written to the repo |
|
||||
| Backend identity disclosure via marker | Information disclosure | Route registered only outside production |
|
||||
| Dev server reachable from the LAN | Spoofing / exposure | `--addr 127.0.0.1:8423`; Centrifugo dedicated instance on loopback |
|
||||
| Dev server reachable from the LAN | Spoofing / exposure | `--addr 127.0.0.1:8423`; Centrifugo dedicated instance on loopback (*superseded by D-18*: no dedicated instance; the shared `:8787` instance is unchanged and the script never edits or restarts it) |
|
||||
|
||||
## Sources
|
||||
|
||||
@@ -649,8 +659,8 @@ err := lighthouse.WithoutBroadcasting[models.Album](ctx, func(ctx context.Contex
|
||||
**Confidence breakdown:**
|
||||
- Go gaps and APIs: HIGH. Read the source and reproduced G1/G2 against a fresh build.
|
||||
- Nuxt/Playwright contract: HIGH. Read the config, specs and c12 source.
|
||||
- Centrifugo integration: MEDIUM. v6 keys cited from the docs; the shared instance's config could not be read (A1).
|
||||
- Plan shape: MEDIUM. Depends on OQ1/OQ2 answers.
|
||||
- Centrifugo integration: MEDIUM. v6 keys cited from the docs; the shared instance's config could not be read (A1). *Superseded by D-18:* the operator inspected the config; A1 was wrong (no proxy).
|
||||
- Plan shape: MEDIUM. Depends on OQ1/OQ2 answers. *Resolved:* OQ1 by D-18, OQ2 by D-19, plan count fixed at 4 by D-22.
|
||||
|
||||
**Suggested plan breakdown (for the plan-count checkpoint):**
|
||||
1. **Go glue** (`fonoteka.go` + `summercms.go`): `dev:seed` (env-gated, idempotent, own DB/bucket, generated covers, token, search flag), the reindex own-DB fix, the dev-only marker route, the `/storage` uploads mount, the opt-in surf access log, module READMEs/docs. Smoke tests only.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
No external API integration: local-run glue around the Phase 14 Centrifugo and Typesense integrations; it adds only reachability probes plus a read-only Centrifugo namespace probe (server API `history`, D-23) and uses the existing publish and index paths unchanged.
|
||||
@@ -96,13 +96,33 @@
|
||||
{
|
||||
"number": "15",
|
||||
"name": "Journal plugin",
|
||||
"status": "pending"
|
||||
"status": "complete"
|
||||
},
|
||||
{
|
||||
"number": "16",
|
||||
"name": "grzybyfunkcjonalne.pl on reusable blog views",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "17",
|
||||
"name": "Blog companions",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "18",
|
||||
"name": "Payment Gateway",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "19",
|
||||
"name": "Chat and ChatChannels",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "19.1",
|
||||
"name": "Chat satellites",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "20",
|
||||
"name": "Płytarium cutover",
|
||||
@@ -112,7 +132,7 @@
|
||||
"next": {
|
||||
"command": "/gsd:progress --next",
|
||||
"label": "Advance to the next step",
|
||||
"reason": "Phase 15 of 26 · executing"
|
||||
"reason": "Phase 14.2 of 30 · executing"
|
||||
},
|
||||
"updated_at": "2026-10-06T16:30:35.416Z"
|
||||
"updated_at": "2026-10-07T19:26:02.872Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user