40 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 14-domain-jobs-and-external-integrations | 05 | execute | 5 |
|
|
true |
|
|
|
Phase Goal
ROADMAP Phase 14 goal (verbatim, not in user-story form): The domain-specific River jobs (CSV import write, Discogs match, wishlist digest), the reindex command, the Discogs client and AI cover recognition are ported on top of the Phase 11 jobs/realtime/search infrastructure and the Phase 13 API surface they serve.
This plan's slice: a visitor or collector sends feedback with a screenshot from the embedded widget, hides the widget if they want, and the team receives the report as a G15Office task; operators configure the widget in the admin (API-08 as reworded by D-13; ROADMAP SC6).
Create the shared core plugin sm-feedback-plugin with the full PHP surface (config, submit, preflight, me/hidden, getApiArray hook, embed.js, settings singleton with admin screen and importer, submissions admin list, G15Office River job on the guarded client), mount it, and record its routes as a new parity section.Purpose: the Nuxt app embeds the widget on every page and reads feedback_widget_hidden from the user payload. Decisions: D-12, D-13, D-15, D-18.
Output: sm-feedback-plugin repo and submodule, app wiring, parity section; 175 routes, 172 ported.
Repos: sm-feedback-plugin (new; commit and push master first), then fonoteka.go (pointer bump as its own commit, then wiring and parity). Never stage submodule files from the app repo. Commits path-scoped; never add co-author tags.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Artifacts this phase produces
(This plan's share.)
- Repo
git@git.golem15.com:golem15/sm-feedback-plugin.git(master), modulegit.golem15.com/golem15/sm-feedback-plugin, submoduleplugins/golem15/feedback. - Package
feedback:Plugin(IDgolem15.feedback, Requires golem15.user),Routes,Buckets(feedback-config,feedback-submit),Jobs,Settings,AdminControllers,Permissions(golem15.feedback.manage_settings),Commands. - models:
Submission(golem15_feedback_submissions),UserPreference(golem15_feedback_user_preferences),Settings(golem15_feedback_settings),IsWidgetHidden,SetWidgetHidden. - classes:
IsAllowedImage,SniffImageMIME(copy),G15OfficeClient,NewG15OfficeClient,CreateTask,AttachFile,SyncG15OfficeArgs{SubmissionID}(kindgolem15.feedback.sync_g15office, queuefeedback),SyncG15Office. - controllers/api:
Config,Submit,MeHidden; controllers:golem15.feedback.submissionsadmin list. - Console:
feedback:import-settings. Config keysgolem15.feedback.g15_office.base_url,.token,.project. - Route
GET /plugins/golem15/feedback/assets/js/embed.js. - Parity:
feedbackRouteIDs, routes.snapshot lines, manifest feedback section, fixtures,fixtures/jobs/feedback-g15office.upstream.yaml. - Tests:
TestFeedbackConfig,TestFeedbackSubmit,TestFeedbackOptionsPreflight,TestMeHidden,TestFeedbackApiArrayHook,TestSyncG15Office,TestFeedbackImportSettings,TestEmbedJSServed,TestFeedbackPluginBoot.
Assumptions
- EDGE-UNCLASSIFIED (API-08, flagged): the edge probe could not classify the feedback requirement; this plan assumes PHP's validation limits (message 5000, page_url 2000, user_agent 500, console_log 20000, screenshot 10240 KB) are the only boundaries, each pinned one step either side in 14-06. Not auto-resolved.
- A3: Winter persists
initSettingsDatadefaults on first access, so the Go singleton generates widget_key once when its row is first created. - A6: River's retry backoff replaces PHP's fixed 30 s; not parity-visible.
- The OPTIONS route is served by surf's CORS layer because pact.Router has no OPTIONS method and adding one would break other Router implementations; the recorded preflight cases must replay green against it.
- PHP has no submissions admin screen; the Go list is net-new (D-13) and not parity-checked.
(1) Repo bootstrap exactly as 14-04 Task 1: clone the empty remote into ../fonoteka.go/plugins/golem15/feedback; go.mod module git.golem15.com/golem15/sm-feedback-plugin, go 1.27.0, require summercms and sm-user-plugin with replace … => ../../../../summercms.go and replace git.golem15.com/golem15/sm-user-plugin => ../user; plugin.go (package feedback, ID golem15.feedback, Requires golem15.user, ConfigFS, LangFS, Migrations, Models, interface assertions, init registration); README with the standard structure and no consuming-application name; commit and push master; git submodule add in fonoteka.go and commit .gitmodules plus the gitlink alone.
(2) Tables in updates/01_feedback.go (IDs 202610030201_create_feedback_submissions, 202610030202_create_feedback_user_preferences, 202610030203_create_feedback_settings): golem15_feedback_submissions and golem15_feedback_user_preferences with PHP's columns and indexes, and golem15_feedback_settings (singleton id 1, the settings columns in the must-haves); models Settings with Instance(ctx, db) creating the row with PHP's defaults (enabled true, allow_hide false, widget_key wk_ plus 32 random alphanumerics from crypto/rand, position bottom-right, g15office_task_priority normal, the pl/en label defaults) and AllowedOriginHosts() (split on line breaks, trim, URL host or the raw line, lowercase, unique).
(3) Routes and config handler: Buckets() registers feedback-config (60 per minute by trusted-proxy client IP) and feedback-submit (10 per minute); routes.go mounts group /_feedback/api/v1 and GET /{key}/config with throttle:feedback-config. controllers/api Config(app): keyMatches (enabled, non-empty key, subtle.ConstantTimeCompare) else 404 {"error":true,"message":"Not found"}; Origin gate per the must-haves; 200 body in PHP key order with ?lang=en selecting the _en labels. winter_errors.go holds the plugin's own JSON error writer (plugins in their own repos cannot import fonoteka's api package).
(4) App wiring as 14-04: go.work, go.mod require plus replace, summer.yaml (golem15.feedback before golem15.fonoteka), app/app.go, every activation list, plugins.gen.go and main.go regenerated by summer build. schema_diff_test: the submissions and preferences tables match the PHP snapshot when it carries them; golem15_feedback_settings joins the Go-only allow-list with the D-18 reason.
(5) Parity: check_corpus.go feedbackRouteIDs with GET /_feedback/api/v1/{key}/config feedback, POST /_feedback/api/v1/{key}/submit feedback, OPTIONS /_feedback/api/v1/{any} feedback, PUT /_feedback/api/v1/me/hidden jwt, appended in comparePHPSnapshot; routes.snapshot gains the four lines; expectedRouteCount and expectedPHPRoutes become 175; manifest gains the four entries (config ported now, the other three pending until Task 2). Seed the settings row on both sides (fonoteka_reset.php writes system_settings item golem15_feedback_settings; feedback_seed_test.go writes the Go row) with a fixed widget key {{secret:widget-key}} and allowed origins. Record config 200 (pl and ?lang=en), 404 bad key, 403 foreign Origin, 403 empty allow-list. expectedPortedRoutes 169. feedback_test.go TestFeedbackConfig (constant-time compare used, no Origin allowed, empty list fails closed, case-insensitive host) and TestFeedbackPluginBoot.
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/feedback/... -count=1 -race -v -run '^(TestFeedbackConfig|TestFeedbackPluginBoot)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestParsePHPRoutesCountAndGroups|TestSchemaMatchesPHPSnapshot)$' && go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets && git -C ../fonoteka.go/plugins/golem15/feedback status --porcelain --branch
<fails_when>Any command exits non-zero; a verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestFeedbackConfig, TestFeedbackPluginBoot and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a missing feedback id, a secret or a mismatch; the submodule status shows "ahead" or a modified file.</fails_when>
<acceptance_criteria>
- grep -c 'path = plugins/golem15/feedback' ../fonoteka.go/.gitmodules prints 1 and git -C ../fonoteka.go/plugins/golem15/feedback rev-parse origin/master exits 0.
- grep -c 'feedbackRouteIDs' ../fonoteka.go/parity/check_corpus.go prints at least 2 and grep -c '_feedback/api/v1' ../fonoteka.go/parity/routes.snapshot prints 4.
- grep -n 'const expectedPHPRoutes' ../fonoteka.go/parity/parity_test.go shows 175 and grep -n 'const expectedRouteCount' ../fonoteka.go/parity/check_corpus.go shows 175.
- grep -c 'ConstantTimeCompare' ../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go prints at least 1.
</acceptance_criteria>
The new core plugin is pushed and mounted, and the widget's config call answers exactly as PHP, including the fail-closed Origin gate.
(1) Submit: classes/image_guard.go copies fonoteka's IsAllowedImage and SniffImageMIME (per-plugin copies are deliberate, as in PHP); models.Submission attaches screenshot through lagoon/attach Relation{Name: "screenshot", Public: true}; Submit(app) for POST /{key}/submit (throttle:feedback-submit): key check and Origin gate as config, multipart parse, PHP's validation rules and 422 envelope, ImageContentGuard failure → 422 with details.screenshot = ["The file is not a valid image."], create the submission (status pending) and attach the screenshot in one lagoon.Transaction that also dispatches SyncG15OfficeArgs{SubmissionID} (kind golem15.feedback.sync_g15office, queue feedback, label as PHP's job name), answer 202 {"success":true}. SyncG15OfficeArgs and its kind and queue are declared in classes/sync_g15office.go here; the queue is not served until Task 3 registers the worker, and conga inserts an unregistered kind through its insert-only client, so the job waits queued exactly as a PHP job waits for its worker.
(2) me/hidden: a second group /_feedback/api/v1 with jwt.auth mounts PUT /me/hidden; MeHidden(app) validates hidden required boolean (Laravel boolean semantics) → 422 {"error":"Validation failed","errors":…}, upserts the preference for the JWT principal only and answers {"hidden":bool}. Boot registers the GetApiArray listener ("golem15.feedback") setting feedback_widget_hidden from models.IsWidgetHidden(ctx, db, userID).
(3) Preflight: no OPTIONS route is registered; surf's path-scoped CORS middleware answers OPTIONS on _feedback/api/* with 204. Record OPTIONS /_feedback/api/v1/{any} with and without preflight headers and confirm both replay green; TestFeedbackOptionsPreflight asserts 204 and an empty body through the assembled router.
(4) Parity: record submit 202 (with and without screenshot), 422 validation, 422 bad image, 403 origin; me/hidden 200 true and false and 422; flip submit, OPTIONS and me/hidden; expectedPortedRoutes 172 (175 routes, 3 pending). Tests: TestFeedbackSubmit (validation one step either side of each limit, screenshot attached publicly, job dispatched in the same transaction, a rolled-back insert dispatches nothing), TestMeHidden (only the caller's row changes), TestFeedbackApiArrayHook (false without a row, true after PUT, through the user plugin's fetch payload), classes_test.go TestImageGuardCopy (same verdicts as fonoteka's guard on the shared vectors).
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/feedback/... -count=1 -race -v -run '^(TestFeedbackSubmit|TestMeHidden|TestFeedbackApiArrayHook|TestFeedbackOptionsPreflight|TestImageGuardCopy|TestFeedbackConfig)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus)$' && go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets
<fails_when>Any command exits non-zero; a verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestFeedbackSubmit, TestMeHidden, TestFeedbackApiArrayHook, TestFeedbackOptionsPreflight and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret or mismatch.</fails_when>
<acceptance_criteria>
- grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 172.
- grep -c 'feedback_widget_hidden' ../fonoteka.go/plugins/golem15/feedback/plugin.go prints at least 1.
- grep -c 'Options(' ../fonoteka.go/plugins/golem15/feedback/routes.go prints 0.
- The four feedback manifest entries have status: ported.
</acceptance_criteria>
Every feedback route answers as PHP, and the user payload carries the hide preference.
(1) classes/g15office_client.go G15OfficeClient from config golem15.feedback.g15_office.{base_url, token, project} (defaults empty; README names the SUMMER_ overrides), IsConfigured(), CreateTask(ctx, fields *orderedFields) (map[string]any, error) → PostJSON <base>/_support/api/v1/projects/<project>/tasks with Authorization: Bearer, Accept: application/json, and AttachFile(ctx, hashID, name, mime string, body io.Reader) → PostMultipart field file to <base>/_support/api/v1/tasks/<hashID>/attachments; both through fetchguard.NewClient in AllowHostsMode limited to the base_url host, 30 s timeout; decode PHP's way (connection error, invalid response body, error truthy → its message); an unconfigured client fails with PHP's not-configured message before any request. classes/sync_g15office.go SyncG15Office(ctx, deps, args): missing submission → nil; title Feedback: plus the collapsed message limited to 80 characters as Str::limit; PHP's markdown description; type map bug→Bug, feature→Feature, other→Task; fields filtered of null and empty with priority default normal and the settings task status; create then attach the screenshot when present; success → status sent plus g15office_task_id; failure → status failed plus g15office_error, then return the error so River retries.
(2) Record the job sidecar: with G15_OFFICE_BASE_URL=https://office.parity.test, a parity token and project exported by php_parity.sh, the proxy in script mode answering both G15Office calls, a sync-queue submit makes PHP run the job inline; save it as parity/fixtures/jobs/feedback-g15office.upstream.yaml. TestSyncG15Office (classes/classes_test.go) replays it through the fake with WithTransport (request bodies, Bearer placeholder, multipart part names and sha256 asserted), and covers unconfigured (no request, failed status, error returned), error envelope and missing task id.
(3) Admin and assets: Settings() item (code settings, model Golem15\Feedback\Models\Settings, permission golem15.feedback.manage_settings, form models/settings/fields.yaml using only cabana field types: colors as text fields, widget_key read-only through attributes); golem15.feedback.submissions read-only list controller (columns id, type, status, email, page_url, created_at; message shown as text, never HTML); Permissions(); feedback:import-settings imports system_settings item golem15_feedback_settings into the singleton when the row still holds its generated defaults (updated_at equals created_at) and otherwise refuses unless --force. Copy embed.js byte for byte and serve it with go:embed at GET /plugins/golem15/feedback/assets/js/embed.js (Content-Type: application/javascript; charset=utf-8). README completes Usage, configuration keys, CLI command and Testing.
(4) Tests in feedback_test.go: TestEmbedJSServed (bytes equal the PHP file's sha256, content type), TestFeedbackImportSettings (imports once, missing system_settings is a clean message), TestFeedbackAdminSchemas (the settings form and submissions list compile under cabana at boot).
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/feedback/... -count=1 -race -v -run '^(TestSyncG15Office|TestEmbedJSServed|TestFeedbackImportSettings|TestFeedbackAdminSchemas|TestFeedbackSubmit)$' && go -C ../fonoteka.go test ./plugins/golem15/feedback/... -count=1 && go -C ../fonoteka.go test ./... -count=1 -short && git -C ../fonoteka.go/plugins/golem15/feedback status --porcelain --branch
<fails_when>Any command exits non-zero; a verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestSyncG15Office, TestEmbedJSServed, TestFeedbackImportSettings and TestFeedbackAdminSchemas; the short suite reports FAIL; the submodule status shows "ahead" or a modified file.</fails_when>
<acceptance_criteria>
- sha256sum /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/assets/js/embed.js ../fonoteka.go/plugins/golem15/feedback/assets/js/embed.js | awk '{print $1}' | uniq | wc -l prints 1.
- grep -c 'colorpicker' ../fonoteka.go/plugins/golem15/feedback/models/settings/fields.yaml prints 0.
- ls ../fonoteka.go/parity/fixtures/jobs/feedback-g15office.upstream.yaml succeeds and it contains a {{ placeholder in its Authorization header.
- grep -c 'conga.MaxAttempts(3)' ../fonoteka.go/plugins/golem15/feedback/jobs.go prints 1.
</acceptance_criteria>
Submissions reach G15Office exactly as PHP sent them, operators manage the widget and read submissions, and the widget script is served where Nuxt expects it.
Canon referrals (not minted as prohibitions)
- Cross-origin abuse of the widget is canon (CORS/CSRF) — the Origin gate truth and /gsd-secure-phase.
- Screenshot type spoofing is canon (OWASP file upload) — the image guard; /gsd-secure-phase.
- Feedback personal data retention is canon (GDPR) — /gsd-secure-phase; this port keeps PHP's retention unchanged.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Any website → feedback routes | Public, unauthenticated widget calls from embedding origins |
| Upload → storage | Screenshots are stored and publicly attached |
| Job → G15Office | Submission content and a bearer token leave the system |
| JWT user → preference row | Per-user state |
| Admin → submissions list | User-supplied text rendered in the admin |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-14-30 | Spoofing | config and submit | high | mitigate | Constant-time widget key compare; Origin allow-list failing closed when empty; TestFeedbackConfig (Task 1), TestFeedbackSubmit (Task 2). |
| T-14-31 | Denial of Service | public feedback routes | medium | mitigate | Named buckets feedback-config 60/min and feedback-submit 10/min by trusted-proxy client IP; route throttles pinned in TestFeedbackSubmit (Task 2). |
| T-14-32 | Tampering | screenshot | medium | mitigate | Rule mimes and 10240 KB, ImageContentGuard sniff; TestFeedbackSubmit bad-image case, TestImageGuardCopy (Task 2). |
| T-14-33 | Information Disclosure | G15Office token | high | mitigate | Token from config only, Bearer masked in the sidecar, never logged; TestSyncG15Office (Task 3). |
| T-14-34 | Information Disclosure | submission forwarding | medium | mitigate | Guarded client limited to the configured host; unconfigured sends nothing; TestSyncG15Office (Task 3). |
| T-14-35 | Elevation of Privilege | me/hidden | medium | mitigate | Preference keyed by the JWT principal only; TestMeHidden (Task 2). |
| T-14-36 | Tampering | admin submissions list | low | mitigate | Columns rendered as text by the admin SPA, no HTML field types; TestFeedbackAdminSchemas (Task 3). |
| T-14-SC | Tampering | package installs | low | accept | No new third-party module; only the in-house sm-feedback-plugin module. |
| </threat_model> |
<success_criteria>
- sm-feedback-plugin ports the PHP plugin in full and is mounted in the application.
- Feedback routes pass the parity diff; the G15Office job matches PHP's recorded requests offline. </success_criteria>