--- phase: 14-domain-jobs-and-external-integrations plan: 05 subsystem: feedback tags: [sm-feedback-plugin, golem15.feedback, feedback-widget, g15office, cors, preflight, river, fetchguard, cabana, parity, upstream-sidecar, submodule] requires: - phase: 14-domain-jobs-and-external-integrations provides: "14-01 fetchguard.Client (AllowHostsMode, PostMultipart, WithTransport), tide upstream sidecars and summer parity:upstream; 14-04 the shared-plugin submodule workflow" - phase: 07-user-plugin provides: "golem15.user jwt.auth, GetApiArrayEvent and the user payload's feedback_widget_hidden default" provides: - "New shared core plugin repo git.golem15.com/golem15/sm-feedback-plugin (master pushed, b4b4135..af5d77c), mounted in fonoteka.go at plugins/golem15/feedback: plugin golem15.feedback, package feedback, requires golem15.user" - "Widget API: GET {key}/config and POST {key}/submit behind a constant-time key check and a fail-closed Origin allow-list, buckets feedback-config (60/min) and feedback-submit (10/min); JWT PUT me/hidden; feedback_widget_hidden on every golem15.user payload" - "Tables golem15_feedback_submissions and golem15_feedback_user_preferences (PHP's columns, strings as TEXT) and the typed singleton golem15_feedback_settings with a generated widget key" - "G15Office River job (kind golem15.feedback.sync_g15office, queue feedback, 3 attempts) on a fetchguard client limited to the configured host, replaying PHP's recorded exchange" - "Settings screen (code feedback), read-only submissions list, golem15.feedback.manage_settings, embed.js byte for byte at /plugins/golem15/feedback/assets/js/embed.js, feedback:import-settings" - "surf (framework): OPTIONS on a CORS path answers with Laravel HandleCors headers (summercms.go b5d20b3)" - "Parity: feedback section (feedbackRouteIDs, 4 routes, 28 PHP-recorded cases), job sidecar fixtures/jobs/feedback-g15office; corpus 175 routes, 172 ported, 3 pending (D-09)" affects: [14-06 unit tests and gate (API-08 marked there), 15 cutover (feedback:import-settings, SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__*)] actuals: tokens: 98000 # chars/4 over the added lines of all three repos (embed.js included) tasks: 3 commits: 1 # MEASURED in summercms.go: plan_head_before..plan_head_after (code only) app_repo_commits: 7 # MEASURED in fonoteka.go: app_repo_head_before..app_repo_head_after plugin_repo_commits: 3 # MEASURED in sm-feedback-plugin: the whole new repo (pushed) plan_head_before: 7eb0174612855067a156d34baf4dcb31af408b05 plan_head_after: b5d20b3bfdd712429e76e17cbb22e85db427e051 app_repo_head_before: eea0b1607ef9b5d14b3a5186bf322e6eb45a4b9b app_repo_head_after: 9fc38d9 plugin_repo_head_after: af5d77c tech-stack: added: [] patterns: - "A shared plugin's G15Office/vendor sidecar is replayed from the plugin's own testdata copy; the application keeps the recorded original under fixtures/jobs with a rows golden and a test that the two copies are byte-identical" - "Origin-gated routes record the Origin and Access-Control-Request-* request headers (capture-rules keep them)" - "A spec's query string goes in query:, never in path:" - "Recordings of routes that queue jobs run PHP with QUEUE_CONNECTION=database; the job itself is recorded separately under sync with the upstream proxy" key-files: created: - ../fonoteka.go/plugins/golem15/feedback/ (whole repo: plugin.go, routes.go, admin.go, jobs.go, assets.go, assets/js/embed.js, README.md, config, lang/{en,pl}, models, updates, controllers/{api,submissions}, classes, console, internal/pgtest) - ../fonoteka.go/parity/feedback_seed_test.go - ../fonoteka.go/parity/fixtures/routes/{GET,POST,OPTIONS,PUT}___feedback_api_v1_* (28 fixtures) - ../fonoteka.go/parity/fixtures/jobs/feedback-g15office.{upstream.yaml,rows.json} - ../fonoteka.go/parity/upstream/scripts/g15office-task.yaml modified: - modules/surf/cors.go - modules/surf/cors_coverage_test.go - modules/surf/README.md - docs/services/routing.md - ../fonoteka.go/{.gitmodules,go.work,go.mod,summer.yaml,plugins.gen.go,app/app.go,README.md} - ../fonoteka.go/parity/{check_corpus.go,routes.snapshot,manifest.yaml,parity_test.go,parity_contract_test.go,schema_diff_test.go,migrate_test.go,rollback_isolation_full_test.go,fonoteka_seed_test.go,fonoteka_reset.php,php_parity.sh,capture-rules.yaml,README.md} key-decisions: - "surf answers OPTIONS on CORS paths with Laravel HandleCors headers: Cache-Control no-cache, private always; on a preflight the requested method (upper-cased) and headers echoed when * allows any, Vary, and PHP's default Content-Type text/html; charset=UTF-8" - "The settings screen's code is feedback, not WinterCMS's settings: cabana keys settings screens by code across all plugins" - "The G15Office job is dispatched through conga with a summer_jobs row labelled Golem15\\Feedback\\Jobs\\SyncFeedbackToG15Office and completed on success (PHP used a plain Laravel queue job)" - "The G15Office client runs in fetchguard AllowHostsMode for the base URL's host, so it refuses an http base URL (PHP's curl would send over http)" - "Submission string columns are TEXT: PHP's user_agent column is 255 characters while its validation allows 500" - "The submissions list is read-only and has no form, so models/submission/fields.yaml was not created" - "API-08 is not marked complete: 14-06 also declares it (as 14-04 did with INTG-02)" patterns-established: - "feedback, feedback-closed and feedback-off are additive seed extras on both sides; without one the reset removes the feedback settings and the parity users' preferences" requirements-completed: [] coverage: - id: D1 description: "sm-feedback-plugin exists, is pushed and mounted; golem15.feedback loads after golem15.user and before golem15.fonoteka" requirement: API-08 verification: - kind: unit ref: "plugins/golem15/feedback#TestFeedbackPluginBoot; parity#TestMigrate* (activateAppPlugins order)" status: pass - kind: other ref: "git -C plugins/golem15/feedback status --porcelain --branch → '## master...origin/master'; .gitmodules, go.mod, summer.yaml, plugins.gen.go" status: pass human_judgment: false - id: D2 description: "Widget config with the constant-time key check and the fail-closed Origin gate" requirement: API-08 verification: - kind: unit ref: "plugins/golem15/feedback#TestFeedbackConfig, TestKeyMatches, TestURLHost, TestAllowedOriginHosts" status: pass - kind: parity ref: "parity#TestParityCorpus/GET___feedback_api_v1_{key}_config_feedback (10 cases)" status: pass human_judgment: false - id: D3 description: "Submit with PHP's validation, the image guard, the public screenshot and the job in one transaction; me/hidden; feedback_widget_hidden; the preflight" requirement: API-08 verification: - kind: unit ref: "plugins/golem15/feedback#TestFeedbackSubmit, TestMeHidden, TestFeedbackApiArrayHook, TestFeedbackOptionsPreflight; classes#TestImageGuardCopy; summercms modules/surf#TestCORSOptionsMatchesLaravel" status: pass - kind: parity ref: "parity#TestParityCorpus/POST___feedback_api_v1_{key}_submit_feedback (9), OPTIONS___feedback_api_v1_{any}_feedback (2), PUT___feedback_api_v1_me_hidden_jwt (7, two with the user fetch); coverage 172 ported, 3 pending" status: pass human_judgment: false - id: D4 description: "G15Office job matches PHP's recorded requests; failures stored and retried; nothing sent unconfigured" requirement: API-08 verification: - kind: unit ref: "plugins/golem15/feedback/classes#TestSyncG15Office (php-recorded sidecar + 9 failure paths), TestTaskText" status: pass - kind: other ref: "parity#TestFeedbackJobSidecarMatchesPlugin, TestUpstreamSidecarsAreReplayed" status: pass human_judgment: false - id: D5 description: "Settings screen, submissions list, embed.js, settings import" requirement: API-08 verification: - kind: unit ref: "plugins/golem15/feedback#TestFeedbackAdminSchemas, TestEmbedJSServed, TestFeedbackImportSettings" status: pass human_judgment: false duration: 100min completed: 2026-10-04 status: complete --- # Phase 14 Plan 05: sm-feedback-plugin Summary **A new shared plugin, sm-feedback-plugin, ports the WinterCMS feedback widget to Go. The embedded widget loads its configuration, a visitor sends a report with a screenshot, and a signed-in collector hides the widget, all as PHP answers, behind the same key and Origin gate. Each report reaches G15Office as a task with its screenshot, in the requests PHP sent. Operators configure the widget and read the reports in the admin.** ## Performance - **Duration:** about 100 min - **Started:** 2026-10-03T21:44:47Z - **Completed:** 2026-10-04T00:25Z - **Tasks:** 3 - **Commits:** 3 in sm-feedback-plugin (pushed), 7 in fonoteka.go, 1 code commit in summercms.go (plus this summary) - **Files:** 37 in the plugin (+5430), 52 in fonoteka.go (+1178/-27), 4 in summercms.go (+106/-2) ## Accomplishments - **sm-feedback-plugin (D-12).** The empty remote was cloned at `plugins/golem15/feedback`, built, committed and pushed, then registered with `git submodule add`. `master` is at `af5d77c` and in sync with origin. fonoteka.go loads `golem15.feedback` after `golem15.user` and before `golem15.fonoteka`: `go.work`, `go.mod` (require and replace), `summer.yaml`, the regenerated `plugins.gen.go` and `app.PluginIDs` all name it. - **Widget config (D-13).** `GET /_feedback/api/v1/{key}/config` checks the key in constant time and answers 404 `Not found` when the widget is disabled, has no key or gets the wrong one. The Origin gate lets a request with no Origin through. A request whose Origin host, lower-cased, is not on the list gets 403 `Origin not allowed`, and an empty list refuses every Origin. `models.URLHost` follows PHP's `parse_url`, including `localhost:3000` and IPv6 brackets. `?lang=en` selects the English labels. - **Submit (D-13).** - `POST {key}/submit` validates with PHP's rules and returns the 422 envelope with Laravel's messages in rule order. - The screenshot must pass a copy of ImageContentGuard, or the answer is 422 `The file is not a valid image.`. - One transaction stores the submission, queues the G15Office job and stores the public screenshot (attachOne `screenshot`). A failed write keeps no row and no job. The answer is 202 `{"success":true}`. - **Hiding the widget.** JWT `PUT me/hidden` accepts Laravel booleans, writes only the caller's row and answers `{"hidden":bool}`. A `GetApiArrayEvent` listener puts `feedback_widget_hidden` on every golem15.user payload. The recorded me/hidden cases include a following `GET /_user/api/v1/fetch`, so the payload key is asserted against PHP. - **Preflight.** surf's CORS layer answers `OPTIONS` on `_feedback/api/*`, and no route is registered for it. surf now sends the headers PHP sends (see Deviation 1). - **G15Office (D-13, D-15).** - `classes.SyncG15Office` builds PHP's title. `Str::limit` is ported with `mb_strwidth` widths, using a table generated from PHP. The whitespace collapse is PCRE `\s`, so it includes VT. - It builds PHP's Markdown description, the type map and the settings' status and priority. - It posts the task JSON, then the screenshot as the multipart part `file`. Both requests go through `fetchguard` in AllowHostsMode for the base URL's host, with `Accept: application/json`, the Bearer token and no User-Agent. - Success stores `sent` and the task id. Failure stores `failed` and PHP's message, and returns the error so River retries, up to `conga.MaxAttempts(3)`. When unconfigured, nothing is sent. - PHP ran the job under the sync queue through the recording proxy. Its exchange is replayed offline. A one-word mutation of the title limit failed the replay. - **Admin and assets.** - The settings screen (code `feedback`) uses cabana's field types only. The colors are text fields, the widget key is read-only through `attributes`, and the defaults are `initSettingsData`. A row saved without a key gets a new one. - The submissions list is read-only: no form and no buttons, every column plain text. - The plugin registers the `golem15.feedback.manage_settings` permission (developer role) and a Feedback menu. - `embed.js` is served byte for byte, with an ETag. - `feedback:import-settings` copies the WinterCMS row once, the widget key included. Without `--force` it refuses settings that were already changed. - **Parity.** - 28 cases were recorded against PHP: 10 config, 9 submit, 2 OPTIONS and 7 me/hidden. - The submit cases include each limit at the boundary and one past it (5000-character multibyte message, page_url, user_agent, console_log), the rule-order 422, a non-image, a spoofed JPEG, a foreign Origin and a wrong key. - The config cases include an uppercase Origin, `localhost:3000`, `null`, an empty list, a disabled widget and an unconfigured widget. - The corpus has 175 routes: 172 ported and passing, and 3 pending (D-09). `check_corpus --require-recorded --check-secrets` is green. ## Task Commits sm-feedback-plugin (`git@git.golem15.com:golem15/sm-feedback-plugin.git`, master, pushed): 1. `b4b4135` feat: golem15.feedback plugin with the widget config route (Task 1) 2. `92e1b4e` feat: feedback submissions with screenshots, the hide-widget preference and its user payload key (Task 2) 3. `af5d77c` feat: G15Office job, widget settings and submissions admin, embed.js and the settings importer (Task 3) fonoteka.go (not pushed): 1. `a29c809` chore(14-05): mount sm-feedback-plugin at plugins/golem15/feedback (`.gitmodules` + gitlink only) 2. `a845178` feat(14-05): the application loads golem15.feedback after golem15.user (Task 1) 3. `2028880` feat(14-05): the embedded widget loads its configuration from golem15.feedback exactly as from PHP (Task 1) 4. `e497e94` chore(14-05): bump sm-feedback-plugin (gitlink only, Task 2) 5. `8d5d451` feat(14-05): a visitor submits feedback, a collector hides the widget and the user payload reports it, as in PHP (Task 2) 6. `86afa59` chore(14-05): bump sm-feedback-plugin (gitlink only, Task 3) 7. `9fc38d9` feat(14-05): the team receives each submission as a G15Office task with its screenshot, as PHP sent it (Task 3) summercms.go: - `b5d20b3` fix(14-05): surf answers OPTIONS on CORS paths with Laravel's HandleCors headers (README and routing docs updated; TestDocsTree and docs:build --check pass) Tracer gate: after Task 1 the `` was re-run end to end (vet, the named tests with `-race`, the corpus, check_corpus, submodule status) and passed. The expansion tasks then went ahead. ## Deviations from Plan ### Auto-fixed issues **1. [Rule 1 - Bug, framework] surf's OPTIONS answer did not match PHP** - **Found during:** Task 1 recording - **Issue:** PHP's preflight answers 204 with `Access-Control-Allow-Methods: POST` and `Access-Control-Allow-Headers: content-type` (php-cors echoes the request when `*` allows any), `Cache-Control: no-cache, private` and `Content-Type: text/html; charset=UTF-8`. A plain OPTIONS answers 204 with the Cache-Control. surf sent `*` for both Allow headers and no Cache-Control or Content-Type, so the plan's assumption that "the recorded preflight cases must replay green" failed. - **Fix:** `surf.writeOptions` sends what HandleCors sends. A configured method or header list is still sent as configured. Added `TestCORSOptionsMatchesLaravel` and updated the surf README and `docs/services/routing.md`. - **Commit:** b5d20b3 (summercms.go) **2. [Rule 3 - Blocking] The Origin gate could not be replayed** - **Issue:** `capture-rules.yaml` keeps only Authorization, Content-Type, Accept and Accept-Language, so a recorded Origin would be dropped and a 403 case would replay as 200. - **Fix:** The rules also keep `Origin`, `Access-Control-Request-Method` and `Access-Control-Request-Headers`. Existing fixtures are unaffected. - **Commit:** 2028880 **3. [Rule 2 - Privacy] The PHP checkout's `.env` could point the job at a real G15Office** - **Fix:** `php_parity.sh` exports `G15_OFFICE_BASE_URL`, `G15_OFFICE_TOKEN` and `G15_OFFICE_PROJECT` empty unless the caller sets them. The job recording sets them to `https://office.parity.test` and a fake token, behind the scripted proxy. - **Commit:** 2028880 **4. [Rule 1 - Bug] The fonoteka seed left alice's user flags to chance** - **Issue:** The me/hidden user-payload step expected `has_self_set_password: true`. The shared replay database left the flag at whatever an earlier user-api replay had set. - **Fix:** `seedFonotekaCase` now sets alice's `has_self_set_password`, `must_change_password`, `marketing_consent` and `is_onboarded` to PHP's values. No other fonoteka-seeded fixture shows them. - **Commit:** 8d5d451 **5. [Rule 3 - Blocking] `TestParityContract` rejected the new ported routes** - **Fix:** Added the `feedbackSeedRoutes` set. - **Commit:** 2028880 ### Plan details refined **6.** All 28 route cases were recorded in one session and committed with Task 1. The submit, OPTIONS and me/hidden routes stayed `pending` until Task 2 flipped them. The routes were recorded with `QUEUE_CONNECTION=database`: under `sync`, PHP runs the job inside the request, and an unconfigured G15Office would make the submit a 500. **7.** The settings screen's code is `feedback`, not WinterCMS's `settings`. cabana keys settings screens by code across all plugins, so a generic code would clash with the next shared plugin. **8.** The job goes through `conga.Dispatch`, with a `summer_jobs` row labelled with the PHP job class, and is completed on success. Inside the one transaction it is queued before the screenshot is written, so `TestFeedbackSubmit/rolled-back-write-queues-nothing` proves that a failed screenshot write leaves no job behind. The order is not visible outside the transaction. **9.** No `models/submission/fields.yaml` was created. The list is read-only and has no form, which `TestFeedbackAdminSchemas` asserts. **10.** `TestImageGuardCopy` compares the copy with the framework guard (`attach.IsAllowedImage`) and with PHP's verdicts on the recorded fixtures. A plugin in its own repository cannot import the application's fonoteka guard. **11.** The plugin replays the G15Office sidecar from its own `classes/testdata` copy. The application keeps the recorded original under `fixtures/jobs`, with a rows golden (required by `TestUpstreamSidecarsAreReplayed`), and `TestFeedbackJobSidecarMatchesPlugin` keeps the two copies byte-identical. **12.** The G15Office client uses AllowHostsMode, so it accepts an https base URL only. PHP's curl would also post over http. **13.** All string columns are TEXT. PHP's `user_agent` is `string(255)`, but its own rule allows 500 characters, which Postgres would refuse. **14.** The submit route has nine cases, because the Go replay keeps one app per route and the bucket allows ten. `TestFeedbackSubmit/throttle` asserts that the 11th post gets a 429. **15.** Extra tests beyond the plan: `TestKeyMatches`, `TestURLHost` (PHP `parse_url` vectors), `TestAllowedOriginHosts` and `TestTaskText` (Str::limit and buildTitle/buildDescription against PHP outputs). **16.** The fonoteka.go README layout table now lists the golem and feedback submodules. The golem row was missing since 14-04. **17.** Commits land on `master` in all three repos, as in the earlier Phase 14 plans (`branching_strategy: none`, sequential executor). gsd-tools reports `master` as protected and `allow_default_branch_commits` is unset; the orchestrator's sequential-execution instructions were followed. --- **Total deviations:** 17. Five are auto-fixes and twelve are refinements. **Impact:** the only framework change is surf's OPTIONS answer. It is documented and limited to OPTIONS requests on CORS paths. There is no scope creep. ## Issues Encountered - `/tmp` hit its disk quota. The parity root moved to `~/.cache/summercms-parity/p1405`, and Go and PHP temporary files to `~/.cache/gotest-tmp`. Older caches in `/tmp` left by earlier sessions were not touched. - A spec with `?lang=en` inside `path:` was sent with an escaped path and got Winter's 404 page. It was re-recorded with `query:`. - `artisan serve` keeps a child `php -S` alive after the parent is killed. The child was stopped by its PID. - The submit recordings wrote two screenshots into the PHP checkout's `storage/app/uploads/public/6ac`, and that directory was removed. `git status` in the PHP checkout shows only the user's own pre-existing files. ## Verification - sm-feedback-plugin: `go vet ./...` and `go test ./...` pass both standalone (`GOWORK=off`) and in the workspace. Every named test passes with `-race`. - fonoteka.go: `go vet ./...` passes, `go test ./... -count=1` passes (the app and parity packages), and `go test ./... -short` passes. The fonoteka, golem and user plugin suites pass with `-count=1`. - Parity: `TestParityCorpus` reports `recorded 175/175 passing 172 failing 0 unrecorded 0 pending 3`. `TestCheckCorpusPortedCaseStatus`, `TestParityContract`, `TestSchemaMatchesPHPSnapshot`, `TestUpstreamSidecarsAreReplayed` and `TestFeedbackJobSidecarMatchesPlugin` pass. `check_corpus --routes … --require-recorded --check-secrets` passes with 175/175. - summercms.go: `go vet ./...` and `go test ./... -count=1` pass. `TestDocsTree` and `summer docs:build --check` pass. - Acceptance greps for all three tasks pass: - `.gitmodules` has 1 feedback path, and the plugin's `origin/master` resolves. - `feedbackRouteIDs` appears 3 times, and `routes.snapshot` has 4 `_feedback/api/v1` lines. - `expectedRouteCount` and `expectedPHPRoutes` are 175, and `expectedPortedRoutes` is 172. - `ConstantTimeCompare` appears once, and `feedback_widget_hidden` is in plugin.go. - `routes.go` has no `Options(`. - The embed.js sha256 values are identical, and fields.yaml has no `colorpicker`. - The job sidecar's Authorization is `Bearer {{secret:g15office-token}}`, and jobs.go has `conga.MaxAttempts(3)`. ## Known Stubs None. ## Threat Flags None. The new surfaces are in the plan's threat register: - The public widget routes (T-14-30, T-14-31), with the constant-time key check, the fail-closed Origin list and the per-IP buckets. - The screenshot (T-14-32), with the rules and the sniff-and-decode guard. - The G15Office token and forwarding (T-14-33, T-14-34). The token comes from config only, the sidecars mask it, the client is limited to the configured host, and the job logs only the submission id. - me/hidden (T-14-35), which writes only the principal's row. - The admin list (T-14-36), which renders text columns only. The `embed.js` route serves an embedded static file and reads no input. No key, token or DSN literal was pushed: the diff was scanned before each push, and the tests use only short fake secrets. ## User Setup Required None for development. At cutover (Phase 15) the operator: - sets `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__BASE_URL` (https), `__TOKEN` and `__PROJECT`; - runs `feedback:import-settings` once, so embed snippets keep their widget key; - runs a queue worker that serves the `feedback` queue. ## Next Phase Readiness - 14-06 picks up the unit-test gate. It should add `./plugins/golem15/feedback/...` to `check-phase14.sh`'s plugin list and mark API-08 (left Pending here because 14-06 also declares it). --- *Phase: 14-domain-jobs-and-external-integrations* *Completed: 2026-10-04* ## Self-Check: PASSED Every listed file exists; commits b5d20b3 (summercms.go), a29c809, a845178, 2028880, e497e94, 8d5d451, 86afa59 and 9fc38d9 (fonoteka.go) and b4b4135, 92e1b4e and af5d77c (sm-feedback-plugin, on origin/master) are present. The final `go test ./... -count=1` in fonoteka.go passed after the last commit.