306 lines
40 KiB
Markdown
306 lines
40 KiB
Markdown
---
|
|
phase: 14-domain-jobs-and-external-integrations
|
|
plan: 05
|
|
type: execute
|
|
wave: 5
|
|
depends_on: ["14-04"]
|
|
files_modified:
|
|
- ../fonoteka.go/plugins/golem15/feedback/go.mod
|
|
- ../fonoteka.go/plugins/golem15/feedback/go.sum
|
|
- ../fonoteka.go/plugins/golem15/feedback/plugin.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/routes.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/admin.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/jobs.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/README.md
|
|
- ../fonoteka.go/plugins/golem15/feedback/assets/js/embed.js
|
|
- ../fonoteka.go/plugins/golem15/feedback/config/config.yaml
|
|
- ../fonoteka.go/plugins/golem15/feedback/lang/en/lang.yaml
|
|
- ../fonoteka.go/plugins/golem15/feedback/lang/pl/lang.yaml
|
|
- ../fonoteka.go/plugins/golem15/feedback/models/submission.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/models/user_preference.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/models/settings.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/models/registry.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/models/settings/fields.yaml
|
|
- ../fonoteka.go/plugins/golem15/feedback/models/submission/columns.yaml
|
|
- ../fonoteka.go/plugins/golem15/feedback/models/submission/fields.yaml
|
|
- ../fonoteka.go/plugins/golem15/feedback/updates/registry.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/updates/01_feedback.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/controllers/api/me_hidden_controller.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/controllers/api/winter_errors.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/controllers/admin_registry.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/controllers/submissions_admin_controller.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/controllers/submissions/config_list.yaml
|
|
- ../fonoteka.go/plugins/golem15/feedback/classes/image_guard.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/classes/g15office_client.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/classes/sync_g15office.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/console/import_settings.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/feedback_test.go
|
|
- ../fonoteka.go/plugins/golem15/feedback/classes/classes_test.go
|
|
- ../fonoteka.go/plugins/golem15/feedback
|
|
- ../fonoteka.go/.gitmodules
|
|
- ../fonoteka.go/go.work
|
|
- ../fonoteka.go/go.work.sum
|
|
- ../fonoteka.go/go.mod
|
|
- ../fonoteka.go/go.sum
|
|
- ../fonoteka.go/summer.yaml
|
|
- ../fonoteka.go/plugins.gen.go
|
|
- ../fonoteka.go/main.go
|
|
- ../fonoteka.go/app/app.go
|
|
- ../fonoteka.go/parity/check_corpus.go
|
|
- ../fonoteka.go/parity/check_corpus_test.go
|
|
- ../fonoteka.go/parity/routes.snapshot
|
|
- ../fonoteka.go/parity/parity_test.go
|
|
- ../fonoteka.go/parity/parity_contract_test.go
|
|
- ../fonoteka.go/parity/schema_diff_test.go
|
|
- ../fonoteka.go/parity/manifest.yaml
|
|
- ../fonoteka.go/parity/feedback_seed_test.go
|
|
- ../fonoteka.go/parity/fonoteka_reset.php
|
|
- ../fonoteka.go/parity/php_parity.sh
|
|
- ../fonoteka.go/parity/fixtures/routes/
|
|
- ../fonoteka.go/parity/fixtures/jobs/
|
|
- ../fonoteka.go/parity/upstream/scripts/
|
|
- ../fonoteka.go/parity/README.md
|
|
autonomous: true
|
|
requirements: [API-08]
|
|
estimate:
|
|
tokens: 380000
|
|
raw_tokens: 380000
|
|
tasks: 3
|
|
confidence: low
|
|
must_haves:
|
|
truths:
|
|
- "Per D-12, the Go port of Golem15.Feedback lives in git@git.golem15.com:golem15/sm-feedback-plugin.git with module `git.golem15.com/golem15/sm-feedback-plugin`, package `feedback`, plugin ID `golem15.feedback` requiring golem15.user, mounted at `plugins/golem15/feedback` as a submodule; tables `golem15_feedback_submissions` and `golem15_feedback_user_preferences` keep PHP's columns; its master is pushed before the pointer is committed."
|
|
- "Per D-13, `GET /_feedback/api/v1/{key}/config` (bucket feedback-config, 60 per minute per IP) answers 404 `{\"error\":true,\"message\":\"Not found\"}` unless the widget is enabled and the key matches in constant time, applies the Origin gate (no Origin allowed; empty allow-list fails closed with 403 `{\"error\":true,\"message\":\"Origin not allowed\"}`; host compared lowercase against the allow-list lines), and answers `{\"success\":true,\"data\":{position, allowHide, colors{primary, accent}, labels{title, placeholder, success}}}` with `?lang=en` selecting the English labels."
|
|
- "Per D-13, `POST /_feedback/api/v1/{key}/submit` (bucket feedback-submit, 10 per minute per IP) validates as PHP (message, type in bug/feature/other, email, page_url, user_agent, console_log, screenshot up to 10240 KB of jpg/jpeg/png/gif/webp) with 422 `{\"error\":true,\"message\":\"Validation failed\",\"details\":…}`, rejects a non-image screenshot through the copied ImageContentGuard, stores the submission and its public screenshot attachment, dispatches the G15Office sync job and answers 202 `{\"success\":true}`."
|
|
- "Per D-13, JWT `PUT /_feedback/api/v1/me/hidden` validates `hidden` as a boolean (422 `{\"error\":\"Validation failed\",\"errors\":…}`) and answers `{\"hidden\":bool}`; the `golem15.user` GetApiArray listener adds `feedback_widget_hidden` (false without a row) to every user payload; an `OPTIONS` preflight on the feedback paths answers 204 from surf's path-scoped CORS layer (config/http.yaml already lists `_feedback/api/*`)."
|
|
- "Per D-13 and D-18, the settings are the typed singleton table `golem15_feedback_settings` (enabled, widget_key, allow_hide, position, allowed_origins, color_primary, color_accent, label_title_pl/en, label_placeholder_pl/en, label_success_pl/en, g15office_task_status, g15office_task_priority) with PHP's defaults (widget_key `wk_` plus 32 random characters generated on first creation), an admin settings screen using only cabana field types, a read-only submissions admin list, and `feedback:import-settings` importing the PHP `system_settings` row `golem15_feedback_settings` once."
|
|
- "Per D-13, the `SyncFeedbackToG15Office` port is a River job (3 attempts) that POSTs the task JSON to `<base>/_support/api/v1/projects/<project>/tasks` and the screenshot multipart to `<base>/_support/api/v1/tasks/<hashId>/attachments` with a Bearer token through a guarded fetchguard client limited to the configured host; success marks the submission sent with the task id, failure marks it failed with the error and returns the error so River retries; an unconfigured client sends nothing; the requests match PHP's recorded job sidecar (D-15)."
|
|
- "`embed.js` is served byte-identical to PHP's file at `GET /plugins/golem15/feedback/assets/js/embed.js`, the path the Nuxt proxy requests."
|
|
- "The four feedback routes are recorded against PHP as a new manifest section (`feedbackRouteIDs`, routes.snapshot) and pass the parity diff; the corpus totals become 175 routes, 172 ported, 3 pending."
|
|
artifacts:
|
|
- path: "../fonoteka.go/plugins/golem15/feedback/plugin.go"
|
|
provides: "feedback.Plugin (golem15.feedback)"
|
|
contains: "golem15.feedback"
|
|
- path: "../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go"
|
|
provides: "Config, Submit"
|
|
contains: "Origin not allowed"
|
|
- path: "../fonoteka.go/plugins/golem15/feedback/classes/g15office_client.go"
|
|
provides: "G15OfficeClient, CreateTask, AttachFile"
|
|
contains: "_support/api/v1"
|
|
- path: "../fonoteka.go/plugins/golem15/feedback/assets/js/embed.js"
|
|
provides: "the widget script, byte-identical to PHP"
|
|
- path: "../fonoteka.go/parity/check_corpus.go"
|
|
provides: "feedbackRouteIDs"
|
|
contains: "feedbackRouteIDs"
|
|
key_links:
|
|
- from: "../fonoteka.go/plugins/golem15/feedback/plugin.go"
|
|
to: "../fonoteka.go/plugins/golem15/user/classes"
|
|
via: "Boot listens for *userclasses.GetApiArrayEvent with listener id golem15.feedback"
|
|
pattern: "GetApiArrayEvent"
|
|
- from: "../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go"
|
|
to: "../fonoteka.go/plugins/golem15/feedback/classes/sync_g15office.go"
|
|
via: "submit dispatches SyncG15OfficeArgs{SubmissionID} through conga"
|
|
pattern: "SyncG15OfficeArgs"
|
|
- from: "../fonoteka.go/plugins/golem15/feedback/classes/g15office_client.go"
|
|
to: "summercms.go modules/fetchguard/client.go"
|
|
via: "PostJSON and PostMultipart in AllowHostsMode for the configured base_url host"
|
|
pattern: "PostMultipart"
|
|
prohibitions:
|
|
- requirement_id: API-08
|
|
category: privacy
|
|
statement: "Feedback content (message, email, console log, page URL, screenshot) MUST NOT be sent anywhere except the configured G15Office project, and nothing is sent when G15Office is not configured"
|
|
status: resolved
|
|
verification: test
|
|
---
|
|
|
|
## 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).
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
|
@~/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md
|
|
@.planning/phases/14-domain-jobs-and-external-integrations/14-RESEARCH.md
|
|
@.planning/phases/14-domain-jobs-and-external-integrations/14-PATTERNS.md
|
|
@.planning/phases/14-domain-jobs-and-external-integrations/14-04-SUMMARY.md
|
|
@.planning/notes/core-plugins-own-repos.md
|
|
|
|
<interfaces>
|
|
- From 14-01: `fetchguard.NewClient`, `PostJSON`, `PostMultipart`, `FormField`, `FormFile`, `Bearer`, `WithTransport`; `tide.LoadUpstream`, `NewUpstreamFake`.
|
|
- From 14-04: the submodule bootstrap sequence and the app wiring points (go.work, go.mod require plus replace, summer.yaml, app/app.go PluginIDs and blank import, test activation lists, `summer build` regeneration).
|
|
- User plugin: `userclasses.GetApiArrayEvent{User}` with `Collected() map[string]any` (sm-user-plugin controllers/api_controller.go:795-806 already defaults `feedback_widget_hidden` to false), `surf.BucketProvider` and `Buckets() map[string]surf.Bucket` with `surf.TrustedProxies(cfg)` in plugins/golem15/user/plugin.go, routes.go group pattern, `controllers/winter_error_page.html` precedent for per-plugin Winter error helpers.
|
|
- fonoteka precedents: models/settings.go (typed singleton), admin_settings.go (`pact.SettingsItem`), controllers/genres_admin_controller.go, classes/image_guard.go (`IsAllowedImage`, `SniffImageMIME`), jobs.go (`conga.Job`, `OnQueue`, `MaxAttempts`), lagoon/attach `Relation{Name, Many, Public}`.
|
|
- Parity: check_corpus.go `userAPIRouteIDs`, `realtimeRouteIDs`, `comparePHPSnapshot` (lines 619-630), `expectedRouteCount = 171`; parity_test.go `expectedPHPRoutes = 171`, `expectedPortedRoutes`; routes.snapshot.
|
|
- PHP: /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/{Plugin.php, routes.php, controllers/api/FeedbackApiController.php, controllers/api/MeHiddenController.php, jobs/SyncFeedbackToG15Office.php, classes/G15OfficeClient.php, classes/ImageContentGuard.php, models/FeedbackSubmission.php, models/Settings.php, models/UserPreference.php, models/settings/fields.yaml, updates/*.php, lang/{en,pl}/lang.php, config/feedback.php, assets/js/embed.js, tests/}; Nuxt /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/nuxt.config.ts (lines 254, 369) and app/stores/auth.ts (me/hidden).
|
|
</interfaces>
|
|
</context>
|
|
|
|
## Artifacts this phase produces
|
|
|
|
(This plan's share.)
|
|
|
|
- Repo `git@git.golem15.com:golem15/sm-feedback-plugin.git` (master), module `git.golem15.com/golem15/sm-feedback-plugin`, submodule `plugins/golem15/feedback`.
|
|
- Package `feedback`: `Plugin` (ID `golem15.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}` (kind `golem15.feedback.sync_g15office`, queue `feedback`), `SyncG15Office`.
|
|
- controllers/api: `Config`, `Submit`, `MeHidden`; controllers: `golem15.feedback.submissions` admin list.
|
|
- Console: `feedback:import-settings`. Config keys `golem15.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 `initSettingsData` defaults 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.
|
|
|
|
<tasks>
|
|
|
|
<task type="tracer">
|
|
<name>Task 1: The embedded widget loads its configuration from the new sm-feedback-plugin exactly as from PHP</name>
|
|
<reversibility rating="costly">Pushing sm-feedback-plugin master fixes the module path, plugin ID and table names other applications mount (D-12, user-decided).</reversibility>
|
|
<precondition>`git ls-remote git@git.golem15.com:golem15/sm-feedback-plugin.git` exits 0, and the isolated PHP instance resets and serves as in 14-02.</precondition>
|
|
<files>../fonoteka.go/plugins/golem15/feedback/go.mod, ../fonoteka.go/plugins/golem15/feedback/go.sum, ../fonoteka.go/plugins/golem15/feedback/plugin.go, ../fonoteka.go/plugins/golem15/feedback/routes.go, ../fonoteka.go/plugins/golem15/feedback/README.md, ../fonoteka.go/plugins/golem15/feedback/config/config.yaml, ../fonoteka.go/plugins/golem15/feedback/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/feedback/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/feedback/models/settings.go, ../fonoteka.go/plugins/golem15/feedback/models/submission.go, ../fonoteka.go/plugins/golem15/feedback/models/user_preference.go, ../fonoteka.go/plugins/golem15/feedback/models/registry.go, ../fonoteka.go/plugins/golem15/feedback/updates/registry.go, ../fonoteka.go/plugins/golem15/feedback/updates/01_feedback.go, ../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go, ../fonoteka.go/plugins/golem15/feedback/controllers/api/winter_errors.go, ../fonoteka.go/plugins/golem15/feedback/feedback_test.go, ../fonoteka.go/.gitmodules, ../fonoteka.go/go.work, ../fonoteka.go/go.mod, ../fonoteka.go/go.sum, ../fonoteka.go/summer.yaml, ../fonoteka.go/plugins.gen.go, ../fonoteka.go/main.go, ../fonoteka.go/app/app.go, ../fonoteka.go/parity/check_corpus.go, ../fonoteka.go/parity/check_corpus_test.go, ../fonoteka.go/parity/routes.snapshot, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/parity_contract_test.go, ../fonoteka.go/parity/schema_diff_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/feedback_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fixtures/routes/</files>
|
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/routes.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/controllers/api/FeedbackApiController.php (config, keyMatches, origin gate), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/models/Settings.php (initSettingsData, getAllowedOriginHosts), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/updates/create_feedback_submissions_table.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/updates/add_type_and_locale_labels.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/updates/create_user_preferences_table.php, ../fonoteka.go/plugins/golem15/user/plugin.go (Buckets, TrustedProxies), ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/go.mod, ../fonoteka.go/plugins/golem15/fonoteka/models/settings.go, ../fonoteka.go/plugins/golem15/golem/go.mod (14-04 bootstrap result), ../fonoteka.go/parity/check_corpus.go (userAPIRouteIDs, realtimeRouteIDs, comparePHPSnapshot), ../fonoteka.go/parity/routes.snapshot, ../fonoteka.go/parity/parity_test.go (expectedPHPRoutes), ../fonoteka.go/parity/user_api_seed_test.go (non-fonoteka seed precedent)</read_first>
|
|
<action>Per D-12, D-13, D-18 (thinnest path: widget → config route → settings table).
|
|
|
|
(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`.</action>
|
|
<verify>
|
|
<automated>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</automated>
|
|
<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>
|
|
</verify>
|
|
<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>
|
|
<done>The new core plugin is pushed and mounted, and the widget's config call answers exactly as PHP, including the fail-closed Origin gate.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: A visitor submits feedback with a screenshot, a collector hides the widget, and the user payload reports it</name>
|
|
<files>../fonoteka.go/plugins/golem15/feedback/plugin.go, ../fonoteka.go/plugins/golem15/feedback/routes.go, ../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go, ../fonoteka.go/plugins/golem15/feedback/controllers/api/me_hidden_controller.go, ../fonoteka.go/plugins/golem15/feedback/classes/image_guard.go, ../fonoteka.go/plugins/golem15/feedback/classes/sync_g15office.go, ../fonoteka.go/plugins/golem15/feedback/models/submission.go, ../fonoteka.go/plugins/golem15/feedback/models/user_preference.go, ../fonoteka.go/plugins/golem15/feedback/feedback_test.go, ../fonoteka.go/plugins/golem15/feedback/classes/classes_test.go, ../fonoteka.go/plugins/golem15/feedback, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/feedback_seed_test.go, ../fonoteka.go/parity/fixtures/routes/</files>
|
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/controllers/api/FeedbackApiController.php (submit), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/controllers/api/MeHiddenController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/classes/ImageContentGuard.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/models/FeedbackSubmission.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/models/UserPreference.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/Plugin.php (getApiArray listener), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/tests/ (functional cases), ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go (multipart intake and attach), ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (GetApiArrayEvent listener lines 92-103), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (lines 795-806), summercms.go modules/surf/cors.go (OPTIONS handling), ../fonoteka.go/config/http.yaml</read_first>
|
|
<action>Per D-13. Changes in the plugin checkout are committed and pushed there; the pointer bump in fonoteka.go is its own commit.
|
|
|
|
(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).</action>
|
|
<verify>
|
|
<automated>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</automated>
|
|
<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>
|
|
</verify>
|
|
<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>
|
|
<done>Every feedback route answers as PHP, and the user payload carries the hide preference.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: The team receives each submission as a G15Office task with its screenshot, and operators manage the widget and read submissions in the admin</name>
|
|
<files>../fonoteka.go/plugins/golem15/feedback/classes/g15office_client.go, ../fonoteka.go/plugins/golem15/feedback/classes/sync_g15office.go, ../fonoteka.go/plugins/golem15/feedback/classes/classes_test.go, ../fonoteka.go/plugins/golem15/feedback/jobs.go, ../fonoteka.go/plugins/golem15/feedback/admin.go, ../fonoteka.go/plugins/golem15/feedback/models/settings/fields.yaml, ../fonoteka.go/plugins/golem15/feedback/models/submission/columns.yaml, ../fonoteka.go/plugins/golem15/feedback/models/submission/fields.yaml, ../fonoteka.go/plugins/golem15/feedback/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/feedback/controllers/submissions_admin_controller.go, ../fonoteka.go/plugins/golem15/feedback/controllers/submissions/config_list.yaml, ../fonoteka.go/plugins/golem15/feedback/console/import_settings.go, ../fonoteka.go/plugins/golem15/feedback/assets/js/embed.js, ../fonoteka.go/plugins/golem15/feedback/routes.go, ../fonoteka.go/plugins/golem15/feedback/config/config.yaml, ../fonoteka.go/plugins/golem15/feedback/README.md, ../fonoteka.go/plugins/golem15/feedback/feedback_test.go, ../fonoteka.go/plugins/golem15/feedback, ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/fixtures/jobs/, ../fonoteka.go/parity/upstream/scripts/, ../fonoteka.go/parity/README.md</files>
|
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/jobs/SyncFeedbackToG15Office.php (whole file, description format lines 198-226 per research), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/classes/G15OfficeClient.php (whole file), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/config/feedback.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/models/settings/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/Plugin.php (permissions, settings), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/assets/js/embed.js, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/nuxt.config.ts (lines 254, 369), ../fonoteka.go/plugins/golem15/fonoteka/admin_settings.go, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres_admin_controller.go, ../fonoteka.go/plugins/golem15/golem/console/import_settings.go (14-04 importer precedent), summercms.go modules/cabana/form_schema.go (accepted field types and keys), ../fonoteka.go/parity/php_parity.sh</read_first>
|
|
<action>Per D-13, D-15, D-18. Plugin changes committed and pushed in the checkout first; pointer bump in fonoteka.go as its own commit.
|
|
|
|
(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).</action>
|
|
<verify>
|
|
<automated>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</automated>
|
|
<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>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
## 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>
|
|
|
|
<verification>
|
|
- sm-feedback-plugin: master pushed; plugin tests green inside the application workspace.
|
|
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; corpus 175 routes, 172 ported, 0 failing, 3 pending; check_corpus `--require-recorded --check-secrets` green.
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
Create `.planning/phases/14-domain-jobs-and-external-integrations/14-05-SUMMARY.md` when done.
|
|
</output>
|