Files
summercms/.planning/phases/14-domain-jobs-and-external-integrations/14-05-SUMMARY.md
2026-10-04 00:28:16 +02:00

23 KiB

phase, plan, subsystem, tags, requires, provides, affects, actuals, plan_head_before, plan_head_after, app_repo_head_before, app_repo_head_after, plugin_repo_head_after, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status
phase plan subsystem tags requires provides affects actuals plan_head_before plan_head_after app_repo_head_before app_repo_head_after plugin_repo_head_after tech-stack key-files key-decisions patterns-established requirements-completed coverage duration completed status
14-domain-jobs-and-external-integrations 05 feedback
sm-feedback-plugin
golem15.feedback
feedback-widget
g15office
cors
preflight
river
fetchguard
cabana
parity
upstream-sidecar
submodule
phase provides
14-domain-jobs-and-external-integrations 14-01 fetchguard.Client (AllowHostsMode, PostMultipart, WithTransport), tide upstream sidecars and summer parity:upstream; 14-04 the shared-plugin submodule workflow
phase provides
07-user-plugin golem15.user jwt.auth, GetApiArrayEvent and the user payload's feedback_widget_hidden default
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)
14-06 unit tests and gate (API-08 marked there)
15 cutover (feedback:import-settings
SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__*)
tokens tasks commits app_repo_commits plugin_repo_commits
98000 3 1 7 3
7eb0174612 b5d20b3bfd eea0b1607ef9b5d14b3a5186bf322e6eb45a4b9b 9fc38d9 af5d77c
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
created modified
../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
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}
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)
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
id description requirement verification human_judgment
D1 sm-feedback-plugin exists, is pushed and mounted; golem15.feedback loads after golem15.user and before golem15.fonoteka API-08
kind ref status
unit plugins/golem15/feedback#TestFeedbackPluginBoot; parity#TestMigrate* (activateAppPlugins order) pass
kind ref status
other git -C plugins/golem15/feedback status --porcelain --branch → '## master...origin/master'; .gitmodules, go.mod, summer.yaml, plugins.gen.go pass
false
id description requirement verification human_judgment
D2 Widget config with the constant-time key check and the fail-closed Origin gate API-08
kind ref status
unit plugins/golem15/feedback#TestFeedbackConfig, TestKeyMatches, TestURLHost, TestAllowedOriginHosts pass
kind ref status
parity parity#TestParityCorpus/GET___feedback_api_v1_{key}_config_feedback (10 cases) pass
false
id description requirement verification human_judgment
D3 Submit with PHP's validation, the image guard, the public screenshot and the job in one transaction; me/hidden; feedback_widget_hidden; the preflight API-08
kind ref status
unit plugins/golem15/feedback#TestFeedbackSubmit, TestMeHidden, TestFeedbackApiArrayHook, TestFeedbackOptionsPreflight; classes#TestImageGuardCopy; summercms modules/surf#TestCORSOptionsMatchesLaravel pass
kind ref status
parity 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 pass
false
id description requirement verification human_judgment
D4 G15Office job matches PHP's recorded requests; failures stored and retried; nothing sent unconfigured API-08
kind ref status
unit plugins/golem15/feedback/classes#TestSyncG15Office (php-recorded sidecar + 9 failure paths), TestTaskText pass
kind ref status
other parity#TestFeedbackJobSidecarMatchesPlugin, TestUpstreamSidecarsAreReplayed pass
false
id description requirement verification human_judgment
D5 Settings screen, submissions list, embed.js, settings import API-08
kind ref status
unit plugins/golem15/feedback#TestFeedbackAdminSchemas, TestEmbedJSServed, TestFeedbackImportSettings pass
false
100min 2026-10-04 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 <verify> 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.